mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 05:54:26 +01:00
Add fee information in wallet tx report and tx list (#6857)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
@@ -15,6 +16,7 @@ using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Server;
|
||||
using BTCPayServer.Views.Stores;
|
||||
@@ -611,7 +613,8 @@ namespace BTCPayServer.Tests
|
||||
await s.RegisterNewUser(true);
|
||||
await s.CreateNewStore();
|
||||
|
||||
await s.AddDerivationScheme("BTC", new ExtKey().Neuter().GetWif(Network.RegTest).ToString() + "-[legacy]");
|
||||
var btcDerivationScheme = new ExtKey().Neuter().GetWif(Network.RegTest).ToString() + "-[legacy]";
|
||||
await s.AddDerivationScheme("BTC", btcDerivationScheme);
|
||||
await s.AddDerivationScheme("LTC", new ExtKey().Neuter().GetWif(NBitcoin.Altcoins.Litecoin.Instance.Regtest).ToString() + "-[legacy]");
|
||||
|
||||
await s.GoToStore();
|
||||
@@ -685,9 +688,41 @@ namespace BTCPayServer.Tests
|
||||
await pmo.AssertRowContains(txId, "500.00 USD");
|
||||
}
|
||||
}
|
||||
await s.GoToWallet(new(s.StoreId, "BTC"), WalletsNavPages.Transactions);
|
||||
await s.ClickViewReport();
|
||||
|
||||
var fee = Money.Zero;
|
||||
var feeRate = FeeRate.Zero;
|
||||
// Quick check on some internal of wallet that isn't related to this test
|
||||
var wallet = s.Server.PayTester.GetService<BTCPayWalletProvider>().GetWallet("BTC");
|
||||
var derivation = s.Server.GetNetwork("BTC").NBXplorerNetwork.DerivationStrategyFactory.Parse(btcDerivationScheme);
|
||||
foreach (var forceHasFeeInfo in new bool?[]{ true, false, null})
|
||||
foreach (var inefficient in new[] { true, false })
|
||||
{
|
||||
wallet.ForceInefficientPath = inefficient;
|
||||
wallet.ForceHasFeeInformation = forceHasFeeInfo;
|
||||
wallet.InvalidateCache(derivation);
|
||||
var fetched = await wallet.FetchTransactionHistory(derivation);
|
||||
var tx = fetched.First(f => f.TransactionId == txIds["BTC"]);
|
||||
if (forceHasFeeInfo is true or null || inefficient)
|
||||
{
|
||||
Assert.NotNull(tx.Fee);
|
||||
Assert.NotNull(tx.FeeRate);
|
||||
fee = tx.Fee;
|
||||
feeRate = tx.FeeRate;
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Null(tx.Fee);
|
||||
Assert.Null(tx.FeeRate);
|
||||
}
|
||||
}
|
||||
wallet.InvalidateCache(derivation);
|
||||
wallet.ForceHasFeeInformation = null;
|
||||
wallet.ForceInefficientPath = false;
|
||||
|
||||
var pmo3 = await s.GoToWalletTransactions(new(s.StoreId, "BTC"));
|
||||
await pmo3.AssertRowContains(txIds["BTC"], $"{fee} ({feeRate})");
|
||||
|
||||
await s.ClickViewReport();
|
||||
var csvTxt = await s.DownloadReportCSV();
|
||||
var csvTester = new CSVWalletsTester(csvTxt);
|
||||
|
||||
@@ -698,6 +733,8 @@ namespace BTCPayServer.Tests
|
||||
csvTester
|
||||
.ForTxId(txIds[cryptoCode].ToString())
|
||||
.AssertValues(
|
||||
("FeeRate", feeRate.SatoshiPerByte.ToString(CultureInfo.InvariantCulture)),
|
||||
("Fee", fee.ToString()),
|
||||
("Rate (USD)", "5000"),
|
||||
("Rate (CAD)", "4500"),
|
||||
("Rate (JPY)", "700000"),
|
||||
@@ -717,6 +754,23 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
}
|
||||
|
||||
// This shouldn't crash if NBX doesn't support fee fetching
|
||||
wallet.ForceHasFeeInformation = false;
|
||||
await s.Page.ReloadAsync();
|
||||
csvTxt = await s.DownloadReportCSV();
|
||||
csvTester = new CSVWalletsTester(csvTxt);
|
||||
csvTester
|
||||
.ForTxId(txIds["BTC"].ToString())
|
||||
.AssertValues(
|
||||
("FeeRate", ""),
|
||||
("Fee", ""),
|
||||
("Rate (USD)", "5000"),
|
||||
("Rate (CAD)", "4500"),
|
||||
("Rate (JPY)", "700000"),
|
||||
("Rate (EUR)", "4000")
|
||||
);
|
||||
wallet.ForceHasFeeInformation = null;
|
||||
|
||||
var invId = await s.CreateInvoice(storeId: s.StoreId, amount: 10_000);
|
||||
await s.GoToInvoiceCheckout(invId);
|
||||
await s.PayInvoice();
|
||||
@@ -1160,51 +1214,51 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
await s.Page.ClickAsync("button:has-text('Pay')");
|
||||
await s.Page.WaitForLoadStateAsync();
|
||||
|
||||
|
||||
await s.Page.WaitForSelectorAsync("iframe[name='btcpay']", new() { Timeout = 10000 });
|
||||
|
||||
|
||||
var iframe = s.Page.Frame("btcpay");
|
||||
Assert.NotNull(iframe);
|
||||
|
||||
|
||||
await iframe.FillAsync("#test-payment-amount", "0.05");
|
||||
await iframe.ClickAsync("#FakePayment");
|
||||
await iframe.WaitForSelectorAsync("#CheatSuccessMessage", new() { Timeout = 10000 });
|
||||
|
||||
|
||||
invoiceId = s.Page.Url.Split('/').Last();
|
||||
}
|
||||
await s.GoToInvoices();
|
||||
|
||||
|
||||
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-toggle");
|
||||
await s.Page.ClickAsync("[data-invoice-state-badge] .dropdown-menu button:has-text('Mark as settled')");
|
||||
await s.Page.WaitForLoadStateAsync();
|
||||
|
||||
|
||||
await s.GoToStore();
|
||||
await s.Page.ClickAsync("#StoreNav-PaymentRequests");
|
||||
await s.Page.WaitForLoadStateAsync();
|
||||
|
||||
|
||||
var opening2 = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("a:has-text('View')");
|
||||
await using (_ = await s.SwitchPage(opening2))
|
||||
{
|
||||
await s.Page.WaitForLoadStateAsync();
|
||||
|
||||
|
||||
var markSettledExists = await s.Page.Locator("button:has-text('Mark as settled')").CountAsync();
|
||||
Assert.True(markSettledExists > 0, "Mark as settled button should be visible on public page after invoice is settled");
|
||||
await s.Page.ClickAsync("button:has-text('Mark as settled')");
|
||||
await s.Page.WaitForLoadStateAsync();
|
||||
}
|
||||
|
||||
|
||||
await s.GoToStore();
|
||||
await s.Page.ClickAsync("#StoreNav-PaymentRequests");
|
||||
await s.Page.WaitForLoadStateAsync();
|
||||
|
||||
|
||||
var listContent = await s.Page.ContentAsync();
|
||||
var isSettledInList = listContent.Contains("Settled");
|
||||
var isPendingInList = listContent.Contains("Pending");
|
||||
|
||||
|
||||
var settledBadgeExists = await s.Page.Locator(".badge:has-text('Settled')").CountAsync();
|
||||
var pendingBadgeExists = await s.Page.Locator(".badge:has-text('Pending')").CountAsync();
|
||||
|
||||
|
||||
Assert.True(isSettledInList || settledBadgeExists > 0, "Payment request should show as Settled in the list");
|
||||
Assert.False(isPendingInList && pendingBadgeExists > 0, "Payment request should not show as Pending anymore");
|
||||
}
|
||||
@@ -1224,10 +1278,10 @@ namespace BTCPayServer.Tests
|
||||
var admin = s.AsTestAccount();
|
||||
await s.GoToHome();
|
||||
await s.GoToServer(ServerNavPages.Policies);
|
||||
|
||||
|
||||
Assert.True(await s.Page.Locator("#EnableRegistration").IsCheckedAsync());
|
||||
Assert.False(await s.Page.Locator("#RequiresUserApproval").IsCheckedAsync());
|
||||
|
||||
|
||||
await s.Page.Locator("#RequiresUserApproval").ClickAsync();
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "Policies updated successfully");
|
||||
@@ -1255,7 +1309,7 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
Assert.Equal("1", await s.Page.Locator("#NotificationsBadge").TextContentAsync());
|
||||
});
|
||||
|
||||
|
||||
await s.Page.Locator("#NotificationsHandle").ClickAsync();
|
||||
Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", await s.Page.Locator("#NotificationsList .notification").TextContentAsync());
|
||||
await s.Page.Locator("#NotificationsMarkAllAsSeen").ClickAsync();
|
||||
@@ -1302,7 +1356,7 @@ namespace BTCPayServer.Tests
|
||||
Assert.Equal(1, await rows.CountAsync());
|
||||
Assert.Contains(autoApproved.RegisterDetails.Email, await rows.First.TextContentAsync());
|
||||
await Expect(s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-approved")).Not.ToBeVisibleAsync();
|
||||
|
||||
|
||||
await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit");
|
||||
await Expect(s.Page.Locator("#Approved")).Not.ToBeVisibleAsync();
|
||||
|
||||
@@ -1313,12 +1367,12 @@ namespace BTCPayServer.Tests
|
||||
Assert.Equal(1, await rows.CountAsync());
|
||||
Assert.Contains(unapproved.RegisterDetails.Email, await rows.First.TextContentAsync());
|
||||
Assert.Contains("Pending Approval", await s.Page.Locator("#UsersList tr.user-overview-row:first-child .user-status").TextContentAsync());
|
||||
|
||||
|
||||
await s.Page.ClickAsync("#UsersList tr.user-overview-row:first-child .user-edit");
|
||||
await s.Page.ClickAsync("#Approved");
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "User successfully updated");
|
||||
|
||||
|
||||
await s.GoToServer(ServerNavPages.Users);
|
||||
Assert.Contains(unapproved.RegisterDetails.Email, await s.Page.GetAttributeAsync("#SearchTerm", "value"));
|
||||
Assert.Equal(1, await rows.CountAsync());
|
||||
|
||||
@@ -658,6 +658,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.Positive = tx.BalanceChange.GetValue(wallet.Network) >= 0;
|
||||
vm.Balance = tx.BalanceChange.ShowMoney(wallet.Network);
|
||||
vm.IsConfirmed = tx.Confirmations != 0;
|
||||
vm.HistoryLine = tx;
|
||||
// If support isn't possible, we want the user to be able to click so he can see why it doesn't work
|
||||
vm.CanBumpFee =
|
||||
tx.Confirmations == 0 &&
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
|
||||
namespace BTCPayServer.Models.WalletViewModels
|
||||
{
|
||||
@@ -23,6 +24,7 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public RateBook WalletRateBook { get; set; }
|
||||
public RateBook InvoiceRateBook { get; set; }
|
||||
public string InvoiceId { get; set; }
|
||||
public TransactionHistoryLine HistoryLine { get; set; }
|
||||
}
|
||||
public HashSet<(string Text, string Color, string TextColor)> Labels { get; set; } = new();
|
||||
public List<TransactionViewModel> Transactions { get; set; } = new();
|
||||
|
||||
@@ -9,6 +9,7 @@ using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Dapper;
|
||||
using NBitcoin;
|
||||
|
||||
@@ -19,7 +20,8 @@ public class OnChainWalletReportProvider(
|
||||
StoreRepository storeRepository,
|
||||
InvoiceRepository invoiceRepository,
|
||||
PaymentMethodHandlerDictionary handlers,
|
||||
WalletRepository walletRepository)
|
||||
WalletRepository walletRepository,
|
||||
BTCPayWalletProvider walletProvider)
|
||||
: ReportProvider
|
||||
{
|
||||
public override string Name => "Wallets";
|
||||
@@ -37,6 +39,8 @@ public class OnChainWalletReportProvider(
|
||||
new("InvoiceId", "invoice_id"),
|
||||
new("Confirmed", "boolean"),
|
||||
new("BalanceChange", "amount"),
|
||||
new("Fee", "amount"),
|
||||
new("FeeRate", "number"),
|
||||
},
|
||||
Charts =
|
||||
{
|
||||
@@ -68,10 +72,12 @@ public class OnChainWalletReportProvider(
|
||||
var network = ((IHasNetwork)handlers[pmi]).Network;
|
||||
cryptoCodes.Add(network.CryptoCode);
|
||||
var walletId = new WalletId(store.Id, network.CryptoCode);
|
||||
|
||||
var selectFee = walletProvider.GetWallet(network).SelectFeeColumns();
|
||||
var command = new CommandDefinition(
|
||||
commandText:
|
||||
"""
|
||||
SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change
|
||||
$"""
|
||||
SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, {selectFee}
|
||||
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
|
||||
@@ -99,6 +105,11 @@ public class OnChainWalletReportProvider(
|
||||
values.Add(null);
|
||||
values.Add((long?)r.blk_height is not null);
|
||||
values.Add(new FormattedAmount(balanceChange, network.Divisibility).ToJObject());
|
||||
|
||||
decimal? fee = r.fee is null ? null : Money.Satoshis((long)r.fee).ToDecimal(MoneyUnit.BTC);
|
||||
decimal? feeRate = r.feerate is null ? null : (decimal)r.feerate;
|
||||
values.Add(fee is null ? null : new FormattedAmount(fee.Value, network.Divisibility).ToJObject());
|
||||
values.Add(feeRate);
|
||||
}
|
||||
|
||||
var objects = await walletRepository.GetWalletObjects(new GetWalletObjectsQuery
|
||||
|
||||
@@ -252,6 +252,7 @@ namespace BTCPayServer.Services.Wallets
|
||||
}
|
||||
return await completionSource.Task;
|
||||
}
|
||||
public bool ForceInefficientPath { get; set; }
|
||||
List<TransactionInformation> dummy = new List<TransactionInformation>();
|
||||
public async Task<IList<TransactionHistoryLine>> FetchTransactionHistory(DerivationStrategyBase derivationStrategyBase, int? skip = null, int? count = null, TimeSpan? interval = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -259,7 +260,7 @@ namespace BTCPayServer.Services.Wallets
|
||||
// * Sometimes we can ask the DB to do the filtering of rows: If that's the case, we should try to filter at the DB level directly as it is the most efficient.
|
||||
// * Sometimes we can't query the DB or the given network need to do additional filtering. In such case, we can't really filter at the DB level, and we need to fetch all transactions in memory.
|
||||
var needAdditionalFiltering = _Network.FilterValidTransactions(dummy) != dummy;
|
||||
if (!NbxplorerConnectionFactory.Available || needAdditionalFiltering)
|
||||
if (ForceInefficientPath || !NbxplorerConnectionFactory.Available || needAdditionalFiltering)
|
||||
{
|
||||
var txs = await FetchTransactions(derivationStrategyBase);
|
||||
var txinfos = txs.UnconfirmedTransactions.Transactions.Concat(txs.ConfirmedTransactions.Transactions)
|
||||
@@ -282,7 +283,7 @@ namespace BTCPayServer.Services.Wallets
|
||||
{
|
||||
await using var ctx = await NbxplorerConnectionFactory.OpenConnection();
|
||||
var cmd = new CommandDefinition(
|
||||
commandText: "SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, r.asset_id, COALESCE((SELECT height FROM get_tip('BTC')) - t.blk_height + 1, 0) AS confs " +
|
||||
commandText: $"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change, r.asset_id, COALESCE((SELECT height FROM get_tip('BTC')) - t.blk_height + 1, 0) AS confs, {SelectFeeColumns()} " +
|
||||
"FROM get_wallets_recent(@wallet_id, @code, @interval, @count, @skip) r " +
|
||||
"JOIN txs t USING (code, tx_id) " +
|
||||
"ORDER BY r.seen_at DESC",
|
||||
@@ -295,7 +296,7 @@ namespace BTCPayServer.Services.Wallets
|
||||
interval = interval is TimeSpan t ? t : TimeSpan.FromDays(365 * 1000)
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
var rows = await ctx.QueryAsync<(string tx_id, DateTimeOffset seen_at, string blk_id, long? blk_height, long balance_change, string asset_id, long confs)>(cmd);
|
||||
var rows = await ctx.QueryAsync<(string tx_id, DateTimeOffset seen_at, string blk_id, long? blk_height, long balance_change, string asset_id, long confs, long? fee, decimal? feerate)>(cmd);
|
||||
rows.TryGetNonEnumeratedCount(out int c);
|
||||
var lines = new List<TransactionHistoryLine>(c);
|
||||
foreach (var row in rows)
|
||||
@@ -307,12 +308,22 @@ namespace BTCPayServer.Services.Wallets
|
||||
SeenAt = row.seen_at,
|
||||
TransactionId = uint256.Parse(row.tx_id),
|
||||
Confirmations = row.confs,
|
||||
BlockHash = string.IsNullOrEmpty(row.blk_id) ? null : uint256.Parse(row.blk_id)
|
||||
BlockHash = string.IsNullOrEmpty(row.blk_id) ? null : uint256.Parse(row.blk_id),
|
||||
Fee = row.fee is null ? null : Money.Satoshis(row.fee.Value),
|
||||
FeeRate = row.feerate is null ? null : new FeeRate(row.feerate.Value)
|
||||
});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
internal string SelectFeeColumns()
|
||||
=> HasFeeInformation() ? "(metadata->'fees')::BIGINT AS fee, (metadata->'feeRate')::NUMERIC AS feerate" : "NULL AS fee, NULL AS feerate";
|
||||
|
||||
public bool? ForceHasFeeInformation { get; set; }
|
||||
internal bool HasFeeInformation()
|
||||
=> ForceHasFeeInformation ?? AsVersion(this.Dashboard.Get(Network.CryptoCode)?.Status?.Version ?? "") >= new Version("2.5.18");
|
||||
|
||||
public async Task<BumpableTransactions> GetBumpableTransactions(DerivationStrategyBase derivationStrategyBase, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new BumpableTransactions();
|
||||
@@ -440,7 +451,9 @@ namespace BTCPayServer.Services.Wallets
|
||||
Confirmations = t.Confirmations,
|
||||
Height = t.Height,
|
||||
SeenAt = t.Timestamp,
|
||||
TransactionId = t.TransactionId
|
||||
TransactionId = t.TransactionId,
|
||||
Fee = t.Metadata?.Fees,
|
||||
FeeRate = t.Metadata?.FeeRate
|
||||
};
|
||||
}
|
||||
|
||||
@@ -570,5 +583,7 @@ namespace BTCPayServer.Services.Wallets
|
||||
public uint256 TransactionId { get; set; }
|
||||
public uint256 BlockHash { get; set; }
|
||||
public IMoney BalanceChange { get; set; }
|
||||
public FeeRate FeeRate { get; set; }
|
||||
public Money Fee { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
<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>
|
||||
<th text-translate="true" class="fee-col">Transaction fee</th>
|
||||
@foreach (var rate in Model.Rates)
|
||||
{
|
||||
<th class="rate-col"><span text-translate="true">Rate</span> <span>(@rate)</span></th>
|
||||
|
||||
@@ -27,6 +27,14 @@
|
||||
<td class="align-middle amount-col">
|
||||
<span data-sensitive class="text-@(transaction.Positive ? "success" : "danger")@(transaction.IsConfirmed ? "" : " opacity-50")">@transaction.Balance</span>
|
||||
</td>
|
||||
<td class="align-middle fee-col">
|
||||
<span data-sensitive class="@(transaction.IsConfirmed ? "" : " opacity-50")">
|
||||
@if (transaction.HistoryLine is { Fee: { } fee, FeeRate: { } fr })
|
||||
{
|
||||
<span><span>@fee</span> <span class="text-muted">(@fr)</span></span>
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
@foreach (var rate in transaction.Rates)
|
||||
{
|
||||
<td class="align-middle rate-col">
|
||||
|
||||
@@ -1182,6 +1182,10 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fee-col {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rate-col {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
|
||||
Reference in New Issue
Block a user