mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
@@ -46,6 +46,7 @@ namespace BTCPayServer.Client.Models
|
||||
public double? PaymentTolerance { get; set; }
|
||||
public bool? AnyoneCanCreateInvoice { get; set; }
|
||||
public string DefaultCurrency { get; set; }
|
||||
public List<string> AdditionalTrackedRates { get; set; }
|
||||
|
||||
public bool? LightningAmountInSatoshi { get; set; }
|
||||
public bool? LightningPrivateRouteHints { get; set; }
|
||||
|
||||
46
BTCPayServer.Tests/CSVInvoicesTester.cs
Normal file
46
BTCPayServer.Tests/CSVInvoicesTester.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace BTCPayServer.Tests;
|
||||
|
||||
internal class CSVInvoicesTester(string text) : CSVTester(text)
|
||||
{
|
||||
string invoice = "";
|
||||
int payment = 0;
|
||||
|
||||
public CSVInvoicesTester ForInvoice(string invoice)
|
||||
{
|
||||
this.payment = 0;
|
||||
this.invoice = invoice;
|
||||
return this;
|
||||
}
|
||||
public CSVInvoicesTester SelectPayment(int payment)
|
||||
{
|
||||
this.payment = payment;
|
||||
return this;
|
||||
}
|
||||
public CSVInvoicesTester AssertCount(int count)
|
||||
{
|
||||
Assert.Equal(count, _lines
|
||||
.Count(l => l[_indexes["InvoiceId"]] == invoice));
|
||||
return this;
|
||||
}
|
||||
|
||||
public CSVInvoicesTester AssertValues(params (string, string)[] values)
|
||||
{
|
||||
var payments = _lines
|
||||
.Where(l => l[_indexes["InvoiceId"]] == invoice)
|
||||
.ToArray();
|
||||
var line = payments[payment];
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
Assert.Equal(value, line[_indexes[key]]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public string GetPaymentId() => _lines
|
||||
.Where(l => l[_indexes["InvoiceId"]] == invoice)
|
||||
.Select(l => l[_indexes["PaymentId"]])
|
||||
.FirstOrDefault();
|
||||
}
|
||||
18
BTCPayServer.Tests/CSVTester.cs
Normal file
18
BTCPayServer.Tests/CSVTester.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace BTCPayServer.Tests;
|
||||
|
||||
public class CSVTester
|
||||
{
|
||||
protected readonly Dictionary<string, int> _indexes;
|
||||
protected readonly List<string[]> _lines;
|
||||
|
||||
public CSVTester(string text)
|
||||
{
|
||||
var lines = text.Split("\r\n").ToList();
|
||||
var headers = lines[0].Split(',');
|
||||
_indexes = headers.Select((h,i) => (h,i)).ToDictionary(h => h.h, h => h.i);
|
||||
_lines = lines.Skip(1).ToList().Select(l => l.Split(',')).ToList();
|
||||
}
|
||||
}
|
||||
@@ -220,16 +220,10 @@ fruit tea:
|
||||
}
|
||||
|
||||
await s.GoToInvoices(s.StoreId);
|
||||
await s.Page.ClickAsync("#view-report");
|
||||
await s.ClickViewReport();
|
||||
|
||||
await s.Page.WaitForSelectorAsync($"xpath=//*[text()=\"{freeInvoiceId}\"]");
|
||||
|
||||
var download = await s.Page.RunAndWaitForDownloadAsync(async () =>
|
||||
{
|
||||
await s.ClickPagePrimary();
|
||||
});
|
||||
var csvTxt = await new StreamReader(await download.CreateReadStreamAsync()).ReadToEndAsync();
|
||||
var csvTester = CSVTester.ParseCSV(csvTxt);
|
||||
var csvTxt = await s.DownloadReportCSV();
|
||||
var csvTester = new CSVInvoicesTester(csvTxt);
|
||||
csvTester
|
||||
.ForInvoice(posInvoiceId)
|
||||
.AssertCount(2)
|
||||
@@ -275,61 +269,13 @@ fruit tea:
|
||||
("PaymentCurrency", "BTC"),
|
||||
("PaymentAmount", "0.40000000"),
|
||||
("PaymentInvoiceAmount", "2000.00"),
|
||||
("Rate", "5000"))
|
||||
("PaymentRate", "5000"))
|
||||
.SelectPayment(1)
|
||||
.AssertValues(
|
||||
("InvoiceStatus", ""),
|
||||
("PaymentCurrency", "BTC"),
|
||||
("PaymentAmount", "0.60000000"),
|
||||
("Rate", "5000"));
|
||||
}
|
||||
|
||||
class CSVTester
|
||||
{
|
||||
public static CSVTester ParseCSV(string csvText) => new(csvText);
|
||||
private readonly Dictionary<string, int> _indexes;
|
||||
private string invoice = "";
|
||||
private int payment = 0;
|
||||
private readonly List<string[]> _lines;
|
||||
|
||||
public CSVTester(string text)
|
||||
{
|
||||
var lines = text.Split("\r\n").ToList();
|
||||
var headers = lines[0].Split(',');
|
||||
_indexes = headers.Select((h,i) => (h,i)).ToDictionary(h => h.h, h => h.i);
|
||||
_lines = lines.Skip(1).ToList().Select(l => l.Split(',')).ToList();
|
||||
}
|
||||
|
||||
public CSVTester ForInvoice(string invoice)
|
||||
{
|
||||
this.payment = 0;
|
||||
this.invoice = invoice;
|
||||
return this;
|
||||
}
|
||||
public CSVTester SelectPayment(int payment)
|
||||
{
|
||||
this.payment = payment;
|
||||
return this;
|
||||
}
|
||||
public CSVTester AssertCount(int count)
|
||||
{
|
||||
Assert.Equal(count, _lines
|
||||
.Count(l => l[_indexes["InvoiceId"]] == invoice));
|
||||
return this;
|
||||
}
|
||||
|
||||
public CSVTester AssertValues(params (string, string)[] values)
|
||||
{
|
||||
var payments = _lines
|
||||
.Where(l => l[_indexes["InvoiceId"]] == invoice)
|
||||
.ToArray();
|
||||
var line = payments[payment];
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
Assert.Equal(value, line[_indexes[key]]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
("PaymentRate", "5000"));
|
||||
}
|
||||
|
||||
[Fact(Timeout = LongRunningTestTimeout)]
|
||||
|
||||
@@ -375,6 +375,14 @@ namespace BTCPayServer.Tests
|
||||
public async Task AddDerivationScheme(string cryptoCode = "BTC",
|
||||
string derivationScheme = "tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]")
|
||||
{
|
||||
if (cryptoCode != "BTC" && derivationScheme ==
|
||||
"tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu-[legacy]")
|
||||
{
|
||||
derivationScheme = new BitcoinExtPubKey("tpubD6NzVbkrYhZ4XxNXjYTcRujMc8z8734diCthtFGgDMimbG5hUsKBuSTCuUyxWL7YwP7R4A5StMTRQiZnb6vE4pdHWPgy9hbiHuVJfBMumUu", Network.RegTest)
|
||||
.ToNetwork(NBitcoin.Altcoins.Litecoin.Instance.Regtest)
|
||||
.ToString()! + "-[legacy]";
|
||||
}
|
||||
|
||||
if (!(await Page.ContentAsync()).Contains($"Setup {cryptoCode} Wallet"))
|
||||
await GoToWalletSettings(cryptoCode);
|
||||
|
||||
@@ -651,6 +659,12 @@ namespace BTCPayServer.Tests
|
||||
public Task BumpFee(uint256? txId = null) => Page.ClickAsync($"{TxRowSelector(txId)} .bumpFee-btn");
|
||||
static string TxRowSelector(uint256? txId = null) => txId is null ? ".transaction-row:first-of-type" : $".transaction-row[data-value=\"{txId}\"]";
|
||||
|
||||
public async Task AssertRowContains(uint256 txId, string expected)
|
||||
{
|
||||
var text = await Page.InnerTextAsync(TxRowSelector(txId));
|
||||
Assert.Contains(expected.NormalizeWhitespaces(), text.NormalizeWhitespaces());
|
||||
}
|
||||
|
||||
public Task AssertHasLabels(string label) => AssertHasLabels(null, label);
|
||||
public async Task AssertHasLabels(uint256? txId, string label)
|
||||
{
|
||||
@@ -733,5 +747,20 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
public async Task Broadcast() => await page.ClickAsync("#BroadcastTransaction");
|
||||
}
|
||||
|
||||
public async Task ClickViewReport()
|
||||
{
|
||||
await Page.ClickAsync("#view-report");
|
||||
await Page.WaitForSelectorAsync("#raw-data-table");
|
||||
}
|
||||
|
||||
public async Task<string> DownloadReportCSV()
|
||||
{
|
||||
var download = await Page.RunAndWaitForDownloadAsync(async () =>
|
||||
{
|
||||
await ClickPagePrimary();
|
||||
});
|
||||
return await new StreamReader(await download.CreateReadStreamAsync()).ReadToEndAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
@@ -15,11 +16,15 @@ using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Server;
|
||||
using BTCPayServer.Views.Stores;
|
||||
using BTCPayServer.Views.Wallets;
|
||||
using Dapper;
|
||||
using ExchangeSharp;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Playwright;
|
||||
using static Microsoft.Playwright.Assertions;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Payment;
|
||||
using NBXplorer;
|
||||
using NBXplorer.Models;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
@@ -592,6 +597,185 @@ namespace BTCPayServer.Tests
|
||||
Assert.Equal(TimeSpan.FromMinutes(15), newStore.InvoiceExpiration);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Altcoins", "Altcoins")]
|
||||
public async Task CanExposeRates()
|
||||
{
|
||||
await using var s = CreatePlaywrightTester();
|
||||
s.Server.ActivateLTC();
|
||||
await s.StartAsync();
|
||||
await s.RegisterNewUser(true);
|
||||
await s.CreateNewStore();
|
||||
|
||||
await s.AddDerivationScheme("BTC", new ExtKey().Neuter().GetWif(Network.RegTest).ToString() + "-[legacy]");
|
||||
await s.AddDerivationScheme("LTC", new ExtKey().Neuter().GetWif(NBitcoin.Altcoins.Litecoin.Instance.Regtest).ToString() + "-[legacy]");
|
||||
|
||||
await s.GoToStore();
|
||||
await s.Page.FillAsync("[name='DefaultCurrency']", "USD");
|
||||
await s.Page.FillAsync("[name='AdditionalTrackedRates']", "CAD,JPY,EUR");
|
||||
await s.ClickPagePrimary();
|
||||
|
||||
await s.GoToStore(StoreNavPages.Rates);
|
||||
await s.Page.ClickAsync($"#PrimarySource_ShowScripting_submit");
|
||||
await s.FindAlertMessage();
|
||||
|
||||
// BTC can solves USD,EUR,CAD
|
||||
// LTC can solves and JPY and USD
|
||||
await s.Page.FillAsync("[name='PrimarySource.Script']",
|
||||
"""
|
||||
BTC_JPY = bitflyer(BTC_JPY);
|
||||
|
||||
BTC_USD = coingecko(BTC_USD);
|
||||
BTC_EUR = coingecko(BTC_EUR);
|
||||
BTC_CAD = coingecko(BTC_CAD);
|
||||
LTC_BTC = coingecko(LTC_BTC);
|
||||
|
||||
LTC_USD = coingecko(LTC_USD);
|
||||
LTC_JPY = LTC_BTC * BTC_JPY;
|
||||
""");
|
||||
await s.ClickPagePrimary();
|
||||
var expectedSolvablePairs = new[]
|
||||
{
|
||||
(Crypto: "BTC", Currency: "JPY"),
|
||||
(Crypto: "BTC", Currency: "USD"),
|
||||
(Crypto: "BTC", Currency: "CAD"),
|
||||
(Crypto: "BTC", Currency: "EUR"),
|
||||
(Crypto: "LTC", Currency: "JPY"),
|
||||
(Crypto: "LTC", Currency: "USD"),
|
||||
};
|
||||
var expectedUnsolvablePairs = new[]
|
||||
{
|
||||
(Crypto: "LTC", Currency: "CAD"),
|
||||
(Crypto: "LTC", Currency: "EUR"),
|
||||
};
|
||||
|
||||
Dictionary<string, uint256> txIds = new();
|
||||
foreach (var cryptoCode in new[] { "BTC", "LTC" })
|
||||
{
|
||||
await s.Server.GetExplorerNode(cryptoCode).EnsureGenerateAsync(1);
|
||||
await s.GoToWallet(new(s.StoreId, cryptoCode), WalletsNavPages.Receive);
|
||||
var address = await s.Page.GetAttributeAsync("#Address", "data-text");
|
||||
var network = s.Server.GetNetwork(cryptoCode);
|
||||
|
||||
var txId = uint256.Zero;
|
||||
await s.Server.WaitForEvent<NewOnChainTransactionEvent>(async () =>
|
||||
{
|
||||
txId = await s.Server.GetExplorerNode(cryptoCode)
|
||||
.SendToAddressAsync(BitcoinAddress.Create(address!, network.NBitcoinNetwork), Money.Coins(1));
|
||||
});
|
||||
txIds.Add(cryptoCode, txId);
|
||||
// The rates are fetched asynchronously... let's wait it's done.
|
||||
await Task.Delay(500);
|
||||
var pmo = await s.GoToWalletTransactions(new(s.StoreId, cryptoCode));
|
||||
await pmo.WaitTransactionsLoaded();
|
||||
if (cryptoCode == "BTC")
|
||||
{
|
||||
await pmo.AssertRowContains(txId, "4,500.00 CAD");
|
||||
await pmo.AssertRowContains(txId, "700,000 JPY");
|
||||
await pmo.AssertRowContains(txId, "4 000,00 EUR");
|
||||
await pmo.AssertRowContains(txId, "5,000.00 USD");
|
||||
}
|
||||
else if (cryptoCode == "LTC")
|
||||
{
|
||||
await pmo.AssertRowContains(txId, "4,321 JPY");
|
||||
await pmo.AssertRowContains(txId, "500.00 USD");
|
||||
}
|
||||
}
|
||||
await s.GoToWallet(new(s.StoreId, "BTC"), WalletsNavPages.Transactions);
|
||||
await s.ClickViewReport();
|
||||
|
||||
var csvTxt = await s.DownloadReportCSV();
|
||||
var csvTester = new CSVWalletsTester(csvTxt);
|
||||
|
||||
foreach (var cryptoCode in new[] { "BTC", "LTC" })
|
||||
{
|
||||
if (cryptoCode == "BTC")
|
||||
{
|
||||
csvTester
|
||||
.ForTxId(txIds[cryptoCode].ToString())
|
||||
.AssertValues(
|
||||
("Rate (USD)", "5000"),
|
||||
("Rate (CAD)", "4500"),
|
||||
("Rate (JPY)", "700000"),
|
||||
("Rate (EUR)", "4000")
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
csvTester
|
||||
.ForTxId(txIds[cryptoCode].ToString())
|
||||
.AssertValues(
|
||||
("Rate (USD)", "500"),
|
||||
("Rate (CAD)", ""),
|
||||
("Rate (JPY)", "4320.9876543209875"),
|
||||
("Rate (EUR)", "")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var invId = await s.CreateInvoice(storeId: s.StoreId, amount: 10_000);
|
||||
await s.GoToInvoiceCheckout(invId);
|
||||
await s.PayInvoice();
|
||||
await s.GoToInvoices(s.StoreId);
|
||||
await s.ClickViewReport();
|
||||
|
||||
await s.Page.ReloadAsync();
|
||||
csvTxt = await s.DownloadReportCSV();
|
||||
var csvInvTester = new CSVInvoicesTester(csvTxt);
|
||||
csvInvTester
|
||||
.ForInvoice(invId)
|
||||
.AssertValues(
|
||||
("Rate (BTC_CAD)", "4500"),
|
||||
("Rate (BTC_JPY)", "700000"),
|
||||
("Rate (BTC_EUR)", "4000"),
|
||||
("Rate (BTC_USD)", "5000"),
|
||||
("Rate (LTC_USD)", "500"),
|
||||
("Rate (LTC_JPY)", "4320.9876543209875"),
|
||||
("Rate (LTC_CAD)", ""),
|
||||
("Rate (LTC_EUR)", "")
|
||||
);
|
||||
|
||||
var txId2 = new uint256(csvInvTester.GetPaymentId().Split("-")[0]);
|
||||
var pmo2 = await s.GoToWalletTransactions(new(s.StoreId, "BTC"));
|
||||
await pmo2.WaitTransactionsLoaded();
|
||||
await pmo2.AssertRowContains(txId2, "5,000.00 USD");
|
||||
|
||||
// When removing the wallet rates, we should still have the rates from the invoice
|
||||
var ctx = s.Server.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
|
||||
Assert.Equal(1, await ctx.Database
|
||||
.GetDbConnection()
|
||||
.ExecuteAsync("""
|
||||
UPDATE "WalletObjects" SET "Data"='{}'::JSONB WHERE "Id"=@txId
|
||||
""", new{ txId = txId2.ToString() }));
|
||||
|
||||
pmo2 = await s.GoToWalletTransactions(new(s.StoreId, "BTC"));
|
||||
await pmo2.WaitTransactionsLoaded();
|
||||
await pmo2.AssertRowContains(txId2, "5,000.00 USD");
|
||||
}
|
||||
|
||||
class CSVWalletsTester(string text) : CSVTester(text)
|
||||
{
|
||||
string txId = "";
|
||||
|
||||
public CSVWalletsTester ForTxId(string txId)
|
||||
{
|
||||
this.txId = txId;
|
||||
return this;
|
||||
}
|
||||
|
||||
public CSVWalletsTester AssertValues(params (string, string)[] values)
|
||||
{
|
||||
var line = _lines
|
||||
.First(l => l[_indexes["TransactionId"]] == txId);
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
Assert.Equal(value, line[_indexes[key]]);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task CanManageWallet()
|
||||
{
|
||||
|
||||
@@ -275,5 +275,16 @@ namespace BTCPayServer.Tests
|
||||
PayTester.Dispose();
|
||||
TestLogs.LogInformation("BTCPayTester disposed");
|
||||
}
|
||||
|
||||
public RPCClient GetExplorerNode(string cryptoCode) =>
|
||||
cryptoCode == "BTC" ? ExplorerNode :
|
||||
cryptoCode == "LTC" ? LTCExplorerNode :
|
||||
throw new NotSupportedException();
|
||||
|
||||
public BTCPayNetwork GetNetwork(string cryptoCode)
|
||||
=> cryptoCode == "BTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("BTC") :
|
||||
cryptoCode == "LTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LTC") :
|
||||
cryptoCode == "LBTC" ? NetworkProvider.GetNetwork<BTCPayNetwork>("LBTC") :
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ namespace BTCPayServer.Controllers.GreenField
|
||||
var result = new List<StoreRateResult>();
|
||||
foreach (var rateTask in rateTasks)
|
||||
{
|
||||
var rateTaskResult = rateTask.Value.Result;
|
||||
var rateTaskResult = await rateTask.Value;
|
||||
|
||||
result.Add(new StoreRateResult()
|
||||
{
|
||||
|
||||
@@ -208,6 +208,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
//we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572)
|
||||
NetworkFeeMode = storeBlob.NetworkFeeMode,
|
||||
DefaultCurrency = storeBlob.DefaultCurrency,
|
||||
AdditionalTrackedRates = (storeBlob.AdditionalTrackedRates ?? []).ToList(),
|
||||
Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null),
|
||||
LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi,
|
||||
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints,
|
||||
@@ -259,6 +260,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
//we do not include OnChainMinValue and LightningMaxValue because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572)
|
||||
blob.NetworkFeeMode = restModel.NetworkFeeMode.Value;
|
||||
blob.DefaultCurrency = restModel.DefaultCurrency;
|
||||
blob.AdditionalTrackedRates = restModel.AdditionalTrackedRates?.ToArray();
|
||||
blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null);
|
||||
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi.Value;
|
||||
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints.Value;
|
||||
|
||||
@@ -40,6 +40,7 @@ public partial class UIStoresController
|
||||
PaymentTolerance = storeBlob.PaymentTolerance,
|
||||
InvoiceExpiration = (int)storeBlob.InvoiceExpiration.TotalMinutes,
|
||||
DefaultCurrency = storeBlob.DefaultCurrency,
|
||||
AdditionalTrackedRates = string.Join(',', storeBlob.AdditionalTrackedRates?.ToArray() ?? []),
|
||||
BOLT11Expiration = (long)storeBlob.RefundBOLT11Expiration.TotalDays,
|
||||
Archived = store.Archived,
|
||||
MonitoringExpiration = (int)storeBlob.MonitoringExpiration.TotalMinutes,
|
||||
@@ -81,7 +82,8 @@ public partial class UIStoresController
|
||||
blob.AnyoneCanInvoice = model.AnyoneCanCreateInvoice;
|
||||
blob.NetworkFeeMode = model.NetworkFeeMode;
|
||||
blob.PaymentTolerance = model.PaymentTolerance;
|
||||
blob.DefaultCurrency = model.DefaultCurrency;
|
||||
blob.DefaultCurrency = model.DefaultCurrency.ToUpperInvariant().Trim();
|
||||
blob.AdditionalTrackedRates = model.AdditionalTrackedRates?.Split(',', StringSplitOptions.RemoveEmptyEntries);
|
||||
blob.ShowRecommendedFee = model.ShowRecommendedFee;
|
||||
blob.RecommendedFeeBlockTarget = model.RecommendedFeeBlockTarget;
|
||||
blob.InvoiceExpiration = TimeSpan.FromMinutes(model.InvoiceExpiration);
|
||||
@@ -174,7 +176,7 @@ public partial class UIStoresController
|
||||
storeId = CurrentStore.Id
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{storeId}/archive")]
|
||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> ToggleArchive(string storeId)
|
||||
@@ -207,7 +209,7 @@ public partial class UIStoresController
|
||||
TempData[WellKnownTempData.SuccessMessage] = "Store successfully deleted.";
|
||||
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("{storeId}/checkout")]
|
||||
public async Task<IActionResult> CheckoutAppearance()
|
||||
{
|
||||
@@ -281,7 +283,7 @@ public partial class UIStoresController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var userId = GetUserId();
|
||||
if (userId is null)
|
||||
return NotFound();
|
||||
|
||||
@@ -76,6 +76,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly DefaultRulesCollection _defaultRules;
|
||||
private readonly Dictionary<PaymentMethodId, ICheckoutModelExtension> _paymentModelExtensions;
|
||||
private readonly TransactionLinkProviders _transactionLinkProviders;
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
||||
private readonly WalletHistogramService _walletHistogramService;
|
||||
|
||||
@@ -109,6 +110,7 @@ namespace BTCPayServer.Controllers
|
||||
Dictionary<PaymentMethodId, ICheckoutModelExtension> paymentModelExtensions,
|
||||
IStringLocalizer stringLocalizer,
|
||||
TransactionLinkProviders transactionLinkProviders,
|
||||
InvoiceRepository invoiceRepository,
|
||||
DisplayFormatter displayFormatter)
|
||||
{
|
||||
_pendingTransactionService = pendingTransactionService;
|
||||
@@ -118,6 +120,7 @@ namespace BTCPayServer.Controllers
|
||||
_handlers = handlers;
|
||||
_paymentModelExtensions = paymentModelExtensions;
|
||||
_transactionLinkProviders = transactionLinkProviders;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
Repository = repo;
|
||||
WalletRepository = walletRepository;
|
||||
RateFetcher = rateProvider;
|
||||
@@ -621,6 +624,7 @@ namespace BTCPayServer.Controllers
|
||||
var model = new ListTransactionsViewModel { Skip = skip, Count = count };
|
||||
|
||||
model.PendingTransactions = await _pendingTransactionService.GetPendingTransactions(walletId.CryptoCode, walletId.StoreId);
|
||||
model.Rates = GetCurrentStore().GetStoreBlob().GetTrackedRates().ToList();
|
||||
|
||||
model.Labels.AddRange(
|
||||
(await WalletRepository.GetWalletLabels(walletId))
|
||||
@@ -663,6 +667,8 @@ namespace BTCPayServer.Controllers
|
||||
var labels = _labelService.CreateTransactionTagModels(transactionInfo, Request);
|
||||
vm.Tags.AddRange(labels);
|
||||
vm.Comment = transactionInfo.Comment;
|
||||
vm.InvoiceId = transactionInfo.Attachments.FirstOrDefault(a => a.Type == WalletObjectData.Types.Invoice)?.Id;
|
||||
vm.WalletRateBook = transactionInfo.Rates;
|
||||
}
|
||||
|
||||
if (labelFilter == null ||
|
||||
@@ -670,6 +676,28 @@ namespace BTCPayServer.Controllers
|
||||
model.Transactions.Add(vm);
|
||||
}
|
||||
|
||||
var trackedCurrencies = GetCurrentStore().GetStoreBlob().GetTrackedRates();
|
||||
var rates = await _invoiceRepository.GetRatesOfInvoices(model.Transactions.Select(r => r.InvoiceId).Where(r => r is not null).ToHashSet());
|
||||
foreach (var vm in model.Transactions)
|
||||
{
|
||||
if (vm.InvoiceId is null)
|
||||
continue;
|
||||
rates.TryGetValue(vm.InvoiceId, out var book);
|
||||
vm.InvoiceRateBook = book;
|
||||
}
|
||||
|
||||
foreach (var vm in model.Transactions)
|
||||
{
|
||||
var book = vm.InvoiceRateBook ?? new();
|
||||
if (vm.WalletRateBook is not null)
|
||||
book.AddRates(vm.WalletRateBook);
|
||||
foreach (var trackedCurrency in trackedCurrencies)
|
||||
{
|
||||
var exists = book.TryGetRate(new CurrencyPair(network.CryptoCode, trackedCurrency), out var rate);
|
||||
vm.Rates.Add(exists ? _displayFormatter.Currency(rate, trackedCurrency) : null);
|
||||
}
|
||||
}
|
||||
|
||||
model.Total = preFiltering ? null : model.Transactions.Count;
|
||||
// if we couldn't filter at the db level, we need to apply skip and count
|
||||
if (!preFiltering)
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using BTCPayServer.Client.JsonConverters;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
@@ -53,9 +54,7 @@ namespace BTCPayServer.Data
|
||||
}
|
||||
set
|
||||
{
|
||||
_DefaultCurrency = value;
|
||||
if (!string.IsNullOrEmpty(_DefaultCurrency))
|
||||
_DefaultCurrency = _DefaultCurrency.Trim().ToUpperInvariant();
|
||||
_DefaultCurrency = NormalizeCurrency(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +284,32 @@ namespace BTCPayServer.Data
|
||||
(PrimaryRateSettings ?? new()).GetRateRules(defaultRules, Spread),
|
||||
FallbackRateSettings?.GetRateRules(defaultRules, Spread));
|
||||
}
|
||||
|
||||
public HashSet<string> GetTrackedRates() => AdditionalTrackedRates.Concat([DefaultCurrency]).ToHashSet();
|
||||
|
||||
private string[] _additionalTrackedRates;
|
||||
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.IgnoreAndPopulate)]
|
||||
public string[] AdditionalTrackedRates
|
||||
{
|
||||
get
|
||||
{
|
||||
return _additionalTrackedRates ?? Array.Empty<string>();
|
||||
}
|
||||
set
|
||||
{
|
||||
if (value is not null)
|
||||
_additionalTrackedRates = value
|
||||
.Select(NormalizeCurrency)
|
||||
.Where(v => v is not null).ToArray();
|
||||
else
|
||||
_additionalTrackedRates = null;
|
||||
}
|
||||
}
|
||||
|
||||
private string NormalizeCurrency(string v) =>
|
||||
v is null ? null :
|
||||
Regex.Replace(v.ToUpperInvariant(), "[^A-Z]", "").Trim() is { Length: > 0 } normalized ? normalized : null;
|
||||
}
|
||||
public class PaymentMethodCriteria
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Labels;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -19,7 +20,7 @@ namespace BTCPayServer.Data
|
||||
}
|
||||
[JsonIgnore]
|
||||
public WalletId WalletId { get; }
|
||||
public string Comment { get; set; } = string.Empty;
|
||||
public string? Comment { get; set; } = string.Empty;
|
||||
[JsonIgnore]
|
||||
public List<Attachment> Attachments { get; set; } = new List<Attachment>();
|
||||
|
||||
@@ -82,6 +83,8 @@ namespace BTCPayServer.Data
|
||||
}
|
||||
}
|
||||
|
||||
public RateBook? Rates { get; set; }
|
||||
|
||||
public WalletTransactionInfo Merge(WalletTransactionInfo? value)
|
||||
{
|
||||
var result = new WalletTransactionInfo(WalletId);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.HostedServices;
|
||||
|
||||
public class OnChainRateTrackerHostedService(
|
||||
EventAggregator eventAggregator,
|
||||
Logs logger,
|
||||
WalletRepository walletRepository,
|
||||
DefaultRulesCollection defaultRateRules,
|
||||
RateFetcher rateFetcher,
|
||||
StoreRepository storeRepository) : EventHostedServiceBase(eventAggregator, logger)
|
||||
{
|
||||
protected override void SubscribeToEvents()
|
||||
{
|
||||
Subscribe<NewOnChainTransactionEvent>();
|
||||
}
|
||||
|
||||
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||
{
|
||||
if (evt is NewOnChainTransactionEvent newOnChainTransactionEvent)
|
||||
await ProcessEventCore(newOnChainTransactionEvent, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task ProcessEventCore(NewOnChainTransactionEvent transactionEvent, CancellationToken cancellationToken)
|
||||
{
|
||||
var derivation = transactionEvent.NewTransactionEvent.DerivationStrategy;
|
||||
if (derivation is null)
|
||||
return;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
// Too late
|
||||
if ((transactionEvent.NewTransactionEvent.TransactionData.Timestamp - now).Duration() > TimeSpan.FromMinutes(10))
|
||||
return;
|
||||
var cryptoCode = transactionEvent.NewTransactionEvent.CryptoCode;
|
||||
|
||||
var stores = await storeRepository.GetStoresFromDerivation(transactionEvent.PaymentMethodId, derivation);
|
||||
foreach (var storeId in stores)
|
||||
{
|
||||
var store = await storeRepository.FindStore(storeId);
|
||||
if (store is null)
|
||||
continue;
|
||||
var blob = store.GetStoreBlob();
|
||||
var trackedCurrencies = blob.GetTrackedRates();
|
||||
var rules = blob.GetRateRules(defaultRateRules);
|
||||
var fetching = rateFetcher.FetchRates(
|
||||
trackedCurrencies
|
||||
.Select(t => new CurrencyPair(cryptoCode, t))
|
||||
.ToHashSet(), rules, new StoreIdRateContext(storeId), CancellationToken);
|
||||
JObject rates = new();
|
||||
foreach (var rate in fetching)
|
||||
{
|
||||
var result = await rate.Value;
|
||||
if (result.BidAsk is { } ba)
|
||||
rates.Add(rate.Key.Right, ba.Center.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
if (!rates.Properties().Any())
|
||||
continue;
|
||||
|
||||
var wid = new WalletId(storeId, cryptoCode);
|
||||
var txObject = new WalletObjectId(wid, WalletObjectData.Types.Tx, transactionEvent.NewTransactionEvent.TransactionData.TransactionHash.ToString());
|
||||
|
||||
await walletRepository.AddOrUpdateWalletObjectData(txObject, new WalletRepository.UpdateOperation.MergeObject(new JObject()
|
||||
{
|
||||
[ "rates" ] = rates,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,6 +435,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
|
||||
services.AddSingleton<IHostedService, AppHubStreamer>();
|
||||
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
|
||||
services.AddSingleton<IHostedService, TransactionLabelMarkerHostedService>();
|
||||
services.AddSingleton<IHostedService, OnChainRateTrackerHostedService>();
|
||||
services.AddSingleton<IHostedService, UserEventHostedService>();
|
||||
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
|
||||
services.AddSingleton<PaymentRequestStreamer>();
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
|
||||
[Display(Name = "Apply the brand color to the store's backend as well")]
|
||||
public bool ApplyBrandColorToBackend { get; set; }
|
||||
|
||||
|
||||
[Display(Name = "Logo")]
|
||||
public IFormFile LogoFile { get; set; }
|
||||
public string LogoUrl { get; set; }
|
||||
@@ -56,6 +56,10 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[MaxLength(10)]
|
||||
public string DefaultCurrency { get; set; }
|
||||
|
||||
[Display(Name = "Additional rates to track")]
|
||||
[MaxLength(30)]
|
||||
public string AdditionalTrackedRates { get; set; }
|
||||
|
||||
[Display(Name = "Minimum acceptable expiration time for BOLT11 for refunds")]
|
||||
[Range(0, 365 * 10)]
|
||||
public long BOLT11Expiration { get; set; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
|
||||
namespace BTCPayServer.Models.WalletViewModels
|
||||
{
|
||||
@@ -17,11 +18,17 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public bool Positive { get; set; }
|
||||
public string Balance { get; set; }
|
||||
public HashSet<TransactionTagModel> Tags { get; set; } = new();
|
||||
public string Rate { get; set; }
|
||||
public List<string> Rates { get; set; } = new();
|
||||
public RateBook WalletRateBook { get; set; }
|
||||
public RateBook InvoiceRateBook { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
}
|
||||
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new();
|
||||
public List<TransactionViewModel> Transactions { get; set; } = new();
|
||||
public override int CurrentPageCount => Transactions.Count;
|
||||
public string CryptoCode { get; set; }
|
||||
public PendingTransaction[] PendingTransactions { get; set; }
|
||||
public List<string> Rates { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,9 +308,13 @@ namespace BTCPayServer.Payments
|
||||
// We need to fetch the rates necessary for the evaluation of the payment method criteria
|
||||
var currency = Prompt.Currency;
|
||||
if (currency is not null)
|
||||
{
|
||||
RequiredRates.Add(currency);
|
||||
foreach (var r in StoreBlob.AdditionalTrackedRates ?? [])
|
||||
OptionalRates.Add(new CurrencyPair(currency, r));
|
||||
}
|
||||
if (currency is not null
|
||||
&& Status is PaymentMethodContext.ContextStatus.WaitingForCreation or PaymentMethodContext.ContextStatus.WaitingForActivation)
|
||||
&& Status is PaymentMethodContext.ContextStatus.WaitingForCreation or PaymentMethodContext.ContextStatus.WaitingForActivation)
|
||||
{
|
||||
foreach (var paymentMethodCriteria in StoreBlob.PaymentMethodCriteria
|
||||
.Where(c => c.Value?.Currency is not null && c.PaymentMethod == PaymentMethodId))
|
||||
|
||||
@@ -315,55 +315,19 @@ namespace BTCPayServer.Services.Invoices
|
||||
throw new InvalidOperationException("The Currency of the invoice isn't set");
|
||||
return GetRate(new CurrencyPair(currency, Currency));
|
||||
}
|
||||
public RateRules GetRateRules()
|
||||
{
|
||||
StringBuilder builder = new StringBuilder();
|
||||
|
||||
public RateRules GetRateRules() => GetInvoiceRates().GetRateRules();
|
||||
|
||||
public bool TryGetRate(string currency, out decimal rate) => GetInvoiceRates().TryGetRate(new(currency, Currency), out rate);
|
||||
|
||||
public bool TryGetRate(CurrencyPair pair, out decimal rate) => GetInvoiceRates().TryGetRate(pair, out rate);
|
||||
|
||||
public decimal GetRate(CurrencyPair pair) => GetInvoiceRates().GetRate(pair);
|
||||
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
foreach (var r in Rates)
|
||||
{
|
||||
if (r.Key.Contains('_', StringComparison.Ordinal))
|
||||
builder.AppendLine($"{r.Key} = {r.Value.ToString(CultureInfo.InvariantCulture)};");
|
||||
else
|
||||
builder.AppendLine($"{r.Key}_{Currency} = {r.Value.ToString(CultureInfo.InvariantCulture)};");
|
||||
}
|
||||
private RateBook GetInvoiceRates() => new RateBook(Currency, Rates);
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
if (RateRules.TryParse(builder.ToString(), out var rules))
|
||||
return rules;
|
||||
throw new FormatException("Invalid rate rules");
|
||||
}
|
||||
public bool TryGetRate(string currency, out decimal rate)
|
||||
{
|
||||
return TryGetRate(new CurrencyPair(currency, Currency), out rate);
|
||||
}
|
||||
public bool TryGetRate(CurrencyPair pair, out decimal rate)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
if (pair.Right == Currency && Rates.TryGetValue(pair.Left, out rate)) // Fast lane
|
||||
return true;
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
var rule = GetRateRules().GetRuleFor(pair);
|
||||
rule.Reevaluate();
|
||||
if (rule.BidAsk is null)
|
||||
{
|
||||
rate = 0.0m;
|
||||
return false;
|
||||
}
|
||||
rate = rule.BidAsk.Bid;
|
||||
return true;
|
||||
}
|
||||
public decimal GetRate(CurrencyPair pair)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pair);
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
if (pair.Right == Currency && Rates.TryGetValue(pair.Left, out var rate)) // Fast lane
|
||||
return rate;
|
||||
#pragma warning restore CS0618 // Type or member is obsolete
|
||||
var rule = GetRateRules().GetRuleFor(pair);
|
||||
rule.Reevaluate();
|
||||
if (rule.BidAsk is null)
|
||||
throw new InvalidOperationException($"Rate rule is not evaluated ({rule.Errors.First()})");
|
||||
return rule.BidAsk.Bid;
|
||||
}
|
||||
|
||||
public void AddRate(CurrencyPair pair, decimal rate)
|
||||
{
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
|
||||
@@ -58,7 +58,7 @@ namespace BTCPayServer.Services.Invoices
|
||||
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)),
|
||||
StoreId = storeId,
|
||||
Version = InvoiceEntity.Lastest_Version,
|
||||
// Truncating was an unintended side effect of previous code. Might want to remove that one day
|
||||
// Truncating was an unintended side effect of previous code. Might want to remove that one day
|
||||
InvoiceTime = DateTimeOffset.UtcNow.TruncateMilliSeconds(),
|
||||
Metadata = new InvoiceMetadata(),
|
||||
#pragma warning disable CS0618
|
||||
@@ -171,6 +171,27 @@ namespace BTCPayServer.Services.Invoices
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, RateBook>> GetRatesOfInvoices(HashSet<string> invoiceIds)
|
||||
{
|
||||
if (invoiceIds.Count == 0)
|
||||
return new();
|
||||
var res = new Dictionary<string, RateBook>();
|
||||
using var ctx = _applicationDbContextFactory.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
var result = await conn.QueryAsync<(string Id, string Rate, string Currency)>(
|
||||
"""
|
||||
SELECT "Id", "Blob2"->'rates' AS "Rate", "Currency" FROM unnest(@invoices) AS searched_invoices("Id")
|
||||
JOIN "Invoices" USING ("Id")
|
||||
WHERE "Blob2"->'rates' IS NOT NULL;
|
||||
""", new { invoices = invoiceIds.ToArray() });
|
||||
foreach (var inv in result)
|
||||
{
|
||||
var rates = RateBook.Parse(inv.Rate, inv.Currency);
|
||||
res.Add(inv.Id, rates);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public async Task<AppData[]> GetAppsTaggingStore(string storeId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storeId);
|
||||
@@ -910,7 +931,7 @@ retry:
|
||||
CurrencyValue = p.Select(v => v.CurrencyValue).Sum()
|
||||
});
|
||||
return new InvoiceStatistics(contributions)
|
||||
{
|
||||
{
|
||||
TotalSettled = totalSettledCurrency,
|
||||
TotalProcessing = totalProcessingCurrency,
|
||||
Total = totalSettledCurrency + totalProcessingCurrency
|
||||
|
||||
149
BTCPayServer/Services/Invoices/RateBook.cs
Normal file
149
BTCPayServer/Services/Invoices/RateBook.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Rating;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Services.Invoices;
|
||||
|
||||
public class RateBook
|
||||
{
|
||||
public static RateBook Parse(string rates, string defaultCurrency)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rates);
|
||||
ArgumentNullException.ThrowIfNull(defaultCurrency);
|
||||
if (rates == "")
|
||||
return new(defaultCurrency, new());
|
||||
var o = JObject.Parse(rates);
|
||||
var ratesDict = new Dictionary<string, decimal>();
|
||||
foreach (var property in o.Properties())
|
||||
{
|
||||
ratesDict.Add(property.Name, decimal.Parse(property.Value.ToString(), CultureInfo.InvariantCulture));
|
||||
}
|
||||
return new RateBook(defaultCurrency, ratesDict);
|
||||
}
|
||||
|
||||
public RateBook()
|
||||
{
|
||||
Rates = new();
|
||||
}
|
||||
public RateBook(
|
||||
string defaultCurrency,
|
||||
Dictionary<string, decimal> rates
|
||||
)
|
||||
{
|
||||
Rates = new(rates.Count);
|
||||
foreach (var rate in rates)
|
||||
{
|
||||
if (!rate.Key.Contains('_', StringComparison.Ordinal))
|
||||
Rates.Add(new CurrencyPair(rate.Key, defaultCurrency), rate.Value);
|
||||
else
|
||||
Rates.Add(CurrencyPair.Parse(rate.Key), rate.Value);
|
||||
}
|
||||
}
|
||||
public Dictionary<CurrencyPair, decimal> Rates { get; }
|
||||
|
||||
public decimal? TryGetRate(CurrencyPair pair)
|
||||
{
|
||||
if (GetFastLaneRate(pair, out var tryGetRate)) return tryGetRate;
|
||||
|
||||
var rule = GetRateRules().GetRuleFor(pair);
|
||||
rule.Reevaluate();
|
||||
if (rule.BidAsk is null)
|
||||
return null;
|
||||
return rule.BidAsk.Bid;
|
||||
}
|
||||
|
||||
private bool GetFastLaneRate(CurrencyPair pair, out decimal v)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pair);
|
||||
if (Rates.TryGetValue(pair, out var rate)) // Fast lane
|
||||
{
|
||||
v = rate;
|
||||
return true;
|
||||
}
|
||||
v = 0m;
|
||||
return false;
|
||||
}
|
||||
|
||||
public decimal GetRate(CurrencyPair pair)
|
||||
{
|
||||
if (GetFastLaneRate(pair, out var v)) return v;
|
||||
var rule = GetRateRules().GetRuleFor(pair);
|
||||
rule.Reevaluate();
|
||||
if (rule.BidAsk is null)
|
||||
throw new InvalidOperationException($"Rate rule is not evaluated ({rule.Errors.First()})");
|
||||
return rule.BidAsk.Bid;
|
||||
}
|
||||
|
||||
public bool TryGetRate(CurrencyPair pair, out decimal rate)
|
||||
{
|
||||
if (GetFastLaneRate(pair, out rate)) return true;
|
||||
var rule = GetRateRules().GetRuleFor(pair);
|
||||
rule.Reevaluate();
|
||||
if (rule.BidAsk is null)
|
||||
{
|
||||
rate = 0.0m;
|
||||
return false;
|
||||
}
|
||||
|
||||
rate = rule.BidAsk.Bid;
|
||||
return true;
|
||||
}
|
||||
|
||||
public RateRules GetRateRules()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var r in Rates)
|
||||
{
|
||||
builder.AppendLine($"{r.Key} = {r.Value.ToString(CultureInfo.InvariantCulture)};");
|
||||
}
|
||||
|
||||
if (RateRules.TryParse(builder.ToString(), out var rules))
|
||||
return rules;
|
||||
throw new FormatException("Invalid rate rules");
|
||||
}
|
||||
|
||||
public void AddRates(RateBook? otherBook)
|
||||
{
|
||||
if (otherBook is null)
|
||||
return;
|
||||
foreach (var rate in otherBook.Rates)
|
||||
{
|
||||
this.Rates.TryAdd(rate.Key, rate.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public static RateBook? FromTxWalletObject(WalletObjectData txObject)
|
||||
{
|
||||
var rates = txObject.GetData()?["rates"] as JObject;
|
||||
if (rates is null)
|
||||
return null;
|
||||
var cryptoCode = WalletId.Parse(txObject.WalletId).CryptoCode;
|
||||
return FromJObject(rates, cryptoCode);
|
||||
}
|
||||
|
||||
public static RateBook? FromJObject(JObject rates, string cryptoCode)
|
||||
{
|
||||
var result = new RateBook();
|
||||
foreach (var property in rates.Properties())
|
||||
{
|
||||
var rate = decimal.Parse(property.Value.ToString(), CultureInfo.InvariantCulture);
|
||||
result.Rates.TryAdd(new CurrencyPair(cryptoCode, property.Name), rate);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void AddCurrencies(HashSet<string> trackedCurrencies)
|
||||
{
|
||||
foreach (var r in Rates)
|
||||
{
|
||||
trackedCurrencies.Add(r.Key.Left);
|
||||
trackedCurrencies.Add(r.Key.Right);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,11 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting;
|
||||
@@ -26,14 +29,13 @@ public class InvoicesReportProvider : ReportProvider
|
||||
foreach (var field in viewDefinitionFields)
|
||||
_baseFields.Add(field.Name);
|
||||
}
|
||||
public bool HasField(string fieldName) => _dict.ContainsKey(fieldName);
|
||||
|
||||
public List<StoreReportResponse.Field> Fields { get; } = new();
|
||||
public Dictionary<string, object?> Values { get; } = new();
|
||||
|
||||
public void TryAdd(string fieldName, object? value)
|
||||
public void TryAdd(string fieldName, object? value, string? columnType = null)
|
||||
{
|
||||
var type = GeColumnType(value);
|
||||
var type = columnType ?? GetColumnType(value);
|
||||
if (type is null || _baseFields.Contains(fieldName))
|
||||
return;
|
||||
var field = new StoreReportResponse.Field(fieldName, type);
|
||||
@@ -47,7 +49,7 @@ public class InvoicesReportProvider : ReportProvider
|
||||
Values.TryAdd(fieldName, value);
|
||||
}
|
||||
|
||||
private string? GeColumnType(object? value)
|
||||
private string? GetColumnType(object? value)
|
||||
=> value switch
|
||||
{
|
||||
null => "text",
|
||||
@@ -69,13 +71,11 @@ public class InvoicesReportProvider : ReportProvider
|
||||
}
|
||||
|
||||
public HashSet<string> CartItems = new();
|
||||
public void HasCartItem(string itemId)
|
||||
{
|
||||
CartItems.Add(itemId);
|
||||
}
|
||||
public void HasCartItem(string itemId) => CartItems.Add(itemId);
|
||||
}
|
||||
|
||||
private readonly InvoiceRepository _invoiceRepository;
|
||||
private readonly StoreRepository _storeRepository;
|
||||
|
||||
|
||||
public override string Name { get; } = "Invoices";
|
||||
@@ -104,7 +104,7 @@ public class InvoicesReportProvider : ReportProvider
|
||||
|
||||
new("PaymentReceivedDate", "datetime"),
|
||||
new("PaymentId", "text"),
|
||||
new("Rate", "amount"),
|
||||
new("PaymentRate", "amount"),
|
||||
new("PaymentAddress", "text"),
|
||||
new("PaymentMethodId", "text"),
|
||||
new("PaymentCurrency", "text"),
|
||||
@@ -114,13 +114,28 @@ public class InvoicesReportProvider : ReportProvider
|
||||
}
|
||||
};
|
||||
|
||||
var trackedCurrencies = (await _storeRepository.FindStore(queryContext.StoreId))?.GetStoreBlob().GetTrackedRates().ToHashSet() ?? new();
|
||||
|
||||
var metadataFields = new MetadataFields(queryContext.ViewDefinition.Fields);
|
||||
foreach (var invoiceEntity in invoices)
|
||||
{
|
||||
var payments = invoiceEntity.GetPayments(true);
|
||||
|
||||
metadataFields.Values.Clear();
|
||||
|
||||
foreach (var currencyPair in
|
||||
(from p in invoiceEntity
|
||||
.GetPaymentPrompts()
|
||||
.Select(c => c.Currency)
|
||||
from c in trackedCurrencies.Concat([invoiceEntity.Currency])
|
||||
where p != c
|
||||
select new CurrencyPair(p, c)).Distinct())
|
||||
{
|
||||
if (!invoiceEntity.TryGetRate(currencyPair, out var rate))
|
||||
metadataFields.TryAdd($"Rate ({currencyPair})", null, "number");
|
||||
else
|
||||
metadataFields.TryAdd($"Rate ({currencyPair})", rate);
|
||||
}
|
||||
|
||||
var firstPayment = payments.FirstOrDefault();
|
||||
if (firstPayment is not null)
|
||||
{
|
||||
@@ -275,9 +290,10 @@ public class InvoicesReportProvider : ReportProvider
|
||||
}
|
||||
}
|
||||
|
||||
public InvoicesReportProvider(DisplayFormatter displayFormatter, InvoiceRepository invoiceRepository)
|
||||
public InvoicesReportProvider(DisplayFormatter displayFormatter, InvoiceRepository invoiceRepository, StoreRepository storeRepository)
|
||||
{
|
||||
DisplayFormatter = displayFormatter;
|
||||
_invoiceRepository = invoiceRepository;
|
||||
_storeRepository = storeRepository;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Dapper;
|
||||
@@ -12,42 +14,33 @@ using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting;
|
||||
|
||||
public class OnChainWalletReportProvider : ReportProvider
|
||||
public class OnChainWalletReportProvider(
|
||||
NBXplorerConnectionFactory nbxplorerConnectionFactory,
|
||||
StoreRepository storeRepository,
|
||||
InvoiceRepository invoiceRepository,
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
WalletRepository walletRepository)
|
||||
: ReportProvider
|
||||
{
|
||||
public OnChainWalletReportProvider(
|
||||
NBXplorerConnectionFactory NbxplorerConnectionFactory,
|
||||
StoreRepository storeRepository,
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
WalletRepository walletRepository)
|
||||
{
|
||||
this.NbxplorerConnectionFactory = NbxplorerConnectionFactory;
|
||||
StoreRepository = storeRepository;
|
||||
_handlers = handlers;
|
||||
WalletRepository = walletRepository;
|
||||
}
|
||||
|
||||
private NBXplorerConnectionFactory NbxplorerConnectionFactory { get; }
|
||||
private StoreRepository StoreRepository { get; }
|
||||
private PaymentMethodHandlerDictionary _handlers;
|
||||
private WalletRepository WalletRepository { get; }
|
||||
public override string Name => "Wallets";
|
||||
|
||||
ViewDefinition CreateViewDefinition()
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Fields =
|
||||
{
|
||||
new ("Date", "datetime"),
|
||||
new ("Crypto", "string"),
|
||||
new("Date", "datetime"),
|
||||
new("Crypto", "string"),
|
||||
// For proper rendering of explorer links, Crypto should always be before tx_id
|
||||
new ("TransactionId", "tx_id"),
|
||||
new ("InvoiceId", "invoice_id"),
|
||||
new ("Confirmed", "boolean"),
|
||||
new ("BalanceChange", "amount")
|
||||
new("TransactionId", "tx_id"),
|
||||
new("InvoiceId", "invoice_id"),
|
||||
new("Confirmed", "boolean"),
|
||||
new("BalanceChange", "amount"),
|
||||
},
|
||||
Charts =
|
||||
{
|
||||
new ()
|
||||
new()
|
||||
{
|
||||
Name = "Group by Crypto",
|
||||
Totals = { "Crypto" },
|
||||
@@ -58,37 +51,39 @@ public class OnChainWalletReportProvider : ReportProvider
|
||||
};
|
||||
}
|
||||
|
||||
public override bool IsAvailable()
|
||||
{
|
||||
return NbxplorerConnectionFactory.Available;
|
||||
}
|
||||
public override bool IsAvailable() => nbxplorerConnectionFactory.Available;
|
||||
|
||||
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||
{
|
||||
queryContext.ViewDefinition = CreateViewDefinition();
|
||||
await using var conn = await NbxplorerConnectionFactory.OpenConnection();
|
||||
var store = await StoreRepository.FindStore(queryContext.StoreId);
|
||||
await using var conn = await nbxplorerConnectionFactory.OpenConnection();
|
||||
var store = await storeRepository.FindStore(queryContext.StoreId);
|
||||
if (store is null)
|
||||
return;
|
||||
Dictionary<(string CryptoCode, string TxId), RateBook> walletBooks = new();
|
||||
HashSet<string> cryptoCodes = new();
|
||||
var interval = DateTimeOffset.UtcNow - queryContext.From;
|
||||
foreach (var (pmi, settings) in store.GetPaymentMethodConfigs<DerivationSchemeSettings>(_handlers))
|
||||
foreach (var (pmi, settings) in store.GetPaymentMethodConfigs<DerivationSchemeSettings>(handlers))
|
||||
{
|
||||
var network = ((IHasNetwork)_handlers[pmi]).Network;
|
||||
var network = ((IHasNetwork)handlers[pmi]).Network;
|
||||
cryptoCodes.Add(network.CryptoCode);
|
||||
var walletId = new WalletId(store.Id, network.CryptoCode);
|
||||
var command = new CommandDefinition(
|
||||
commandText:
|
||||
"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change " +
|
||||
"FROM get_wallets_recent(@wallet_id, @code, @asset_id, @interval, NULL, NULL) r " +
|
||||
"JOIN txs t USING (code, tx_id) " +
|
||||
"ORDER BY r.seen_at",
|
||||
parameters: new
|
||||
{
|
||||
asset_id = GetAssetId(network),
|
||||
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(network.CryptoCode, settings.AccountDerivation.ToString()),
|
||||
code = network.CryptoCode,
|
||||
interval
|
||||
},
|
||||
cancellationToken: cancellation);
|
||||
commandText:
|
||||
"""
|
||||
SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change
|
||||
FROM get_wallets_recent(@wallet_id, @code, @asset_id, @interval, NULL, NULL) r
|
||||
JOIN txs t USING (code, tx_id)
|
||||
ORDER BY r.seen_at
|
||||
""",
|
||||
parameters: new
|
||||
{
|
||||
asset_id = GetAssetId(network),
|
||||
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(network.CryptoCode, settings.AccountDerivation.ToString()),
|
||||
code = network.CryptoCode,
|
||||
interval
|
||||
},
|
||||
cancellationToken: cancellation);
|
||||
|
||||
var rows = await conn.QueryAsync(command);
|
||||
foreach (var r in rows)
|
||||
@@ -105,22 +100,67 @@ public class OnChainWalletReportProvider : ReportProvider
|
||||
values.Add((long?)r.blk_height is not null);
|
||||
values.Add(new FormattedAmount(balanceChange, network.Divisibility).ToJObject());
|
||||
}
|
||||
var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery
|
||||
|
||||
var objects = await walletRepository.GetWalletObjects(new GetWalletObjectsQuery
|
||||
{
|
||||
Ids = queryContext.Data.Select(d => (string)d[2]!).ToArray(),
|
||||
WalletId = walletId,
|
||||
Type = "tx"
|
||||
Type = WalletObjectData.Types.Tx
|
||||
});
|
||||
foreach (var row in queryContext.Data)
|
||||
{
|
||||
if (!objects.TryGetValue(new WalletObjectId(walletId, "tx", (string)row[2]!), out var txObject))
|
||||
if (!objects.TryGetValue(new WalletObjectId(walletId, WalletObjectData.Types.Tx, (string)row[2]!), out var txObject))
|
||||
continue;
|
||||
var invoiceId = txObject.GetLinks().Where(t => t.type == "invoice").Select(t => t.id).FirstOrDefault();
|
||||
var invoiceId = txObject.GetLinks().Where(t => t.type == WalletObjectData.Types.Invoice).Select(t => t.id).FirstOrDefault();
|
||||
row[3] = invoiceId;
|
||||
if (RateBook.FromTxWalletObject(txObject) is {} book)
|
||||
walletBooks.Add(GetKey(row), book);
|
||||
}
|
||||
}
|
||||
|
||||
// The currencies appearing in this report are:
|
||||
// - The currently tracked rates of the store
|
||||
// - The rates that were tracked at the invoices level
|
||||
// - The rates that were tracked at the wallet level
|
||||
var trackedCurrencies = store.GetStoreBlob().GetTrackedRates().ToHashSet();
|
||||
var rates = await invoiceRepository.GetRatesOfInvoices(queryContext.Data.Select(r => r[3]).OfType<string>().ToHashSet());
|
||||
foreach (var book in rates.Select(r => r.Value))
|
||||
{
|
||||
book.AddCurrencies(trackedCurrencies);
|
||||
}
|
||||
foreach (var row in queryContext.Data)
|
||||
{
|
||||
walletBooks.TryGetValue(GetKey(row), out var rateData);
|
||||
rateData?.AddCurrencies(trackedCurrencies);
|
||||
}
|
||||
trackedCurrencies.ExceptWith(cryptoCodes);
|
||||
foreach (var trackedCurrency in trackedCurrencies)
|
||||
{
|
||||
// We don't use amount here. Rounding the rates is dangerous when the price of the
|
||||
// shitcoin is very low.
|
||||
queryContext.ViewDefinition.Fields.Add(new($"Rate ({trackedCurrency})", "number"));
|
||||
}
|
||||
|
||||
foreach (var row in queryContext.Data)
|
||||
{
|
||||
var k = GetKey(row);
|
||||
walletBooks.TryGetValue(k, out var rateData);
|
||||
var invoiceId = row[3] as string;
|
||||
rates.TryGetValue(invoiceId ?? "", out var r);
|
||||
r ??= new("", new());
|
||||
r.AddRates(rateData);
|
||||
foreach (var trackedCurrency in trackedCurrencies)
|
||||
{
|
||||
if (r.TryGetRate(new CurrencyPair(k.CryptoCode, trackedCurrency)) is decimal v)
|
||||
row.Add(v);
|
||||
else
|
||||
row.Add(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private (string CryptoCode, string TxId) GetKey(IList<object?> row) => ((string)row[1]!, (string)row[2]!);
|
||||
|
||||
private string? GetAssetId(BTCPayNetwork network)
|
||||
{
|
||||
if (network is Plugins.Altcoins.ElementsBTCPayNetwork elNetwork)
|
||||
|
||||
@@ -14,6 +14,7 @@ using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin;
|
||||
using NBitcoin.DataEncoders;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -703,6 +704,20 @@ retry:
|
||||
""", new { storeId })) is true;
|
||||
}
|
||||
|
||||
public async Task<string[]> GetStoresFromDerivation(PaymentMethodId paymentMethodId, DerivationStrategyBase derivation)
|
||||
{
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
var res = await connection.QueryAsync<string>(
|
||||
"""
|
||||
SELECT "Id" FROM "Stores"
|
||||
WHERE jsonb_extract_path_text("DerivationStrategies", @pmi, 'accountDerivation') = @derivation;
|
||||
""",
|
||||
new { pmi = paymentMethodId.ToString(), derivation = derivation.ToString() }
|
||||
);
|
||||
return res.ToArray();
|
||||
}
|
||||
|
||||
public async Task<StoreData> GetDefaultStoreTemplate()
|
||||
{
|
||||
var data = new StoreData();
|
||||
|
||||
@@ -8,10 +8,13 @@ using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Models.WalletViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Npgsql;
|
||||
@@ -258,7 +261,8 @@ namespace BTCPayServer.Services
|
||||
var data = obj.Data is null ? null : JObject.Parse(obj.Data);
|
||||
var info = new WalletTransactionInfo(walletId)
|
||||
{
|
||||
Comment = data?["comment"]?.Value<string>()
|
||||
Comment = data?["comment"]?.Value<string>(),
|
||||
Rates = data?["rates"] is JObject o ? RateBook.FromJObject(o, walletId.CryptoCode) : null
|
||||
};
|
||||
result.Add(obj.Id, info);
|
||||
foreach (var link in obj.GetLinks())
|
||||
@@ -485,9 +489,9 @@ namespace BTCPayServer.Services
|
||||
ArgumentNullException.ThrowIfNull(id);
|
||||
ArgumentNullException.ThrowIfNull(comment);
|
||||
if (!string.IsNullOrEmpty(comment))
|
||||
await ModifyWalletObjectData(id, (o) => o["comment"] = comment.Trim().Truncate(MaxCommentSize));
|
||||
await AddOrUpdateWalletObjectData(id, new UpdateOperation.MergeObject(new(){ ["comment"] = comment.Trim().Truncate(MaxCommentSize) }));
|
||||
else
|
||||
await ModifyWalletObjectData(id, (o) => o.Remove("comment"));
|
||||
await AddOrUpdateWalletObjectData(id, new UpdateOperation.RemoveProperty("comment"));
|
||||
}
|
||||
|
||||
|
||||
@@ -512,6 +516,8 @@ namespace BTCPayServer.Services
|
||||
Data = data?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
[Obsolete("Use AddOrUpdateWalletObjectData instead")]
|
||||
public async Task ModifyWalletObjectData(WalletObjectId id, Action<JObject> modify)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(id);
|
||||
@@ -683,17 +689,57 @@ namespace BTCPayServer.Services
|
||||
await conn.ExecuteAsync("INSERT INTO \"WalletObjects\" VALUES (@WalletId, @Type, @Id, @Data::JSONB) ON CONFLICT DO NOTHING", walletObjectDatas);
|
||||
}
|
||||
|
||||
public record UpdateOperation
|
||||
{
|
||||
public record RemoveProperty(string Property) : UpdateOperation;
|
||||
|
||||
public record MergeObject(JObject Data) : UpdateOperation;
|
||||
}
|
||||
|
||||
public async Task AddOrUpdateWalletObjectData(WalletObjectId walletObjectId, UpdateOperation? op)
|
||||
{
|
||||
if (op is UpdateOperation.MergeObject { Data: { } data })
|
||||
{
|
||||
using var ctx = this._ContextFactory.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
await conn.ExecuteAsync("""
|
||||
INSERT INTO "WalletObjects" VALUES (@WalletId, @Type, @Id, @Data::JSONB)
|
||||
ON CONFLICT ("WalletId", "Type", "Id")
|
||||
DO UPDATE SET "Data" = COALESCE("WalletObjects"."Data", '{}'::JSONB) || EXCLUDED."Data"
|
||||
""",
|
||||
new { WalletId = walletObjectId.WalletId.ToString(), Type = walletObjectId.Type, Id = walletObjectId.Id, Data = data.ToString() });
|
||||
}
|
||||
if (op is UpdateOperation.RemoveProperty { Property: { } prop })
|
||||
{
|
||||
using var ctx = this._ContextFactory.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
await conn.ExecuteAsync("""
|
||||
INSERT INTO "WalletObjects" VALUES (@WalletId, @Type, @Id)
|
||||
ON CONFLICT ("WalletId", "Type", "Id")
|
||||
DO UPDATE SET "Data" = COALESCE("WalletObjects"."Data", '{}'::JSONB) - @Property
|
||||
""",
|
||||
new { WalletId = walletObjectId.WalletId.ToString(), Type = walletObjectId.Type, Id = walletObjectId.Id, Property = prop });
|
||||
}
|
||||
else if (op is null)
|
||||
{
|
||||
await EnsureWalletObject(walletObjectId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnsureCreated(List<WalletObjectData>? walletObjects,
|
||||
List<WalletObjectLinkData>? walletObjectLinks)
|
||||
{
|
||||
walletObjects ??= new List<WalletObjectData>();
|
||||
walletObjectLinks ??= new List<WalletObjectLinkData>();
|
||||
if (walletObjects.Count is 0 && walletObjectLinks.Count is 0)
|
||||
return;
|
||||
var objs = walletObjects.Concat(ExtractObjectsFromLinks(walletObjectLinks).Except(walletObjects)).ToArray();
|
||||
await using var ctx = _ContextFactory.CreateContext();
|
||||
var connection = ctx.Database.GetDbConnection();
|
||||
await EnsureWalletObjects(ctx,connection, objs);
|
||||
await EnsureWalletObjectLinks(ctx,connection, walletObjectLinks);
|
||||
}
|
||||
|
||||
#nullable restore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
</div>
|
||||
<article v-if="srv.result.data">
|
||||
<h3 id="raw-data">Raw data</h3>
|
||||
<div class="table-responsive" v-if="srv.result.data.length">
|
||||
<div id="raw-data-table" class="table-responsive" v-if="srv.result.data.length">
|
||||
<table class="table table-hover">
|
||||
<thead class="sticky-top bg-body">
|
||||
<tr>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-group">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<label asp-for="LogoFile" class="form-label"></label>
|
||||
@@ -119,6 +119,12 @@
|
||||
<input asp-for="DefaultCurrency" class="form-control w-auto" currency-selection />
|
||||
<span asp-validation-for="DefaultCurrency" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="AdditionalTrackedRates" class="form-label"></label>
|
||||
<input asp-for="AdditionalTrackedRates" class="form-control" placeholder="@StringLocalizer["Comma-separated list of currencies (eg. USD,EUR,JPY)"]" />
|
||||
<div class="form-text" text-translate="true">The rates of those currencies, in addition to the default currency, will be recorded when a new invoice is created. The rates will then be accessible through reports.</div>
|
||||
<span asp-validation-for="AdditionalTrackedRates" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group mt-4">
|
||||
<label asp-for="NetworkFeeMode" class="form-label"></label>
|
||||
<a href="https://docs.btcpayserver.org/FAQ/Stores/#add-network-fee-to-invoice-vary-with-mining-fees" target="_blank" rel="noreferrer noopener" title="@StringLocalizer["More information..."]">
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
var cryptoCode = Context.GetRouteValue("cryptoCode")?.ToString();
|
||||
var labelFilter = Context.Request.Query["labelFilter"].ToString();
|
||||
var wallet = walletId != null ? WalletId.Parse(walletId) : new WalletId(storeId, cryptoCode);
|
||||
storeId = wallet.StoreId;
|
||||
|
||||
ViewData.SetActivePage(WalletsNavPages.Transactions, StringLocalizer["{0} Transactions", Model.CryptoCode], walletId);
|
||||
}
|
||||
@@ -42,7 +43,7 @@
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#LoadingIndicator {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -59,24 +60,24 @@
|
||||
const $list = document.getElementById('WalletTransactionsList');
|
||||
const $dropdowns = document.getElementById('Dropdowns');
|
||||
const $indicator = document.getElementById('LoadingIndicator');
|
||||
|
||||
|
||||
delegate('click', '#GoToTop', () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
|
||||
if ($actions && $actions.offsetTop - window.innerHeight > 0) {
|
||||
document.getElementById('GoToTop').classList.remove('d-none');
|
||||
}
|
||||
|
||||
|
||||
const count = @Safe.Json(Model.Count);
|
||||
const skipInitial = @Safe.Json(Model.Skip);
|
||||
const loadMoreUrl = @Safe.Json(Url.Action("WalletTransactions", new {walletId, labelFilter, skip = Model.Skip, count = Model.Count, loadTransactions = true}));
|
||||
// The next time we load transactions, skip will become 0
|
||||
let skip = @Safe.Json(Model.Skip) - count;
|
||||
|
||||
|
||||
async function loadMoreTransactions() {
|
||||
$indicator.classList.remove('d-none');
|
||||
|
||||
|
||||
const skipNext = skip + count;
|
||||
const url = loadMoreUrl.replace(`skip=${skipInitial}`, `skip=${skipNext}`)
|
||||
const response = await fetch(url, {
|
||||
@@ -85,13 +86,13 @@
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const html = await response.text();
|
||||
const responseEmpty = html.trim() === '';
|
||||
$list.insertAdjacentHTML('beforeend', html);
|
||||
skip = skipNext;
|
||||
|
||||
|
||||
if (responseEmpty) {
|
||||
// in case the response html was empty, remove the observer and stop loading
|
||||
observer.unobserve($actions);
|
||||
@@ -105,19 +106,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$indicator.classList.add('d-none');
|
||||
formatDateTimes(document.querySelector('#WalletTransactions .switch-time-format').dataset.mode);
|
||||
initLabelManagers();
|
||||
}
|
||||
|
||||
|
||||
const observer = new IntersectionObserver(async entries => {
|
||||
const { isIntersecting } = entries[0];
|
||||
if (isIntersecting) {
|
||||
await loadMoreTransactions();
|
||||
}
|
||||
}, { rootMargin: '128px' });
|
||||
|
||||
|
||||
// the actions div marks the end of the list table
|
||||
observer.observe($actions);
|
||||
</script>
|
||||
@@ -156,7 +157,19 @@
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="dropdown d-inline-flex align-items-center gap-3 ms-auto" id="Export">
|
||||
<a
|
||||
id="view-report"
|
||||
permission="@Policies.CanViewReports"
|
||||
asp-controller="UIReports"
|
||||
asp-action="StoreReports"
|
||||
asp-route-storeId="@storeId"
|
||||
asp-route-viewName="Wallets"
|
||||
class="btn btn-secondary">
|
||||
<vc:icon symbol="nav-reporting" />
|
||||
<span text-translate="true">Reporting</span>
|
||||
</a>
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="ExportDropdownToggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" text-translate="true">
|
||||
Export
|
||||
</button>
|
||||
@@ -175,7 +188,7 @@
|
||||
<table class="table table-hover ">
|
||||
<thead>
|
||||
<th>Id</th>
|
||||
<th>State</th>
|
||||
<th>State</th>
|
||||
<th>Signatures</th>
|
||||
<th>Scheme</th>
|
||||
<th>Actions</th>
|
||||
@@ -190,10 +203,10 @@
|
||||
<td><span id="Sigs_@(index)__Collected">@ptblob?.SignaturesCollected</span></td>
|
||||
<td><span id="Sigs_@(index)__Scheme">@ptblob?.SignaturesNeeded/@ptblob?.SignaturesTotal</span></td>
|
||||
<td>
|
||||
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId"
|
||||
<a asp-action="ViewPendingTransaction" asp-route-walletId="@walletId"
|
||||
asp-route-pendingTransactionId="@pendingTransaction.Id">@(pendingTransaction.State == PendingTransactionState.Signed ? "Broadcast" : "View")</a>
|
||||
-
|
||||
<a asp-action="CancelPendingTransaction" asp-route-walletId="@walletId"
|
||||
<a asp-action="CancelPendingTransaction" asp-route-walletId="@walletId"
|
||||
asp-route-pendingTransactionId="@pendingTransaction.Id">Abort</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -221,6 +234,10 @@
|
||||
<th text-translate="true" style="min-width:125px">Label</th>
|
||||
<th text-translate="true">Transaction</th>
|
||||
<th text-translate="true" class="amount-col">Amount</th>
|
||||
@foreach (var rate in Model.Rates)
|
||||
{
|
||||
<th class="rate-col"><span text-translate="true">Rate</span> <span>(@rate)</span></th>
|
||||
}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -230,7 +247,7 @@
|
||||
<input type="checkbox" class="form-check-input mass-action-select-all" />
|
||||
</th>
|
||||
<th colspan="5">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||
<div>
|
||||
<strong class="mass-action-selected-count">0</strong>
|
||||
<span text-translate="true">selected</span>
|
||||
@@ -265,6 +282,6 @@
|
||||
|
||||
<p class="mt-4 mb-0">
|
||||
@ViewLocalizer["If BTCPay Server shows you an invalid balance, {0}.<br />If some transactions appear in BTCPay Server, but are missing in another wallet, {1}.",
|
||||
Html.ActionLink(StringLocalizer["rescan your wallet"], "WalletRescan", "UIWallets", new { walletId = Context.GetRouteValue("walletId") }),
|
||||
Html.ActionLink(StringLocalizer["rescan your wallet"], "WalletRescan", "UIWallets", new { walletId = Context.GetRouteValue("walletId") }),
|
||||
new HtmlString($"<a href=\"https://docs.btcpayserver.org/FAQ/Wallet/#missing-payments-in-my-software-or-hardware-wallet\" target=\"_blank\" rel=\"noreferrer noopener\">{StringLocalizer["follow these instructions"]}</a>")]
|
||||
</p>
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
<td class="align-middle amount-col">
|
||||
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
|
||||
</td>
|
||||
@foreach (var rate in transaction.Rates)
|
||||
{
|
||||
<td class="align-middle rate-col">
|
||||
<span>@rate</span>
|
||||
</td>
|
||||
}
|
||||
<td class="align-middle text-end">
|
||||
<div class="d-inline-flex gap-3 align-items-center">
|
||||
@if (transaction.CanBumpFee)
|
||||
|
||||
@@ -143,8 +143,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return chart && (chart.rows.length || chart.hasGrandTotal);
|
||||
},
|
||||
titleCase(str, shorten) {
|
||||
const result = str.replace(/([A-Z])/g, " $1");
|
||||
const title = result.charAt(0).toUpperCase() + result.slice(1)
|
||||
const result = str.replace(/([a-z])([A-Z])/g, '$1 $2'); // only split camelCase
|
||||
const title = result.charAt(0).toUpperCase() + result.slice(1);
|
||||
return shorten && title.endsWith(' Amount') ? 'Amount' : title;
|
||||
},
|
||||
displayValue,
|
||||
|
||||
@@ -1182,6 +1182,10 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rate-col {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.actions-col {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -507,6 +507,15 @@
|
||||
"default": "USD",
|
||||
"example": "USD"
|
||||
},
|
||||
"additionalTrackedRates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Additional rates to track.\nThe rates of those currencies, in addition to the default currency, will be recorded when a new invoice is created. The rates will then be accessible through reports.",
|
||||
"default": [],
|
||||
"example": ["JPY", "EUR"]
|
||||
},
|
||||
"invoiceExpiration": {
|
||||
"default": 900,
|
||||
"minimum": 60,
|
||||
|
||||
Reference in New Issue
Block a user