Can apply tax rates to PoS items (#6724)

This commit is contained in:
Nicolas Dorier
2025-05-19 10:35:46 +09:00
committed by GitHub
parent 80d6ba3e5b
commit 932d313dee
36 changed files with 1224 additions and 807 deletions

View File

@@ -30,9 +30,18 @@ namespace BTCPayServer.JsonConverters
case JTokenType.Integer:
case JTokenType.String:
if (objectType == typeof(decimal) || objectType == typeof(decimal?))
{
if (objectType == typeof(decimal?) && string.IsNullOrWhiteSpace(token.ToString()))
return null;
return decimal.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
}
if (objectType == typeof(double) || objectType == typeof(double?))
{
if (objectType == typeof(double?) && string.IsNullOrWhiteSpace(token.ToString()))
return null;
return double.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
}
throw new JsonSerializationException("Unexpected object type: " + objectType);
case JTokenType.Null when objectType == typeof(decimal?) || objectType == typeof(double?):
return null;

View File

@@ -1,5 +1,7 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
@@ -10,4 +12,7 @@ public class AppCartItem
public int Count { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Price { get; set; }
[JsonExtensionData]
public Dictionary<string, JToken> AdditionalData { get; set; }
}

View File

@@ -30,6 +30,9 @@ public class AppItem
public string BuyButtonText { get; set; }
public int? Inventory { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? TaxRate { get; set; }
[JsonExtensionData]
public Dictionary<string, JToken> AdditionalData { get; set; }
}

View File

@@ -41,6 +41,7 @@ public static class PosDataParser
result.TryAdd(item.Key, ParsePosData(item.Value));
break;
case null:
case JTokenType.Null:
break;
default:
result.TryAdd(item.Key, item.Value.ToString());

View File

@@ -802,65 +802,5 @@ g:
Assert.Equal("new", topupInvoice.Status);
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUsePoSAppJsonEndpoint()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
var apps = user.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = PointOfSaleAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
apps.HttpContext.SetAppData(appData);
pos.HttpContext.SetAppData(appData);
var vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "App POS";
vmpos.Currency = "EUR";
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
// Failing requests
var (invoiceId1, error1) = await PosJsonRequest(tester, app.Id, "amount=-21&discount=10&tip=2");
Assert.Null(invoiceId1);
Assert.Equal("Negative amount is not allowed", error1);
var (invoiceId2, error2) = await PosJsonRequest(tester, app.Id, "amount=21&discount=-10&tip=-2");
Assert.Null(invoiceId2);
Assert.Equal("Negative tip or discount is not allowed", error2);
// Successful request
var (invoiceId3, error3) = await PosJsonRequest(tester, app.Id, "amount=21");
Assert.NotNull(invoiceId3);
Assert.Null(error3);
// Check generated invoice
var invoices = await user.BitPay.GetInvoicesAsync();
var invoice = invoices.First();
Assert.Equal(invoiceId3, invoice.Id);
Assert.Equal(21.00m, invoice.Price);
Assert.Equal("EUR", invoice.Currency);
}
private async Task<(string invoiceId, string error)> PosJsonRequest(ServerTester tester, string appId, string query)
{
var uriBuilder = new UriBuilder(tester.PayTester.ServerUri) { Path = $"/apps/{appId}/pos/light", Query = query };
var request = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri);
request.Headers.Add("Accept", "application/json");
var response = await tester.PayTester.HttpClient.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(content);
return (json["invoiceId"]?.Value<string>(), json["error"]?.Value<string>());
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
@@ -59,6 +60,8 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Error", driver.Title, StringComparison.OrdinalIgnoreCase);
}
public static string NormalizeWhitespaces(this string input) =>
string.Concat((input??"").Where(c => !char.IsWhiteSpace(c)));
public static async Task AssertNoError(this IPage page)
{

View File

@@ -1,4 +1,6 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
@@ -11,6 +13,8 @@ using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Playwright;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
using static BTCPayServer.Tests.UnitTest1;
@@ -19,12 +23,8 @@ using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests
{
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class POSTests : UnitTestBase
public class POSTests(ITestOutputHelper helper) : UnitTestBase(helper)
{
public POSTests(ITestOutputHelper helper) : base(helper)
{
}
[Fact]
[Trait("Fast", "Fast")]
public void CanParseOldYmlCorrectly()
@@ -227,5 +227,521 @@ donation:
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
Assert.Empty(appList.Apps);
}
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanUsePOSCart()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
// Create users
var user = await s.RegisterNewUser();
var userAccount = s.AsTestAccount();
await s.GoToHome();
await s.Logout();
await s.GoToRegister();
await s.RegisterNewUser(true);
// Setup store and associate user
(_, string storeId) = await s.CreateNewStore();
await s.GoToStore();
await s.AddDerivationScheme();
await s.AddUserToStore(storeId, user, "Guest");
// Setup POS
await s.CreateApp("PointOfSale");
await s.Page.ClickAsync("label[for='DefaultView_Cart']");
await s.Page.FillAsync("#Currency", "EUR");
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
await s.Page.ClickAsync("#EnableTips");
Assert.True(await s.Page.IsCheckedAsync("#EnableTips"));
await s.Page.FillAsync("#CustomTipPercentages", "10,21");
Assert.False(await s.Page.IsCheckedAsync("#ShowDiscount"));
await s.Page.ClickAsync("#ShowDiscount");
// Default tax of 8.375%, but 10% for the first item.
await s.Page.FillAsync("#DefaultTaxRate", "8.375");
await s.Page.Locator(".template-item").First.ClickAsync();
await s.Page.Locator("#item-form div").Filter(new() { HasText = "Tax rate %" }).GetByRole(AriaRole.Spinbutton).FillAsync("10");
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "App updated");
// View
var o = s.Page.Context.WaitForPageAsync();
await s.Page.ClickAsync("#ViewApp");
await s.SwitchPage(o);
await s.Page.WaitForSelectorAsync("#PosItems");
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
var posUrl = s.Page.Url;
// Select and clear
await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
Assert.Single(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
await s.Page.ClickAsync("#CartClear");
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
// Select simple items
await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
Assert.Single(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
await s.Page.ClickAsync(".posItem:nth-child(2) .btn-primary");
await s.Page.ClickAsync(".posItem:nth-child(2) .btn-primary");
Assert.Equal(2, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
await AssertCartSummary(s, new()
{
Subtotal = "3,00€",
Taxes = "0,27 €",
Total = "3,27 €"
});
// Select item with inventory - two of it
Assert.Equal("5 left", await s.Page.TextContentAsync(".posItem:nth-child(3) .badge.inventory"));
await s.Page.ClickAsync(".posItem:nth-child(3) .btn-primary");
await s.Page.ClickAsync(".posItem:nth-child(3) .btn-primary");
Assert.Equal(3, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
await AssertCartSummary(s, new()
{
Subtotal = "5,40 €",
Taxes = "0,47 €",
Total = "5,87 €"
});
// Select items with minimum amount
await s.Page.ClickAsync(".posItem:nth-child(5) .btn-primary");
Assert.Equal(4, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
await AssertCartSummary(s, new()
{
Subtotal = "7,20 €",
Taxes = "0,62 €",
Total = "7,82 €"
});
// Select items with adjusted minimum amount
await s.Page.FillAsync(".posItem:nth-child(5) input[name='amount']", "");
await s.Page.FillAsync(".posItem:nth-child(5) input[name='amount']", "2.3");
await s.Page.ClickAsync(".posItem:nth-child(5) .btn-primary");
Assert.Equal(5, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
await AssertCartSummary(s, new()
{
Subtotal = "9,50 €",
Taxes = "0,81 €",
Total = "10,31 €"
});
// Select items with custom amount
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", "");
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", ".2");
await s.Page.ClickAsync(".posItem:nth-child(6) .btn-primary");
Assert.Equal(6, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
await AssertCartSummary(s, new()
{
Subtotal = "9,70 €",
Taxes = "0,83 €",
Total = "10,53 €"
});
// Select items with another custom amount
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", "");
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", ".3");
await s.Page.ClickAsync(".posItem:nth-child(6) .btn-primary");
Assert.Equal(7, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
await AssertCartSummary(s, new()
{
Subtotal = "10,00 €",
Taxes = "0,86 €",
Total = "10,86 €"
});
// Discount: 10%
Assert.False(await s.Page.IsVisibleAsync("#CartDiscount"));
await s.Page.FillAsync("#Discount", "10");
await AssertCartSummary(s, new()
{
ItemsTotal = "10,00 €",
Discount = "1,00 € (10%)",
Subtotal = "9,00 €",
Taxes = "0,77 €",
Total = "9,77 €"
});
// Tip: 10%
Assert.False(await s.Page.IsVisibleAsync("#CartTip"));
await s.Page.ClickAsync("#Tip-10");
await AssertCartSummary(s, new()
{
ItemsTotal = "10,00 €",
Discount = "1,00 € (10%)",
Subtotal = "9,00 €",
Tip = "0,90 € (10%)",
Taxes = "0,77 €",
Total = "10,67 €"
});
// Check values on checkout page
await s.Page.ClickAsync("#CartSubmit");
await s.Page.WaitForSelectorAsync("#Checkout");
await s.Page.ClickAsync("#DetailsToggle");
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
Assert.Contains("0,77 €", await s.Page.TextContentAsync("#PaymentDetails-TaxIncluded"));
Assert.Contains("10,67 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
//
// Pay
await s.PayInvoice(true);
// Receipt
await s.Page.ClickAsync("#ReceiptLink");
await s.Page.WaitForSelectorAsync("#CartData table");
await AssertReceipt(s, new()
{
Items = [
new("Black Tea", "2 x 1,00 € = 2,00 €"),
new("Green Tea", "1 x 1,00 € = 1,00 €"),
new("Rooibos (limited)", "2 x 1,20 € = 2,40 €"),
new("Herbal Tea (minimum) (1,80 €)", "1 x 1,80 € = 1,80 €"),
new("Herbal Tea (minimum) (2,30 €)", "1 x 2,30 € = 2,30 €"),
new("Fruit Tea (any amount) (0,20 €)", "1 x 0,20 € = 0,20 €"),
new("Fruit Tea (any amount) (0,30 €)", "1 x 0,30 € = 0,30 €")
],
Sums = [
new("Items total", "10,00 €"),
new("Discount", "1,00 € (10%)"),
new("Subtotal", "9,00 €"),
new("Tax", "0,77 €"),
new("Tip", "0,90 € (10%)"),
new("Total", "10,67 €")
]
});
// Check inventory got updated and is now 3 instead of 5
await s.GoToUrl(posUrl);
Assert.Equal("3 left", await s.Page.TextContentAsync(".posItem:nth-child(3) .badge.inventory"));
// Guest user can access recent transactions
await s.GoToHome();
await s.Logout();
await s.LogIn(user, userAccount.RegisterDetails.Password);
await s.GoToUrl(posUrl);
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
await s.GoToHome();
await s.Logout();
// Unauthenticated user can't access recent transactions
await s.GoToUrl(posUrl);
Assert.False(await s.Page.IsVisibleAsync("#RecentTransactionsToggle"));
}
public class CartSummaryAssertion
{
public string Subtotal { get; set; }
public string Taxes { get; set; }
public string Total { get; set; }
public string ItemsTotal { get; set; }
public string Discount { get; set; }
public string Tip { get; set; }
}
private async Task AssertCartSummary(PlaywrightTester s, CartSummaryAssertion o)
{
string[] ids = ["CartItemsTotal", "CartDiscount", "CartAmount", "CartTip", "CartTax", "CartTotal"];
string[] values = [o.ItemsTotal, o.Discount, o.Subtotal, o.Tip, o.Taxes, o.Total];
for (int i = 0; i < ids.Length; i++)
{
if (values[i] != null)
{
var text = await s.Page.TextContentAsync("#" + ids[i]);
Assert.Equal(values[i].NormalizeWhitespaces(), text.NormalizeWhitespaces());
}
else
{
Assert.False(await s.Page.IsVisibleAsync("#" + ids[i]));
}
}
}
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanUsePOSKeypad()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
// Create users
var user = await s.RegisterNewUser();
var userAccount = s.AsTestAccount();
await s.GoToHome();
await s.Logout();
await s.GoToRegister();
await s.RegisterNewUser(true);
// Setup store and associate user
(_, string storeId) = await s.CreateNewStore();
await s.GoToStore();
await s.AddDerivationScheme();
await s.AddUserToStore(storeId, user, "Guest");
// Setup POS
await s.CreateApp("PointOfSale");
var editUrl = s.Page.Url;
await s.Page.ClickAsync("label[for='DefaultView_Light']");
await s.Page.FillAsync("#Currency", "EUR");
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
await s.Page.ClickAsync("#EnableTips");
Assert.True(await s.Page.IsCheckedAsync("#EnableTips"));
await s.Page.FillAsync("#CustomTipPercentages", "");
await s.Page.FillAsync("#CustomTipPercentages", "10,21");
Assert.False(await s.Page.IsCheckedAsync("#ShowDiscount"));
Assert.False(await s.Page.IsCheckedAsync("#ShowItems"));
await s.Page.ClickAsync("#ShowDiscount");
await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "App updated");
// View
var o = s.Page.Context.WaitForPageAsync();
await s.Page.ClickAsync("#ViewApp");
await s.SwitchPage(o);
// basic checks
var keypadUrl = s.Page.Url;
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
Assert.Null(await s.Page.QuerySelectorAsync("#ItemsListToggle"));
Assert.Contains("EUR", await s.Page.TextContentAsync("#Currency"));
Assert.Contains("0,00", await s.Page.TextContentAsync("#Amount"));
Assert.Equal("", await s.Page.TextContentAsync("#Calculation"));
Assert.True(await s.Page.IsCheckedAsync("#ModeTablist-amounts"));
Assert.False(await s.Page.IsEnabledAsync("#ModeTablist-discount"));
Assert.False(await s.Page.IsEnabledAsync("#ModeTablist-tip"));
// Amount: 1234,56
await EnterKeypad(s, "123400");
Assert.Equal("1.234,00", await s.Page.TextContentAsync("#Amount"));
Assert.Equal("", await s.Page.TextContentAsync("#Calculation"));
await EnterKeypad(s, "+56");
Assert.Equal("1.234,56", await s.Page.TextContentAsync("#Amount"));
Assert.True(await s.Page.IsEnabledAsync("#ModeTablist-discount"));
Assert.True(await s.Page.IsEnabledAsync("#ModeTablist-tip"));
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 €");
// Discount: 10%
await s.Page.ClickAsync("label[for='ModeTablist-discount']");
await EnterKeypad(s, "10");
Assert.Contains("1.111,10", await s.Page.TextContentAsync("#Amount"));
Assert.Contains("10% discount", await s.Page.TextContentAsync("#Discount"));
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 € - 123,46 € (10%)");
// Tip: 10%
await s.Page.ClickAsync("label[for='ModeTablist-tip']");
await s.Page.ClickAsync("#Tip-10");
Assert.Contains("1.222,21", await s.Page.TextContentAsync("#Amount"));
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 € - 123,46 € (10%) + 111,11 € (10%)");
// Pay
await s.Page.ClickAsync("#pay-button");
await s.Page.ClickAsync("#DetailsToggle");
Assert.Contains("1 222,21 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
await s.PayInvoice(true);
// Receipt
await s.Page.ClickAsync("#ReceiptLink");
await AssertReceipt(s, new()
{
Items = [
new("Custom Amount 1", "1 234,00 €"),
new("Custom Amount 2", "0,56 €")
],
Sums = [
new("Items total", "1 234,56 €"),
new("Discount", "123,46 € (10%)"),
new("Subtotal", "1 111,10 €"),
new("Tip", "111,11 € (10%)"),
new("Total", "1 222,21 €")
]
});
await s.GoToUrl(editUrl);
await s.Page.ClickAsync("#ShowItems");
await s.Page.FillAsync("#DefaultTaxRate", "10");
await s.ClickPagePrimary();
await s.FindAlertMessage(partialText: "App updated");
await s.GoToUrl(keypadUrl);
await s.Page.ClickAsync("#ItemsListToggle");
await s.Page.WaitForSelectorAsync("#PosItems");
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(1) .btn-plus");
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(1) .btn-plus");
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(2) .btn-plus");
await s.Page.ClickAsync("#ItemsListOffcanvas button[data-bs-dismiss='offcanvas']");
await EnterKeypad(s, "123");
Assert.Contains("4,65", await s.Page.TextContentAsync("#Amount"));
await AssertKeypadCalculation(s, "2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 € + 0,42 € (10%)");
// Pay
await s.Page.ClickAsync("#pay-button");
await s.Page.WaitForSelectorAsync("#Checkout");
await s.Page.ClickAsync("#DetailsToggle");
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
Assert.Contains("4,65 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
await s.PayInvoice(true);
// Receipt
await s.Page.ClickAsync("#ReceiptLink");
await AssertReceipt(s, new()
{
Items = [
new("Black Tea", "1 x 1,00 € = 1,00 €"),
new("Green Tea", "2 x 1,00 € = 2,00 €"),
new("Custom Amount 1", "1,23 €")
],
Sums = [
new("Subtotal", "4,23 €"),
new("Tax", "0,42 €"),
new("Total", "4,65 €")
]
});
// Guest user can access recent transactions
await s.GoToHome();
await s.Logout();
await s.LogIn(user, userAccount.RegisterDetails.Password);
await s.GoToUrl(keypadUrl);
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
await s.GoToHome();
await s.Logout();
// Unauthenticated user can't access recent transactions
await s.GoToUrl(keypadUrl);
Assert.False(await s.Page.IsVisibleAsync("#RecentTransactionsToggle"));
// But they can generate invoices
await EnterKeypad(s, "123");
await s.Page.ClickAsync("#pay-button");
await s.Page.WaitForSelectorAsync("#Checkout");
await s.Page.ClickAsync("#DetailsToggle");
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
Assert.Contains("1,35 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
}
private static async Task AssertKeypadCalculation(PlaywrightTester s, string expected)
{
Assert.Equal(expected.NormalizeWhitespaces(), (await s.Page.TextContentAsync("#Calculation")).NormalizeWhitespaces());
}
public class AssertReceiptAssertion
{
public record Line(string Key, string Value);
public Line[] Items { get; set; }
public Line[] Sums { get; set; }
}
private async Task AssertReceipt(PlaywrightTester s, AssertReceiptAssertion assertion)
{
await AssertReceipt(s, assertion, "#CartData table tbody tr", "#CartData table tfoot tr");
// Receipt print
var o = s.Page.Context.WaitForPageAsync();
await s.Page.ClickAsync("#ReceiptLinkPrint");
await using (await s.SwitchPage(o))
{
await AssertReceipt(s, assertion, "#PaymentDetails table tr.cart-data", "#PaymentDetails table tr.sums-data");
}
}
private async Task AssertReceipt(PlaywrightTester s, AssertReceiptAssertion assertion, string itemSelector, string sumsSelector)
{
var items = await s.Page.QuerySelectorAllAsync(itemSelector);
var sums = await s.Page.QuerySelectorAllAsync(sumsSelector);
Assert.Equal(assertion.Items.Length, items.Count);
Assert.Equal(assertion.Sums.Length, sums.Count);
for (int i = 0; i < assertion.Items.Length; i++)
{
var txt = (await items[i].TextContentAsync()).NormalizeWhitespaces();
Assert.Contains(assertion.Items[i].Key.NormalizeWhitespaces(), txt);
Assert.Contains(assertion.Items[i].Value.NormalizeWhitespaces(), txt);
}
for (int i = 0; i < assertion.Sums.Length; i++)
{
var txt = (await sums[i].TextContentAsync()).NormalizeWhitespaces();
Assert.Contains(assertion.Sums[i].Key.NormalizeWhitespaces(), txt);
Assert.Contains(assertion.Sums[i].Value.NormalizeWhitespaces(), txt);
}
}
private async Task EnterKeypad(PlaywrightTester tester, string text)
{
foreach (char c in text)
{
await tester.Page.ClickAsync($".keypad [data-key='{c}']");
}
}
[Fact]
[Trait("Integration", "Integration")]
public async Task CanUsePoSAppJsonEndpoint()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
var apps = user.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = PointOfSaleAppType.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
apps.HttpContext.SetAppData(appData);
pos.HttpContext.SetAppData(appData);
var vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "App POS";
vmpos.Currency = "EUR";
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
// Failing requests
var (invoiceId1, error1) = await PosJsonRequest(tester, app.Id, "amount=-21&discount=10&tip=2");
Assert.Null(invoiceId1);
Assert.Equal("Negative amount is not allowed", error1);
var (invoiceId2, error2) = await PosJsonRequest(tester, app.Id, "amount=21&discount=-10&tip=-2");
Assert.Null(invoiceId2);
Assert.Equal("Negative tip or discount is not allowed", error2);
// Successful request
var (invoiceId3, error3) = await PosJsonRequest(tester, app.Id, "amount=21");
Assert.NotNull(invoiceId3);
Assert.Null(error3);
// Check generated invoice
var invoices = await user.BitPay.GetInvoicesAsync();
var invoice = invoices.First();
Assert.Equal(invoiceId3, invoice.Id);
Assert.Equal(21.00m, invoice.Price);
Assert.Equal("EUR", invoice.Currency);
}
private async Task<(string invoiceId, string error)> PosJsonRequest(ServerTester tester, string appId, string query)
{
var uriBuilder = new UriBuilder(tester.PayTester.ServerUri) { Path = $"/apps/{appId}/pos/light", Query = query };
var request = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri);
request.Headers.Add("Accept", "application/json");
var response = await tester.PayTester.HttpClient.SendAsync(request);
var content = await response.Content.ReadAsStringAsync();
var json = JObject.Parse(content);
return (json["invoiceId"]?.Value<string>(), json["error"]?.Value<string>());
}
}
}

View File

@@ -55,32 +55,6 @@ namespace BTCPayServer.Tests
await Page.AssertNoError();
}
public async Task PayInvoiceAsync(IPage page, bool mine = false, decimal? amount = null)
{
if (amount is not null)
{
try
{
await page.Locator("#test-payment-amount").ClearAsync();
}
catch (PlaywrightException)
{
await page.Locator("#test-payment-amount").ClearAsync();
}
await page.Locator("#test-payment-amount").FillAsync(amount.ToString());
}
await page.Locator("#FakePayment").WaitForAsync();
await page.Locator("#FakePayment").ClickAsync();
await TestUtils.EventuallyAsync(async () =>
{
await page.Locator("#CheatSuccessMessage").WaitForAsync();
});
if (mine)
{
await MineBlockOnInvoiceCheckout(page);
}
}
public async Task GoToInvoices(string storeId = null)
{
if (storeId is null)
@@ -152,13 +126,6 @@ namespace BTCPayServer.Tests
}
}
public async Task MineBlockOnInvoiceCheckout(IPage page)
{
retry:
try { await page.Locator("#mine-block button").ClickAsync(); }
catch (PlaywrightException) { goto retry; }
}
public async Task<ILocator> FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success, string partialText = null)
{
var locator = await FindAlertMessage(new[] { severity });
@@ -190,14 +157,16 @@ namespace BTCPayServer.Tests
}
}
public async Task GoToUrl(string relativeUrl)
public async Task GoToUrl(string uri)
{
await Page.GotoAsync(Link(relativeUrl), new() { WaitUntil = WaitUntilState.Commit } );
await Page.GotoAsync(Link(uri), new() { WaitUntil = WaitUntilState.Commit } );
}
public string Link(string relativeLink)
public string Link(string uri)
{
return ServerUri.AbsoluteUri.WithoutEndingSlash() + relativeLink.WithStartingSlash();
if (Uri.IsWellFormedUriString(uri, UriKind.Absolute))
return uri;
return ServerUri.AbsoluteUri.WithoutEndingSlash() + uri.WithStartingSlash();
}
public async Task<string> RegisterNewUser(bool isAdmin = false)
@@ -333,6 +302,18 @@ namespace BTCPayServer.Tests
}
else { await GoToUrl("/"); }
}
public async Task AddUserToStore(string storeId, string email, string role)
{
var addUser = Page.Locator("#AddUser");
if (!await addUser.IsVisibleAsync())
{
await GoToStore(storeId, StoreNavPages.Users);
}
await Page.FillAsync("#Email", email);
await Page.SelectOptionAsync("#Role", role);
await Page.ClickAsync("#AddUser");
await FindAlertMessage(partialText: "The user has been added successfully");
}
public async Task LogIn(string user, string password = "123456")
{
await Page.FillAsync("#Email", user);
@@ -424,6 +405,8 @@ namespace BTCPayServer.Tests
StoreId = storeId;
if (WalletId != null)
WalletId = new WalletId(storeId, WalletId.CryptoCode);
if (storeNavPage != StoreNavPages.General)
await Page.Locator($"#StoreNav-{StoreNavPages.General}").ClickAsync();
}
await Page.Locator($"#StoreNav-{storeNavPage}").ClickAsync();
}
@@ -502,5 +485,59 @@ namespace BTCPayServer.Tests
{
await Page.ClickAsync(".modal.fade.show .modal-confirm");
}
public async Task<(string appName, string appId)> CreateApp(string type, string name = null)
{
if (string.IsNullOrEmpty(name))
name = $"{type}-{Guid.NewGuid().ToString()[..14]}";
await Page.Locator($"#StoreNav-Create{type}").ClickAsync();
await Page.Locator("[name='AppName']").FillAsync(name);
await ClickPagePrimary();
await FindAlertMessage(partialText: "App successfully created");
var appId = Page.Url.Split('/')[^3];
return (name, appId);
}
public async Task PayInvoice(bool mine = false, decimal? amount = null)
{
if (amount is not null)
{
await Page.FillAsync("#test-payment-amount", amount.ToString());
}
await Page.ClickAsync("#FakePayment");
await Page.Locator("#CheatSuccessMessage").WaitForAsync();
if (mine)
{
await MineBlockOnInvoiceCheckout();
}
}
public async Task MineBlockOnInvoiceCheckout()
{
await Page.ClickAsync("#mine-block button");
}
class SwitchDisposable(IPage newPage, IPage oldPage, PlaywrightTester tester, bool closeAfter) : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
if (closeAfter)
await newPage.CloseAsync();
tester.Page = oldPage;
}
}
public async Task<IAsyncDisposable> SwitchPage(Task<IPage> page, bool closeAfter = true)
{
var p = await page;
return await SwitchPage(p, closeAfter);
}
public async Task<IAsyncDisposable> SwitchPage(IPage page, bool closeAfter = true)
{
var old = Page;
Page = page;
await page.BringToFrontAsync();
return new SwitchDisposable(page, old, this, closeAfter);
}
}
}

View File

@@ -57,12 +57,15 @@ namespace BTCPayServer.Tests
await s.FindAlertMessage(partialText: "App updated");
await s.Page.ClickAsync("#ViewApp");
var popOutPage = await s.Page.Context.WaitForPageAsync();
await popOutPage.Locator("button[type='submit']").First.ClickAsync();
await popOutPage.FillAsync("[name='buyerEmail']", "aa@aa.com");
await popOutPage.ClickAsync("input[type='submit']");
await s.PayInvoiceAsync(popOutPage, true);
var invoiceId = popOutPage.Url[(popOutPage.Url.LastIndexOf("/", StringComparison.Ordinal) + 1)..];
await popOutPage.CloseAsync();
string invoiceId;
await using (var o = await s.SwitchPage(popOutPage))
{
await s.Page.Locator("button[type='submit']").First.ClickAsync();
await s.Page.FillAsync("[name='buyerEmail']", "aa@aa.com");
await s.Page.ClickAsync("input[type='submit']");
await s.PayInvoice(true);
invoiceId = s.Page.Url[(s.Page.Url.LastIndexOf("/", StringComparison.Ordinal) + 1)..];
}
await s.Page.Context.Pages.First().BringToFrontAsync();
await s.GoToUrl($"/invoices/{invoiceId}/");
@@ -109,7 +112,7 @@ namespace BTCPayServer.Tests
Assert.Contains("CustomFormInputTest", await s.Page.ContentAsync());
await s.Page.FillAsync("[name='buyerEmail']", "aa@aa.com");
await s.Page.ClickAsync("input[type='submit']");
await s.PayInvoiceAsync(s.Page, true, 0.001m);
await s.PayInvoice(true, 0.001m);
var result = await s.Server.PayTester.HttpClient.GetAsync(formUrl);
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
await s.GoToHome();

View File

@@ -2448,427 +2448,6 @@ namespace BTCPayServer.Tests
}
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUsePOSKeypad()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
// Create users
var user = s.RegisterNewUser();
var userAccount = s.AsTestAccount();
s.GoToHome();
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true);
// Setup store and associate user
(_, string storeId) = s.CreateNewStore();
s.GoToStore();
s.AddDerivationScheme();
s.AddUserToStore(storeId, user, "Guest");
// Setup POS
s.CreateApp("PointOfSale");
var editUrl = s.Driver.Url;
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Light']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected);
s.Driver.FindElement(By.Id("EnableTips")).Click();
Assert.True(s.Driver.FindElement(By.Id("EnableTips")).Selected);
Thread.Sleep(250);
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
Assert.False(s.Driver.FindElement(By.Id("ShowDiscount")).Selected);
Assert.False(s.Driver.FindElement(By.Id("ShowItems")).Selected);
s.Driver.FindElement(By.Id("ShowDiscount")).Click();
s.ClickPagePrimary();
Assert.Contains("App updated", s.FindAlertMessage().Text);
// View
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
var keypadUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
s.Driver.ElementDoesNotExist(By.Id("ItemsListToggle"));
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-amounts")).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='0']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='0']")).Click();
Assert.Equal("1.234,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
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("1.234,00 € + 0,56 €", 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,00 € + 0,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,00 € + 0,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"));
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);
s.PayInvoice(true);
TestUtils.Eventually(() =>
{
s.MineBlockOnInvoiceCheckout();
Assert.True(s.Driver.WaitForElement(By.Id("settled")).Displayed);
});
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
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("Custom Amount 1", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Custom Amount 2", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 123,46 €", sums[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 111,11 €", sums[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector("td")).Text);
// Receipt print
s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click();
windows = s.Driver.WindowHandles;
Assert.Equal(3, windows.Count);
s.Driver.SwitchTo().Window(windows[2]);
var paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table"));
items = paymentDetails.FindElements(By.CssSelector("tr.cart-data"));
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Custom Amount 1", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Custom Amount 2", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("10% = 123,46 €", sums[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("10% = 111,11 €", sums[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector(".val")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// Once more with items
s.GoToUrl(editUrl);
s.Driver.FindElement(By.Id("ShowItems")).Click();
s.ClickPagePrimary();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.GoToUrl(keypadUrl);
s.Driver.WaitForElement(By.ClassName("keypad"));
s.Driver.FindElement(By.Id("ItemsListToggle")).Click();
Thread.Sleep(250);
Assert.True(s.Driver.WaitForElement(By.Id("PosItems")).Displayed);
s.Driver.FindElement(By.CssSelector("#PosItems .posItem--displayed:nth-child(1) .btn-plus")).Click();
s.Driver.FindElement(By.CssSelector("#PosItems .posItem--displayed:nth-child(1) .btn-plus")).Click();
s.Driver.FindElement(By.CssSelector("#PosItems .posItem--displayed:nth-child(2) .btn-plus")).Click();
s.Driver.FindElement(By.CssSelector("#ItemsListOffcanvas button[data-bs-dismiss=\"offcanvas\"]")).Click();
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();
Assert.Contains("4,23", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 €", s.Driver.FindElement(By.Id("Calculation")).Text);
// Pay
s.Driver.FindElement(By.Id("pay-button")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("4,23 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
s.PayInvoice(true);
TestUtils.Eventually(() =>
{
s.MineBlockOnInvoiceCheckout();
Assert.True(s.Driver.WaitForElement(By.Id("settled")).Displayed);
});
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
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.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("Custom Amount 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text);
// Receipt print
s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click();
windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table"));
items = paymentDetails.FindElements(By.CssSelector("tr.cart-data"));
sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data"));
Assert.Equal(3, items.Count);
Assert.Single(sums);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Custom Amount 1", items[2].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector(".val")).Text);
Assert.Contains("Total", sums[0].FindElement(By.CssSelector(".key")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector(".val")).Text);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// Guest user can access recent transactions
s.GoToHome();
s.Logout();
s.LogIn(user, userAccount.RegisterDetails.Password);
s.GoToUrl(keypadUrl);
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
s.GoToHome();
s.Logout();
// Unauthenticated user can't access recent transactions
s.GoToUrl(keypadUrl);
s.Driver.ElementDoesNotExist(By.Id("RecentTransactionsToggle"));
// But they can generate invoices
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.Id("pay-button")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("1,23 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
}
[Fact]
[Trait("Selenium", "Selenium")]
public async Task CanUsePOSCart()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
// Create users
var user = s.RegisterNewUser();
var userAccount = s.AsTestAccount();
s.GoToHome();
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true);
// Setup store and associate user
(_, string storeId) = s.CreateNewStore();
s.GoToStore();
s.AddDerivationScheme();
s.AddUserToStore(storeId, user, "Guest");
// Setup POS
s.CreateApp("PointOfSale");
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected);
s.Driver.FindElement(By.Id("EnableTips")).Click();
Assert.True(s.Driver.FindElement(By.Id("EnableTips")).Selected);
Thread.Sleep(250);
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
Assert.False(s.Driver.FindElement(By.Id("ShowDiscount")).Selected);
s.Driver.FindElement(By.Id("ShowDiscount")).Click();
s.ClickPagePrimary();
Assert.Contains("App updated", s.FindAlertMessage().Text);
// View
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.Id("PosItems"));
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
var posUrl = s.Driver.Url;
// Select and clear
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
s.Driver.FindElement(By.Id("CartClear")).Click();
Thread.Sleep(250);
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
// Select simple items
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("3,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select item with inventory - two of it
Assert.Equal("5 left", s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .badge.inventory")).Text);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .btn-primary")).Click();
Thread.Sleep(250);
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(3, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("5,40 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with minimum amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(4, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("7,20 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with adjusted minimum amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) input[name='amount']")).Clear();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) input[name='amount']")).SendKeys("2.3");
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(5) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(5, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("9,50 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with custom amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).Clear();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).SendKeys(".2");
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(6, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("9,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Select items with another custom amount
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).Clear();
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) input[name='amount']")).SendKeys(".3");
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(6) .btn-primary")).Click();
Thread.Sleep(250);
Assert.Equal(7, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
Assert.Equal("10,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Discount: 10%
s.Driver.ElementDoesNotExist(By.Id("CartDiscount"));
s.Driver.FindElement(By.Id("Discount")).SendKeys("10");
Assert.Contains("10% = 1,00 €", s.Driver.FindElement(By.Id("CartDiscount")).Text);
Assert.Equal("9,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Tip: 10%
s.Driver.ElementDoesNotExist(By.Id("CartTip"));
s.Driver.FindElement(By.Id("Tip-10")).Click();
Assert.Contains("10% = 0,90 €", s.Driver.FindElement(By.Id("CartTip")).Text);
Assert.Equal("9,90 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
// Check values on checkout page
s.Driver.FindElement(By.Id("CartSubmit")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("9,90 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
// Pay
s.PayInvoice(true);
TestUtils.Eventually(() =>
{
s.MineBlockOnInvoiceCheckout();
Assert.True(s.Driver.WaitForElement(By.Id("settled")).Displayed);
});
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
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);
Assert.Contains("2 x 1,00 € = 2,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Rooibos (limited)", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,20 € = 2,40 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Herbal Tea (minimum) (1,80 €)", items[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,80 € = 1,80 €", items[3].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Herbal Tea (minimum) (2,30 €)", items[4].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 2,30 € = 2,30 €", items[4].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Fruit Tea (any amount) (0,20 €)", items[5].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 0,20 € = 0,20 €", items[5].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Fruit Tea (any amount) (0,30 €)", items[6].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 0,30 € = 0,30 €", items[6].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10,00 €", sums[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 1,00 €", sums[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 0,90 €", sums[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("9,90 €", sums[3].FindElement(By.CssSelector("td")).Text);
// Check inventory got updated and is now 3 instead of 5
s.Driver.Navigate().GoToUrl(posUrl);
Assert.Equal("3 left", s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .badge.inventory")).Text);
// Guest user can access recent transactions
s.GoToHome();
s.Logout();
s.LogIn(user, userAccount.RegisterDetails.Password);
s.GoToUrl(posUrl);
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
s.GoToHome();
s.Logout();
// Unauthenticated user can't access recent transactions
s.GoToUrl(posUrl);
s.Driver.ElementDoesNotExist(By.Id("RecentTransactionsToggle"));
}
[Fact]
[Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]

View File

@@ -238,16 +238,15 @@ namespace BTCPayServer.Controllers
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())))
if (receiptData.Keys.Any(WellKnownPosData.IsWellKnown))
{
vm.CartData = new Dictionary<string, object>();
foreach (var key in cartKeys)
foreach (var key in receiptData.Keys.Where(WellKnownPosData.IsWellKnown))
{
if (!receiptData.ContainsKey(key)) continue;
if (!receiptData.TryGetValue(key, out object? value)) continue;
// add it to cart data and remove it from the general data
vm.CartData.Add(key.ToLowerInvariant(), receiptData[key]);
vm.CartData.Add(key.ToLowerInvariant(), value);
receiptData.Remove(key);
}
}
@@ -261,6 +260,7 @@ namespace BTCPayServer.Controllers
}
var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders, _handlers);
vm.TaxIncluded = i.Metadata?.TaxIncluded ?? 0.0m;
vm.Amount = i.PaidAmount.Net;
vm.Payments = receipt.ShowPayments is false ? null : payments;
@@ -929,6 +929,12 @@ namespace BTCPayServer.Controllers
model.PaymentMethodId = paymentMethodId.ToString();
model.OrderAmountFiat = OrderAmountFromInvoice(model.PaymentMethodCurrency, invoice, DisplayFormatter.CurrencyFormat.Symbol);
model.TaxIncluded = new();
if (invoice.Metadata.TaxIncluded is { } t)
{
model.TaxIncluded.Formatted = _displayFormatter.Currency(t, invoice.Currency, DisplayFormatter.CurrencyFormat.Symbol);
model.TaxIncluded.Value = t;
}
if (storeBlob.PlaySoundOnPayment)
{

View File

@@ -9,6 +9,11 @@ namespace BTCPayServer.Models.InvoicingModels
{
public class CheckoutModel
{
public class Amount
{
public decimal Value { get; set; }
public string Formatted { get; set; }
}
public string CheckoutBodyComponentName { get; set; }
public class AvailablePaymentMethod
{
@@ -52,6 +57,7 @@ namespace BTCPayServer.Models.InvoicingModels
public string Rate { get; set; }
public string OrderAmount { get; set; }
public string OrderAmountFiat { get; set; }
public Amount TaxIncluded { get; set; }
public string InvoiceBitcoinUrl { get; set; }
public string InvoiceBitcoinUrlQR { get; set; }
public int TxCount { get; set; }

View File

@@ -21,5 +21,6 @@ namespace BTCPayServer.Models.InvoicingModels
public ReceiptOptions ReceiptOptions { get; set; }
public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; }
public string RedirectUrl { get; set; }
public decimal TaxIncluded { get; set; }
}
}

View File

@@ -136,6 +136,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
CustomButtonText = settings.CustomButtonText,
CustomTipText = settings.CustomTipText,
CustomTipPercentages = settings.CustomTipPercentages,
DefaultTaxRate = settings.DefaultTaxRate,
AppId = appId,
StoreId = store.Id,
HtmlLang = settings.HtmlLang,
@@ -171,22 +172,31 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
// Distinguish JSON requests coming via the mobile app
var wantsJson = Request.Headers.Accept.FirstOrDefault()?.StartsWith("application/json") is true;
IActionResult Error(string message)
{
if (wantsJson)
return Json(new { error = message });
TempData.SetStatusMessageModel(new StatusMessageModel
{
Message = message,
Severity = StatusMessageModel.StatusSeverity.Error,
AllowDismiss = true
});
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
if (app == null)
return wantsJson
? Json(new { error = "App not found" })
? Json(new { error = StringLocalizer["App not found"].Value })
: NotFound();
// not allowing negative tips or discounts
if (tip < 0 || discount < 0)
return wantsJson
? Json(new { error = "Negative tip or discount is not allowed" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
return Error(StringLocalizer["Negative tip or discount is not allowed"].Value);
if (string.IsNullOrEmpty(choiceKey) && amount <= 0)
return wantsJson
? Json(new { error = "Negative amount is not allowed" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
if (string.IsNullOrEmpty(choiceKey) && (amount <= 0 || customAmount <= 0))
return Error(StringLocalizer["Negative amount is not allowed"].Value);
var settings = app.GetSettings<PointOfSaleSettings>();
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
@@ -196,86 +206,56 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
}
var jposData = TryParseJObject(posData);
string title;
decimal? price;
var choices = AppService.Parse(settings.Template, false);
var jposData = PosAppData.TryParse(posData) ?? new();
PoSOrder order = new(_currencies.GetNumberFormatInfo(settings.Currency, true).CurrencyDecimalDigits);
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
AppItem choice = null;
List<AppCartItem> cartItems = null;
AppItem[] choices = null;
List<AppItem> selectedChoices = new();
if (!string.IsNullOrEmpty(choiceKey))
{
choices = AppService.Parse(settings.Template, false);
choice = choices.FirstOrDefault(c => c.Id == choiceKey);
if (choice == null)
return NotFound();
title = choice.Title;
if (choice.PriceType == AppItemPriceType.Topup)
{
price = null;
}
else
{
price = choice.Price.Value;
if (amount > price)
price = amount;
jposData.Cart = new PosAppCartItem[] { new() { Id = choiceKey, Count = 1, Price = amount ?? 0 } };
}
jposData.Cart ??= [];
if (choice.Inventory is <= 0)
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
}
else
{
if (!settings.ShowCustomAmount && currentView != PosViewType.Cart && currentView != PosViewType.Light)
if (currentView is PosViewType.Print)
return NotFound();
if (currentView is PosViewType.Cart or PosViewType.Static && jposData.Cart.Length == 0)
return NotFound();
title = settings.Title;
// if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
price = amount;
if (AppService.TryParsePosCartItems(jposData, out cartItems))
if (jposData.Amounts is null &&
currentView == PosViewType.Light &&
amount is { } o)
{
price = jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray
? amountsArray.Values<decimal>().Sum()
: 0.0m;
choices = AppService.Parse(settings.Template, false);
foreach (var cartItem in cartItems)
order.AddLine(new("", 1, o, settings.DefaultTaxRate));
}
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
{
order.AddLine(new($"Custom Amount {i + 1}", 1, jposData.Amounts[i], settings.DefaultTaxRate));
}
foreach (var cartItem in jposData.Cart)
{
var itemChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
if (itemChoice == null)
return NotFound();
selectedChoices.Add(itemChoice);
if (itemChoice.Inventory is <= 0 ||
itemChoice.Inventory is { } inv && inv < cartItem.Count)
return Error(StringLocalizer["Inventory for {0} exhausted: {1} available", itemChoice.Title, itemChoice.Inventory]);
if (itemChoice.Inventory.HasValue)
if (itemChoice.PriceType is not AppItemPriceType.Topup)
{
switch (itemChoice.Inventory)
{
case <= 0:
case { } inventory when inventory < cartItem.Count:
return wantsJson
? Json(new { error = $"Inventory for {itemChoice.Title} exhausted: {itemChoice.Inventory} available" })
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
}
var expectedCartItemPrice = itemChoice.PriceType != AppItemPriceType.Topup
? itemChoice.Price ?? 0
: 0;
var expectedCartItemPrice = itemChoice.Price ?? 0;
if (cartItem.Price < expectedCartItemPrice)
cartItem.Price = expectedCartItemPrice;
price += cartItem.Price * cartItem.Count;
}
if (customAmount is { } c)
price += c;
order.AddLine(new(cartItem.Id, cartItem.Count, cartItem.Price, itemChoice.TaxRate ?? settings.DefaultTaxRate));
}
if (customAmount is { } c && settings.ShowCustomAmount)
order.AddLine(new("", 1, c, settings.DefaultTaxRate));
if (discount is { } d)
price -= price * d / 100.0m;
order.AddDiscountRate(d);
if (tip is { } t)
price += t;
}
}
order.AddTip(t);
var store = await _appService.GetStore(app);
var storeBlob = store.GetStoreBlob();
@@ -317,38 +297,82 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var amtField = form.GetFieldByFullName($"{FormDataService.InvoiceParameterPrefix}amount");
if (amtField is null)
{
form.Fields.Add(new Field
amtField = new Field
{
Name = $"{FormDataService.InvoiceParameterPrefix}amount",
Type = "hidden",
Value = price?.ToString(),
Constant = true
});
}
else
{
amtField.Value = price?.ToString();
};
form.Fields.Add(amtField);
}
amtField.Value = order.Calculate().PriceTaxExcluded.ToString(CultureInfo.InvariantCulture);
formResponseJObject = FormDataService.GetValues(form);
var invoiceRequest = FormDataService.GenerateInvoiceParametersFromForm(form);
if (invoiceRequest.Amount is not null)
{
price = invoiceRequest.Amount.Value;
order.AddLine(new("", 1, invoiceRequest.Amount.Value, settings.DefaultTaxRate));
}
break;
}
var receiptData = new PosReceiptData();
var summary = order.Calculate();
bool isTopup = summary.PriceTaxIncludedWithTips == 0 && currentView == PosViewType.Static;
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);
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;
}
try
{
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
{
Amount = price,
Amount = isTopup ? null : summary.PriceTaxIncludedWithTips,
Currency = settings.Currency,
Metadata = new InvoiceMetadata
{
ItemCode = choice?.Id,
ItemDesc = title,
ItemCode = selectedChoices is [{} c1] ? c1.Id : null,
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
BuyerEmail = email,
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
OrderId = orderId ?? AppService.GetRandomOrderId()
}.ToJObject(),
Checkout = new InvoiceDataBase.CheckoutOptions()
@@ -369,59 +393,37 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
entity.FullNotifications = true;
entity.ExtendedNotifications = true;
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
entity.Metadata.PosData = jposData;
var receiptData = new JObject();
if (choice is not null)
entity.Metadata.PosData = JObject.FromObject(jposData);
if (selectedChoices.Count == 1)
{
var dict = new Dictionary<string, string> { { "Title", choice.Title } };
if (!string.IsNullOrEmpty(choice.Description))
dict["Description"] = choice.Description;
receiptData = JObject.FromObject(dict);
receiptData.Title = selectedChoices[0].Title;
if (!string.IsNullOrEmpty(selectedChoices[0].Description))
receiptData.Description = selectedChoices[0].Description;
}
else if (jposData is not null)
Dictionary<string,string> cartData = null;
foreach (var cartItem in jposData.Cart)
{
var appPosData = jposData.ToObject<PosAppData>();
receiptData = new JObject();
if (cartItems is not null && choices is not null)
{
var posCartItems = cartItems.ToList();
var selectedChoices = choices
.Where(item => posCartItems.Any(cartItem => cartItem.Id == item.Id))
.ToDictionary(item => item.Id);
var cartData = new JObject();
foreach (AppCartItem cartItem in posCartItems)
{
if (!selectedChoices.TryGetValue(cartItem.Id, out var selectedChoice))
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}");
}
if (jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray)
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
{
for (var i = 0; i < amountsArray.Count; i++)
{
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(amountsArray[i].ToObject<decimal>(), settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
}
}
receiptData.Add("Cart", cartData);
}
receiptData.Add("Subtotal", _displayFormatter.Currency(appPosData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
if (appPosData.DiscountAmount > 0)
{
var discountFormatted = _displayFormatter.Currency(appPosData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
receiptData.Add("Discount", appPosData.DiscountPercentage > 0 ? $"{appPosData.DiscountPercentage}% = {discountFormatted}" : discountFormatted);
}
if (appPosData.Tip > 0)
{
var tipFormatted = _displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
receiptData.Add("Tip", appPosData.TipPercentage > 0 ? $"{appPosData.TipPercentage}% = {tipFormatted}" : tipFormatted);
}
receiptData.Add("Total", _displayFormatter.Currency(appPosData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
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)
@@ -433,7 +435,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var data = new { invoiceId = invoice.Id };
if (wantsJson)
return Json(data);
if (price is 0 && storeBlob.ReceiptOptions?.Enabled is true)
if (!isTopup && summary.PriceTaxIncludedWithTips is 0 && storeBlob.ReceiptOptions?.Enabled is true)
return RedirectToAction(nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", data);
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", data);
}
@@ -606,6 +608,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
Archived = app.Archived,
AppName = app.Name,
Title = settings.Title,
DefaultTaxRate = settings.DefaultTaxRate,
DefaultView = settings.DefaultView,
ShowItems = settings.ShowItems,
ShowCustomAmount = settings.ShowCustomAmount,
@@ -697,11 +700,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return View("PointOfSale/UpdatePointOfSale", vm);
}
bool wasHtmlModified;
var settings = new PointOfSaleSettings
{
Title = vm.Title,
DefaultView = vm.DefaultView,
DefaultTaxRate = vm.DefaultTaxRate ?? 0,
ShowItems = vm.ShowItems,
ShowCustomAmount = vm.ShowCustomAmount,
ShowDiscount = vm.ShowDiscount,
@@ -717,7 +720,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
NotificationUrl = vm.NotificationUrl,
RedirectUrl = vm.RedirectUrl,
HtmlLang = vm.HtmlLang,
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out wasHtmlModified),
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out bool wasHtmlModified),
Description = vm.Description,
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically),
FormId = vm.FormId

View File

@@ -41,6 +41,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public bool ShowCategories { get; set; }
[Display(Name = "Enable tips")]
public bool EnableTips { get; set; }
[Display(Name = "Default Tax Rate")]
[Range(0.0, 100.0)]
[DisplayFormat(DataFormatString = "{0:0.00####}", ApplyFormatInEditMode = true)]
public decimal? DefaultTaxRate { get; set; }
public string Example1 { get; internal set; }
public string Example2 { get; internal set; }
public string ExampleCallback { get; internal set; }

View File

@@ -73,5 +73,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string Description { get; set; }
public SelectList AllCategories { get; set; }
public string StoreId { get; set; }
public decimal DefaultTaxRate { get; set; }
}
}

View File

@@ -0,0 +1,75 @@
#nullable enable
using System;
using System.Collections.Generic;
namespace BTCPayServer.Plugins.PointOfSale;
public class PoSOrder
{
private readonly int _decimals;
decimal _discount;
decimal _tip;
List<ItemLine> ItemLines = new();
public PoSOrder(int decimals)
{
_decimals = decimals;
}
public record ItemLine(string ItemId, int Count, decimal UnitPrice, decimal TaxRate);
public void AddLine(ItemLine line)
{
ItemLines.Add(line);
}
public class OrderSummary
{
public decimal Discount { get; set; }
public decimal Tax { get; set; }
public decimal ItemsTotal { get; set; }
public decimal PriceTaxExcluded { get; set; }
public decimal Tip { get; set; }
public decimal PriceTaxIncluded { get; set; }
public decimal PriceTaxIncludedWithTips { get; set; }
}
public OrderSummary Calculate()
{
var ctx = new OrderSummary();
foreach (var item in ItemLines)
{
var linePrice = item.UnitPrice * item.Count;
var discount = linePrice * _discount / 100.0m;
discount = Round(discount);
ctx.Discount += discount;
linePrice -= discount;
var tax = linePrice * item.TaxRate / 100.0m;
tax = Round(tax);
ctx.Tax += tax;
ctx.PriceTaxExcluded += linePrice;
}
ctx.PriceTaxExcluded = Round(ctx.PriceTaxExcluded);
ctx.PriceTaxIncluded = ctx.PriceTaxExcluded + ctx.Tax;
ctx.PriceTaxIncludedWithTips = ctx.PriceTaxIncluded + _tip;
ctx.PriceTaxIncludedWithTips = Round(ctx.PriceTaxIncludedWithTips);
ctx.Tip = Round(_tip);
ctx.ItemsTotal = ctx.PriceTaxExcluded + ctx.Discount;
return ctx;
}
decimal Round(decimal value) => Math.Round(value, _decimals, MidpointRounding.AwayFromZero);
public void AddTip(decimal tip)
{
_tip = Round(tip);
}
/// <summary>
///
/// </summary>
/// <param name="discount">From 0 to 100</param>
public void AddDiscountRate(decimal discount)
{
_discount = discount;
}
}

View File

@@ -1,4 +1,5 @@
using BTCPayServer.Client.Models;
using Newtonsoft.Json;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Services.Apps
@@ -83,6 +84,8 @@ namespace BTCPayServer.Services.Apps
public string Currency { get; set; }
public string Template { get; set; }
public bool EnableShoppingCart { get; set; }
[JsonConverter(typeof(JsonConverters.NumericStringJsonConverter))]
public decimal DefaultTaxRate { get; set; }
public PosViewType DefaultView { get; set; }
public bool ShowItems { get; set; }
public bool ShowCustomAmount { get; set; }

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -7,6 +8,18 @@ namespace BTCPayServer.Services.Invoices;
public class PosAppData
{
public static PosAppData TryParse(string posData)
{
try
{
return JObject.Parse(posData).ToObject<PosAppData>();
}
catch
{
}
return null;
}
[JsonProperty(PropertyName = "cart")]
public PosAppCartItem[] Cart { get; set; }
@@ -19,18 +32,19 @@ public class PosAppData
[JsonProperty(PropertyName = "discountPercentage")]
public decimal DiscountPercentage { get; set; }
[JsonProperty(PropertyName = "discountAmount")]
public decimal DiscountAmount { get; set; }
[JsonProperty(PropertyName = "tipPercentage")]
public decimal TipPercentage { get; set; }
[JsonProperty(PropertyName = "tip")]
public decimal Tip { get; set; }
[JsonProperty(PropertyName = "itemsTotal")]
public decimal ItemsTotal { get; set; }
[JsonProperty(PropertyName = "discountAmount")]
public decimal DiscountAmount { get; set; }
[JsonProperty(PropertyName = "subTotal")]
public decimal Subtotal { get; set; }
[JsonProperty(PropertyName = "tax")]
public decimal Tax { get; set; }
[JsonProperty(PropertyName = "tip")]
public decimal Tip { get; set; }
[JsonProperty(PropertyName = "total")]
public decimal Total { get; set; }
}

View File

@@ -0,0 +1,19 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Invoices;
[JsonObject(ItemNullValueHandling = NullValueHandling.Ignore)]
public class PosReceiptData
{
public string Description { get; set; }
public string Title { get; set; }
public Dictionary<string, string> Cart { get; set; }
public string Subtotal { get; set; }
public string Discount { get; set; }
public string Tip { get; set; }
public string Total { get; set; }
public string ItemsTotal { get; set; }
public string Tax { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Invoices;
public class WellKnownPosData
{
public static WellKnownPosData TryParse(Dictionary<string, object> data)
{
try
{
return JObject.FromObject(data).ToObject<WellKnownPosData>();
}
catch
{
}
return null;
}
public static bool IsWellKnown(string field)
=> field.ToLowerInvariant() is "cart" or "subtotal" or "discount" or "tip" or "total" or "tax" or "itemstotal" or "discountamount";
public object Subtotal { get; set; }
public object Discount { get; set; }
public object Tip { get; set; }
public object Total { get; set; }
public object ItemsTotal { get; set; }
public object Tax { get; set; }
}

View File

@@ -48,6 +48,7 @@ public class LegacyInvoiceExportReportProvider : ReportProvider
new("InvoiceCurrency", "text"),
new("InvoiceDue", "number"),
new("InvoicePrice", "number"),
new("InvoiceTaxIncluded", "number"),
new("InvoiceItemCode", "text"),
new("InvoiceItemDesc", "text"),
new("InvoiceFullStatus", "text"),
@@ -91,6 +92,7 @@ public class LegacyInvoiceExportReportProvider : ReportProvider
data.Add(invoiceEntity.Currency);
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits));
data.Add(invoiceEntity.Price);
data.Add(invoiceEntity.Metadata.TaxIncluded ?? 0.0m);
data.Add(invoiceEntity.Metadata.ItemCode);
data.Add(invoiceEntity.Metadata.ItemDesc);
data.Add(invoiceEntity.GetInvoiceState().ToString());
@@ -125,6 +127,7 @@ public class LegacyInvoiceExportReportProvider : ReportProvider
data.Add(invoiceEntity.Currency);
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits)); // InvoiceDue
data.Add(invoiceEntity.Price);
data.Add(invoiceEntity.Metadata.TaxIncluded ?? 0.0m);
data.Add(invoiceEntity.Metadata.ItemCode);
data.Add(invoiceEntity.Metadata.ItemDesc);
data.Add(invoiceEntity.GetInvoiceState().ToString());

View File

@@ -60,16 +60,6 @@ public class ProductsReportProvider : ReportProvider
{
values = values.ToList();
values.Add(appId);
if (i.Metadata?.ItemCode is string code)
{
values.Add(code);
values.Add(1);
values.Add(i.Price);
values.Add(i.Currency);
queryContext.Data.Add(values);
}
else
{
if (AppService.TryParsePosCartItems(i.Metadata?.PosData, out var items))
{
foreach (var item in items)
@@ -82,6 +72,13 @@ public class ProductsReportProvider : ReportProvider
queryContext.Data.Add(copy);
}
}
else if (i.Metadata?.ItemCode is string code)
{
values.Add(code);
values.Add(1);
values.Add(i.Price);
values.Add(i.Currency);
queryContext.Data.Add(values);
}
}
}

View File

@@ -226,22 +226,30 @@
</tr>
</table>
<table class="table table-borderless mt-4 mb-0">
<tr>
<td class="align-middle" text-translate="true">Subtotal</td>
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
<tr v-if="itemsTotalNumeric">
<td class="align-middle h6 border-0" text-translate="true">Items total</td>
<td class="align-middle h6 border-0 text-end" id="CartItemsTotal">{{ formatCurrency(itemsTotalNumeric, true) }}</td>
</tr>
<tr v-if="discountNumeric">
<td class="align-middle" text-translate="true">Discount</td>
<td class="align-middle text-end" id="CartDiscount">
<span v-if="discountPercent">{{discountPercent}}% =</span>
{{ formatCurrency(discountNumeric, true) }}
<span>{{ formatCurrency(discountNumeric, true) }}</span>&nbsp;<span v-if="discountPercent">({{discountPercent}}%)</span>
</td>
</tr>
<tr v-if="subtotalNumeric">
<td class="align-middle h6 border-0" text-translate="true">Subtotal</td>
<td class="align-middle h6 border-0 text-end" id="CartAmount">{{ formatCurrency(subtotalNumeric, true) }}</td>
</tr>
<tr v-if="tipNumeric">
<td class="align-middle" text-translate="true">Tip</td>
<td class="align-middle text-end" id="CartTip">
<span v-if="tipPercent">{{tipPercent}}% =</span>
{{ formatCurrency(tipNumeric, true) }}
<span>{{ formatCurrency(tipNumeric, true) }}</span>&nbsp;<span v-if="tipPercent">({{tipPercent}}%)</span>
</td>
</tr>
<tr v-if="taxNumeric">
<td class="align-middle" text-translate="true">Taxes</td>
<td class="align-middle text-end" id="CartTax">
{{ formatCurrency(taxNumeric, true) }}
</td>
</tr>
<tr>

View File

@@ -22,7 +22,7 @@
<input type="hidden" name="posdata" :value="posdata">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(totalNumeric, false) }}</div>
<div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
</div>
<div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
@@ -61,7 +61,7 @@
</div>
<div id="ModeTablist" class="nav btcpay-pills align-items-center justify-content-center mb-n2 pb-1" role="tablist" v-if="modes.length > 1">
<template v-for="m in modes" :key="m.value">
<input :id="`ModeTablist-${m.type}`" name="mode" :value="m.type" type="radio" role="tab" data-bs-toggle="pill" :data-bs-target="`#Mode-${m.type}`" :disabled="m.type != 'amounts' && amountNumeric == 0" :aria-controls="`Mode-${m.type}`" :aria-selected="mode === m.type" :checked="mode === m.type" v-on:click="mode = m.type">
<input :id="`ModeTablist-${m.type}`" name="mode" :value="m.type" type="radio" role="tab" data-bs-toggle="pill" :data-bs-target="`#Mode-${m.type}`" :disabled="m.type != 'amounts' && summary.priceTaxExcluded == 0" :aria-controls="`Mode-${m.type}`" :aria-selected="mode === m.type" :checked="mode === m.type" v-on:click="mode = m.type">
<label :for="`ModeTablist-${m.type}`">{{ m.title }}</label>
</template>
</div>

View File

@@ -164,6 +164,20 @@
</div>
</div>
</fieldset>
<fieldset id="taxes" class="mt-2">
<legend class="h5 mb-3 fw-semibold" text-translate="true">Taxes</legend>
<div class="form-group">
<label asp-for="DefaultTaxRate" class="form-label"></label>
<div class="input-group">
<input inputmode="decimal" asp-for="DefaultTaxRate" class="form-control" />
<span class="input-group-text">%</span>
</div>
<div class="form-text" text-translate="true">This rate can also be overridden per item.</div>
<span asp-validation-for="DefaultTaxRate" class="text-danger"></span>
</div>
</fieldset>
<fieldset id="discounts" class="mt-2">
<legend class="h5 mb-3 fw-semibold" text-translate="true">Discounts</legend>
<div class="form-group d-flex align-items-center">

View File

@@ -1,18 +1,13 @@
@using BTCPayServer.Services.Invoices
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model (Dictionary<string, object> Items, int Level)
@if (Model.Items.Any())
{
@* Use titlecase and lowercase versions for backwards-compatibility *@
string[] cartKeys = ["cart", "subtotal", "discount", "tip", "total"];
<table class="table my-0" v-pre>
@if (Model.Items.Keys.Any(key => cartKeys.Contains(key.ToLowerInvariant())))
@if (Model.Items.Keys.Any(WellKnownPosData.IsWellKnown))
{
_ = 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>
@@ -35,33 +30,48 @@
}
</tbody>
}
var posData = WellKnownPosData.TryParse(Model.Items) ?? new();
<tfoot style="border-top-width:0">
@if (hasSubtotal && (hasDiscount || hasTip))
@if (posData.ItemsTotal != null)
{
<tr style="border-top-width:3px">
<th>Subtotal</th>
<td class="text-end">@subtotal</td>
<th text-translate="true">Items total</th>
<td class="text-end">@posData.ItemsTotal</td>
</tr>
}
@if (hasDiscount)
@if (posData.Discount != null)
{
<tr>
<th>Discount</th>
<td class="text-end">@discount</td>
<th text-translate="true">Discount</th>
<td class="text-end">@posData.Discount</td>
</tr>
}
@if (hasTip)
{
<tr>
<th>Tip</th>
<td class="text-end">@tip</td>
</tr>
}
@if (hasTotal)
@if (posData.Subtotal != null)
{
<tr style="border-top-width:3px">
<th>Total</th>
<td class="text-end">@total</td>
<th text-translate="true">Subtotal</th>
<td class="text-end">@posData.Subtotal</td>
</tr>
}
@if (posData.Tax != null)
{
<tr>
<th text-translate="true">Tax</th>
<td class="text-end">@posData.Tax</td>
</tr>
}
@if (posData.Tip != null)
{
<tr>
<th text-translate="true">Tip</th>
<td class="text-end">@posData.Tip</td>
</tr>
}
@if (posData.Total != null)
{
<tr style="border-top-width:3px">
<th text-translate="true">Total</th>
<td class="text-end">@posData.Total</td>
</tr>
}
</tfoot>

View File

@@ -50,6 +50,18 @@
</div>
<div class="text-danger" v-if="errors.price">{{errors.price}}</div>
</div>
<div class="form-group">
<label for="EditorTaxRate" class="form-label" text-translate="true">Tax rate</label>
<div class="input-group">
<input inputmode="decimal" v-model="editingItem && editingItem.taxRate"
pattern="\d*"
step="any"
min="0"
type="number"
class="form-control" />
<span class="input-group-text">%</span>
</div>
</div>
<div class="form-group">
<label for="EditorImageUrl" class="form-label" text-translate="true">Image Url</label>
<input id="EditorImageUrl" class="form-control mb-2" v-model="editingItem && editingItem.image" ref="txtImage" />

View File

@@ -256,6 +256,10 @@
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
<dt v-t="'total_fiat'"></dt>
<dd class="clipboard-button clipboard-button-hover" :data-clipboard="asNumber(srvModel.orderAmountFiat)" data-clipboard-hover="start">{{srvModel.orderAmountFiat}}</dd>
</div>
<div v-if="srvModel.taxIncluded.value > 0 && srvModel.taxIncluded.formatted" id="PaymentDetails-TaxIncluded" key="TaxIncluded">
<dt v-t="'tax_included'"></dt>
<dd class="clipboard-button clipboard-button-hover" :data-clipboard="srvModel.taxIncluded.value" data-clipboard-hover="start">{{srvModel.taxIncluded.formatted}}</dd>
</div>
<div v-if="srvModel.rate && srvModel.paymentMethodCurrency" id="PaymentDetails-ExchangeRate" key="ExchangeRate">
<dt v-t="'exchange_rate'"></dt>

View File

@@ -70,6 +70,13 @@
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
</div>
@if (Model.TaxIncluded != 0.0m)
{
<div class="d-flex flex-column">
<dd class="text-muted mb-0 fw-semibold">Total Taxes</dd>
<dt class="fs-5 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.TaxIncluded, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
</div>
}
<div class="d-flex flex-column">
<dd class="text-muted mb-0 fw-semibold">Date</dd>
<dt class="fs-5 mb-0 text-nowrap fw-semibold">@Model.Timestamp.ToBrowserDate()</dt>

View File

@@ -116,10 +116,7 @@
@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);
var posData = WellKnownPosData.TryParse(Model.CartData) ?? new();
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
{
@foreach (var (key, value) in cartDict)
@@ -139,38 +136,53 @@
</tr>
}
}
if (hasSubtotal && (hasDiscount || hasTip))
@if (posData.ItemsTotal != null)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr class="sums-data">
<td class="key text-secondary">Subtotal</td>
<td class="val text-end">@subtotal</td>
<td class="key text-secondary">Items total</td>
<td class="val text-end">@posData.ItemsTotal</td>
</tr>
}
if (hasDiscount)
@if (posData.Discount != null)
{
<tr class="sums-data">
<td class="key text-secondary">Discount</td>
<td class="val text-end">@discount</td>
<td class="val text-end">@posData.Discount</td>
</tr>
}
if (hasTip)
@if (posData.Subtotal != null)
{
<tr class="sums-data">
<td class="key text-secondary">Subtotal</td>
<td class="val text-end">@posData.Subtotal</td>
</tr>
}
@if (posData.Tax != null)
{
<tr class="sums-data">
<td class="key text-secondary">Tax</td>
<td class="val text-end">@posData.Tax</td>
</tr>
}
@if (posData.Tip != null)
{
<tr class="sums-data">
<td class="key text-secondary">Tip</td>
<td class="val text-end">@tip</td>
<td class="val text-end">@posData.Tip</td>
</tr>
}
if (hasTotal)
@if (posData.Total != null)
{
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
<tr class="sums-data">
<td class="key text-secondary">Total</td>
<td class="val text-end fw-semibold">@total</td>
<td class="val text-end fw-semibold">@posData.Total</td>
</tr>
}
}

View File

@@ -16,6 +16,7 @@
"invoice_id": "Invoice ID",
"order_id": "Order ID",
"total_price": "Total Price",
"tax_included": "Total Taxes",
"total_fiat": "Total Fiat",
"exchange_rate": "Exchange Rate",
"amount_paid": "Amount Paid",

View File

@@ -1,5 +1,97 @@
const POS_ITEM_ADDED_CLASS = 'posItem--added';
class PoSOrder {
constructor(decimals) {
this._decimals = decimals;
this._discount = 0;
this._tip = 0;
this._tipPercent = 0;
this.itemLines = [];
}
static ItemLine = class {
constructor(itemId, count, unitPrice, taxRate = null) {
this.itemId = itemId;
this.count = count;
this.unitPrice = unitPrice;
this.taxRate = taxRate;
}
}
addLine(line) {
this.itemLines.push(line);
}
setTip(tip) {
this._tip = this._round(tip);
this._tipPercent = 0;
}
setTipPercent(tip) {
this._tipPercent = tip;
this._tip = 0;
}
addDiscountRate(discount) {
this._discount = discount;
}
setCart(cart, amounts, defaultTaxRate) {
this.itemLines = [];
for (const item of cart) {
this.addLine(new PoSOrder.ItemLine(item.id, item.count, item.price, item.taxRate ?? defaultTaxRate));
}
if (amounts) {
var i = 1;
for (const item of amounts) {
if (!item) continue;
this.addLine(new PoSOrder.ItemLine("Custom Amount " + i, 1, item, defaultTaxRate));
i++;
}
}
}
calculate() {
const ctx = {
discount: 0,
tax: 0,
itemsTotal: 0,
priceTaxExcluded: 0,
tip: 0,
priceTaxIncluded: 0,
priceTaxIncludedWithTips: 0
};
for (const item of this.itemLines) {
let linePrice = item.unitPrice * item.count;
let discount = linePrice * this._discount / 100;
discount = this._round(discount);
ctx.discount += discount;
linePrice -= discount;
let taxRate = item.taxRate ?? 0;
let tax = linePrice * taxRate / 100;
tax = this._round(tax);
ctx.tax += tax;
ctx.priceTaxExcluded += linePrice;
}
ctx.priceTaxExcluded = this._round(ctx.priceTaxExcluded);
ctx.tip = this._round(this._tip);
ctx.tip += this._round(ctx.priceTaxExcluded * this._tipPercent / 100);
ctx.priceTaxIncluded = ctx.priceTaxExcluded + ctx.tax;
ctx.priceTaxIncludedWithTips = ctx.priceTaxIncluded + ctx.tip;
ctx.priceTaxIncludedWithTips = this._round(ctx.priceTaxIncludedWithTips);
ctx.itemsTotal = ctx.priceTaxExcluded + ctx.discount;
return ctx;
}
_round(value) {
const factor = Math.pow(10, this._decimals);
return Math.round(value * factor + Number.EPSILON) / factor;
}
}
function storageKey(name) {
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
}
@@ -39,7 +131,7 @@ const posCommon = {
data () {
return {
...srvModel,
amount: null,
posOrder: new PoSOrder(srvModel.currencyInfo.divisibility),
tip: null,
tipPercent: null,
discount: null,
@@ -56,20 +148,30 @@ const posCommon = {
}
},
computed: {
amountNumeric () {
const { divisibility } = this.currencyInfo
const cart = this.cart.reduce((res, item) => res + (item.price || 0) * item.count, 0).toFixed(divisibility)
const value = parseFloat(this.amount || 0) + parseFloat(cart)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(divisibility))
summary() {
return this.posOrder.calculate();
},
itemsTotalNumeric() {
// We don't want to show the items total if there is no discount or tip
if (this.summary.itemsTotal === this.summary.priceTaxExcluded) return 0;
return this.summary.itemsTotal;
},
taxNumeric() {
return this.summary.tax;
},
subtotalNumeric () {
// We don't want to show the subtotal if there is no tax or tips
if (this.summary.priceTaxExcluded === this.summary.priceTaxIncludedWithTips) return 0;
return this.summary.priceTaxExcluded;
},
posdata () {
const data = { subTotal: this.amountNumeric, total: this.totalNumeric }
const data = { subTotal: this.summary.priceTaxExcluded, total: this.summary.priceTaxIncludedWithTips }
const amounts = this.amounts.filter(e => e) // clear empty or zero values
if (amounts) data.amounts = amounts.map(parseFloat)
if (this.cart) data.cart = this.cart
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.summary.discount > 0) data.discountAmount = this.summary.discount
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.summary.tip > 0) data.tip = this.summary.tip
if (this.tipPercent > 0) data.tipPercentage = this.tipPercent
return JSON.stringify(data)
},
@@ -78,29 +180,13 @@ const posCommon = {
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
},
discountNumeric () {
return this.amountNumeric && this.discountPercentNumeric
? parseFloat((this.amountNumeric * (this.discountPercentNumeric / 100)).toFixed(this.currencyInfo.divisibility))
: 0.0;
},
amountMinusDiscountNumeric () {
return parseFloat((this.amountNumeric - this.discountNumeric).toFixed(this.currencyInfo.divisibility))
return this.summary.discount;
},
tipNumeric () {
if (this.tipPercent) {
return parseFloat((this.amountMinusDiscountNumeric * (this.tipPercent / 100)).toFixed(this.currencyInfo.divisibility))
} else {
if (this.tip < 0) {
this.tip = 0
}
const value = parseFloat(this.tip)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
}
},
total () {
return this.amountNumeric - this.discountNumeric + this.tipNumeric
return this.summary.tip;
},
totalNumeric () {
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
return this.summary.priceTaxIncludedWithTips;
},
cartCount() {
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
@@ -119,9 +205,11 @@ const posCommon = {
else if (value < 0) this.discountPercent = '0'
else if (value > 100) this.discountPercent = '100'
else this.discountPercent = value.toString()
this.posOrder.addDiscountRate(isNaN(value) ? null : value)
},
tip(val) {
this.tipPercent = null
this.posOrder.setTip(val)
},
cart: {
handler(newCart) {
@@ -132,11 +220,12 @@ const posCommon = {
if (this.persistState) {
saveState('cart', newCart)
}
this.posOrder.setCart(newCart, this.amounts, this.defaultTaxRate)
},
deep: true
},
amounts (values) {
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
this.posOrder.setCart(this.cart, values, this.defaultTaxRate)
}
},
methods: {
@@ -155,6 +244,7 @@ const posCommon = {
this.tipPercent = this.tipPercent !== percentage
? percentage
: null;
this.posOrder.setTipPercent(this.tipPercent)
},
formatCrypto(value, withSymbol) {
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
@@ -207,10 +297,7 @@ const posCommon = {
// Add new item because it doesn't exist yet
if (!itemInCart) {
itemInCart = {
id: item.id,
title: item.title,
price: item.price,
inventory: item.inventory,
...item,
count
}
this.cart.push(itemInCart);
@@ -221,11 +308,13 @@ const posCommon = {
// Animate
if (!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
return itemInCart;
},
removeFromCart(id) {
const index = this.cart.findIndex(lineItem => lineItem.id === id);
this.cart.splice(index, 1);
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
},
getQuantity(id) {
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
@@ -245,6 +334,7 @@ const posCommon = {
if (itemInCart && itemInCart.count <= 0 && addOrRemove) {
this.removeFromCart(itemInCart.id);
}
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
},
clear() {
this.cart = [];
@@ -305,6 +395,7 @@ const posCommon = {
if (this.persistState) {
this.cart = loadState('cart');
}
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
},
mounted () {
if (this.$refs.categories) {

View File

@@ -30,15 +30,17 @@ document.addEventListener("DOMContentLoaded",function () {
}
},
calculation () {
if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0) && this.amounts.length < 2 && this.cart.length === 0) return null
if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0 || this.summary.tax > 0) && this.amounts.length < 2 && this.cart.length === 0) return null
let calc = ''
const hasAmounts = this.amounts.length && this.amounts.reduce((sum, amt) => sum + parseFloat(amt || 0), 0) > 0;
if (this.cart.length) calc += this.cart.map(item => `${item.count} x ${item.title} (${this.formatCurrency(item.price, true)}) = ${this.formatCurrency((item.price||0) * item.count, true)}`).join(' + ')
if (this.cart.length && hasAmounts) calc += ' + '
if (hasAmounts) calc += this.amounts.map(amt => this.formatCurrency(amt || 0, true)).join(' + ')
if (this.discountNumeric > 0 || this.discountPercentNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
if (this.summary.tip > 0) calc += ` + ${this.formatCurrency(this.summary.tip, true)}`
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
if (this.summary.tax) calc += ` + ${this.formatCurrency(this.summary.tax, true)}`
if (this.defaultTaxRate) calc += ` (${this.defaultTaxRate}%)`
return calc
}
},

View File

@@ -4,6 +4,7 @@
<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/=NBXplorer/@EntryIndexedValue">NBXplorer</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=POS/@EntryIndexedValue">POS</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=PSBT/@EntryIndexedValue">PSBT</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SSH/@EntryIndexedValue">SSH</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=TX/@EntryIndexedValue">TX</s:String>