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,6 +80,7 @@ namespace BTCPayServer.Controllers
private readonly PendingTransactionService _pendingTransactionService;
readonly CurrencyNameTable _currencyTable;
private readonly DisplayFormatter _displayFormatter;
public UIWalletsController(
PendingTransactionService pendingTransactionService,
@ -107,7 +107,8 @@ namespace BTCPayServer.Controllers
PaymentMethodHandlerDictionary handlers,
Dictionary<PaymentMethodId, ICheckoutModelExtension> paymentModelExtensions,
IStringLocalizer stringLocalizer,
TransactionLinkProviders transactionLinkProviders)
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
{
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)
var r = await FetchRate(walletId);
model.Rate = r.Rate;
model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(r.Fiat, true)
.CurrencyDecimalDigits;
model.Fiat = currencyPair.Right;
}
else
{
model.RateError =
$"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType<object>().ToArray())})";
}
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,14 +31,13 @@
<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">
@ -43,8 +48,9 @@
@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">
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>
}
@ -53,8 +59,13 @@
</div>
}
</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>
}
</tbody>
@ -87,9 +98,10 @@
@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" />
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>
@ -97,8 +109,13 @@
</div>
}
</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>
}
</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>