mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
Greenfield: Add lightning histogram
This commit is contained in:
parent
380f9d5ec5
commit
e6afc487df
13 changed files with 176 additions and 37 deletions
|
@ -21,6 +21,13 @@ public partial class BTCPayServerClient
|
|||
return await SendHttpRequest<LightningNodeBalanceData>($"api/v1/server/lightning/{cryptoCode}/balance", null, HttpMethod.Get, token);
|
||||
}
|
||||
|
||||
public virtual async Task<HistogramData> GetLightningNodeHistogram(string cryptoCode, HistogramType? type = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var queryPayload = type == null ? null : new Dictionary<string, string> { ["type"] = type.ToString() };
|
||||
return await SendHttpRequest<HistogramData>($"api/v1/server/lightning/{cryptoCode}/histogram", queryPayload, HttpMethod.Get, token);
|
||||
}
|
||||
|
||||
public virtual async Task ConnectToLightningNode(string cryptoCode, ConnectToNodeRequest request,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
|
|
|
@ -21,6 +21,13 @@ public partial class BTCPayServerClient
|
|||
return await SendHttpRequest<LightningNodeBalanceData>($"api/v1/stores/{storeId}/lightning/{cryptoCode}/balance", null, HttpMethod.Get, token);
|
||||
}
|
||||
|
||||
public virtual async Task<HistogramData> GetLightningNodeHistogram(string storeId, string cryptoCode, HistogramType? type = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
var queryPayload = type == null ? null : new Dictionary<string, string> { ["type"] = type.ToString() };
|
||||
return await SendHttpRequest<HistogramData>($"api/v1/stores/{storeId}/lightning/{cryptoCode}/histogram", queryPayload, HttpMethod.Get, token);
|
||||
}
|
||||
|
||||
public virtual async Task ConnectToLightningNode(string storeId, string cryptoCode, ConnectToNodeRequest request,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using NBitcoin;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
|
@ -19,6 +19,6 @@ public class HistogramData
|
|||
public HistogramType Type { get; set; }
|
||||
public List<decimal> Series { get; set; }
|
||||
public List<DateTimeOffset> Labels { get; set; }
|
||||
[JsonConverter(typeof(JsonConverters.MoneyJsonConverter))]
|
||||
public Money Balance { get; set; }
|
||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||
public decimal Balance { get; set; }
|
||||
}
|
||||
|
|
|
@ -1,23 +1,13 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitcoin;
|
||||
using NBXplorer;
|
||||
using NBXplorer.Client;
|
||||
|
||||
namespace BTCPayServer.Components.StoreWalletBalance;
|
||||
|
||||
|
|
|
@ -1,14 +1,10 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayApp.CommonServer;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
@ -32,8 +28,9 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
PoliciesSettings policiesSettings, LightningClientFactoryService lightningClientFactory,
|
||||
IOptions<LightningNetworkOptions> lightningNetworkOptions,
|
||||
IAuthorizationService authorizationService,
|
||||
PaymentMethodHandlerDictionary handlers
|
||||
) : base(policiesSettings, authorizationService, handlers)
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
LightningHistogramService lnHistogramService
|
||||
) : base(policiesSettings, authorizationService, handlers, lnHistogramService)
|
||||
{
|
||||
_lightningClientFactory = lightningClientFactory;
|
||||
_lightningNetworkOptions = lightningNetworkOptions;
|
||||
|
@ -56,6 +53,14 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
return base.GetBalance(cryptoCode, cancellationToken);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanUseInternalLightningNode,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/server/lightning/{cryptoCode}/histogram")]
|
||||
public override Task<IActionResult> GetHistogram(string cryptoCode, [FromQuery] HistogramType? type = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return base.GetHistogram(cryptoCode, type, cancellationToken);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanUseInternalLightningNode,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpPost("~/api/v1/server/lightning/{cryptoCode}/connect")]
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayApp.CommonServer;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
@ -35,7 +32,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
IOptions<LightningNetworkOptions> lightningNetworkOptions,
|
||||
LightningClientFactoryService lightningClientFactory, PaymentMethodHandlerDictionary handlers,
|
||||
PoliciesSettings policiesSettings,
|
||||
IAuthorizationService authorizationService) : base(policiesSettings, authorizationService, handlers)
|
||||
IAuthorizationService authorizationService,
|
||||
LightningHistogramService lnHistogramService) : base(policiesSettings, authorizationService, handlers, lnHistogramService)
|
||||
{
|
||||
_lightningNetworkOptions = lightningNetworkOptions;
|
||||
_lightningClientFactory = lightningClientFactory;
|
||||
|
@ -57,6 +55,13 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
{
|
||||
return base.GetBalance(cryptoCode, cancellationToken);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanUseLightningNodeInStore, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/histogram")]
|
||||
public override Task<IActionResult> GetHistogram(string cryptoCode, [FromQuery] HistogramType? type = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return base.GetHistogram(cryptoCode, type, cancellationToken);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanUseLightningNodeInStore,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
|
@ -65,6 +70,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
{
|
||||
return base.ConnectToNode(cryptoCode, request, cancellationToken);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanUseLightningNodeInStore,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")]
|
||||
|
@ -72,6 +78,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
{
|
||||
return base.GetChannels(cryptoCode, cancellationToken);
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanUseLightningNodeInStore,
|
||||
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpPost("~/api/v1/stores/{storeId}/lightning/{cryptoCode}/channels")]
|
||||
|
|
|
@ -9,12 +9,13 @@ using BTCPayServer.Client.Models;
|
|||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Controllers.Greenfield
|
||||
|
@ -33,15 +34,18 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
private readonly PoliciesSettings _policiesSettings;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly PaymentMethodHandlerDictionary _handlers;
|
||||
private readonly LightningHistogramService _lnHistogramService;
|
||||
|
||||
protected GreenfieldLightningNodeApiController(
|
||||
PoliciesSettings policiesSettings,
|
||||
IAuthorizationService authorizationService,
|
||||
PaymentMethodHandlerDictionary handlers)
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
LightningHistogramService lnHistogramService)
|
||||
{
|
||||
_policiesSettings = policiesSettings;
|
||||
_authorizationService = authorizationService;
|
||||
_handlers = handlers;
|
||||
_lnHistogramService = lnHistogramService;
|
||||
}
|
||||
|
||||
public virtual async Task<IActionResult> GetInfo(string cryptoCode, CancellationToken cancellationToken = default)
|
||||
|
@ -87,6 +91,22 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
: null
|
||||
});
|
||||
}
|
||||
|
||||
public virtual async Task<IActionResult> GetHistogram(string cryptoCode, HistogramType? type = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Enum.TryParse<WalletHistogramType>(type.ToString(), true, out var histType);
|
||||
var lightningClient = await GetLightningClient(cryptoCode, true);
|
||||
var data = await _lnHistogramService.GetHistogram(lightningClient, histType, cancellationToken);
|
||||
if (data == null) return this.CreateAPIError(404, "histogram-not-found", "The lightning histogram was not found.");
|
||||
|
||||
return Ok(new HistogramData
|
||||
{
|
||||
Type = Enum.Parse<HistogramType>(data.Type.ToString(), true),
|
||||
Balance = data.Balance,
|
||||
Series = data.Series,
|
||||
Labels = data.Labels
|
||||
});
|
||||
}
|
||||
|
||||
public virtual async Task<IActionResult> ConnectToNode(string cryptoCode, ConnectToNodeRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
|
|
@ -116,7 +116,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpGet("~/api/v1/stores/{storeId}/payment-methods/onchain/{cryptoCode}/histogram")]
|
||||
public async Task<IActionResult> GetOnChainWalletHistogram(string storeId, string cryptoCode, [FromQuery] string type)
|
||||
public async Task<IActionResult> GetOnChainWalletHistogram(string storeId, string cryptoCode, [FromQuery] string? type = null)
|
||||
{
|
||||
if (IsInvalidWalletRequest(cryptoCode, out _, out _, out var actionResult))
|
||||
return actionResult;
|
||||
|
@ -124,8 +124,15 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
var walletId = new WalletId(storeId, cryptoCode);
|
||||
Enum.TryParse<WalletHistogramType>(type, true, out var histType);
|
||||
var data = await _walletHistogramService.GetHistogram(Store, walletId, histType);
|
||||
if (data == null) return this.CreateAPIError(404, "histogram-not-found", "The wallet histogram was not found.");
|
||||
|
||||
return Ok(data);
|
||||
return Ok(new HistogramData
|
||||
{
|
||||
Type = Enum.Parse<HistogramType>(data.Type.ToString(), true),
|
||||
Balance = data.Balance,
|
||||
Series = data.Series,
|
||||
Labels = data.Labels
|
||||
});
|
||||
}
|
||||
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
|
|
|
@ -13,7 +13,6 @@ using BTCPayServer.Client;
|
|||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.GreenField;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Security.Greenfield;
|
||||
using BTCPayServer.Services.Mails;
|
||||
using BTCPayServer.Services.Stores;
|
||||
|
@ -21,7 +20,6 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NBitcoin;
|
||||
|
@ -385,6 +383,13 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
await GetController<GreenfieldStoreLightningNodeApiController>().GetBalance(cryptoCode, token));
|
||||
}
|
||||
|
||||
public override async Task<HistogramData> GetLightningNodeHistogram(string storeId, string cryptoCode, HistogramType? type = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<HistogramData>(
|
||||
await GetController<GreenfieldStoreLightningNodeApiController>().GetHistogram(cryptoCode, type, token));
|
||||
}
|
||||
|
||||
public override async Task ConnectToLightningNode(string storeId, string cryptoCode,
|
||||
ConnectToNodeRequest request, CancellationToken token = default)
|
||||
{
|
||||
|
@ -461,6 +466,13 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
await GetController<GreenfieldInternalLightningNodeApiController>().GetBalance(cryptoCode));
|
||||
}
|
||||
|
||||
public override async Task<HistogramData> GetLightningNodeHistogram(string cryptoCode, HistogramType? type = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<HistogramData>(
|
||||
await GetController<GreenfieldInternalLightningNodeApiController>().GetHistogram(cryptoCode, type, token));
|
||||
}
|
||||
|
||||
public override async Task ConnectToLightningNode(string cryptoCode, ConnectToNodeRequest request,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
|
@ -703,10 +715,10 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
await GetController<GreenfieldStoreOnChainWalletsController>().ShowOnChainWalletOverview(storeId, cryptoCode));
|
||||
}
|
||||
|
||||
public override async Task<HistogramData> GetOnChainWalletHistogram(string storeId, string cryptoCode, string type, CancellationToken token = default)
|
||||
public override async Task<HistogramData> GetOnChainWalletHistogram(string storeId, string cryptoCode, HistogramType? type = null, CancellationToken token = default)
|
||||
{
|
||||
return GetFromActionResult<HistogramData>(
|
||||
await GetController<GreenfieldStoreOnChainWalletsController>().GetOnChainWalletHistogram(storeId, cryptoCode, type));
|
||||
await GetController<GreenfieldStoreOnChainWalletsController>().GetOnChainWalletHistogram(storeId, cryptoCode, type?.ToString()));
|
||||
}
|
||||
|
||||
public override async Task<OnChainWalletAddressData> GetOnChainWalletReceiveAddress(string storeId,
|
||||
|
|
|
@ -46,6 +46,7 @@ using NBXplorer.Models;
|
|||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
using WalletHistogramType = BTCPayServer.Services.Wallets.WalletHistogramType;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
|
@ -313,10 +314,6 @@ namespace BTCPayServer.Controllers
|
|||
var store = GetCurrentStore();
|
||||
var data = await _walletHistogramService.GetHistogram(store, walletId, type);
|
||||
if (data == null) return NotFound();
|
||||
|
||||
const int labelCount = 6;
|
||||
var pointCount = data.Series.Count;
|
||||
var labelEvery = pointCount / labelCount;
|
||||
|
||||
return Json(data);
|
||||
}
|
||||
|
|
|
@ -182,6 +182,7 @@ namespace BTCPayServer.Hosting
|
|||
services.TryAddSingleton<UserService>();
|
||||
services.TryAddSingleton<UriResolver>();
|
||||
services.TryAddSingleton<WalletHistogramService>();
|
||||
services.TryAddSingleton<LightningHistogramService>();
|
||||
services.AddSingleton<ApplicationDbContextFactory>();
|
||||
services.AddOptions<BTCPayServerOptions>().Configure(
|
||||
(options) =>
|
||||
|
|
88
BTCPayServer/Services/LightningHistogramService.cs
Normal file
88
BTCPayServer/Services/LightningHistogramService.cs
Normal file
|
@ -0,0 +1,88 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
|
||||
namespace BTCPayServer.Services;
|
||||
|
||||
public class LightningHistogramService
|
||||
{
|
||||
public async Task<WalletHistogramData> GetHistogram(ILightningClient lightningClient, WalletHistogramType type, CancellationToken cancellationToken)
|
||||
{
|
||||
var (days, pointCount) = type switch
|
||||
{
|
||||
WalletHistogramType.Week => (7, 30),
|
||||
WalletHistogramType.Month => (30, 30),
|
||||
WalletHistogramType.Year => (365, 30),
|
||||
_ => throw new ArgumentException($"WalletHistogramType {type} does not exist.")
|
||||
};
|
||||
var to = DateTimeOffset.UtcNow;
|
||||
var from = to - TimeSpan.FromDays(days);
|
||||
var ticks = (to - from).Ticks;
|
||||
var interval = TimeSpan.FromTicks(ticks / pointCount);
|
||||
|
||||
try
|
||||
{
|
||||
// general balance
|
||||
var lnBalance = await lightningClient.GetBalance(cancellationToken);
|
||||
/*var totalOnchain = lnBalance.OnchainBalance != null
|
||||
? (lnBalance.OnchainBalance.Confirmed ?? 0L) + (lnBalance.OnchainBalance.Reserved ?? 0L) +
|
||||
(lnBalance.OnchainBalance.Unconfirmed ?? 0L)
|
||||
: new Money(0L);
|
||||
var totalOffchain = lnBalance.OffchainBalance != null
|
||||
? (lnBalance.OffchainBalance.Opening ?? 0) + (lnBalance.OffchainBalance.Local ?? 0) +
|
||||
(lnBalance.OffchainBalance.Closing ?? 0)
|
||||
: null;*/
|
||||
var total = lnBalance.OffchainBalance.Local;//(totalOnchain + new Money(totalOffchain?.ToDecimal(LightMoneyUnit.Satoshi) ?? 0, MoneyUnit.Satoshi)).ToDecimal(MoneyUnit.Satoshi);
|
||||
var totalBtc = total.ToDecimal(LightMoneyUnit.BTC);
|
||||
// prepare transaction data
|
||||
var lnInvoices = await lightningClient.ListInvoices(cancellationToken);
|
||||
var lnPayments = await lightningClient.ListPayments(cancellationToken);
|
||||
var lnTransactions = lnInvoices
|
||||
.Where(inv => inv.Status == LightningInvoiceStatus.Paid && inv.PaidAt >= from)
|
||||
.Select(inv => new LnTx { Amount = inv.Amount.ToDecimal(LightMoneyUnit.BTC), Settled = inv.PaidAt.GetValueOrDefault() })
|
||||
.Concat(lnPayments
|
||||
.Where(pay => pay.Status == LightningPaymentStatus.Complete && pay.CreatedAt >= from)
|
||||
.Select(pay => new LnTx { Amount = pay.Amount.ToDecimal(LightMoneyUnit.BTC) * -1, Settled = pay.CreatedAt.GetValueOrDefault() }))
|
||||
.OrderByDescending(tx => tx.Settled)
|
||||
.ToList();
|
||||
// assemble graph data going backwards
|
||||
var series = new List<decimal>(pointCount);
|
||||
var labels = new List<DateTimeOffset>(pointCount);
|
||||
var balance = totalBtc;
|
||||
for (var i = pointCount; i > 0; i--)
|
||||
{
|
||||
var txs = lnTransactions.Where(t =>
|
||||
t.Settled.Ticks >= from.Ticks + interval.Ticks * i &&
|
||||
t.Settled.Ticks < from.Ticks + interval.Ticks * (i + 1));
|
||||
var sum = txs.Sum(tx => tx.Amount);
|
||||
balance -= sum;
|
||||
series.Add(balance);
|
||||
labels.Add(from + interval * i);
|
||||
}
|
||||
// reverse the lists
|
||||
series.Reverse();
|
||||
labels.Reverse();
|
||||
return new WalletHistogramData
|
||||
{
|
||||
Type = type,
|
||||
Balance = totalBtc,
|
||||
Series = series,
|
||||
Labels = labels
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private class LnTx
|
||||
{
|
||||
public DateTimeOffset Settled { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.JsonConverters;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using Dapper;
|
||||
|
|
Loading…
Add table
Reference in a new issue