Fix divisibility in invoice details of lightning amounts (#6202)

* Fix divisibility in invoice details of lightning amounts

This PR will show 11 decimal in the invoice details for BTC amount
of lightning payment methods.

It also hacks around the fact that some
lightning clients don't create the requested amount of sats, which
resulted in over or under payments. (Blink not supporting msats, and
strike)

Now, In that case, a payment method fee (which can be negative) called tweak fee
will be added to the prompt.

We are also hiding this tweak fee from the user in the checkout page in
order to not disturb the UI with inconsequential fee of 0.000000001 sats.

* Only show 8 digits in checkout, even if amount is 11 digits
This commit is contained in:
Nicolas Dorier
2024-09-12 12:43:08 +09:00
committed by GitHub
parent f3d485da53
commit b4946f4db1
11 changed files with 95 additions and 52 deletions

View File

@@ -20,6 +20,7 @@ using BTCPayServer.Data;
using BTCPayServer.HostedServices; using BTCPayServer.HostedServices;
using BTCPayServer.Hosting; using BTCPayServer.Hosting;
using BTCPayServer.JsonConverters; using BTCPayServer.JsonConverters;
using BTCPayServer.Lightning;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
@@ -505,7 +506,6 @@ namespace BTCPayServer.Tests
var paymentMethod = entity.GetPaymentPrompts().TryGet(PaymentTypes.CHAIN.GetPaymentMethodId("BTC")); var paymentMethod = entity.GetPaymentPrompts().TryGet(PaymentTypes.CHAIN.GetPaymentMethodId("BTC"));
var accounting = paymentMethod.Calculate(); var accounting = paymentMethod.Calculate();
Assert.Equal(1.0m, accounting.ToSmallestUnit(Money.Satoshis(1.0m).ToDecimal(MoneyUnit.BTC)));
Assert.Equal(1.1m, accounting.Due); Assert.Equal(1.1m, accounting.Due);
Assert.Equal(1.1m, accounting.TotalDue); Assert.Equal(1.1m, accounting.TotalDue);

View File

@@ -19,6 +19,7 @@ using BTCPayServer.NTag424;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Wallets; using BTCPayServer.Services.Wallets;
using BTCPayServer.Views.Manage; using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server; using BTCPayServer.Views.Server;
@@ -1905,7 +1906,7 @@ namespace BTCPayServer.Tests
Assert.EndsWith("psbt/ready", s.Driver.Url); Assert.EndsWith("psbt/ready", s.Driver.Url);
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click(); s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click();
Assert.Equal(walletTransactionUri.ToString(), s.Driver.Url); Assert.Equal(walletTransactionUri.ToString(), s.Driver.Url);
var bip21 = invoice.EntityToDTO(s.Server.PayTester.GetService<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>()).CryptoInfo.First().PaymentUrls.BIP21; var bip21 = invoice.EntityToDTO(s.Server.PayTester.GetService<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(), s.Server.PayTester.GetService<CurrencyNameTable>()).CryptoInfo.First().PaymentUrls.BIP21;
//let's make bip21 more interesting //let's make bip21 more interesting
bip21 += "&label=Solid Snake&message=Snake? Snake? SNAAAAKE!"; bip21 += "&label=Solid Snake&message=Snake? Snake? SNAAAAKE!";
var parsedBip21 = new BitcoinUrlBuilder(bip21, Network.RegTest); var parsedBip21 = new BitcoinUrlBuilder(bip21, Network.RegTest);

View File

@@ -14,6 +14,7 @@ using BTCPayServer.Models;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Security.Greenfield; using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NBitpayClient; using NBitpayClient;
@@ -27,14 +28,17 @@ namespace BTCPayServer.Controllers
{ {
private readonly UIInvoiceController _InvoiceController; private readonly UIInvoiceController _InvoiceController;
private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions; private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
private readonly CurrencyNameTable _currencyNameTable;
private readonly InvoiceRepository _InvoiceRepository; private readonly InvoiceRepository _InvoiceRepository;
public BitpayInvoiceController(UIInvoiceController invoiceController, public BitpayInvoiceController(UIInvoiceController invoiceController,
Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
CurrencyNameTable currencyNameTable,
InvoiceRepository invoiceRepository) InvoiceRepository invoiceRepository)
{ {
_InvoiceController = invoiceController; _InvoiceController = invoiceController;
_bitpayExtensions = bitpayExtensions; _bitpayExtensions = bitpayExtensions;
_currencyNameTable = currencyNameTable;
_InvoiceRepository = invoiceRepository; _InvoiceRepository = invoiceRepository;
} }
@@ -59,7 +63,7 @@ namespace BTCPayServer.Controllers
})).FirstOrDefault(); })).FirstOrDefault();
if (invoice == null) if (invoice == null)
throw new BitpayHttpException(404, "Object not found"); throw new BitpayHttpException(404, "Object not found");
return new DataWrapper<InvoiceResponse>(invoice.EntityToDTO(_bitpayExtensions, Url)); return new DataWrapper<InvoiceResponse>(invoice.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable));
} }
[HttpGet] [HttpGet]
[Route("invoices")] [Route("invoices")]
@@ -93,7 +97,7 @@ namespace BTCPayServer.Controllers
}; };
var entities = (await _InvoiceRepository.GetInvoices(query)) var entities = (await _InvoiceRepository.GetInvoices(query))
.Select((o) => o.EntityToDTO(_bitpayExtensions, Url)).ToArray(); .Select((o) => o.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable)).ToArray();
return Json(DataWrapper.Create(entities)); return Json(DataWrapper.Create(entities));
} }
@@ -103,7 +107,7 @@ namespace BTCPayServer.Controllers
CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null) CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
{ {
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator); var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
var resp = entity.EntityToDTO(_bitpayExtensions, Url); var resp = entity.EntityToDTO(_bitpayExtensions, Url, _currencyNameTable);
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" }; return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
} }

View File

@@ -94,6 +94,8 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> GetNotificationSettings() public async Task<IActionResult> GetNotificationSettings()
{ {
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null)
return NotFound();
var model = GetNotificationSettingsData(user); var model = GetNotificationSettingsData(user);
return Ok(model); return Ok(model);
} }
@@ -103,6 +105,8 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> UpdateNotificationSettings(UpdateNotificationSettingsRequest request) public async Task<IActionResult> UpdateNotificationSettings(UpdateNotificationSettingsRequest request)
{ {
var user = await _userManager.GetUserAsync(User); var user = await _userManager.GetUserAsync(User);
if (user is null)
return NotFound();
if (request.Disabled.Contains("all")) if (request.Disabled.Contains("all"))
{ {
user.DisabledNotifications = "all"; user.DisabledNotifications = "all";

View File

@@ -576,10 +576,10 @@ namespace BTCPayServer.Controllers
PaymentMethodRaw = data, PaymentMethodRaw = data,
PaymentMethodId = paymentMethodId, PaymentMethodId = paymentMethodId,
PaymentMethod = paymentMethodId.ToString(), PaymentMethod = paymentMethodId.ToString(),
TotalDue = _displayFormatter.Currency(accounting.TotalDue, data.Currency), TotalDue = _displayFormatter.Currency(accounting.TotalDue, data.Currency, divisibility: data.Divisibility),
Due = hasPayment ? _displayFormatter.Currency(accounting.Due, data.Currency) : null, Due = hasPayment ? _displayFormatter.Currency(accounting.Due, data.Currency, divisibility: data.Divisibility) : null,
Paid = hasPayment ? _displayFormatter.Currency(accounting.PaymentMethodPaid, data.Currency) : null, Paid = hasPayment ? _displayFormatter.Currency(accounting.PaymentMethodPaid, data.Currency, divisibility: data.Divisibility) : null,
Overpaid = hasPayment ? _displayFormatter.Currency(overpaidAmount, data.Currency) : null, Overpaid = hasPayment ? _displayFormatter.Currency(overpaidAmount, data.Currency, divisibility: data.Divisibility) : null,
Address = data.Destination Address = data.Destination
}; };
}).ToList(), }).ToList(),
@@ -876,6 +876,13 @@ namespace BTCPayServer.Controllers
_paymentModelExtensions.TryGetValue(paymentMethodId, out var extension); _paymentModelExtensions.TryGetValue(paymentMethodId, out var extension);
return extension?.Image ?? ""; return extension?.Image ?? "";
} }
// Show the "Common divisibility" rather than the payment method disibility.
// For example, BTC has commonly 8 digits, but on lightning it has 11. In this case, pick 8.
if (this._CurrencyNameTable.GetCurrencyData(prompt.Currency, false)?.Divisibility is not int divisibility)
divisibility = prompt.Divisibility;
string ShowMoney(decimal value) => MoneyExtensions.ShowMoney(value, divisibility);
var model = new PaymentModel var model = new PaymentModel
{ {
Activated = prompt.Activated, Activated = prompt.Activated,
@@ -893,10 +900,11 @@ namespace BTCPayServer.Controllers
OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback, OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback,
CryptoImage = Request.GetRelativePathOrAbsolute(GetPaymentMethodImage(paymentMethodId)), CryptoImage = Request.GetRelativePathOrAbsolute(GetPaymentMethodImage(paymentMethodId)),
BtcAddress = prompt.Destination, BtcAddress = prompt.Destination,
BtcDue = accounting.ShowMoney(accounting.Due), BtcDue = ShowMoney(accounting.Due),
BtcPaid = accounting.ShowMoney(accounting.Paid), BtcPaid = ShowMoney(accounting.Paid),
InvoiceCurrency = invoice.Currency, InvoiceCurrency = invoice.Currency,
OrderAmount = accounting.ShowMoney(accounting.TotalDue - accounting.PaymentMethodFee), // The Tweak is part of the PaymentMethodFee, but let's not show it in the UI as it's negligible.
OrderAmount = ShowMoney(accounting.TotalDue - (prompt.PaymentMethodFee - prompt.TweakFee)),
IsUnsetTopUp = invoice.IsUnsetTopUp(), IsUnsetTopUp = invoice.IsUnsetTopUp(),
CustomerEmail = invoice.Metadata.BuyerEmail, CustomerEmail = invoice.Metadata.BuyerEmail,
ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds), ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds),
@@ -928,7 +936,8 @@ namespace BTCPayServer.Controllers
}, },
ReceivedConfirmations = handler is BitcoinLikePaymentHandler bh ? invoice.GetAllBitcoinPaymentData(bh, false).FirstOrDefault()?.ConfirmationCount : null, ReceivedConfirmations = handler is BitcoinLikePaymentHandler bh ? invoice.GetAllBitcoinPaymentData(bh, false).FirstOrDefault()?.ConfirmationCount : null,
Status = invoice.Status.ToString(), Status = invoice.Status.ToString(),
NetworkFee = prompt.PaymentMethodFee, // The Tweak is part of the PaymentMethodFee, but let's not show it in the UI as it's negligible.
NetworkFee = prompt.PaymentMethodFee - prompt.TweakFee,
IsMultiCurrency = invoice.GetPayments(false).Select(p => p.PaymentMethodId).Concat(new[] { prompt.PaymentMethodId }).Distinct().Count() > 1, IsMultiCurrency = invoice.GetPayments(false).Select(p => p.PaymentMethodId).Concat(new[] { prompt.PaymentMethodId }).Distinct().Count() > 1,
StoreId = store.Id, StoreId = store.Id,
AvailableCryptos = invoice.GetPaymentPrompts() AvailableCryptos = invoice.GetPaymentPrompts()

View File

@@ -11,6 +11,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using MimeKit; using MimeKit;
@@ -45,6 +46,7 @@ namespace BTCPayServer.HostedServices
private readonly EmailSenderFactory _EmailSenderFactory; private readonly EmailSenderFactory _EmailSenderFactory;
private readonly StoreRepository _StoreRepository; private readonly StoreRepository _StoreRepository;
private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions; private readonly Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> _bitpayExtensions;
private readonly CurrencyNameTable _currencyNameTable;
public const string NamedClient = "bitpay-ipn"; public const string NamedClient = "bitpay-ipn";
public BitpayIPNSender( public BitpayIPNSender(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
@@ -53,6 +55,7 @@ namespace BTCPayServer.HostedServices
InvoiceRepository invoiceRepository, InvoiceRepository invoiceRepository,
StoreRepository storeRepository, StoreRepository storeRepository,
Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions,
CurrencyNameTable currencyNameTable,
EmailSenderFactory emailSenderFactory) EmailSenderFactory emailSenderFactory)
{ {
_Client = httpClientFactory.CreateClient(NamedClient); _Client = httpClientFactory.CreateClient(NamedClient);
@@ -62,11 +65,12 @@ namespace BTCPayServer.HostedServices
_EmailSenderFactory = emailSenderFactory; _EmailSenderFactory = emailSenderFactory;
_StoreRepository = storeRepository; _StoreRepository = storeRepository;
_bitpayExtensions = bitpayExtensions; _bitpayExtensions = bitpayExtensions;
_currencyNameTable = currencyNameTable;
} }
async Task Notify(InvoiceEntity invoice, InvoiceEvent invoiceEvent, bool extendedNotification, bool sendMail) async Task Notify(InvoiceEntity invoice, InvoiceEvent invoiceEvent, bool extendedNotification, bool sendMail)
{ {
var dto = invoice.EntityToDTO(_bitpayExtensions); var dto = invoice.EntityToDTO(_bitpayExtensions, _currencyNameTable);
var notification = new InvoicePaymentNotificationEventWrapper() var notification = new InvoicePaymentNotificationEventWrapper()
{ {
Data = new InvoicePaymentNotification() Data = new InvoicePaymentNotification()

View File

@@ -3,6 +3,7 @@ using System.Linq;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NBitpayClient; using NBitpayClient;
@@ -13,9 +14,11 @@ namespace BTCPayServer.Payments.Bitcoin
public BitcoinPaymentMethodBitpayAPIExtension( public BitcoinPaymentMethodBitpayAPIExtension(
PaymentMethodId paymentMethodId, PaymentMethodId paymentMethodId,
IEnumerable<IPaymentLinkExtension> paymentLinkExtensions, IEnumerable<IPaymentLinkExtension> paymentLinkExtensions,
CurrencyNameTable currencyNameTable,
PaymentMethodHandlerDictionary handlers) PaymentMethodHandlerDictionary handlers)
{ {
PaymentMethodId = paymentMethodId; PaymentMethodId = paymentMethodId;
_currencyNameTable = currencyNameTable;
paymentLinkExtension = paymentLinkExtensions.Single(p => p.PaymentMethodId == PaymentMethodId); paymentLinkExtension = paymentLinkExtensions.Single(p => p.PaymentMethodId == PaymentMethodId);
handler = (BitcoinLikePaymentHandler)handlers[paymentMethodId]; handler = (BitcoinLikePaymentHandler)handlers[paymentMethodId];
} }
@@ -23,6 +26,16 @@ namespace BTCPayServer.Payments.Bitcoin
private IPaymentLinkExtension paymentLinkExtension; private IPaymentLinkExtension paymentLinkExtension;
private BitcoinLikePaymentHandler handler; private BitcoinLikePaymentHandler handler;
private readonly CurrencyNameTable _currencyNameTable;
internal static decimal ToSmallestUnit(int divisibility, decimal v)
{
for (int i = 0; i < divisibility; i++)
{
v *= 10.0m;
}
return v;
}
public void PopulateCryptoInfo(Services.Invoices.InvoiceCryptoInfo cryptoInfo, InvoiceResponse dto, PaymentPrompt prompt, IUrlHelper urlHelper) public void PopulateCryptoInfo(Services.Invoices.InvoiceCryptoInfo cryptoInfo, InvoiceResponse dto, PaymentPrompt prompt, IUrlHelper urlHelper)
{ {
@@ -32,8 +45,11 @@ namespace BTCPayServer.Payments.Bitcoin
BIP21 = paymentLinkExtension.GetPaymentLink(prompt, urlHelper), BIP21 = paymentLinkExtension.GetPaymentLink(prompt, urlHelper),
}; };
var minerInfo = new MinerFeeInfo(); var minerInfo = new MinerFeeInfo();
minerInfo.TotalFee = accounting.ToSmallestUnit(accounting.PaymentMethodFee);
if (_currencyNameTable.GetCurrencyData(prompt.Currency, false)?.Divisibility is int divisibility)
{
minerInfo.TotalFee = ToSmallestUnit(divisibility, accounting.PaymentMethodFee);
}
minerInfo.SatoshiPerBytes = handler.ParsePaymentPromptDetails(prompt.Details).RecommendedFeeRate.SatoshiPerByte; minerInfo.SatoshiPerBytes = handler.ParsePaymentPromptDetails(prompt.Details).RecommendedFeeRate.SatoshiPerByte;
dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo); dto.MinerFees.TryAdd(cryptoInfo.CryptoCode, minerInfo);

View File

@@ -70,7 +70,7 @@ namespace BTCPayServer.Payments.Lightning
public IOptions<LightningNetworkOptions> Options { get; } public IOptions<LightningNetworkOptions> Options { get; }
public BTCPayNetwork Network => _Network; public BTCPayNetwork Network => _Network;
static LightMoney OneSat = LightMoney.FromUnit(1.0m, LightMoneyUnit.Satoshi);
public async Task ConfigurePrompt(PaymentMethodContext context) public async Task ConfigurePrompt(PaymentMethodContext context)
{ {
if (context.InvoiceEntity.Type == InvoiceType.TopUp) if (context.InvoiceEntity.Type == InvoiceType.TopUp)
@@ -89,15 +89,7 @@ namespace BTCPayServer.Payments.Lightning
var nodeInfo = GetNodeInfo(config, context.Logs, preferOnion); var nodeInfo = GetNodeInfo(config, context.Logs, preferOnion);
var invoice = context.InvoiceEntity; var invoice = context.InvoiceEntity;
decimal due = Extensions.RoundUp(invoice.Price / paymentPrompt.Rate, paymentPrompt.Divisibility); decimal due = paymentPrompt.Calculate().Due;
try
{
due = paymentPrompt.Calculate().Due;
}
catch (Exception)
{
// ignored
}
var client = config.CreateLightningClient(_Network, Options.Value, _lightningClientFactory); var client = config.CreateLightningClient(_Network, Options.Value, _lightningClientFactory);
var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow; var expiry = invoice.ExpirationTime - DateTimeOffset.UtcNow;
if (expiry < TimeSpan.Zero) if (expiry < TimeSpan.Zero)
@@ -116,6 +108,12 @@ namespace BTCPayServer.Payments.Lightning
var request = new CreateInvoiceParams(new LightMoney(due, LightMoneyUnit.BTC), description, expiry); var request = new CreateInvoiceParams(new LightMoney(due, LightMoneyUnit.BTC), description, expiry);
request.PrivateRouteHints = storeBlob.LightningPrivateRouteHints; request.PrivateRouteHints = storeBlob.LightningPrivateRouteHints;
lightningInvoice = await client.CreateInvoice(request, cts.Token); lightningInvoice = await client.CreateInvoice(request, cts.Token);
var diff = request.Amount - lightningInvoice.Amount;
if (diff != LightMoney.Zero)
{
// Some providers doesn't round up to msat. So we tweak the fees so the due match the BOLT11's amount.
paymentPrompt.AddTweakFee(-diff.ToUnit(LightMoneyUnit.BTC));
}
} }
catch (OperationCanceledException) when (cts.IsCancellationRequested) catch (OperationCanceledException) when (cts.IsCancellationRequested)
{ {

View File

@@ -31,16 +31,16 @@ public class DisplayFormatter
/// <param name="currency">Currency code</param> /// <param name="currency">Currency code</param>
/// <param name="format">The format, defaults to amount + code, e.g. 1.234,56 USD</param> /// <param name="format">The format, defaults to amount + code, e.g. 1.234,56 USD</param>
/// <returns>Formatted amount and currency string</returns> /// <returns>Formatted amount and currency string</returns>
public string Currency(decimal value, string currency, CurrencyFormat format = CurrencyFormat.Code) public string Currency(decimal value, string currency, CurrencyFormat format = CurrencyFormat.Code, int? divisibility = null)
{ {
var provider = _currencyNameTable.GetNumberFormatInfo(currency, true); var provider = _currencyNameTable.GetNumberFormatInfo(currency, true);
var currencyData = _currencyNameTable.GetCurrencyData(currency, true); var currencyData = _currencyNameTable.GetCurrencyData(currency, true);
var divisibility = currencyData.Divisibility; var div = divisibility is int d ? d : currencyData.Divisibility;
value = value.RoundToSignificant(ref divisibility); value = value.RoundToSignificant(ref div);
if (divisibility != provider.CurrencyDecimalDigits) if (divisibility != provider.CurrencyDecimalDigits)
{ {
provider = (NumberFormatInfo)provider.Clone(); provider = (NumberFormatInfo)provider.Clone();
provider.CurrencyDecimalDigits = divisibility; provider.CurrencyDecimalDigits = div;
} }
var formatted = value.ToString("C", provider); var formatted = value.ToString("C", provider);

View File

@@ -13,6 +13,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.Rating; using BTCPayServer.Rating;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
@@ -521,11 +522,11 @@ namespace BTCPayServer.Services.Invoices
return DateTimeOffset.UtcNow > ExpirationTime; return DateTimeOffset.UtcNow > ExpirationTime;
} }
public InvoiceResponse EntityToDTO(IDictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions) public InvoiceResponse EntityToDTO(IDictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, CurrencyNameTable currencyNameTable)
{ {
return EntityToDTO(bitpayExtensions, null); return EntityToDTO(bitpayExtensions, null, currencyNameTable);
} }
public InvoiceResponse EntityToDTO(IDictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, IUrlHelper urlHelper) public InvoiceResponse EntityToDTO(IDictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension> bitpayExtensions, IUrlHelper urlHelper, CurrencyNameTable currencyNameTable)
{ {
ServerUrl = ServerUrl ?? ""; ServerUrl = ServerUrl ?? "";
InvoiceResponse dto = new InvoiceResponse InvoiceResponse dto = new InvoiceResponse
@@ -608,8 +609,11 @@ namespace BTCPayServer.Services.Invoices
// is for legacy compatibility with the Bitpay API // is for legacy compatibility with the Bitpay API
var paymentCode = GetPaymentCode(info.Currency, paymentId); var paymentCode = GetPaymentCode(info.Currency, paymentId);
dto.PaymentCodes.Add(paymentCode, cryptoInfo.PaymentUrls); dto.PaymentCodes.Add(paymentCode, cryptoInfo.PaymentUrls);
dto.PaymentSubtotals.Add(paymentCode, accounting.ToSmallestUnit(subtotalPrice)); if (info.Currency is not null && currencyNameTable.GetCurrencyData(info.Currency, true)?.Divisibility is int divisibility)
dto.PaymentTotals.Add(paymentCode, accounting.ToSmallestUnit(accounting.TotalDue)); {
dto.PaymentSubtotals.Add(paymentCode, BitcoinPaymentMethodBitpayAPIExtension.ToSmallestUnit(divisibility, subtotalPrice));
dto.PaymentTotals.Add(paymentCode, BitcoinPaymentMethodBitpayAPIExtension.ToSmallestUnit(divisibility, accounting.TotalDue));
}
dto.SupportedTransactionCurrencies.TryAdd(cryptoCode, new InvoiceSupportedTransactionCurrency() dto.SupportedTransactionCurrencies.TryAdd(cryptoCode, new InvoiceSupportedTransactionCurrency()
{ {
Enabled = true Enabled = true
@@ -810,7 +814,6 @@ namespace BTCPayServer.Services.Invoices
public class PaymentMethodAccounting public class PaymentMethodAccounting
{ {
public int Divisibility { get; set; }
/// <summary>Total amount of this invoice</summary> /// <summary>Total amount of this invoice</summary>
public decimal TotalDue { get; set; } public decimal TotalDue { get; set; }
@@ -850,23 +853,9 @@ namespace BTCPayServer.Services.Invoices
/// </summary> /// </summary>
public decimal PaymentMethodFee { get; set; } public decimal PaymentMethodFee { get; set; }
/// <summary> /// <summary>
/// Amount of fee already paid in the invoice's currency
/// </summary>
public decimal PaymentMethodFeeAlreadyPaid { get; set; }
/// <summary>
/// Minimum required to be paid in order to accept invoice as paid /// Minimum required to be paid in order to accept invoice as paid
/// </summary> /// </summary>
public decimal MinimumTotalDue { get; set; } public decimal MinimumTotalDue { get; set; }
public decimal ToSmallestUnit(decimal v)
{
for (int i = 0; i < Divisibility; i++)
{
v *= 10.0m;
}
return v;
}
public string ShowMoney(decimal v) => MoneyExtensions.ShowMoney(v, Divisibility);
} }
public class PaymentPrompt public class PaymentPrompt
@@ -884,6 +873,26 @@ namespace BTCPayServer.Services.Invoices
public int Divisibility { get; set; } public int Divisibility { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))] [JsonConverter(typeof(NumericStringJsonConverter))]
public decimal PaymentMethodFee { get; set; } public decimal PaymentMethodFee { get; set; }
/// <summary>
/// A fee, hidden from UI, meant to be used when a payment method has a service provider which
/// have a different way of converting the invoice's amount into the currency of the payment method.
/// This fee can avoid under/over payments when this case happens.
///
/// Please use <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
/// </summary>
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal TweakFee { get; set; }
/// <summary>
/// A fee, hidden from UI, meant to be used when a payment method has a service provider which
/// have a different way of converting the invoice's amount into the currency of the payment method.
/// This fee can avoid under/over payments when this case happens.
/// </summary>
/// <param name="value"></param>
public void AddTweakFee(decimal value)
{
TweakFee += value;
PaymentMethodFee += value;
}
public string Destination { get; set; } public string Destination { get; set; }
public JToken Details { get; set; } public JToken Details { get; set; }
@@ -901,7 +910,6 @@ namespace BTCPayServer.Services.Invoices
accounting.TxRequired++; accounting.TxRequired++;
grossDue += rate * PaymentMethodFee; grossDue += rate * PaymentMethodFee;
} }
accounting.Divisibility = Divisibility;
accounting.TotalDue = Coins(grossDue / rate, Divisibility); accounting.TotalDue = Coins(grossDue / rate, Divisibility);
accounting.Paid = Coins(i.PaidAmount.Gross / rate, Divisibility); accounting.Paid = Coins(i.PaidAmount.Gross / rate, Divisibility);
accounting.PaymentMethodPaid = Coins(thisPaymentMethodPayments.Sum(p => p.PaidAmount.Gross), Divisibility); accounting.PaymentMethodPaid = Coins(thisPaymentMethodPayments.Sum(p => p.PaidAmount.Gross), Divisibility);
@@ -913,7 +921,6 @@ namespace BTCPayServer.Services.Invoices
accounting.Due = Max(accounting.DueUncapped, 0.0m); accounting.Due = Max(accounting.DueUncapped, 0.0m);
accounting.PaymentMethodFee = Coins((grossDue - i.Price) / rate, Divisibility); accounting.PaymentMethodFee = Coins((grossDue - i.Price) / rate, Divisibility);
accounting.PaymentMethodFeeAlreadyPaid = Coins(i.PaidFee / rate, Divisibility);
accounting.MinimumTotalDue = Max(Smallest(Divisibility), Coins((grossDue * (1.0m - ((decimal)i.PaymentTolerance / 100.0m))) / rate, Divisibility)); accounting.MinimumTotalDue = Max(Smallest(Divisibility), Coins((grossDue * (1.0m - ((decimal)i.PaymentTolerance / 100.0m))) / rate, Divisibility));
return accounting; return accounting;

View File

@@ -22,7 +22,7 @@
Type = prettyName.PrettyName(payment.PaymentMethodId), Type = prettyName.PrettyName(payment.PaymentMethodId),
BOLT11 = offChainPaymentData.BOLT11, BOLT11 = offChainPaymentData.BOLT11,
PaymentProof = offChainPaymentData.Preimage?.ToString(), PaymentProof = offChainPaymentData.Preimage?.ToString(),
Amount = DisplayFormatter.Currency(payment.Value, handler.Network.CryptoCode) Amount = DisplayFormatter.Currency(payment.Value, handler.Network.CryptoCode, divisibility: payment.Divisibility)
}; };
}) })
.Where(model => model != null) .Where(model => model != null)