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,6 +80,7 @@ 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,
@ -107,7 +107,8 @@ namespace BTCPayServer.Controllers
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
{ {
cts.CancelAfter(TimeSpan.FromSeconds(5)); var r = await FetchRate(walletId);
var result = await RateFetcher.FetchRate(currencyPair, rateRules, new StoreIdRateContext(walletId.StoreId), cts.Token)
.WithCancellation(cts.Token); model.Rate = r.Rate;
if (result.BidAsk != null) model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(r.Fiat, true)
{
model.Rate = result.BidAsk.Center;
model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true)
.CurrencyDecimalDigits; .CurrencyDecimalDigits;
model.Fiat = currencyPair.Right; model.Fiat = r.Fiat;
}
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,14 +31,13 @@
<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">
@ -43,8 +48,9 @@
@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"
data-bs-custom-class="transaction-label-tooltip">
<vc:icon symbol="info" /> <vc:icon symbol="info" />
</a> </a>
} }
@ -53,8 +59,13 @@
</div> </div>
} }
</td> </td>
<td class="text-end text-@(input.Positive ? "success" : "danger")">@input.BalanceChange</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>
</tr> </tr>
} }
</tbody> </tbody>
@ -87,9 +98,10 @@
@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">
<vc:icon symbol="info"/>
</a> </a>
} }
</div> </div>
@ -97,8 +109,13 @@
</div> </div>
} }
</td> </td>
<td class="text-end text-@(destination.Positive ? "success" : "danger")">@destination.Balance</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>
</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>