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()
@@ -79,8 +79,8 @@ fruit tea:
Assert.Equal( 1 ,parsedDefault[0].Price);
Assert.Equal( AppItemPriceType.Fixed ,parsedDefault[0].PriceType);
Assert.Null( parsedDefault[0].AdditionalData);
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
Assert.Equal( "herbal tea" ,parsedDefault[4].Id);
Assert.Equal( "Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!" ,parsedDefault[4].Description);
@@ -126,7 +126,7 @@ fruit tea:
items = AppService.Parse(missingId);
Assert.Single(items);
Assert.Equal("black-tea", items[0].Id);
// Throws for missing ID
Assert.Throws<ArgumentException>(() => AppService.Parse(missingId, true, true));
@@ -134,11 +134,11 @@ fruit tea:
var duplicateId = template.Replace(@"""id"": ""green-tea"",", @"""id"": ""black-tea"",");
items = AppService.Parse(duplicateId);
Assert.Empty(items);
// Throws for duplicate IDs
Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true));
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUsePoSApp1()
@@ -191,7 +191,7 @@ donation:
// apple is not found
Assert.IsType<NotFoundResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
// List
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
app = appList.Apps[0];
@@ -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;
}
if (choice.Inventory is <= 0)
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
jposData.Cart = new PosAppCartItem[] { new() { Id = choiceKey, Count = 1, Price = amount ?? 0 } };
}
else
jposData.Cart ??= [];
if (currentView is PosViewType.Print)
return NotFound();
if (currentView is PosViewType.Cart or PosViewType.Static && jposData.Cart.Length == 0)
return NotFound();
if (jposData.Amounts is null &&
currentView == PosViewType.Light &&
amount is { } o)
{
if (!settings.ShowCustomAmount && currentView != PosViewType.Cart && currentView != PosViewType.Light)
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))
{
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)
{
var itemChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
if (itemChoice == null)
return NotFound();
if (itemChoice.Inventory.HasValue)
{
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;
if (cartItem.Price < expectedCartItemPrice)
cartItem.Price = expectedCartItemPrice;
price += cartItem.Price * cartItem.Count;
}
if (customAmount is { } c)
price += c;
if (discount is { } d)
price -= price * d / 100.0m;
if (tip is { } t)
price += t;
}
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.PriceType is not AppItemPriceType.Topup)
{
var expectedCartItemPrice = itemChoice.Price ?? 0;
if (cartItem.Price < expectedCartItemPrice)
cartItem.Price = expectedCartItemPrice;
}
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)
order.AddDiscountRate(d);
if (tip is { } 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)
{
var dict = new Dictionary<string, string> { { "Title", choice.Title } };
if (!string.IsNullOrEmpty(choice.Description))
dict["Description"] = choice.Description;
receiptData = JObject.FromObject(dict);
}
else if (jposData is not null)
{
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))
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.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
}
entity.Metadata.PosData = JObject.FromObject(jposData);
if (jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray)
{
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));
if (selectedChoices.Count == 1)
{
receiptData.Title = selectedChoices[0].Title;
if (!string.IsNullOrEmpty(selectedChoices[0].Description))
receiptData.Description = selectedChoices[0].Description;
}
Dictionary<string,string> cartData = null;
foreach (var cartItem in jposData.Cart)
{
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}");
}
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
{
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);
}
@@ -561,7 +563,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
viewModel.StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob);
return View("Views/UIForms/View", viewModel);
}
[Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("/apps/{appId}/pos/recent-transactions")]
public async Task<IActionResult> RecentTransactions(string appId)
@@ -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,
@@ -663,7 +666,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}";
await FillUsers(vm);
return View("PointOfSale/UpdatePointOfSale", vm);
}
@@ -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

@@ -17,7 +17,7 @@ public class ProductsReportProvider : ReportProvider
_displayFormatter = displayFormatter;
Apps = apps;
}
private readonly DisplayFormatter _displayFormatter;
private InvoiceRepository InvoiceRepository { get; }
private AppService Apps { get; }
@@ -60,7 +60,19 @@ public class ProductsReportProvider : ReportProvider
{
values = values.ToList();
values.Add(appId);
if (i.Metadata?.ItemCode is string code)
if (AppService.TryParsePosCartItems(i.Metadata?.PosData, out var items))
{
foreach (var item in items)
{
var copy = values.ToList();
copy.Add(item.Id);
copy.Add(item.Count);
copy.Add(item.Price * item.Count);
copy.Add(i.Currency);
queryContext.Data.Add(copy);
}
}
else if (i.Metadata?.ItemCode is string code)
{
values.Add(code);
values.Add(1);
@@ -68,21 +80,6 @@ public class ProductsReportProvider : ReportProvider
values.Add(i.Currency);
queryContext.Data.Add(values);
}
else
{
if (AppService.TryParsePosCartItems(i.Metadata?.PosData, out var items))
{
foreach (var item in items)
{
var copy = values.ToList();
copy.Add(item.Id);
copy.Add(item.Count);
copy.Add(item.Price * item.Count);
copy.Add(i.Currency);
queryContext.Data.Add(copy);
}
}
}
}
}
// Round the currency amount

View File

@@ -150,7 +150,7 @@
</button>
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="cross" />
</button>
</button>
</header>
<div class="offcanvas-body py-0">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" v-if="cartCount !== 0">
@@ -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

@@ -2,7 +2,7 @@
@using BTCPayServer.Abstractions.Contracts
@inject LanguageService LangService
@inject BTCPayServerEnvironment Env
@inject IEnumerable<IUIExtension> UiExtensions
@inject IEnumerable<IUIExtension> UiExtensions
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model CheckoutModel
@{
@@ -247,7 +247,7 @@
</p>
</div>
</noscript>
<script type="text/x-template" id="payment-details">
<script type="text/x-template" id="payment-details">
<dl>
<div v-if="orderAmount > 0" id="PaymentDetails-TotalPrice" key="TotalPrice">
<dt v-t="'total_price'"></dt>
@@ -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>
@@ -86,7 +93,7 @@
</div>
}
</div>
@if (isProcessing)
{
<small class="d-block text-muted text-center px-4">This page will refresh periodically until the invoice is settled.</small>

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)
@@ -130,8 +127,8 @@
</tr>
}
}
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
{
@foreach (var value in cartCollection)
{
<tr>
@@ -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

@@ -245,7 +245,7 @@ function initApp() {
await this.setupNFC();
}
updateLanguageSelect();
window.parent.postMessage('loaded', '*');
},
beforeDestroy () {
@@ -329,7 +329,7 @@ function initApp() {
},
async fetchData () {
if (this.isPluginPaymentMethod) return;
const url = `${statusUrl}&paymentMethodId=${this.pmId}`;
const response = await fetch(url);
if (response.ok) {
@@ -346,7 +346,7 @@ function initApp() {
const newEnd = new Date();
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
this.endDate = newEnd;
// updating ui
this.srvModel = data;
},

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",
@@ -42,4 +43,4 @@
"copy_confirm": "Copied",
"powered_by": "Powered by",
"conversion_body": "This service is provided by 3rd party. Please keep in mind that we have no control over how providers will forward your funds. Invoice will only be marked paid once funds are received on the {{cryptoCode}} blockchain."
}
}

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) {
@@ -328,7 +419,7 @@ const posCommon = {
});
adjustCategories();
}
this.forEachItem(item => {
item.addEventListener('transitionend', () => {
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
@@ -336,7 +427,7 @@ const posCommon = {
}
});
})
if (this.$refs.RecentTransactions) {
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
}
@@ -347,7 +438,7 @@ const posCommon = {
localStorage.removeItem(storageKey('cart'));
}
})
this.updateDisplay()
}
}

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>