diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index ac8fbf0f9..f35f9faa6 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -2620,9 +2620,9 @@ namespace BTCPayServer.Tests // Receipt s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); - var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table")); - var items = additionalData.FindElements(By.CssSelector("tbody tr")); - var sums = additionalData.FindElements(By.CssSelector("tfoot tr")); + var cartData = s.Driver.FindElement(By.CssSelector("#CartData table")); + var items = cartData.FindElements(By.CssSelector("tbody tr")); + var sums = cartData.FindElements(By.CssSelector("tfoot tr")); Assert.Equal(2, items.Count); Assert.Equal(4, sums.Count); Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text); @@ -2676,21 +2676,19 @@ namespace BTCPayServer.Tests // Receipt s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); - additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table")); - items = additionalData.FindElements(By.CssSelector("tbody tr")); - sums = additionalData.FindElements(By.CssSelector("tfoot tr")); + cartData = s.Driver.FindElement(By.CssSelector("#CartData table")); + items = cartData.FindElements(By.CssSelector("tbody tr")); + sums = cartData.FindElements(By.CssSelector("tfoot tr")); Assert.Equal(3, items.Count); - Assert.Equal(2, sums.Count); + Assert.Single(sums); Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text); Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector("td")).Text); Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text); Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector("td")).Text); Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector("th")).Text); Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).Text); - Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text); + Assert.Contains("Total", sums[0].FindElement(By.CssSelector("th")).Text); Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text); - Assert.Contains("Total", sums[1].FindElement(By.CssSelector("th")).Text); - Assert.Contains("4,23 €", sums[1].FindElement(By.CssSelector("td")).Text); // Guest user can access recent transactions s.GoToHome(); @@ -2837,9 +2835,9 @@ namespace BTCPayServer.Tests // Receipt s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); - var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table")); - var items = additionalData.FindElements(By.CssSelector("tbody tr")); - var sums = additionalData.FindElements(By.CssSelector("tfoot tr")); + var cartData = s.Driver.FindElement(By.CssSelector("#CartData table")); + var items = cartData.FindElements(By.CssSelector("tbody tr")); + var sums = cartData.FindElements(By.CssSelector("tfoot tr")); Assert.Equal(7, items.Count); Assert.Equal(4, sums.Count); Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text); diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 73b2528d1..8229a759f 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -162,10 +162,10 @@ namespace BTCPayServer.Controllers model.Overpaid = details.Overpaid; model.StillDue = details.StillDue; model.HasRates = details.HasRates; - - if (additionalData.ContainsKey("receiptData")) + + if (additionalData.TryGetValue("receiptData", out object? receiptData)) { - model.ReceiptData = (Dictionary)additionalData["receiptData"]; + model.ReceiptData = (Dictionary)receiptData; additionalData.Remove("receiptData"); } @@ -226,15 +226,40 @@ namespace BTCPayServer.Controllers { return View(vm); } - - JToken? receiptData = null; - i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData); + + var metaData = PosDataParser.ParsePosData(i.Metadata?.ToJObject()); + var additionalData = metaData + .Where(dict => !InvoiceAdditionalDataExclude.Contains(dict.Key)) + .ToDictionary(dict => dict.Key, dict => dict.Value); + + // Split receipt data into cart and additional data + if (additionalData.TryGetValue("receiptData", out object? combinedReceiptData)) + { + var receiptData = new Dictionary((Dictionary)combinedReceiptData, StringComparer.OrdinalIgnoreCase); + string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"]; + // extract cart data and lowercase keys to handle data uniformly in PosData partial + if (receiptData.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant()))) + { + vm.CartData = new Dictionary(); + foreach (var key in cartKeys) + { + if (!receiptData.ContainsKey(key)) continue; + // add it to cart data and remove it from the general data + vm.CartData.Add(key.ToLowerInvariant(), receiptData[key]); + receiptData.Remove(key); + } + } + // assign the rest to additional data + if (receiptData.Any()) + { + vm.AdditionalData = receiptData; + } + } var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders); vm.Amount = i.PaidAmount.Net; vm.Payments = receipt.ShowPayments is false ? null : payments; - vm.AdditionalData = PosDataParser.ParsePosData(receiptData); return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm); } diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs index 1065596d8..50c85a53a 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs @@ -17,6 +17,7 @@ namespace BTCPayServer.Models.InvoicingModels public decimal Amount { get; set; } public DateTimeOffset Timestamp { get; set; } public Dictionary AdditionalData { get; set; } + public Dictionary CartData { get; set; } public ReceiptOptions ReceiptOptions { get; set; } public List Payments { get; set; } public string OrderUrl { get; set; } diff --git a/BTCPayServer/Views/Shared/PosData.cshtml b/BTCPayServer/Views/Shared/PosData.cshtml index d06fa021b..d7f0296ab 100644 --- a/BTCPayServer/Views/Shared/PosData.cshtml +++ b/BTCPayServer/Views/Shared/PosData.cshtml @@ -1,3 +1,4 @@ +@using Microsoft.AspNetCore.Mvc.TagHelpers @model (Dictionary Items, int Level) @functions { @@ -10,14 +11,20 @@ @if (Model.Items.Any()) { - var hasCart = Model.Items.ContainsKey("Cart"); + @* Use titlecase and lowercase versions for backwards-compatibility *@ + string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"]; - @if (hasCart || (Model.Items.ContainsKey("Subtotal") && Model.Items.ContainsKey("Total"))) + @if (Model.Items.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant()))) { - @if (hasCart) + _ = Model.Items.TryGetValue("cart", out var cart) || Model.Items.TryGetValue("Cart", out cart); + var hasTotal = Model.Items.TryGetValue("total", out var total) || Model.Items.TryGetValue("Total", out total); + var hasSubtotal = Model.Items.TryGetValue("subtotal", out var subtotal) || Model.Items.TryGetValue("subTotal", out subtotal) || Model.Items.TryGetValue("Subtotal", out subtotal); + var hasDiscount = Model.Items.TryGetValue("discount", out var discount) || Model.Items.TryGetValue("Discount", out discount); + var hasTip = Model.Items.TryGetValue("tip", out var tip) || Model.Items.TryGetValue("Tip", out tip); + if (cart is Dictionary { Keys.Count: > 0 } cartDict) { - @foreach (var (key, value) in (Dictionary)Model.Items["Cart"]) + @foreach (var (key, value) in cartDict) { @@ -26,35 +33,46 @@ } } - - @if (Model.Items.ContainsKey("Subtotal")) + else if (cart is ICollection { Count: > 0 } cartCollection) + { + + @foreach (var value in cartCollection) { - - - - } - @if (Model.Items.ContainsKey("Discount")) - { - - - - - } - @if (Model.Items.ContainsKey("Tip")) - { - - - - - } - @if (Model.Items.ContainsKey("Total")) - { - - - + } + + } + + @if (hasSubtotal && (hasDiscount || hasTip)) + { + + + + + } + @if (hasDiscount) + { + + + + + } + @if (hasTip) + { + + + + + } + @if (hasTotal) + { + + + + + } } else diff --git a/BTCPayServer/Views/UIInvoice/Invoice.cshtml b/BTCPayServer/Views/UIInvoice/Invoice.cshtml index da959bba5..a2fd0b2de 100644 --- a/BTCPayServer/Views/UIInvoice/Invoice.cshtml +++ b/BTCPayServer/Views/UIInvoice/Invoice.cshtml @@ -431,7 +431,7 @@
@key
Subtotal@Model.Items["Subtotal"]
Discount@Model.Items["Discount"]
Tip@Model.Items["Tip"]
Total@Model.Items["Total"]@value
Subtotal@subtotal
Discount@discount
Tip@tip
Total@total
} - @if (Model.ReceiptData != null && Model.ReceiptData.Any()) + @if (Model.ReceiptData?.Any() is true) {

@@ -443,7 +443,7 @@

} - @if (Model.AdditionalData != null && Model.AdditionalData.Any()) + @if (Model.AdditionalData?.Any() is true) {

diff --git a/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml b/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml index 749019e9d..8eee5336d 100644 --- a/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml +++ b/BTCPayServer/Views/UIInvoice/InvoiceReceipt.cshtml @@ -35,8 +35,8 @@ #InvoiceSummary { gap: var(--btcpay-space-l); } #PaymentDetails table tbody tr:first-child td { padding-top: 1rem; } #PaymentDetails table tbody:not(:last-child) tr:last-child > th,td { padding-bottom: 1rem; } - #posData td > table:last-child { margin-bottom: 0 !important; } - #posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; } + #AdditionalData td > table:last-child, #CartData td > table:last-child { margin-bottom: 0 !important; } + #AdditionalData table > tbody > tr:first-child > td > h4, #CartData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; } @@ -62,7 +62,7 @@ { if (Model.ReceiptOptions.ShowQR is true) { - + }
@@ -102,6 +102,15 @@

} + if (Model.CartData?.Any() is true) + { +
+

Cart

+
+ +
+
+ } if (Model.Payments?.Any() is true) {
diff --git a/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml b/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml index 86071d7bf..219848617 100644 --- a/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml +++ b/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml @@ -1,11 +1,11 @@ @model BTCPayServer.Models.InvoicingModels.InvoiceReceiptViewModel @functions { - public bool IsManualEntryCart(object data) + public bool IsManualEntryCart(Dictionary additionalData) { - if (data is Dictionary) + _ = additionalData.TryGetValue("cart", out var data) || additionalData.TryGetValue("Cart", out data); + if (data is Dictionary cart) { - Dictionary cart = (Dictionary)data; return cart.Count == 1 && cart.ContainsKey("Manual entry 1"); } @@ -103,6 +103,7 @@ } else { + var hasCart = Model.CartData?.Any() is true;
@if (!string.IsNullOrEmpty(Model.OrderId)) @@ -112,20 +113,29 @@ @Model.Timestamp.ToBrowserDate()
- - - - - - - - @if (Model.AdditionalData?.Any() is true && - ((Model.AdditionalData.ContainsKey("Cart") && !IsManualEntryCart(Model.AdditionalData["Cart"])) - || Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip"))) + @if (Model.AdditionalData?.Any() is true) { - @if (Model.AdditionalData.ContainsKey("Cart")) + @foreach (var (key, value) in Model.AdditionalData) { - @foreach (var (key, value) in (Dictionary)Model.AdditionalData["Cart"]) + + + + + } + + + + } + @if (hasCart && !IsManualEntryCart(Model.AdditionalData)) + { + _ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart); + var hasTotal = Model.CartData.TryGetValue("total", out var total) || Model.CartData.TryGetValue("Total", out total); + var hasSubtotal = Model.CartData.TryGetValue("subtotal", out var subtotal) || Model.CartData.TryGetValue("subTotal", out subtotal) || Model.CartData.TryGetValue("Subtotal", out subtotal); + var hasDiscount = Model.CartData.TryGetValue("discount", out var discount) || Model.CartData.TryGetValue("Discount", out discount); + var hasTip = Model.CartData.TryGetValue("tip", out var tip) || Model.CartData.TryGetValue("Tip", out tip); + if (cart is Dictionary { Keys.Count: > 0 } cartDict) + { + @foreach (var (key, value) in cartDict) { @@ -133,33 +143,62 @@ } } - @if (Model.AdditionalData.ContainsKey("Subtotal")) + else if (cart is ICollection { Count: > 0 } cartCollection) + { + @foreach (var value in cartCollection) + { + + + + } + } + if (hasSubtotal && (hasDiscount || hasTip)) { + + + - + } - @if (Model.AdditionalData.ContainsKey("Discount")) + if (hasDiscount) { - + } - @if (Model.AdditionalData.ContainsKey("Tip")) + if (hasTip) { - + } + if (hasTotal) + { + + + + + + + + } + } + else + { - + + } @if (Model.Payments?.Any() is true) { + + + @for (var i = 0; i < Model.Payments.Count; i++) { var payment = Model.Payments[i]; @@ -230,7 +269,9 @@
+ diff --git a/BTCPayServer/wwwroot/main/site.js b/BTCPayServer/wwwroot/main/site.js index e40d38ba4..7358864ba 100644 --- a/BTCPayServer/wwwroot/main/site.js +++ b/BTCPayServer/wwwroot/main/site.js @@ -2,19 +2,6 @@ const baseUrl = Object.values(document.scripts).find(s => s.src.includes('/main/ const flatpickrInstances = []; -const formatDateTimes = format => { - // select only elements which haven't been initialized before, those without data-localized - document.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => { - const date = new Date($el.getAttribute("datetime")); - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat - const { dateStyle = 'short', timeStyle = 'short' } = $el.dataset; - // initialize and set localized attribute - $el.dataset.localized = new Intl.DateTimeFormat('default', { dateStyle, timeStyle }).format(date); - // set text to chosen mode - const mode = format || $el.dataset.initial; - if ($el.dataset[mode]) $el.innerText = $el.dataset[mode]; - }); -}; const switchTimeFormat = event => { const curr = event.target.dataset.mode || 'localized'; @@ -166,8 +153,9 @@ document.addEventListener("DOMContentLoaded", () => { } // initialize timezone offset value if field is present in page - var timezoneOffset = new Date().getTimezoneOffset(); - $("#TimezoneOffset").val(timezoneOffset); + const $timezoneOffset = document.getElementById("TimezoneOffset"); + const timezoneOffset = new Date().getTimezoneOffset(); + if ($timezoneOffset) $timezoneOffset.value = timezoneOffset; // localize all elements that have localizeDate class formatDateTimes(); diff --git a/BTCPayServer/wwwroot/main/utils.js b/BTCPayServer/wwwroot/main/utils.js index 7bda6caed..6c65d63c2 100644 --- a/BTCPayServer/wwwroot/main/utils.js +++ b/BTCPayServer/wwwroot/main/utils.js @@ -15,3 +15,17 @@ function debounce(key, fn, delay = 250) { clearTimeout(DEBOUNCE_TIMERS[key]) DEBOUNCE_TIMERS[key] = setTimeout(fn, delay) } + +function formatDateTimes(format) { + // select only elements which haven't been initialized before, those without data-localized + document.querySelectorAll("time[datetime]:not([data-localized])").forEach($el => { + const date = new Date($el.getAttribute("datetime")); + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/DateTimeFormat + const { dateStyle = 'short', timeStyle = 'short' } = $el.dataset; + // initialize and set localized attribute + $el.dataset.localized = new Intl.DateTimeFormat('default', { dateStyle, timeStyle }).format(date); + // set text to chosen mode + const mode = format || $el.dataset.initial; + if ($el.dataset[mode]) $el.innerText = $el.dataset[mode]; + }); +}
Total@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)

@key@value

@key
@value

Subtotal@Model.AdditionalData["Subtotal"]@subtotal
Discount@Model.AdditionalData["Discount"]@discount
Tip@Model.AdditionalData["Tip"]@tip

Total@total

Total@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)