Apply taxes and set receipt properly for PoS Print View

This commit is contained in:
nicolas.dorier
2025-06-12 14:03:25 +09:00
parent 5959d22964
commit 9f04bd473a
9 changed files with 264 additions and 131 deletions

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
@@ -13,8 +14,10 @@ using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
using LNURL;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Playwright; using Microsoft.Playwright;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
@@ -477,20 +480,19 @@ goodies:
[Fact] [Fact]
[Trait("Playwright", "Playwright")] [Trait("Playwright", "Playwright")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePOSProductList() public async Task CanUsePOSProductList()
{ {
await using var s = CreatePlaywrightTester(); await using var s = CreatePlaywrightTester();
s.Server.ActivateLightning();
await s.StartAsync(); await s.StartAsync();
await s.RegisterNewUser(true);
await s.RegisterNewUser();
s.AsTestAccount();
await s.GoToHome();
await s.CreateNewStore(); await s.CreateNewStore();
await s.GoToStore();
await s.AddDerivationScheme(); await s.AddDerivationScheme();
await s.AddLightningNode();
// Let's check Custom amount works as expected // Let's check Custom amount works as expected
await s.CreateApp("PointOfSale"); var (_, appId) = await s.CreateApp("PointOfSale");
var appUrl = s.Page.Url; var appUrl = s.Page.Url;
await s.Page.FillAsync("#Currency", "BTC"); await s.Page.FillAsync("#Currency", "BTC");
await s.Page.SetCheckedAsync("#ShowCustomAmount", true); await s.Page.SetCheckedAsync("#ShowCustomAmount", true);
@@ -538,6 +540,41 @@ goodies:
await s.ClickPagePrimary(); await s.ClickPagePrimary();
await AssertInvoiceAmount(s, "1.50000000 BTC"); await AssertInvoiceAmount(s, "1.50000000 BTC");
} }
await s.Page.FillAsync("#DefaultTaxRate", "10");
await s.ClickPagePrimary();
await GoToLNUrlCheckout(s, appId, "black-tea");
await AssertInvoiceAmount(s, "1.10000000 BTC");
await s.MarkAsSettled();
await s.Page.ClickAsync("#ReceiptLink");
await AssertReceipt(s, new()
{
Items = [
new("BlackTea", "1.00000000 BTC"),
],
Sums = [
new("Subtotal", "1.00000000 BTC"),
new("Tax", "0.10000000 BTC (10%)"),
new("Total", "1.10000000 BTC")
]
});
await GoToLNUrlCheckout(s, appId, "fruit-tea");
await AssertInvoiceAmount(s, "Any amount");
await GoToLNUrlCheckout(s, appId, "");
await AssertInvoiceAmount(s, "Any amount");
}
private static async Task GoToLNUrlCheckout(PlaywrightTester s, string appId, string item)
{
var result = await s.Server.PayTester.HttpClient.GetStringAsync($"BTC/lnurl/pay/app/{appId}/{item}");
var req = JsonConvert.DeserializeObject<LNURLPayRequest>(result);
var invoiceId = Regex.Replace(req.Callback.AbsoluteUri, ".*/pay/i/(.*)", "$1");
s.InvoiceId = invoiceId;
await s.GoToInvoiceCheckout();
} }
private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount) private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
@@ -634,12 +671,10 @@ goodies:
await AssertReceipt(s, new() await AssertReceipt(s, new()
{ {
Items = [ Items = [
new("Custom Amount 1", "1 234,00 €"), new("Custom Amount 1", "1 234,00 €"),
new("Custom Amount 2", "0,56 €") new("Custom Amount 2", "0,56 €")
], ],
Sums = [ Sums = [
new("Items total", "1 234,56 €"), new("Items total", "1 234,56 €"),
new("Discount", "123,46 € (10%)"), new("Discount", "123,46 € (10%)"),
new("Subtotal", "1 111,10 €"), new("Subtotal", "1 111,10 €"),

View File

@@ -7,6 +7,9 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Blazor.VaultBridge.Elements; using BTCPayServer.Blazor.VaultBridge.Elements;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Views.Manage; using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server; using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
@@ -15,6 +18,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Playwright; using Microsoft.Playwright;
using NBitcoin; using NBitcoin;
using NBitcoin.RPC; using NBitcoin.RPC;
using OpenQA.Selenium;
using Xunit; using Xunit;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
@@ -23,7 +27,7 @@ namespace BTCPayServer.Tests
{ {
public Uri ServerUri; public Uri ServerUri;
private string CreatedUser; private string CreatedUser;
private string InvoiceId; internal string InvoiceId;
public Logging.ILog TestLogs => Server.TestLogs; public Logging.ILog TestLogs => Server.TestLogs;
public IPage Page { get; set; } public IPage Page { get; set; }
public IBrowser Browser { get; private set; } public IBrowser Browser { get; private set; }
@@ -107,9 +111,7 @@ namespace BTCPayServer.Tests
public async Task GoToInvoiceCheckout(string invoiceId = null) public async Task GoToInvoiceCheckout(string invoiceId = null)
{ {
invoiceId ??= InvoiceId; invoiceId ??= InvoiceId;
await Page.Locator("#StoreNav-Invoices").ClickAsync(); await GoToUrl($"/i/{invoiceId}");
await Page.Locator($"#invoice-checkout-{invoiceId}").ClickAsync();
await Page.Locator("#Checkout").WaitForAsync();
} }
public async Task GoToWallet(WalletId walletId = null, WalletsNavPages navPages = WalletsNavPages.Send) public async Task GoToWallet(WalletId walletId = null, WalletsNavPages navPages = WalletsNavPages.Send)
@@ -380,6 +382,61 @@ namespace BTCPayServer.Tests
await FindAlertMessage(); await FindAlertMessage();
} }
public async Task AddLightningNode(string connectionType = null, bool test = true)
{
var cryptoCode = "BTC";
if (!(await Page.ContentAsync()).Contains("Connect to a Lightning node"))
{
await GoToLightningSettings();
}
var connectionString = connectionType switch
{
LightningConnectionType.CLightning =>
$"type=clightning;server={((CLightningClient)Server.MerchantLightningD).Address.AbsoluteUri}",
LightningConnectionType.LndREST =>
$"type=lnd-rest;server={Server.MerchantLnd.Swagger.BaseUrl};allowinsecure=true",
_ => null
};
if (connectionString == null)
{
Assert.True(await Page.IsEnabledAsync("#LightningNodeType-Internal"), "Usage of the internal Lightning node is disabled.");
await Page.ClickAsync("label[for=\"LightningNodeType-Internal\"]");
}
else
{
await Page.ClickAsync("label[for=\"LightningNodeType-Custom\"]");
await Page.FillAsync("#ConnectionString", connectionString);
if (test)
{
await Page.ClickAsync("#test");
await FindAlertMessage(partialText: "Connection to the Lightning node successful.");
}
}
await ClickPagePrimary();
await FindAlertMessage(partialText: $"{cryptoCode} Lightning node updated.");
var enabled = await Page.WaitForSelectorAsync($"#{cryptoCode}LightningEnabled");
if (!await enabled!.IsCheckedAsync())
{
await enabled.ClickAsync();
await ClickPagePrimary();
await FindAlertMessage(partialText: $"{cryptoCode} Lightning settings successfully updated");
}
}
public async Task GoToLightningSettings(string cryptoCode = "BTC")
{
await Page.ClickAsync($"#StoreNav-Lightning{cryptoCode}");
// if Lightning is already set up we need to navigate to the settings
if ((await Page.ContentAsync()).Contains("id=\"StoreNav-LightningSettings\""))
{
await Page.ClickAsync("#StoreNav-LightningSettings");
}
}
public async Task ClickPagePrimary() public async Task ClickPagePrimary()
{ {
await Page.Locator("#page-primary").ClickAsync(); await Page.Locator("#page-primary").ClickAsync();
@@ -628,5 +685,12 @@ namespace BTCPayServer.Tests
public Task SetFeeRate(decimal val) => page.FillAsync("[name=\"FeeSatoshiPerByte\"]", val.ToString(CultureInfo.InvariantCulture)); public Task SetFeeRate(decimal val) => page.FillAsync("[name=\"FeeSatoshiPerByte\"]", val.ToString(CultureInfo.InvariantCulture));
} }
public async Task MarkAsSettled()
{
var txId = Regex.Replace(Page.Url, ".*/(.*)$", "$1");
var client = await this.AsTestAccount().CreateClient();
await client.MarkInvoiceStatus(StoreId, txId, new() { Status = InvoiceStatus.Settled });
}
} }
} }

View File

@@ -45,6 +45,7 @@ namespace BTCPayServer.Controllers
typeof(InvoiceMetadata) typeof(InvoiceMetadata)
.GetProperties() .GetProperties()
.Select(p => p.Name) .Select(p => p.Name)
.Where(p => p != "ReceiptData")
.ToHashSet(StringComparer.OrdinalIgnoreCase); .ToHashSet(StringComparer.OrdinalIgnoreCase);
InvoiceAdditionalDataExclude.Remove(nameof(InvoiceMetadata.PosData)); InvoiceAdditionalDataExclude.Remove(nameof(InvoiceMetadata.PosData));
} }

View File

@@ -34,6 +34,7 @@ using BTCPayServer.Services.Stores;
using LNURL; using LNURL;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
@@ -62,6 +63,8 @@ namespace BTCPayServer
private readonly InvoiceActivator _invoiceActivator; private readonly InvoiceActivator _invoiceActivator;
private readonly PaymentMethodHandlerDictionary _handlers; private readonly PaymentMethodHandlerDictionary _handlers;
private readonly PayoutProcessorService _payoutProcessorService; private readonly PayoutProcessorService _payoutProcessorService;
private readonly CurrencyNameTable _currencies;
private readonly DisplayFormatter _displayFormatter;
public IStringLocalizer StringLocalizer { get; } public IStringLocalizer StringLocalizer { get; }
public UILNURLController(InvoiceRepository invoiceRepository, public UILNURLController(InvoiceRepository invoiceRepository,
@@ -78,8 +81,11 @@ namespace BTCPayServer
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
IPluginHookService pluginHookService, IPluginHookService pluginHookService,
IStringLocalizer stringLocalizer, IStringLocalizer stringLocalizer,
InvoiceActivator invoiceActivator) InvoiceActivator invoiceActivator,
CurrencyNameTable currencies, DisplayFormatter displayFormatter)
{ {
_currencies = currencies;
_displayFormatter = displayFormatter;
_invoiceRepository = invoiceRepository; _invoiceRepository = invoiceRepository;
_eventAggregator = eventAggregator; _eventAggregator = eventAggregator;
_payoutHandlers = payoutHandlers; _payoutHandlers = payoutHandlers;
@@ -112,7 +118,7 @@ namespace BTCPayServer
{ {
return NotFound(); return NotFound();
} }
var pmi = PayoutTypes.LN.GetPayoutMethodId(cryptoCode); var pmi = PayoutTypes.LN.GetPayoutMethodId(cryptoCode);
var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode); var paymentMethodId = PaymentTypes.LN.GetPaymentMethodId(cryptoCode);
var pp = await _pullPaymentHostedService.GetPullPayment(pullPaymentId, true); var pp = await _pullPaymentHostedService.GetPullPayment(pullPaymentId, true);
@@ -264,31 +270,17 @@ namespace BTCPayServer
return network; return network;
} }
[HttpGet("pay/app/{appId}/{itemCode}")] [HttpGet("pay/app/{appId}/{itemCode?}")]
public async Task<IActionResult> GetLNURLForApp(string cryptoCode, string appId, string itemCode = null) public async Task<IActionResult> GetLNURLForApp(string cryptoCode, string appId, string itemCode = null)
{ {
var network = GetNetwork(cryptoCode); var network = GetNetwork(cryptoCode);
if (network is null || !network.SupportLightning)
{
return NotFound();
}
var app = await _appService.GetApp(appId, null, true); var app = await _appService.GetApp(appId, null, true);
if (app is null) var store = app?.StoreData;
{
return NotFound();
}
var store = app.StoreData;
if (store is null) if (store is null)
{
return NotFound(); return NotFound();
} if (network?.SupportLightning is not true ||
GetLNUrlPaymentMethodId(cryptoCode, store, out _) is null)
if (string.IsNullOrEmpty(itemCode)) return NotFound(StringLocalizer["LNURL or LN is disabled"]);
{
return NotFound();
}
AppItem[] items; AppItem[] items;
string currencyCode; string currencyCode;
@@ -313,9 +305,6 @@ namespace BTCPayServer
AppItem item = null; AppItem item = null;
if (!string.IsNullOrEmpty(itemCode)) if (!string.IsNullOrEmpty(itemCode))
{ {
var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _);
if (pmi is null)
return NotFound(StringLocalizer["LNURL or LN is disabled"]);
var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode); var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode);
item = items.FirstOrDefault(item1 => item = items.FirstOrDefault(item1 =>
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) || item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
@@ -329,9 +318,22 @@ namespace BTCPayServer
return NotFound(); return NotFound();
} }
var order = new PoSOrder(_currencies.GetNumberFormatInfo(currencyCode, true).CurrencyDecimalDigits);
var posAppData = new PosAppData();
posAppData.Cart = [];
if (item != null)
{
posAppData.Cart = new PosAppCartItem[] { new() { Id = item.Id, Count = 1, Price = item.Price ?? 0 } };
order.AddLine(new(item.Id ?? "", 1, item.Price ?? 0m, item.TaxRate ?? posS?.DefaultTaxRate ?? 0m));
}
var summary = order.Calculate();
var isTopup = item is null or { PriceType: AppItemPriceType.Topup } or { Price: null };
var receiptData = PosReceiptData.Create(isTopup, item is {} ? new[]{ item } : [], posAppData, order, summary, currencyCode, _displayFormatter);
var createInvoice = new CreateInvoiceRequest var createInvoice = new CreateInvoiceRequest
{ {
Amount = item?.PriceType == AppItemPriceType.Topup ? null : item?.Price, Amount = isTopup ? null : summary.PriceTaxIncludedWithTips,
Currency = currencyCode, Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions Checkout = new InvoiceDataBase.CheckoutOptions
{ {
@@ -342,17 +344,20 @@ namespace BTCPayServer
_ => null _ => null
} }
}, },
Metadata = new InvoiceMetadata
{
ItemCode = item?.Id,
ItemDesc = item?.Title,
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
OrderId = AppService.GetRandomOrderId(),
OrderUrl = Request.GetDisplayUrl(),
PosData = JObject.FromObject(posAppData),
ReceiptData = receiptData
}.ToJObject(),
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) } AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
}; };
var allowOverpay = item?.PriceType is not AppItemPriceType.Fixed; var allowOverpay = item?.PriceType is not AppItemPriceType.Fixed;
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
if (item != null)
{
invoiceMetadata.ItemCode = item.Id;
invoiceMetadata.ItemDesc = item.Description;
}
createInvoice.Metadata = invoiceMetadata.ToJObject();
return await GetLNURLRequest( return await GetLNURLRequest(
cryptoCode, cryptoCode,
@@ -406,7 +411,7 @@ namespace BTCPayServer
{ {
if (string.IsNullOrEmpty(username)) if (string.IsNullOrEmpty(username))
return NotFound("Unknown username"); return NotFound("Unknown username");
LNURLPayRequest lnurlRequest; LNURLPayRequest lnurlRequest;
// Check core and fall back to lookup Lightning Address via plugins // Check core and fall back to lookup Lightning Address via plugins
@@ -425,7 +430,7 @@ namespace BTCPayServer
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
if (store is null) if (store is null)
return NotFound("Unknown username"); return NotFound("Unknown username");
var cryptoCode = "BTC"; var cryptoCode = "BTC";
if (GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod) is null) if (GetLNUrlPaymentMethodId(cryptoCode, store, out var lnUrlMethod) is null)
return NotFound("LNURL not available for store"); return NotFound("LNURL not available for store");
@@ -503,8 +508,8 @@ namespace BTCPayServer
public async Task<IActionResult> GetLNUrlForStore( public async Task<IActionResult> GetLNUrlForStore(
string cryptoCode, string cryptoCode,
string storeId, string storeId,
string currency = null, string currency = null,
string orderId = null, string orderId = null,
decimal? amount = null) decimal? amount = null)
{ {
var store = await _storeRepository.FindStore(storeId); var store = await _storeRepository.FindStore(storeId);

View File

@@ -321,54 +321,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
break; break;
} }
var receiptData = new PosReceiptData();
var summary = order.Calculate(); var summary = order.Calculate();
var isTopup = currentView == PosViewType.Static && var isTopup = currentView == PosViewType.Static &&
selectedChoices.Any(c => c.PriceType == AppItemPriceType.Topup); selectedChoices.Any(c => c.PriceType == AppItemPriceType.Topup);
if (!isTopup)
{
jposData.ItemsTotal = summary.ItemsTotal;
jposData.DiscountAmount = summary.Discount;
jposData.Subtotal = summary.PriceTaxExcluded;
jposData.Tax = summary.Tax;
jposData.Tip = summary.Tip;
jposData.Total = summary.PriceTaxIncludedWithTips;
receiptData.Subtotal = _displayFormatter.Currency(jposData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
if (jposData.DiscountAmount > 0)
{
var discountFormatted = _displayFormatter.Currency(jposData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
receiptData.Discount = jposData.DiscountPercentage > 0 ? $"{discountFormatted} ({jposData.DiscountPercentage}%)" : discountFormatted;
}
if (jposData.Tip > 0)
{
var tipFormatted = _displayFormatter.Currency(jposData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
receiptData.Tip = jposData.TipPercentage > 0 ? $"{tipFormatted} ({jposData.TipPercentage}%)" : tipFormatted;
}
if (jposData.Tax > 0)
{
var taxFormatted = _displayFormatter.Currency(jposData.Tax, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
if (order.GetTaxRate() is { } r)
taxFormatted = $"{taxFormatted} ({r:0.######}%)";
receiptData.Tax = taxFormatted;
}
if (jposData.ItemsTotal > 0)
{
var itemsTotal = _displayFormatter.Currency(jposData.ItemsTotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
receiptData.ItemsTotal = itemsTotal;
}
receiptData.Total = _displayFormatter.Currency(jposData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
if (receiptData.ItemsTotal == receiptData.Subtotal)
receiptData.ItemsTotal = null;
if (receiptData.Subtotal == receiptData.Total)
receiptData.Subtotal = null;
}
var receiptData = PosReceiptData.Create(isTopup, selectedChoices, jposData, order, summary, settings.Currency, _displayFormatter);
try try
{ {
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
@@ -381,7 +338,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null, ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
BuyerEmail = email, BuyerEmail = email,
TaxIncluded = summary.Tax == 0m ? null : summary.Tax, TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
OrderId = orderId ?? AppService.GetRandomOrderId() OrderId = orderId ?? AppService.GetRandomOrderId(),
OrderUrl = Request.GetDisplayUrl(),
PosData = JObject.FromObject(jposData),
ReceiptData = receiptData
}.ToJObject(), }.ToJObject(),
Checkout = new InvoiceDataBase.CheckoutOptions() Checkout = new InvoiceDataBase.CheckoutOptions()
{ {
@@ -400,45 +360,12 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl; string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl;
entity.FullNotifications = true; entity.FullNotifications = true;
entity.ExtendedNotifications = true; entity.ExtendedNotifications = true;
entity.Metadata.OrderUrl = Request.GetDisplayUrl(); if (formResponseJObject is not null)
entity.Metadata.PosData = JObject.FromObject(jposData);
if (selectedChoices.Count == 1)
{ {
receiptData.Title = selectedChoices[0].Title; var meta = entity.Metadata.ToJObject();
if (!string.IsNullOrEmpty(selectedChoices[0].Description)) meta.Merge(formResponseJObject);
receiptData.Description = selectedChoices[0].Description; entity.Metadata = InvoiceMetadata.FromJObject(meta);
} }
Dictionary<string,string> cartData = null;
foreach (var cartItem in jposData.Cart)
{
var selectedChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
if (selectedChoice is null)
continue;
var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
var ident = selectedChoice.Title ?? selectedChoice.Id;
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
cartData ??= new();
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
}
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
{
cartData ??= new();
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(jposData.Amounts[i], settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
}
receiptData.Cart = cartData;
entity.Metadata.SetAdditionalData("receiptData", receiptData);
if (formResponseJObject is null)
return;
var meta = entity.Metadata.ToJObject();
meta.Merge(formResponseJObject);
entity.Metadata = InvoiceMetadata.FromJObject(meta);
}); });
var data = new { invoiceId = invoice.Id }; var data = new { invoiceId = invoice.Id };
if (wantsJson) if (wantsJson)

View File

@@ -212,6 +212,13 @@ namespace BTCPayServer.Services.Invoices
[JsonExtensionData] [JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } public IDictionary<string, JToken> AdditionalData { get; set; }
[JsonIgnore]
public PosReceiptData ReceiptData
{
get => this.GetAdditionalData<PosReceiptData>("receiptData");
set => this.SetAdditionalData("receiptData", value);
}
public static InvoiceMetadata FromJObject(JObject jObject) public static InvoiceMetadata FromJObject(JObject jObject)
{ {
return jObject.ToObject<InvoiceMetadata>(MetadataSerializer); return jObject.ToObject<InvoiceMetadata>(MetadataSerializer);
@@ -940,7 +947,7 @@ namespace BTCPayServer.Services.Invoices
/// An additional fee, hidden from UI, meant to be used when a payment method has a service provider which /// An additional 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. /// 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. /// This fee can avoid under/over payments when this case happens.
/// ///
/// You need to increment it with <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>. /// You need to increment it with <see cref="AddTweakFee(decimal)"/> so that the tweak fee is also added to the <see cref="PaymentMethodFee"/>.
/// </summary> /// </summary>
[JsonConverter(typeof(NumericStringJsonConverter))] [JsonConverter(typeof(NumericStringJsonConverter))]

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using BTCPayServer.Plugins.PointOfSale;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -58,6 +59,16 @@ public class PosAppData
public decimal Tip { get; set; } public decimal Tip { get; set; }
[JsonProperty(PropertyName = "total")] [JsonProperty(PropertyName = "total")]
public decimal Total { get; set; } public decimal Total { get; set; }
internal void UpdateFrom(PoSOrder.OrderSummary summary)
{
ItemsTotal = summary.ItemsTotal;
DiscountAmount = summary.Discount;
Subtotal = summary.PriceTaxExcluded;
Tax = summary.Tax;
Tip = summary.Tip;
Total = summary.PriceTaxIncludedWithTips;
}
} }
public class PosAppCartItem public class PosAppCartItem

View File

@@ -1,4 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Plugins.PointOfSale;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -16,4 +19,83 @@ public class PosReceiptData
public string Total { get; set; } public string Total { get; set; }
public string ItemsTotal { get; set; } public string ItemsTotal { get; set; }
public string Tax { get; set; } public string Tax { get; set; }
void UpdateTotals(PosAppData appData, PoSOrder order, PoSOrder.OrderSummary summary, string currency, DisplayFormatter displayFormatter)
{
Subtotal = displayFormatter.Currency(summary.PriceTaxExcluded, currency, DisplayFormatter.CurrencyFormat.Symbol);
if (summary.Discount > 0)
{
var discountFormatted = displayFormatter.Currency(summary.Discount, currency, DisplayFormatter.CurrencyFormat.Symbol);
Discount = appData.DiscountPercentage > 0 ? $"{discountFormatted} ({appData.DiscountPercentage}%)" : discountFormatted;
}
if (summary.Tip > 0)
{
var tipFormatted = displayFormatter.Currency(summary.Tip, currency, DisplayFormatter.CurrencyFormat.Symbol);
Tip = appData.TipPercentage > 0 ? $"{tipFormatted} ({appData.TipPercentage}%)" : tipFormatted;
}
if (summary.Tax > 0)
{
var taxFormatted = displayFormatter.Currency(summary.Tax, currency, DisplayFormatter.CurrencyFormat.Symbol);
if (order.GetTaxRate() is { } r)
taxFormatted = $"{taxFormatted} ({r:0.######}%)";
Tax = taxFormatted;
}
if (summary.ItemsTotal > 0)
{
var itemsTotal = displayFormatter.Currency(summary.ItemsTotal, currency, DisplayFormatter.CurrencyFormat.Symbol);
ItemsTotal = itemsTotal;
}
Total = displayFormatter.Currency(summary.PriceTaxIncludedWithTips, currency, DisplayFormatter.CurrencyFormat.Symbol);
if (ItemsTotal == Subtotal)
ItemsTotal = null;
if (Subtotal == Total)
Subtotal = null;
}
void UpdateFromCart(IEnumerable<AppItem> appItems, PosAppData jposData, string currency, DisplayFormatter displayFormatter)
{
Dictionary<string,string> cartData = new();
foreach (var cartItem in jposData.Cart ?? [])
{
var selectedChoice = appItems.FirstOrDefault(item => item.Id == cartItem.Id);
if (selectedChoice is null)
continue;
if (jposData.Cart.Length == 1)
{
Title = selectedChoice.Title;
if (!string.IsNullOrEmpty(selectedChoice.Description))
Description = selectedChoice.Description;
}
var singlePrice = displayFormatter.Currency(cartItem.Price, currency, DisplayFormatter.CurrencyFormat.Symbol);
var totalPrice = displayFormatter.Currency(cartItem.Price * cartItem.Count, currency, DisplayFormatter.CurrencyFormat.Symbol);
var ident = selectedChoice.Title ?? selectedChoice.Id;
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
}
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
{
cartData.Add($"Custom Amount {i + 1}", displayFormatter.Currency(jposData.Amounts[i], currency, DisplayFormatter.CurrencyFormat.Symbol));
}
Cart = cartData.Count > 0 ? cartData : null;
}
public static PosReceiptData Create(bool isTopup, IEnumerable<AppItem> choices, PosAppData jposData, PoSOrder order, PoSOrder.OrderSummary summary, string currency, DisplayFormatter displayFormatter)
{
var receiptData = new PosReceiptData();
if (!isTopup)
{
jposData.UpdateFrom(summary);
receiptData.UpdateTotals(jposData, order, summary, currency, displayFormatter);
}
receiptData.UpdateFromCart(choices, jposData, currency, displayFormatter);
return receiptData;
}
} }

View File

@@ -2,6 +2,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BTC/@EntryIndexedValue">BTC</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BTC/@EntryIndexedValue">BTC</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=CPFP/@EntryIndexedValue">CPFP</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=CPFP/@EntryIndexedValue">CPFP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HWI/@EntryIndexedValue">HWI</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HWI/@EntryIndexedValue">HWI</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LN/@EntryIndexedValue">LN</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LNURL/@EntryIndexedValue">LNURL</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=LNURL/@EntryIndexedValue">LNURL</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NB/@EntryIndexedValue">NBX</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NB/@EntryIndexedValue">NBX</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NBXplorer/@EntryIndexedValue">NBXplorer</s:String> <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=NBXplorer/@EntryIndexedValue">NBXplorer</s:String>