Display fiat amount previews in Transaction Details page (#6610)

* Code cleanup

* Preparing model to include data needed for fiat display

* Displaying fiat amount and allowing switching between it and BTC

* Restoring parts removed by vibe coding

* Making ToFiatAmount method work for in wider variety of cases

* Tweaks for display and negative values

* Calculating amounts on serverside and simplifying

* Fix warnings

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
rockstardev 2025-03-08 06:33:19 -06:00 committed by GitHub
parent 71dbfd9f28
commit 9dcf8d3251
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 199 additions and 109 deletions

View file

@ -367,6 +367,19 @@ namespace BTCPayServer.Controllers
} }
} }
// fetch helper that will be used to convert the value to fiat
FiatRate rate = null;
try
{
rate = await FetchRate(walletId);
}
catch (Exception)
{
// keeping model simple
// vm.RateError = ex.Message;
}
//
if (psbtObject.IsAllFinalized()) if (psbtObject.IsAllFinalized())
{ {
vm.CanCalculateBalance = false; vm.CanCalculateBalance = false;
@ -374,7 +387,7 @@ namespace BTCPayServer.Controllers
else else
{ {
var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath); var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath);
vm.BalanceChange = ValueToString(balanceChange, network); vm.BalanceChange = ValueToString(balanceChange, network, rate);
vm.CanCalculateBalance = true; vm.CanCalculateBalance = true;
vm.Positive = balanceChange >= Money.Zero; vm.Positive = balanceChange >= Money.Zero;
} }
@ -390,7 +403,7 @@ namespace BTCPayServer.Controllers
var balanceChange2 = txOut?.Value ?? Money.Zero; var balanceChange2 = txOut?.Value ?? Money.Zero;
if (mine) if (mine)
balanceChange2 = -balanceChange2; balanceChange2 = -balanceChange2;
inputVm.BalanceChange = ValueToString(balanceChange2, network); inputVm.BalanceChange = ValueToString(balanceChange2, network, rate);
inputVm.Positive = balanceChange2 >= Money.Zero; inputVm.Positive = balanceChange2 >= Money.Zero;
inputVm.Index = (int)input.Index; inputVm.Index = (int)input.Index;
@ -412,7 +425,7 @@ namespace BTCPayServer.Controllers
var balanceChange2 = output.Value; var balanceChange2 = output.Value;
if (!mine) if (!mine)
balanceChange2 = -balanceChange2; balanceChange2 = -balanceChange2;
dest.Balance = ValueToString(balanceChange2, network); dest.Balance = ValueToString(balanceChange2, network, rate);
dest.Positive = balanceChange2 >= Money.Zero; dest.Positive = balanceChange2 >= Money.Zero;
dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString(); dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString(); var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
@ -426,7 +439,7 @@ namespace BTCPayServer.Controllers
vm.Destinations.Add(new WalletPSBTReadyViewModel.DestinationViewModel vm.Destinations.Add(new WalletPSBTReadyViewModel.DestinationViewModel
{ {
Positive = false, Positive = false,
Balance = ValueToString(-fee, network), Balance = ValueToString(-fee, network, rate),
Destination = "Mining fees" Destination = "Mining fees"
}); });
} }

View file

@ -21,6 +21,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.PayJoin; using BTCPayServer.Payments.PayJoin;
using BTCPayServer.Payouts; using BTCPayServer.Payouts;
using BTCPayServer.Rating;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Labels; using BTCPayServer.Services.Labels;
@ -33,9 +34,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities; using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using NBitcoin; using NBitcoin;
@ -58,9 +57,9 @@ namespace BTCPayServer.Controllers
private WalletRepository WalletRepository { get; } private WalletRepository WalletRepository { get; }
private BTCPayNetworkProvider NetworkProvider { get; } private BTCPayNetworkProvider NetworkProvider { get; }
private ExplorerClientProvider ExplorerClientProvider { get; } private ExplorerClientProvider ExplorerClientProvider { get; }
public IServiceProvider ServiceProvider { get; } private IServiceProvider ServiceProvider { get; }
public RateFetcher RateFetcher { get; } private RateFetcher RateFetcher { get; }
public IStringLocalizer StringLocalizer { get; } private IStringLocalizer StringLocalizer { get; }
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly NBXplorerDashboard _dashboard; private readonly NBXplorerDashboard _dashboard;
@ -81,33 +80,35 @@ namespace BTCPayServer.Controllers
private readonly PendingTransactionService _pendingTransactionService; private readonly PendingTransactionService _pendingTransactionService;
readonly CurrencyNameTable _currencyTable; readonly CurrencyNameTable _currencyTable;
private readonly DisplayFormatter _displayFormatter;
public UIWalletsController( public UIWalletsController(
PendingTransactionService pendingTransactionService, PendingTransactionService pendingTransactionService,
StoreRepository repo, StoreRepository repo,
WalletRepository walletRepository, WalletRepository walletRepository,
CurrencyNameTable currencyTable, CurrencyNameTable currencyTable,
BTCPayNetworkProvider networkProvider, BTCPayNetworkProvider networkProvider,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
NBXplorerDashboard dashboard, NBXplorerDashboard dashboard,
WalletHistogramService walletHistogramService, WalletHistogramService walletHistogramService,
RateFetcher rateProvider, RateFetcher rateProvider,
IAuthorizationService authorizationService, IAuthorizationService authorizationService,
ExplorerClientProvider explorerProvider, ExplorerClientProvider explorerProvider,
IFeeProviderFactory feeRateProvider, IFeeProviderFactory feeRateProvider,
BTCPayWalletProvider walletProvider, BTCPayWalletProvider walletProvider,
WalletReceiveService walletReceiveService, WalletReceiveService walletReceiveService,
SettingsRepository settingsRepository, SettingsRepository settingsRepository,
DelayedTransactionBroadcaster broadcaster, DelayedTransactionBroadcaster broadcaster,
PayjoinClient payjoinClient, PayjoinClient payjoinClient,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
PullPaymentHostedService pullPaymentHostedService, PullPaymentHostedService pullPaymentHostedService,
LabelService labelService, LabelService labelService,
DefaultRulesCollection defaultRules, DefaultRulesCollection defaultRules,
PaymentMethodHandlerDictionary handlers, PaymentMethodHandlerDictionary handlers,
Dictionary<PaymentMethodId, ICheckoutModelExtension> paymentModelExtensions, Dictionary<PaymentMethodId, ICheckoutModelExtension> paymentModelExtensions,
IStringLocalizer stringLocalizer, IStringLocalizer stringLocalizer,
TransactionLinkProviders transactionLinkProviders) TransactionLinkProviders transactionLinkProviders,
DisplayFormatter displayFormatter)
{ {
_pendingTransactionService = pendingTransactionService; _pendingTransactionService = pendingTransactionService;
_currencyTable = currencyTable; _currencyTable = currencyTable;
@ -134,6 +135,7 @@ namespace BTCPayServer.Controllers
ServiceProvider = serviceProvider; ServiceProvider = serviceProvider;
_walletHistogramService = walletHistogramService; _walletHistogramService = walletHistogramService;
StringLocalizer = stringLocalizer; StringLocalizer = stringLocalizer;
_displayFormatter = displayFormatter;
} }
[HttpGet("{walletId}/pending/{transactionId}/cancel")] [HttpGet("{walletId}/pending/{transactionId}/cancel")]
@ -513,10 +515,7 @@ namespace BTCPayServer.Controllers
var network = this.NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode); var network = this.NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
if (network == null || network.ReadonlyWallet) if (network == null || network.ReadonlyWallet)
return NotFound(); return NotFound();
var storeData = store.GetStoreBlob();
var rateRules = store.GetStoreBlob().GetRateRules(_defaultRules);
rateRules.Spread = 0.0m;
var currencyPair = new Rating.CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency);
double.TryParse(defaultAmount, out var amount); double.TryParse(defaultAmount, out var amount);
var model = new WalletSendModel var model = new WalletSendModel
@ -587,31 +586,46 @@ namespace BTCPayServer.Controllers
model.FeeSatoshiPerByte = recommendedFees[1].GetAwaiter().GetResult()?.FeeRate; model.FeeSatoshiPerByte = recommendedFees[1].GetAwaiter().GetResult()?.FeeRate;
model.CryptoDivisibility = network.Divisibility; model.CryptoDivisibility = network.Divisibility;
using (CancellationTokenSource cts = new CancellationTokenSource())
try
{ {
try var r = await FetchRate(walletId);
{
cts.CancelAfter(TimeSpan.FromSeconds(5)); model.Rate = r.Rate;
var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(walletId.StoreId), cts.Token) model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(r.Fiat, true)
.WithCancellation(cts.Token); .CurrencyDecimalDigits;
if (result.BidAsk != null) model.Fiat = r.Fiat;
{
model.Rate = result.BidAsk.Center;
model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true)
.CurrencyDecimalDigits;
model.Fiat = currencyPair.Right;
}
else
{
model.RateError =
$"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})";
}
}
catch (Exception ex) { model.RateError = ex.Message; }
} }
catch (Exception ex) { model.RateError = ex.Message; }
return View(model); return View(model);
} }
public record FiatRate(decimal Rate, string Fiat);
private async Task<FiatRate> FetchRate(WalletId walletId)
{
var store = await Repository.FindStore(walletId.StoreId);
if (store is null)
throw new Exception("Store not found");
var storeData = store.GetStoreBlob();
var rateRules = storeData.GetRateRules(_defaultRules);
rateRules.Spread = 0.0m;
var currencyPair = new CurrencyPair(walletId.CryptoCode, storeData.DefaultCurrency);
using CancellationTokenSource cts = new();
cts.CancelAfter(TimeSpan.FromSeconds(5));
var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(store.Id), cts.Token)
.WithCancellation(cts.Token);
if (result.BidAsk == null)
{
throw new Exception(
$"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})");
}
return new (result.BidAsk.Center, currencyPair.Right);
}
private async Task<string?> GetSeed(WalletId walletId, BTCPayNetwork network) private async Task<string?> GetSeed(WalletId walletId, BTCPayNetwork network)
{ {
return await CanUseHotWallet() && return await CanUseHotWallet() &&
@ -1214,10 +1228,13 @@ namespace BTCPayServer.Controllers
}); });
} }
private string ValueToString(Money v, BTCPayNetworkBase network) private WalletPSBTReadyViewModel.StringAmounts ValueToString(Money v, BTCPayNetworkBase network,
{ FiatRate? rate) =>
return v.ToString() + " " + network.CryptoCode; new(
} CryptoAmount : _displayFormatter.Currency(v.ToDecimal(MoneyUnit.BTC), network.CryptoCode),
FiatAmount : rate is null ? null
: _displayFormatter.Currency(rate.Rate * v.ToDecimal(MoneyUnit.BTC), rate.Fiat)
);
[HttpGet("{walletId}/rescan")] [HttpGet("{walletId}/rescan")]
public async Task<IActionResult> WalletRescan( public async Task<IActionResult> WalletRescan(

View file

@ -15,7 +15,7 @@ namespace BTCPayServer.Models.WalletViewModels
{ {
public bool Positive { get; set; } public bool Positive { get; set; }
public string Destination { get; set; } public string Destination { get; set; }
public string Balance { get; set; } public StringAmounts Balance { get; set; }
public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>(); public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>();
} }
@ -24,7 +24,7 @@ namespace BTCPayServer.Models.WalletViewModels
public int Index { get; set; } public int Index { get; set; }
public string Error { get; set; } public string Error { get; set; }
public bool Positive { get; set; } public bool Positive { get; set; }
public string BalanceChange { get; set; } public StringAmounts BalanceChange { get; set; }
public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>(); public IEnumerable<TransactionTagModel> Labels { get; set; } = new List<TransactionTagModel>();
} }
public class AmountViewModel public class AmountViewModel
@ -34,7 +34,7 @@ namespace BTCPayServer.Models.WalletViewModels
} }
public AmountViewModel ReplacementBalanceChange { get; set; } public AmountViewModel ReplacementBalanceChange { get; set; }
public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error)); public bool HasErrors => Inputs.Count == 0 || Inputs.Any(i => !string.IsNullOrEmpty(i.Error));
public string BalanceChange { get; set; } public StringAmounts BalanceChange { get; set; }
public bool CanCalculateBalance { get; set; } public bool CanCalculateBalance { get; set; }
public bool Positive { get; set; } public bool Positive { get; set; }
public List<DestinationViewModel> Destinations { get; set; } = new List<DestinationViewModel>(); public List<DestinationViewModel> Destinations { get; set; } = new List<DestinationViewModel>();
@ -50,5 +50,7 @@ namespace BTCPayServer.Models.WalletViewModels
Inputs[(int)err.InputIndex].Error = err.Message; Inputs[(int)err.InputIndex].Error = err.Message;
} }
} }
public record StringAmounts(string CryptoAmount, string FiatAmount);
} }
} }

View file

@ -5,7 +5,13 @@
<p class="lead text-center text-secondary"> <p class="lead text-center text-secondary">
<span text-translate="true">This transaction will change your balance:</span> <span text-translate="true">This transaction will change your balance:</span>
<br> <br>
<span class="text-@(Model.Positive ? "success" : "danger")">@Model.BalanceChange</span> <span id="balance-toggle" class="text-@(Model.Positive ? "success" : "danger") cursor-pointer">
@Model.BalanceChange.CryptoAmount
@if (Model.BalanceChange.FiatAmount != null)
{
<small style="text-decoration: underline dotted;">(@Model.BalanceChange.FiatAmount)</small>
}
</span>
</p> </p>
} }
@ -25,36 +31,41 @@
<tr> <tr>
@if (input.Error != null) @if (input.Error != null)
{ {
<td> <td>@input.Index <span class="text-danger" title="@input.Error"><vc:icon symbol="warning"/></span></td>
@input.Index <span class="text-danger" title="@input.Error"><vc:icon symbol="warning"/></span>
</td>
} }
else else
{ {
<td>@input.Index</td> <td>@input.Index</td>
}<td> }
<td>
@if (input.Labels.Any()) @if (input.Labels.Any())
{ {
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in input.Labels) @foreach (var label in input.Labels)
{ {
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor"> <div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span> <span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link)) @if (!string.IsNullOrEmpty(label.Link))
{ {
<a class="transaction-label-info transaction-details-icon" href="@label.Link" <a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true" target="_blank" rel="noreferrer noopener" title="@label.Tooltip"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"> data-bs-html="true" data-bs-toggle="tooltip"
<vc:icon symbol="info" /> data-bs-custom-class="transaction-label-tooltip">
</a> <vc:icon symbol="info" />
} </a>
</div> }
} </div>
</div> }
} </div>
}
</td>
<td class="text-end text-@(input.Positive ? "success" : "danger")">
<span class="amount-toggle cursor-pointer"
data-btc="@input.BalanceChange.CryptoAmount"
data-fiat="@input.BalanceChange.FiatAmount">
@input.BalanceChange.CryptoAmount
</span>
</td> </td>
<td class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</td>
</tr> </tr>
} }
</tbody> </tbody>
@ -77,28 +88,34 @@
<tr> <tr>
<td class="text-break">@destination.Destination</td> <td class="text-break">@destination.Destination</td>
<td> <td>
@if (destination.Labels.Any()) @if (destination.Labels.Any())
{ {
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in destination.Labels) @foreach (var label in destination.Labels)
{ {
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor"> <div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span> <span>@label.Text</span>
@if (!string.IsNullOrEmpty(label.Link)) @if (!string.IsNullOrEmpty(label.Link))
{ {
<a class="transaction-label-info transaction-details-icon" href="@label.Link" <a class="transaction-label-info transaction-details-icon" href="@label.Link"
target="_blank" rel="noreferrer noopener" title="@label.Tooltip" data-bs-html="true" target="_blank" rel="noreferrer noopener" title="@label.Tooltip"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip"> data-bs-html="true" data-bs-toggle="tooltip"
<vc:icon symbol="info" /> data-bs-custom-class="transaction-label-tooltip">
</a> <vc:icon symbol="info"/>
} </a>
</div> }
} </div>
</div> }
} </div>
}
</td>
<td class="text-end text-@(destination.Positive ? "success" : "danger")">
<span class="amount-toggle cursor-pointer"
data-btc="@destination.Balance.CryptoAmount"
data-fiat="@destination.Balance.FiatAmount">
@destination.Balance.CryptoAmount
</span>
</td> </td>
<td class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</td>
</tr> </tr>
} }
</tbody> </tbody>
@ -112,3 +129,44 @@
<b>@Model.FeeRate</b> <b>@Model.FeeRate</b>
</p> </p>
} }
<script>
document.addEventListener("DOMContentLoaded", function () {
const balanceElement = document.getElementById("balance-toggle");
const amounts = document.querySelectorAll(".amount-toggle");
let showFiat = false;
// Toggle all amounts when clicking balance header
balanceElement.addEventListener("click", function () {
// Check if all elements have a non-empty data-fiat
const allHaveValidFiat = Array.from(amounts).every(el => {
const fiatAmount = el.getAttribute("data-fiat");
return fiatAmount !== null && fiatAmount.trim() !== "";
});
if (!allHaveValidFiat) return; // If any is missing or empty, do nothing
showFiat = !showFiat;
amounts.forEach(el => {
const btcAmount = el.getAttribute("data-btc");
const fiatAmount = el.getAttribute("data-fiat");
el.innerText = showFiat ? fiatAmount : btcAmount;
});
});
// Toggle individual amounts
amounts.forEach(el => {
el.addEventListener("click", function (event) {
event.stopPropagation();
const btcAmount = el.getAttribute("data-btc");
const fiatAmount = el.getAttribute("data-fiat");
if (fiatAmount !== null && fiatAmount.trim() !== "") {
el.innerText = el.innerText === btcAmount ? fiatAmount : btcAmount;
}
});
});
});
</script>