Improve data display on receipt (#5896)

Once more an improvement for the receipt, which also fixes #5882:

- Unify data displayed on the web and print version
- Split cart and additional data and ensure additional data is displayed
- Do not display extra subtotal row if there are no tips or discounts
- Make PosData partial more universal and backwards-compatible by using case insensitive key lookups
This commit is contained in:
d11n 2024-04-24 10:22:00 +02:00 committed by GitHub
parent aeb836da76
commit 56d57bbd84
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 181 additions and 87 deletions

View file

@ -2620,9 +2620,9 @@ namespace BTCPayServer.Tests
// Receipt // Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table")); var cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr")); var items = cartData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr")); var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(2, items.Count); Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count); Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text); Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text);
@ -2676,21 +2676,19 @@ namespace BTCPayServer.Tests
// Receipt // Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table")); cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
items = additionalData.FindElements(By.CssSelector("tbody tr")); items = cartData.FindElements(By.CssSelector("tbody tr"));
sums = additionalData.FindElements(By.CssSelector("tfoot tr")); sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(3, items.Count); Assert.Equal(3, items.Count);
Assert.Equal(2, sums.Count); Assert.Equal(1, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text); 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("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("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("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("Manual entry 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).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("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 // Guest user can access recent transactions
s.GoToHome(); s.GoToHome();
@ -2837,9 +2835,9 @@ namespace BTCPayServer.Tests
// Receipt // Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table")); var cartData = s.Driver.FindElement(By.CssSelector("#CartData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr")); var items = cartData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr")); var sums = cartData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(7, items.Count); Assert.Equal(7, items.Count);
Assert.Equal(4, sums.Count); Assert.Equal(4, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text); Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);

View file

@ -164,9 +164,9 @@ namespace BTCPayServer.Controllers
model.StillDue = details.StillDue; model.StillDue = details.StillDue;
model.HasRates = details.HasRates; model.HasRates = details.HasRates;
if (additionalData.ContainsKey("receiptData")) if (additionalData.TryGetValue("receiptData", out object? receiptData))
{ {
model.ReceiptData = (Dictionary<string, object>)additionalData["receiptData"]; model.ReceiptData = (Dictionary<string, object>)receiptData;
additionalData.Remove("receiptData"); additionalData.Remove("receiptData");
} }
@ -227,15 +227,39 @@ namespace BTCPayServer.Controllers
{ {
return View(vm); return View(vm);
} }
JToken? receiptData = null; var metaData = PosDataParser.ParsePosData(i.Metadata?.ToJObject());
i.Metadata?.AdditionalData?.TryGetValue("receiptData", out receiptData); 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<string, object>((Dictionary<string, object>)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<string, object>();
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, _handlers); var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders, _handlers);
vm.Amount = i.PaidAmount.Net; vm.Amount = i.PaidAmount.Net;
vm.Payments = receipt.ShowPayments is false ? null : payments; vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm); return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm);
} }

View file

@ -17,6 +17,7 @@ namespace BTCPayServer.Models.InvoicingModels
public decimal Amount { get; set; } public decimal Amount { get; set; }
public DateTimeOffset Timestamp { get; set; } public DateTimeOffset Timestamp { get; set; }
public Dictionary<string, object> AdditionalData { get; set; } public Dictionary<string, object> AdditionalData { get; set; }
public Dictionary<string, object> CartData { get; set; }
public ReceiptOptions ReceiptOptions { get; set; } public ReceiptOptions ReceiptOptions { get; set; }
public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; } public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; }
public string OrderUrl { get; set; } public string OrderUrl { get; set; }

View file

@ -1,3 +1,4 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model (Dictionary<string, object> Items, int Level) @model (Dictionary<string, object> Items, int Level)
@functions { @functions {
@ -10,14 +11,20 @@
@if (Model.Items.Any()) @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"];
<table class="table my-0" v-pre> <table class="table my-0" v-pre>
@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<string, object> { Keys.Count: > 0 } cartDict)
{ {
<tbody> <tbody>
@foreach (var (key, value) in (Dictionary<string, object>)Model.Items["Cart"]) @foreach (var (key, value) in cartDict)
{ {
<tr> <tr>
<th>@key</th> <th>@key</th>
@ -26,35 +33,46 @@
} }
</tbody> </tbody>
} }
<tfoot style="border-top-width:@(hasCart ? "3px" : "0")"> else if (cart is ICollection<object> { Count: > 0 } cartCollection)
@if (Model.Items.ContainsKey("Subtotal")) {
<tbody>
@foreach (var value in cartCollection)
{ {
<tr> <tr>
<th>Subtotal</th> <td>@value</td>
<td class="text-end">@Model.Items["Subtotal"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Discount"))
{
<tr>
<th>Discount</th>
<td class="text-end">@Model.Items["Discount"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Tip"))
{
<tr>
<th>Tip</th>
<td class="text-end">@Model.Items["Tip"]</td>
</tr>
}
@if (Model.Items.ContainsKey("Total"))
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@Model.Items["Total"]</td>
</tr> </tr>
} }
</tbody>
}
<tfoot style="border-top-width:0">
@if (hasSubtotal && (hasDiscount || hasTip))
{
<tr style="border-top-width:3px">
<th>Subtotal</th>
<td class="text-end">@subtotal</td>
</tr>
}
@if (hasDiscount)
{
<tr>
<th>Discount</th>
<td class="text-end">@discount</td>
</tr>
}
@if (hasTip)
{
<tr>
<th>Tip</th>
<td class="text-end">@tip</td>
</tr>
}
@if (hasTotal)
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@total</td>
</tr>
}
</tfoot> </tfoot>
} }
else else

View file

@ -422,7 +422,7 @@
</table> </table>
</div> </div>
} }
@if (Model.ReceiptData != null && Model.ReceiptData.Any()) @if (Model.ReceiptData?.Any() is true)
{ {
<div> <div>
<h3 class="mb-3"> <h3 class="mb-3">
@ -434,7 +434,7 @@
<partial name="PosData" model="(Model.ReceiptData, 1)" /> <partial name="PosData" model="(Model.ReceiptData, 1)" />
</div> </div>
} }
@if (Model.AdditionalData != null && Model.AdditionalData.Any()) @if (Model.AdditionalData?.Any() is true)
{ {
<div> <div>
<h3 class="mb-3"> <h3 class="mb-3">

View file

@ -35,8 +35,8 @@
#InvoiceSummary { gap: var(--btcpay-space-l); } #InvoiceSummary { gap: var(--btcpay-space-l); }
#PaymentDetails table tbody tr:first-child td { padding-top: 1rem; } #PaymentDetails table tbody tr:first-child td { padding-top: 1rem; }
#PaymentDetails table tbody:not(:last-child) tr:last-child > th,td { padding-bottom: 1rem; } #PaymentDetails table tbody:not(:last-child) tr:last-child > th,td { padding-bottom: 1rem; }
#posData td > table:last-child { margin-bottom: 0 !important; } #AdditionalData td > table:last-child, #CartData td > table:last-child { margin-bottom: 0 !important; }
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; } #AdditionalData table > tbody > tr:first-child > td > h4, #CartData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
</style> </style>
</head> </head>
<body class="min-vh-100"> <body class="min-vh-100">
@ -62,7 +62,7 @@
{ {
if (Model.ReceiptOptions.ShowQR is true) if (Model.ReceiptOptions.ShowQR is true)
{ {
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code> <vc:qr-code data="@Context.Request.GetCurrentUrl()" />
} }
<div class="d-flex gap-4 mb-0 flex-fill"> <div class="d-flex gap-4 mb-0 flex-fill">
<dl class="d-flex flex-column gap-4 mb-0 flex-fill"> <dl class="d-flex flex-column gap-4 mb-0 flex-fill">
@ -102,6 +102,15 @@
</div> </div>
</div> </div>
} }
if (Model.CartData?.Any() is true)
{
<div id="CartData" class="tile">
<h2 class="h4 mb-3">Cart</h2>
<div class="table-responsive my-0">
<partial name="PosData" model="(Model.CartData, 1)" />
</div>
</div>
}
if (Model.Payments?.Any() is true) if (Model.Payments?.Any() is true)
{ {
<div id="PaymentDetails" class="tile"> <div id="PaymentDetails" class="tile">

View file

@ -90,6 +90,7 @@
} }
else else
{ {
var hasCart = Model.CartData?.Any() is true;
<div id="PaymentDetails"> <div id="PaymentDetails">
<div class="my-2 text-center small"> <div class="my-2 text-center small">
@if (!string.IsNullOrEmpty(Model.OrderId)) @if (!string.IsNullOrEmpty(Model.OrderId))
@ -99,19 +100,29 @@
@Model.Timestamp.ToBrowserDate() @Model.Timestamp.ToBrowserDate()
</div> </div>
<table class="table table-borderless table-sm small my-0"> <table class="table table-borderless table-sm small my-0">
<tr> @if (Model.AdditionalData?.Any() is true)
<td class="text-nowrap text-secondary">Total</td>
<td class="text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@if (Model.AdditionalData?.Any() is true &&
(Model.AdditionalData.ContainsKey("Cart") || Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
{ {
@if (Model.AdditionalData.ContainsKey("Cart")) @foreach (var (key, value) in Model.AdditionalData)
{ {
@foreach (var (key, value) in (Dictionary<string, object>)Model.AdditionalData["Cart"]) <tr>
<td class="text-secondary">@key</td>
<td class="text-end">@value</td>
</tr>
}
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
}
@if (hasCart)
{
_ = 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<string, object> { Keys.Count: > 0 } cartDict)
{
@foreach (var (key, value) in cartDict)
{ {
<tr> <tr>
<td class="text-secondary">@key</td> <td class="text-secondary">@key</td>
@ -119,33 +130,62 @@
</tr> </tr>
} }
} }
@if (Model.AdditionalData.ContainsKey("Subtotal")) else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
@foreach (var value in cartCollection)
{
<tr>
<td class="text-end">@value</td>
</tr>
}
}
if (hasSubtotal && (hasDiscount || hasTip))
{ {
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr> <tr>
<td class="text-secondary">Subtotal</td> <td class="text-secondary">Subtotal</td>
<td class="text-end">@Model.AdditionalData["Subtotal"]</td> <td class="text-end">@subtotal</td>
</tr> </tr>
} }
@if (Model.AdditionalData.ContainsKey("Discount")) if (hasDiscount)
{ {
<tr> <tr>
<td class="text-secondary">Discount</td> <td class="text-secondary">Discount</td>
<td class="text-end">@Model.AdditionalData["Discount"]</td> <td class="text-end">@discount</td>
</tr> </tr>
} }
@if (Model.AdditionalData.ContainsKey("Tip")) if (hasTip)
{ {
<tr> <tr>
<td class="text-secondary">Tip</td> <td class="text-secondary">Tip</td>
<td class="text-end">@Model.AdditionalData["Tip"]</td> <td class="text-end">@tip</td>
</tr> </tr>
} }
if (hasTotal)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr>
<th class="text-secondary">Total</th>
<td class="text-end fw-semibold">@total</td>
</tr>
}
}
else
{
<tr> <tr>
<td colspan="2"><hr class="w-100 my-0"/></td> <td class="text-nowrap text-secondary">Total</td>
<td class="text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr> </tr>
} }
@if (Model.Payments?.Any() is true) @if (Model.Payments?.Any() is true)
{ {
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@for (var i = 0; i < Model.Payments.Count; i++) @for (var i = 0; i < Model.Payments.Count; i++)
{ {
var payment = Model.Payments[i]; var payment = Model.Payments[i];
@ -216,7 +256,9 @@
<hr class="w-100 my-0 bg-none"/> <hr class="w-100 my-0 bg-none"/>
</center> </center>
</body> </body>
<script src="~/main/utils.js" asp-append-version="true"></script>
<script> <script>
formatDateTimes();
window.print(); window.print();
</script> </script>
</html> </html>

View file

@ -2,19 +2,6 @@ const baseUrl = Object.values(document.scripts).find(s => s.src.includes('/main/
const flatpickrInstances = []; 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 switchTimeFormat = event => {
const curr = event.target.dataset.mode || 'localized'; const curr = event.target.dataset.mode || 'localized';
@ -166,8 +153,9 @@ document.addEventListener("DOMContentLoaded", () => {
} }
// initialize timezone offset value if field is present in page // initialize timezone offset value if field is present in page
var timezoneOffset = new Date().getTimezoneOffset(); const $timezoneOffset = document.getElementById("TimezoneOffset");
$("#TimezoneOffset").val(timezoneOffset); const timezoneOffset = new Date().getTimezoneOffset();
if ($timezoneOffset) $timezoneOffset.value = timezoneOffset;
// localize all elements that have localizeDate class // localize all elements that have localizeDate class
formatDateTimes(); formatDateTimes();

View file

@ -15,3 +15,17 @@ function debounce(key, fn, delay = 250) {
clearTimeout(DEBOUNCE_TIMERS[key]) clearTimeout(DEBOUNCE_TIMERS[key])
DEBOUNCE_TIMERS[key] = setTimeout(fn, delay) 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];
});
}