mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-12 02:08:32 +01:00
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:
parent
71dbfd9f28
commit
9dcf8d3251
4 changed files with 199 additions and 109 deletions
|
@ -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"
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue