mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 14:22:40 +01:00
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:
parent
82c5e0e43d
commit
13203c3e2b
9 changed files with 169 additions and 19 deletions
|
@ -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]
|
[Fact]
|
||||||
[Trait("Selenium", "Selenium")]
|
[Trait("Selenium", "Selenium")]
|
||||||
[Trait("Lightning", "Lightning")]
|
[Trait("Lightning", "Lightning")]
|
||||||
|
|
|
@ -172,7 +172,7 @@ namespace BTCPayServer.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("i/{invoiceId}/receipt")]
|
[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);
|
var i = await _InvoiceRepository.GetInvoice(invoiceId);
|
||||||
if (i is null)
|
if (i is null)
|
||||||
|
@ -255,7 +255,7 @@ namespace BTCPayServer.Controllers
|
||||||
vm.Payments = receipt.ShowPayments is false ? null : payments;
|
vm.Payments = receipt.ShowPayments is false ? null : payments;
|
||||||
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
|
vm.AdditionalData = PosDataParser.ParsePosData(receiptData);
|
||||||
|
|
||||||
return View(vm);
|
return View(print ? "InvoiceReceiptPrint" : "InvoiceReceipt", vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)
|
private string? GetTransactionLink(PaymentMethodId paymentMethodId, string txId)
|
||||||
|
|
|
@ -343,10 +343,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||||
|
|
||||||
if (appPosData.Tip > 0)
|
if (appPosData.Tip > 0)
|
||||||
{
|
{
|
||||||
receiptData.Add("Tip",
|
receiptData.Add("Tip", _displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||||
$"{_displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
entity.Metadata.SetAdditionalData("receiptData", receiptData);
|
entity.Metadata.SetAdditionalData("receiptData", receiptData);
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
using BTCPayServer.Models.AppViewModels;
|
|
||||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
@ -54,7 +53,6 @@ public class PosAppCartItemPrice
|
||||||
[JsonProperty(PropertyName = "formatted")]
|
[JsonProperty(PropertyName = "formatted")]
|
||||||
public string Formatted { get; set; }
|
public string Formatted { get; set; }
|
||||||
|
|
||||||
|
|
||||||
[JsonProperty(PropertyName = "type")]
|
[JsonProperty(PropertyName = "type")]
|
||||||
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
|
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
<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">
|
<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 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-semibold text-muted" id="Currency">{{srvModel.currencyCode}}</div>
|
||||||
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }">{{ formatCurrency(total, false) }}</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 class="text-muted text-center mt-2" id="Calculation" v-if="srvModel.showDiscount || srvModel.enableTips">{{ calculation }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="ModeTabs" class="tab-content mb-n2" v-if="srvModel.showDiscount || srvModel.enableTips">
|
<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 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
|
<span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -18,6 +18,7 @@
|
||||||
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
|
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
|
||||||
<template v-if="srvModel.customTipPercentages">
|
<template v-if="srvModel.customTipPercentages">
|
||||||
<button
|
<button
|
||||||
|
id="Tip-Custom"
|
||||||
type="button"
|
type="button"
|
||||||
class="btcpay-pill"
|
class="btcpay-pill"
|
||||||
:class="{ active: !tipPercent }"
|
:class="{ active: !tipPercent }"
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
type="button"
|
type="button"
|
||||||
class="btcpay-pill"
|
class="btcpay-pill"
|
||||||
:class="{ active: tipPercent == percentage }"
|
:class="{ active: tipPercent == percentage }"
|
||||||
|
:id="`Tip-${percentage}`"
|
||||||
v-on:click.prevent="tipPercentage(percentage)">
|
v-on:click.prevent="tipPercentage(percentage)">
|
||||||
{{ percentage }}%
|
{{ percentage }}%
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
@using System.Text.RegularExpressions
|
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@model (Dictionary<string, object> Items, int Level)
|
@model (Dictionary<string, object> Items, int Level)
|
||||||
|
|
||||||
|
|
|
@ -71,9 +71,7 @@
|
||||||
<dl class="d-flex flex-column gap-4 mb-0 flex-fill">
|
<dl class="d-flex flex-column gap-4 mb-0 flex-fill">
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<div class="d-flex align-items-center justify-content-between">
|
<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()">
|
<a href="?print=true" class="btn btn-link p-0 d-print-none fw-semibold order-1" target="_blank">Print</a>
|
||||||
Print
|
|
||||||
</button>
|
|
||||||
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
|
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
|
||||||
</div>
|
</div>
|
||||||
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
|
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
|
||||||
|
|
72
BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml
Normal file
72
BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml
Normal 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> </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> </p>
|
||||||
|
<p class="text-center"><strong>Payments</strong></p>
|
||||||
|
@foreach (var payment in Model.Payments)
|
||||||
|
{
|
||||||
|
<p> </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> </p>
|
||||||
|
<p class="text-center"><strong>Additional Data</strong></p>
|
||||||
|
<partial name="PosData" model="(Model.AdditionalData, 1)"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.OrderId))
|
||||||
|
{
|
||||||
|
<p> </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>
|
|
@ -74,11 +74,11 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||||
},
|
},
|
||||||
posdata () {
|
posdata () {
|
||||||
const data = {
|
const data = {
|
||||||
subTotal: this.formatCurrency(this.amountNumeric),
|
subTotal: this.amountNumeric,
|
||||||
total: this.formatCurrency(this.totalNumeric)
|
total: this.totalNumeric
|
||||||
}
|
}
|
||||||
if (this.tipNumeric > 0) data.tip = this.formatCurrency(this.tipNumeric)
|
if (this.tipNumeric > 0) data.tip = this.tipNumeric
|
||||||
if (this.discountNumeric > 0) data.discountAmount = this.formatCurrency(this.discountNumeric)
|
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
|
||||||
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
|
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
|
||||||
return JSON.stringify(data)
|
return JSON.stringify(data)
|
||||||
}
|
}
|
||||||
|
@ -138,7 +138,7 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||||
const currency = this.srvModel.currencyCode;
|
const currency = this.srvModel.currencyCode;
|
||||||
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol);
|
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol);
|
||||||
const divisibility = this.srvModel.currencyInfo.divisibility;
|
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 style = withSymbol ? 'currency' : 'decimal';
|
||||||
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility };
|
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility };
|
||||||
try {
|
try {
|
||||||
|
@ -179,6 +179,14 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||||
this.tipPercent = this.tipPercent !== percentage
|
this.tipPercent = this.tipPercent !== percentage
|
||||||
? percentage
|
? percentage
|
||||||
: null;
|
: 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 () {
|
created () {
|
||||||
|
|
Loading…
Add table
Reference in a new issue