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())
{
vm.CanCalculateBalance = false;
@ -374,7 +387,7 @@ namespace BTCPayServer.Controllers
else
{
var balanceChange = psbtObject.GetBalance(derivationSchemeSettings.AccountDerivation, signingKey, signingKeyPath);
vm.BalanceChange = ValueToString(balanceChange, network);
vm.BalanceChange = ValueToString(balanceChange, network, rate);
vm.CanCalculateBalance = true;
vm.Positive = balanceChange >= Money.Zero;
}
@ -390,7 +403,7 @@ namespace BTCPayServer.Controllers
var balanceChange2 = txOut?.Value ?? Money.Zero;
if (mine)
balanceChange2 = -balanceChange2;
inputVm.BalanceChange = ValueToString(balanceChange2, network);
inputVm.BalanceChange = ValueToString(balanceChange2, network, rate);
inputVm.Positive = balanceChange2 >= Money.Zero;
inputVm.Index = (int)input.Index;
@ -412,7 +425,7 @@ namespace BTCPayServer.Controllers
var balanceChange2 = output.Value;
if (!mine)
balanceChange2 = -balanceChange2;
dest.Balance = ValueToString(balanceChange2, network);
dest.Balance = ValueToString(balanceChange2, network, rate);
dest.Positive = balanceChange2 >= Money.Zero;
dest.Destination = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString() ?? output.ScriptPubKey.ToString();
var address = output.ScriptPubKey.GetDestinationAddress(network.NBitcoinNetwork)?.ToString();
@ -426,7 +439,7 @@ namespace BTCPayServer.Controllers
vm.Destinations.Add(new WalletPSBTReadyViewModel.DestinationViewModel
{
Positive = false,
Balance = ValueToString(-fee, network),
Balance = ValueToString(-fee, network, rate),
Destination = "Mining fees"
});
}

View file

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

View file

@ -15,7 +15,7 @@ namespace BTCPayServer.Models.WalletViewModels
{
public bool Positive { 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>();
}
@ -24,7 +24,7 @@ namespace BTCPayServer.Models.WalletViewModels
public int Index { get; set; }
public string Error { 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 class AmountViewModel
@ -34,7 +34,7 @@ namespace BTCPayServer.Models.WalletViewModels
}
public AmountViewModel ReplacementBalanceChange { get; set; }
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 Positive { get; set; }
public List<DestinationViewModel> Destinations { get; set; } = new List<DestinationViewModel>();
@ -50,5 +50,7 @@ namespace BTCPayServer.Models.WalletViewModels
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">
<span text-translate="true">This transaction will change your balance:</span>
<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>
}
@ -25,36 +31,41 @@
<tr>
@if (input.Error != null)
{
<td>
@input.Index <span class="text-danger" title="@input.Error"><vc:icon symbol="warning"/></span>
</td>
<td>@input.Index <span class="text-danger" title="@input.Error"><vc:icon symbol="warning"/></span></td>
}
else
{
<td>@input.Index</td>
}<td>
}
<td>
@if (input.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in input.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(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"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in input.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(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" data-bs-toggle="tooltip"
data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</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 class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</td>
</tr>
}
</tbody>
@ -77,28 +88,34 @@
<tr>
<td class="text-break">@destination.Destination</td>
<td>
@if (destination.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in destination.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(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"
data-bs-toggle="tooltip" data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" />
</a>
}
</div>
}
</div>
}
@if (destination.Labels.Any())
{
<div class="d-flex flex-wrap gap-2 align-items-center">
@foreach (var label in destination.Labels)
{
<div class="transaction-label" style="--label-bg:@label.Color;--label-fg:@label.TextColor">
<span>@label.Text</span>
@if (!string.IsNullOrEmpty(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" data-bs-toggle="tooltip"
data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info"/>
</a>
}
</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 class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</td>
</tr>
}
</tbody>
@ -112,3 +129,44 @@
<b>@Model.FeeRate</b>
</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>