Receipt improvements (#5077)

* Remove Order ID link

* Add separate print version for receipt

* Fix POS number handling and add keypad test

Fixes #5056.

* Add formatting function

* Remove OrderUrl for POS, bring back order link for receipt

* Update BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
This commit is contained in:
d11n 2023-06-22 08:57:29 +02:00 committed by GitHub
parent 82c5e0e43d
commit 13203c3e2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 169 additions and 19 deletions

View file

@ -2074,6 +2074,81 @@ namespace BTCPayServer.Tests
}
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePOSKeypad()
{
using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true);
s.CreateNewStore();
s.GoToStore();
s.AddLightningNode(LightningConnectionType.CLightning, false);
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
s.Driver.FindElement(By.Id("AppName")).SendKeys(Guid.NewGuid().ToString());
s.Driver.FindElement(By.Id("Create")).Click();
TestUtils.Eventually(() => Assert.Contains("App successfully created", s.FindAlertMessage().Text));
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Light']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.WaitForElement(By.ClassName("keypad"));
// basic checks
Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text);
Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-amount")).Selected);
Assert.False(s.Driver.FindElement(By.Id("ModeTablist-discount")).Enabled);
Assert.False(s.Driver.FindElement(By.Id("ModeTablist-tip")).Enabled);
// Amount: 1234,56
s.Driver.FindElement(By.CssSelector(".keypad [data-key='1']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='2']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='3']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='4']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='.']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='5']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='6']")).Click();
Assert.Equal("1.234,56", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-discount")).Enabled);
Assert.True(s.Driver.FindElement(By.Id("ModeTablist-tip")).Enabled);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
// Discount: 10%
s.Driver.FindElement(By.CssSelector("label[for='ModeTablist-discount']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='1']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='0']")).Click();
Assert.Contains("1.111,10", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("10% discount", s.Driver.FindElement(By.Id("Discount")).Text);
Assert.Contains("1.234,56 € - 123,46 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
// Tip: 10%
s.Driver.FindElement(By.CssSelector("label[for='ModeTablist-tip']")).Click();
s.Driver.WaitForElement(By.Id("Tip-Custom"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("1.222,21", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("1.234,56 € - 123,46 € (10%) + 111,11 € (10%)", s.Driver.FindElement(By.Id("Calculation")).Text);
// Pay
s.Driver.FindElement(By.Id("pay-button")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("1 222,21 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]

View file

@ -172,7 +172,7 @@ namespace BTCPayServer.Controllers
}
[HttpGet("i/{invoiceId}/receipt")]
public async Task<IActionResult> InvoiceReceipt(string invoiceId)
public async Task<IActionResult> InvoiceReceipt(string invoiceId, [FromQuery] bool print = false)
{
var i = await _InvoiceRepository.GetInvoice(invoiceId);
if (i is null)
@ -255,7 +255,7 @@ namespace BTCPayServer.Controllers
vm.Payments = receipt.ShowPayments is false ? null : payments;
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
return View(vm);
return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm);
}
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)

View file

@ -343,10 +343,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (appPosData.Tip > 0)
{
receiptData.Add("Tip",
$"{_displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}");
receiptData.Add("Tip", _displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
}
}
entity.Metadata.SetAdditionalData("receiptData", receiptData);

View file

@ -1,4 +1,3 @@
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.PointOfSale.Models;
using Newtonsoft.Json;
@ -54,7 +53,6 @@ public class PosAppCartItemPrice
[JsonProperty(PropertyName = "formatted")]
public string Formatted { get; set; }
[JsonProperty(PropertyName = "type")]
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
}

View file

@ -4,13 +4,13 @@
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<input id="posdata" type="hidden" name="posdata" v-model="posdata">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted">{{srvModel.currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }">{{ formatCurrency(total, false) }}</div>
<div class="fw-semibold text-muted" id="Currency">{{srvModel.currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
<div class="text-muted text-center mt-2" id="Calculation" v-if="srvModel.showDiscount || srvModel.enableTips">{{ calculation }}</div>
</div>
<div id="ModeTabs" class="tab-content mb-n2" v-if="srvModel.showDiscount || srvModel.enableTips">
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="srvModel.showDiscount">
<div class="h4 fw-semibold text-muted text-center">
<div class="h4 fw-semibold text-muted text-center" id="Discount">
<span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
</div>
</div>
@ -18,6 +18,7 @@
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
<template v-if="srvModel.customTipPercentages">
<button
id="Tip-Custom"
type="button"
class="btcpay-pill"
:class="{ active: !tipPercent }"
@ -30,6 +31,7 @@
type="button"
class="btcpay-pill"
:class="{ active: tipPercent == percentage }"
:id="`Tip-${percentage}`"
v-on:click.prevent="tipPercentage(percentage)">
{{ percentage }}%
</button>

View file

@ -1,4 +1,3 @@
@using System.Text.RegularExpressions
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model (Dictionary<string, object> Items, int Level)

View file

@ -71,9 +71,7 @@
<dl class="d-flex flex-column gap-4 mb-0 flex-fill">
<div class="d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<button type="button" class="btn btn-link p-0 d-print-none fw-semibold order-1" onclick="window.print()">
Print
</button>
<a href="?print=true" class="btn btn-link p-0 d-print-none fw-semibold order-1" target="_blank">Print</a>
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
</div>
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>

View file

@ -0,0 +1,72 @@
@model BTCPayServer.Models.InvoicingModels.InvoiceReceiptViewModel
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@inject DisplayFormatter DisplayFormatter
@{
Layout = null;
ViewData["Title"] = $"Receipt from {Model.StoreName}";
var isProcessing = Model.Status == InvoiceStatus.Processing;
var isSettled = Model.Status == InvoiceStatus.Settled;
}
<link href="~/main/bootstrap/bootstrap.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<p class="text-center">@Model.StoreName</p>
<p class="text-center">@Model.Timestamp.ToBrowserDate()</p>
<p>&nbsp;</p>
@if (isProcessing)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-processing">
The invoice has detected a payment but is still waiting to be settled.
</div>
}
else if (!isSettled)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-unsettled">
The invoice is not settled.
</div>
}
else
{
<h3 class="text-center">
<strong>@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</strong>
</h3>
@if (Model.Payments?.Any() is true)
{
<p>&nbsp;</p>
<p class="text-center"><strong>Payments</strong></p>
@foreach (var payment in Model.Payments)
{
<p>&nbsp;</p>
<p class="text-center">@payment.Amount <span class="text-nowrap">@payment.PaymentMethod</span></p>
<p class="text-center">Rate: @payment.RateFormatted</p>
<p class="text-center">= @payment.PaidFormatted</p>
}
}
if (Model.AdditionalData?.Any() is true)
{
<p>&nbsp;</p>
<p class="text-center"><strong>Additional Data</strong></p>
<partial name="PosData" model="(Model.AdditionalData, 1)"/>
}
@if (!string.IsNullOrEmpty(Model.OrderId))
{
<p>&nbsp;</p>
<p class="text-break">Order ID: @Model.OrderId</p>
}
}
@if (Model.ReceiptOptions.ShowQR is true)
{
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code>
}
<script>window.print();</script>

View file

@ -74,11 +74,11 @@ document.addEventListener("DOMContentLoaded",function () {
},
posdata () {
const data = {
subTotal: this.formatCurrency(this.amountNumeric),
total: this.formatCurrency(this.totalNumeric)
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.formatCurrency(this.tipNumeric)
if (this.discountNumeric > 0) data.discountAmount = this.formatCurrency(this.discountNumeric)
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
}
@ -138,7 +138,7 @@ document.addEventListener("DOMContentLoaded",function () {
const currency = this.srvModel.currencyCode;
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol);
const divisibility = this.srvModel.currencyInfo.divisibility;
const locale = currency === 'USD' ? 'en-US' : navigator.language;
const locale = this.getLocale(currency);
const style = withSymbol ? 'currency' : 'decimal';
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility };
try {
@ -179,6 +179,14 @@ document.addEventListener("DOMContentLoaded",function () {
this.tipPercent = this.tipPercent !== percentage
? percentage
: null;
},
getLocale(currency) {
switch (currency) {
case 'USD': return 'en-US';
case 'EUR': return 'de-DE';
case 'JPY': return 'ja-JP';
default: return navigator.language;
}
}
},
created () {