mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 14:34:23 +01:00
Can apply tax rates to PoS items (#6724)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
@@ -11,6 +13,8 @@ using BTCPayServer.Plugins.PointOfSale.Controllers;
|
||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Playwright;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using static BTCPayServer.Tests.UnitTest1;
|
||||
@@ -19,12 +23,8 @@ using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
[Collection(nameof(NonParallelizableCollectionDefinition))]
|
||||
public class POSTests : UnitTestBase
|
||||
public class POSTests(ITestOutputHelper helper) : UnitTestBase(helper)
|
||||
{
|
||||
public POSTests(ITestOutputHelper helper) : base(helper)
|
||||
{
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Fast", "Fast")]
|
||||
public void CanParseOldYmlCorrectly()
|
||||
@@ -227,5 +227,521 @@ donation:
|
||||
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
|
||||
Assert.Empty(appList.Apps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Playwright", "Playwright")]
|
||||
public async Task CanUsePOSCart()
|
||||
{
|
||||
await using var s = CreatePlaywrightTester();
|
||||
await s.StartAsync();
|
||||
|
||||
// Create users
|
||||
var user = await s.RegisterNewUser();
|
||||
var userAccount = s.AsTestAccount();
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.GoToRegister();
|
||||
await s.RegisterNewUser(true);
|
||||
|
||||
// Setup store and associate user
|
||||
(_, string storeId) = await s.CreateNewStore();
|
||||
await s.GoToStore();
|
||||
await s.AddDerivationScheme();
|
||||
await s.AddUserToStore(storeId, user, "Guest");
|
||||
|
||||
// Setup POS
|
||||
await s.CreateApp("PointOfSale");
|
||||
await s.Page.ClickAsync("label[for='DefaultView_Cart']");
|
||||
await s.Page.FillAsync("#Currency", "EUR");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.ClickAsync("#EnableTips");
|
||||
Assert.True(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.FillAsync("#CustomTipPercentages", "10,21");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#ShowDiscount"));
|
||||
await s.Page.ClickAsync("#ShowDiscount");
|
||||
|
||||
|
||||
// Default tax of 8.375%, but 10% for the first item.
|
||||
await s.Page.FillAsync("#DefaultTaxRate", "8.375");
|
||||
await s.Page.Locator(".template-item").First.ClickAsync();
|
||||
await s.Page.Locator("#item-form div").Filter(new() { HasText = "Tax rate %" }).GetByRole(AriaRole.Spinbutton).FillAsync("10");
|
||||
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
|
||||
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "App updated");
|
||||
|
||||
// View
|
||||
var o = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("#ViewApp");
|
||||
await s.SwitchPage(o);
|
||||
await s.Page.WaitForSelectorAsync("#PosItems");
|
||||
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
var posUrl = s.Page.Url;
|
||||
|
||||
// Select and clear
|
||||
await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
|
||||
Assert.Single(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
await s.Page.ClickAsync("#CartClear");
|
||||
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
|
||||
// Select simple items
|
||||
await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
|
||||
Assert.Single(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
await s.Page.ClickAsync(".posItem:nth-child(2) .btn-primary");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(2) .btn-primary");
|
||||
Assert.Equal(2, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "3,00€",
|
||||
Taxes = "0,27 €",
|
||||
Total = "3,27 €"
|
||||
});
|
||||
|
||||
// Select item with inventory - two of it
|
||||
Assert.Equal("5 left", await s.Page.TextContentAsync(".posItem:nth-child(3) .badge.inventory"));
|
||||
await s.Page.ClickAsync(".posItem:nth-child(3) .btn-primary");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(3) .btn-primary");
|
||||
Assert.Equal(3, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "5,40 €",
|
||||
Taxes = "0,47 €",
|
||||
Total = "5,87 €"
|
||||
});
|
||||
|
||||
// Select items with minimum amount
|
||||
await s.Page.ClickAsync(".posItem:nth-child(5) .btn-primary");
|
||||
Assert.Equal(4, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "7,20 €",
|
||||
Taxes = "0,62 €",
|
||||
Total = "7,82 €"
|
||||
});
|
||||
|
||||
// Select items with adjusted minimum amount
|
||||
await s.Page.FillAsync(".posItem:nth-child(5) input[name='amount']", "");
|
||||
await s.Page.FillAsync(".posItem:nth-child(5) input[name='amount']", "2.3");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(5) .btn-primary");
|
||||
Assert.Equal(5, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "9,50 €",
|
||||
Taxes = "0,81 €",
|
||||
Total = "10,31 €"
|
||||
});
|
||||
|
||||
// Select items with custom amount
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", "");
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", ".2");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(6) .btn-primary");
|
||||
Assert.Equal(6, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "9,70 €",
|
||||
Taxes = "0,83 €",
|
||||
Total = "10,53 €"
|
||||
});
|
||||
|
||||
// Select items with another custom amount
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", "");
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", ".3");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(6) .btn-primary");
|
||||
Assert.Equal(7, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "10,00 €",
|
||||
Taxes = "0,86 €",
|
||||
Total = "10,86 €"
|
||||
});
|
||||
|
||||
// Discount: 10%
|
||||
Assert.False(await s.Page.IsVisibleAsync("#CartDiscount"));
|
||||
await s.Page.FillAsync("#Discount", "10");
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
ItemsTotal = "10,00 €",
|
||||
Discount = "1,00 € (10%)",
|
||||
Subtotal = "9,00 €",
|
||||
Taxes = "0,77 €",
|
||||
Total = "9,77 €"
|
||||
});
|
||||
|
||||
// Tip: 10%
|
||||
Assert.False(await s.Page.IsVisibleAsync("#CartTip"));
|
||||
await s.Page.ClickAsync("#Tip-10");
|
||||
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
ItemsTotal = "10,00 €",
|
||||
Discount = "1,00 € (10%)",
|
||||
Subtotal = "9,00 €",
|
||||
Tip = "0,90 € (10%)",
|
||||
Taxes = "0,77 €",
|
||||
Total = "10,67 €"
|
||||
});
|
||||
|
||||
// Check values on checkout page
|
||||
await s.Page.ClickAsync("#CartSubmit");
|
||||
await s.Page.WaitForSelectorAsync("#Checkout");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
|
||||
Assert.Contains("0,77 €", await s.Page.TextContentAsync("#PaymentDetails-TaxIncluded"));
|
||||
Assert.Contains("10,67 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
//
|
||||
// Pay
|
||||
await s.PayInvoice(true);
|
||||
|
||||
|
||||
// Receipt
|
||||
await s.Page.ClickAsync("#ReceiptLink");
|
||||
await s.Page.WaitForSelectorAsync("#CartData table");
|
||||
await AssertReceipt(s, new()
|
||||
{
|
||||
Items = [
|
||||
new("Black Tea", "2 x 1,00 € = 2,00 €"),
|
||||
new("Green Tea", "1 x 1,00 € = 1,00 €"),
|
||||
new("Rooibos (limited)", "2 x 1,20 € = 2,40 €"),
|
||||
new("Herbal Tea (minimum) (1,80 €)", "1 x 1,80 € = 1,80 €"),
|
||||
new("Herbal Tea (minimum) (2,30 €)", "1 x 2,30 € = 2,30 €"),
|
||||
new("Fruit Tea (any amount) (0,20 €)", "1 x 0,20 € = 0,20 €"),
|
||||
new("Fruit Tea (any amount) (0,30 €)", "1 x 0,30 € = 0,30 €")
|
||||
],
|
||||
Sums = [
|
||||
new("Items total", "10,00 €"),
|
||||
new("Discount", "1,00 € (10%)"),
|
||||
new("Subtotal", "9,00 €"),
|
||||
new("Tax", "0,77 €"),
|
||||
new("Tip", "0,90 € (10%)"),
|
||||
new("Total", "10,67 €")
|
||||
]
|
||||
});
|
||||
|
||||
// Check inventory got updated and is now 3 instead of 5
|
||||
await s.GoToUrl(posUrl);
|
||||
Assert.Equal("3 left", await s.Page.TextContentAsync(".posItem:nth-child(3) .badge.inventory"));
|
||||
|
||||
// Guest user can access recent transactions
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.LogIn(user, userAccount.RegisterDetails.Password);
|
||||
await s.GoToUrl(posUrl);
|
||||
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
|
||||
// Unauthenticated user can't access recent transactions
|
||||
await s.GoToUrl(posUrl);
|
||||
Assert.False(await s.Page.IsVisibleAsync("#RecentTransactionsToggle"));
|
||||
}
|
||||
|
||||
public class CartSummaryAssertion
|
||||
{
|
||||
public string Subtotal { get; set; }
|
||||
public string Taxes { get; set; }
|
||||
public string Total { get; set; }
|
||||
public string ItemsTotal { get; set; }
|
||||
public string Discount { get; set; }
|
||||
public string Tip { get; set; }
|
||||
}
|
||||
private async Task AssertCartSummary(PlaywrightTester s, CartSummaryAssertion o)
|
||||
{
|
||||
string[] ids = ["CartItemsTotal", "CartDiscount", "CartAmount", "CartTip", "CartTax", "CartTotal"];
|
||||
string[] values = [o.ItemsTotal, o.Discount, o.Subtotal, o.Tip, o.Taxes, o.Total];
|
||||
for (int i = 0; i < ids.Length; i++)
|
||||
{
|
||||
if (values[i] != null)
|
||||
{
|
||||
var text = await s.Page.TextContentAsync("#" + ids[i]);
|
||||
Assert.Equal(values[i].NormalizeWhitespaces(), text.NormalizeWhitespaces());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(await s.Page.IsVisibleAsync("#" + ids[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Playwright", "Playwright")]
|
||||
public async Task CanUsePOSKeypad()
|
||||
{
|
||||
await using var s = CreatePlaywrightTester();
|
||||
await s.StartAsync();
|
||||
|
||||
// Create users
|
||||
var user = await s.RegisterNewUser();
|
||||
var userAccount = s.AsTestAccount();
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.GoToRegister();
|
||||
await s.RegisterNewUser(true);
|
||||
|
||||
// Setup store and associate user
|
||||
(_, string storeId) = await s.CreateNewStore();
|
||||
await s.GoToStore();
|
||||
await s.AddDerivationScheme();
|
||||
await s.AddUserToStore(storeId, user, "Guest");
|
||||
|
||||
// Setup POS
|
||||
await s.CreateApp("PointOfSale");
|
||||
var editUrl = s.Page.Url;
|
||||
await s.Page.ClickAsync("label[for='DefaultView_Light']");
|
||||
await s.Page.FillAsync("#Currency", "EUR");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.ClickAsync("#EnableTips");
|
||||
Assert.True(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.FillAsync("#CustomTipPercentages", "");
|
||||
await s.Page.FillAsync("#CustomTipPercentages", "10,21");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#ShowDiscount"));
|
||||
Assert.False(await s.Page.IsCheckedAsync("#ShowItems"));
|
||||
await s.Page.ClickAsync("#ShowDiscount");
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "App updated");
|
||||
|
||||
// View
|
||||
var o = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("#ViewApp");
|
||||
await s.SwitchPage(o);
|
||||
|
||||
// basic checks
|
||||
var keypadUrl = s.Page.Url;
|
||||
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
|
||||
Assert.Null(await s.Page.QuerySelectorAsync("#ItemsListToggle"));
|
||||
Assert.Contains("EUR", await s.Page.TextContentAsync("#Currency"));
|
||||
Assert.Contains("0,00", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.Equal("", await s.Page.TextContentAsync("#Calculation"));
|
||||
Assert.True(await s.Page.IsCheckedAsync("#ModeTablist-amounts"));
|
||||
Assert.False(await s.Page.IsEnabledAsync("#ModeTablist-discount"));
|
||||
Assert.False(await s.Page.IsEnabledAsync("#ModeTablist-tip"));
|
||||
|
||||
// Amount: 1234,56
|
||||
await EnterKeypad(s, "123400");
|
||||
Assert.Equal("1.234,00", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.Equal("", await s.Page.TextContentAsync("#Calculation"));
|
||||
await EnterKeypad(s, "+56");
|
||||
Assert.Equal("1.234,56", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.True(await s.Page.IsEnabledAsync("#ModeTablist-discount"));
|
||||
Assert.True(await s.Page.IsEnabledAsync("#ModeTablist-tip"));
|
||||
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 €");
|
||||
|
||||
// Discount: 10%
|
||||
await s.Page.ClickAsync("label[for='ModeTablist-discount']");
|
||||
await EnterKeypad(s, "10");
|
||||
Assert.Contains("1.111,10", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.Contains("10% discount", await s.Page.TextContentAsync("#Discount"));
|
||||
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 € - 123,46 € (10%)");
|
||||
|
||||
// Tip: 10%
|
||||
await s.Page.ClickAsync("label[for='ModeTablist-tip']");
|
||||
await s.Page.ClickAsync("#Tip-10");
|
||||
Assert.Contains("1.222,21", await s.Page.TextContentAsync("#Amount"));
|
||||
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 € - 123,46 € (10%) + 111,11 € (10%)");
|
||||
|
||||
// Pay
|
||||
await s.Page.ClickAsync("#pay-button");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
Assert.Contains("1 222,21 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
await s.PayInvoice(true);
|
||||
|
||||
// Receipt
|
||||
await s.Page.ClickAsync("#ReceiptLink");
|
||||
await AssertReceipt(s, new()
|
||||
{
|
||||
Items = [
|
||||
|
||||
new("Custom Amount 1", "1 234,00 €"),
|
||||
new("Custom Amount 2", "0,56 €")
|
||||
],
|
||||
Sums = [
|
||||
|
||||
new("Items total", "1 234,56 €"),
|
||||
new("Discount", "123,46 € (10%)"),
|
||||
new("Subtotal", "1 111,10 €"),
|
||||
new("Tip", "111,11 € (10%)"),
|
||||
new("Total", "1 222,21 €")
|
||||
]
|
||||
});
|
||||
|
||||
await s.GoToUrl(editUrl);
|
||||
await s.Page.ClickAsync("#ShowItems");
|
||||
await s.Page.FillAsync("#DefaultTaxRate", "10");
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "App updated");
|
||||
|
||||
await s.GoToUrl(keypadUrl);
|
||||
await s.Page.ClickAsync("#ItemsListToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PosItems");
|
||||
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(1) .btn-plus");
|
||||
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(1) .btn-plus");
|
||||
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(2) .btn-plus");
|
||||
await s.Page.ClickAsync("#ItemsListOffcanvas button[data-bs-dismiss='offcanvas']");
|
||||
|
||||
await EnterKeypad(s, "123");
|
||||
Assert.Contains("4,65", await s.Page.TextContentAsync("#Amount"));
|
||||
await AssertKeypadCalculation(s, "2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 € + 0,42 € (10%)");
|
||||
|
||||
// Pay
|
||||
await s.Page.ClickAsync("#pay-button");
|
||||
await s.Page.WaitForSelectorAsync("#Checkout");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
|
||||
Assert.Contains("4,65 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
await s.PayInvoice(true);
|
||||
|
||||
|
||||
// Receipt
|
||||
await s.Page.ClickAsync("#ReceiptLink");
|
||||
|
||||
await AssertReceipt(s, new()
|
||||
{
|
||||
Items = [
|
||||
new("Black Tea", "1 x 1,00 € = 1,00 €"),
|
||||
new("Green Tea", "2 x 1,00 € = 2,00 €"),
|
||||
new("Custom Amount 1", "1,23 €")
|
||||
],
|
||||
Sums = [
|
||||
new("Subtotal", "4,23 €"),
|
||||
new("Tax", "0,42 €"),
|
||||
new("Total", "4,65 €")
|
||||
]
|
||||
});
|
||||
|
||||
// Guest user can access recent transactions
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.LogIn(user, userAccount.RegisterDetails.Password);
|
||||
await s.GoToUrl(keypadUrl);
|
||||
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
|
||||
// Unauthenticated user can't access recent transactions
|
||||
await s.GoToUrl(keypadUrl);
|
||||
Assert.False(await s.Page.IsVisibleAsync("#RecentTransactionsToggle"));
|
||||
|
||||
// But they can generate invoices
|
||||
await EnterKeypad(s, "123");
|
||||
|
||||
await s.Page.ClickAsync("#pay-button");
|
||||
await s.Page.WaitForSelectorAsync("#Checkout");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
|
||||
Assert.Contains("1,35 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
}
|
||||
|
||||
private static async Task AssertKeypadCalculation(PlaywrightTester s, string expected)
|
||||
{
|
||||
Assert.Equal(expected.NormalizeWhitespaces(), (await s.Page.TextContentAsync("#Calculation")).NormalizeWhitespaces());
|
||||
}
|
||||
|
||||
public class AssertReceiptAssertion
|
||||
{
|
||||
public record Line(string Key, string Value);
|
||||
public Line[] Items { get; set; }
|
||||
public Line[] Sums { get; set; }
|
||||
}
|
||||
|
||||
private async Task AssertReceipt(PlaywrightTester s, AssertReceiptAssertion assertion)
|
||||
{
|
||||
await AssertReceipt(s, assertion, "#CartData table tbody tr", "#CartData table tfoot tr");
|
||||
// Receipt print
|
||||
var o = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("#ReceiptLinkPrint");
|
||||
await using (await s.SwitchPage(o))
|
||||
{
|
||||
await AssertReceipt(s, assertion, "#PaymentDetails table tr.cart-data", "#PaymentDetails table tr.sums-data");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertReceipt(PlaywrightTester s, AssertReceiptAssertion assertion, string itemSelector, string sumsSelector)
|
||||
{
|
||||
var items = await s.Page.QuerySelectorAllAsync(itemSelector);
|
||||
var sums = await s.Page.QuerySelectorAllAsync(sumsSelector);
|
||||
Assert.Equal(assertion.Items.Length, items.Count);
|
||||
Assert.Equal(assertion.Sums.Length, sums.Count);
|
||||
for (int i = 0; i < assertion.Items.Length; i++)
|
||||
{
|
||||
var txt = (await items[i].TextContentAsync()).NormalizeWhitespaces();
|
||||
Assert.Contains(assertion.Items[i].Key.NormalizeWhitespaces(), txt);
|
||||
Assert.Contains(assertion.Items[i].Value.NormalizeWhitespaces(), txt);
|
||||
}
|
||||
|
||||
for (int i = 0; i < assertion.Sums.Length; i++)
|
||||
{
|
||||
var txt = (await sums[i].TextContentAsync()).NormalizeWhitespaces();
|
||||
Assert.Contains(assertion.Sums[i].Key.NormalizeWhitespaces(), txt);
|
||||
Assert.Contains(assertion.Sums[i].Value.NormalizeWhitespaces(), txt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnterKeypad(PlaywrightTester tester, string text)
|
||||
{
|
||||
foreach (char c in text)
|
||||
{
|
||||
await tester.Page.ClickAsync($".keypad [data-key='{c}']");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePoSAppJsonEndpoint()
|
||||
{
|
||||
using var tester = CreateServerTester();
|
||||
await tester.StartAsync();
|
||||
var user = tester.NewAccount();
|
||||
await user.GrantAccessAsync();
|
||||
user.RegisterDerivationScheme("BTC");
|
||||
var apps = user.GetController<UIAppsController>();
|
||||
var pos = user.GetController<UIPointOfSaleController>();
|
||||
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
|
||||
var appType = PointOfSaleAppType.AppType;
|
||||
vm.AppName = "test";
|
||||
vm.SelectedAppType = appType;
|
||||
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
|
||||
Assert.EndsWith("/settings/pos", redirect.Url);
|
||||
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
|
||||
var app = appList.Apps[0];
|
||||
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
|
||||
apps.HttpContext.SetAppData(appData);
|
||||
pos.HttpContext.SetAppData(appData);
|
||||
var vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
|
||||
vmpos.Title = "App POS";
|
||||
vmpos.Currency = "EUR";
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
|
||||
// Failing requests
|
||||
var (invoiceId1, error1) = await PosJsonRequest(tester, app.Id, "amount=-21&discount=10&tip=2");
|
||||
Assert.Null(invoiceId1);
|
||||
Assert.Equal("Negative amount is not allowed", error1);
|
||||
var (invoiceId2, error2) = await PosJsonRequest(tester, app.Id, "amount=21&discount=-10&tip=-2");
|
||||
Assert.Null(invoiceId2);
|
||||
Assert.Equal("Negative tip or discount is not allowed", error2);
|
||||
|
||||
// Successful request
|
||||
var (invoiceId3, error3) = await PosJsonRequest(tester, app.Id, "amount=21");
|
||||
Assert.NotNull(invoiceId3);
|
||||
Assert.Null(error3);
|
||||
|
||||
// Check generated invoice
|
||||
var invoices = await user.BitPay.GetInvoicesAsync();
|
||||
var invoice = invoices.First();
|
||||
Assert.Equal(invoiceId3, invoice.Id);
|
||||
Assert.Equal(21.00m, invoice.Price);
|
||||
Assert.Equal("EUR", invoice.Currency);
|
||||
}
|
||||
|
||||
private async Task<(string invoiceId, string error)> PosJsonRequest(ServerTester tester, string appId, string query)
|
||||
{
|
||||
var uriBuilder = new UriBuilder(tester.PayTester.ServerUri) { Path = $"/apps/{appId}/pos/light", Query = query };
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
var response = await tester.PayTester.HttpClient.SendAsync(request);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JObject.Parse(content);
|
||||
return (json["invoiceId"]?.Value<string>(), json["error"]?.Value<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
CustomButtonText = settings.CustomButtonText,
|
||||
CustomTipText = settings.CustomTipText,
|
||||
CustomTipPercentages = settings.CustomTipPercentages,
|
||||
DefaultTaxRate = settings.DefaultTaxRate,
|
||||
AppId = appId,
|
||||
StoreId = store.Id,
|
||||
HtmlLang = settings.HtmlLang,
|
||||
@@ -171,22 +172,31 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
// Distinguish JSON requests coming via the mobile app
|
||||
var wantsJson = Request.Headers.Accept.FirstOrDefault()?.StartsWith("application/json") is true;
|
||||
|
||||
IActionResult Error(string message)
|
||||
{
|
||||
if (wantsJson)
|
||||
return Json(new { error = message });
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||
{
|
||||
Message = message,
|
||||
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||
AllowDismiss = true
|
||||
});
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
|
||||
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
|
||||
if (app == null)
|
||||
return wantsJson
|
||||
? Json(new { error = "App not found" })
|
||||
? Json(new { error = StringLocalizer["App not found"].Value })
|
||||
: NotFound();
|
||||
|
||||
// not allowing negative tips or discounts
|
||||
if (tip < 0 || discount < 0)
|
||||
return wantsJson
|
||||
? Json(new { error = "Negative tip or discount is not allowed" })
|
||||
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
return Error(StringLocalizer["Negative tip or discount is not allowed"].Value);
|
||||
|
||||
if (string.IsNullOrEmpty(choiceKey) && amount <= 0)
|
||||
return wantsJson
|
||||
? Json(new { error = "Negative amount is not allowed" })
|
||||
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
if (string.IsNullOrEmpty(choiceKey) && (amount <= 0 || customAmount <= 0))
|
||||
return Error(StringLocalizer["Negative amount is not allowed"].Value);
|
||||
|
||||
var settings = app.GetSettings<PointOfSaleSettings>();
|
||||
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
|
||||
@@ -196,86 +206,56 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
{
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
|
||||
}
|
||||
|
||||
var jposData = TryParseJObject(posData);
|
||||
string title;
|
||||
decimal? price;
|
||||
var choices = AppService.Parse(settings.Template, false);
|
||||
var jposData = PosAppData.TryParse(posData) ?? new();
|
||||
PoSOrder order = new(_currencies.GetNumberFormatInfo(settings.Currency, true).CurrencyDecimalDigits);
|
||||
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
|
||||
AppItem choice = null;
|
||||
List<AppCartItem> cartItems = null;
|
||||
AppItem[] choices = null;
|
||||
List<AppItem> selectedChoices = new();
|
||||
if (!string.IsNullOrEmpty(choiceKey))
|
||||
{
|
||||
choices = AppService.Parse(settings.Template, false);
|
||||
choice = choices.FirstOrDefault(c => c.Id == choiceKey);
|
||||
if (choice == null)
|
||||
return NotFound();
|
||||
title = choice.Title;
|
||||
if (choice.PriceType == AppItemPriceType.Topup)
|
||||
{
|
||||
price = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
price = choice.Price.Value;
|
||||
if (amount > price)
|
||||
price = amount;
|
||||
jposData.Cart = new PosAppCartItem[] { new() { Id = choiceKey, Count = 1, Price = amount ?? 0 } };
|
||||
}
|
||||
jposData.Cart ??= [];
|
||||
|
||||
if (choice.Inventory is <= 0)
|
||||
{
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!settings.ShowCustomAmount && currentView != PosViewType.Cart && currentView != PosViewType.Light)
|
||||
if (currentView is PosViewType.Print)
|
||||
return NotFound();
|
||||
if (currentView is PosViewType.Cart or PosViewType.Static && jposData.Cart.Length == 0)
|
||||
return NotFound();
|
||||
|
||||
title = settings.Title;
|
||||
// if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
|
||||
price = amount;
|
||||
if (AppService.TryParsePosCartItems(jposData, out cartItems))
|
||||
if (jposData.Amounts is null &&
|
||||
currentView == PosViewType.Light &&
|
||||
amount is { } o)
|
||||
{
|
||||
price = jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray
|
||||
? amountsArray.Values<decimal>().Sum()
|
||||
: 0.0m;
|
||||
choices = AppService.Parse(settings.Template, false);
|
||||
foreach (var cartItem in cartItems)
|
||||
order.AddLine(new("", 1, o, settings.DefaultTaxRate));
|
||||
}
|
||||
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
|
||||
{
|
||||
order.AddLine(new($"Custom Amount {i + 1}", 1, jposData.Amounts[i], settings.DefaultTaxRate));
|
||||
}
|
||||
foreach (var cartItem in jposData.Cart)
|
||||
{
|
||||
var itemChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
|
||||
if (itemChoice == null)
|
||||
return NotFound();
|
||||
selectedChoices.Add(itemChoice);
|
||||
if (itemChoice.Inventory is <= 0 ||
|
||||
itemChoice.Inventory is { } inv && inv < cartItem.Count)
|
||||
return Error(StringLocalizer["Inventory for {0} exhausted: {1} available", itemChoice.Title, itemChoice.Inventory]);
|
||||
|
||||
if (itemChoice.Inventory.HasValue)
|
||||
if (itemChoice.PriceType is not AppItemPriceType.Topup)
|
||||
{
|
||||
switch (itemChoice.Inventory)
|
||||
{
|
||||
case <= 0:
|
||||
case { } inventory when inventory < cartItem.Count:
|
||||
return wantsJson
|
||||
? Json(new { error = $"Inventory for {itemChoice.Title} exhausted: {itemChoice.Inventory} available" })
|
||||
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
}
|
||||
|
||||
var expectedCartItemPrice = itemChoice.PriceType != AppItemPriceType.Topup
|
||||
? itemChoice.Price ?? 0
|
||||
: 0;
|
||||
|
||||
var expectedCartItemPrice = itemChoice.Price ?? 0;
|
||||
if (cartItem.Price < expectedCartItemPrice)
|
||||
cartItem.Price = expectedCartItemPrice;
|
||||
|
||||
price += cartItem.Price * cartItem.Count;
|
||||
}
|
||||
if (customAmount is { } c)
|
||||
price += c;
|
||||
order.AddLine(new(cartItem.Id, cartItem.Count, cartItem.Price, itemChoice.TaxRate ?? settings.DefaultTaxRate));
|
||||
}
|
||||
if (customAmount is { } c && settings.ShowCustomAmount)
|
||||
order.AddLine(new("", 1, c, settings.DefaultTaxRate));
|
||||
if (discount is { } d)
|
||||
price -= price * d / 100.0m;
|
||||
order.AddDiscountRate(d);
|
||||
if (tip is { } t)
|
||||
price += t;
|
||||
}
|
||||
}
|
||||
order.AddTip(t);
|
||||
|
||||
var store = await _appService.GetStore(app);
|
||||
var storeBlob = store.GetStoreBlob();
|
||||
@@ -317,38 +297,82 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
var amtField = form.GetFieldByFullName($"{FormDataService.InvoiceParameterPrefix}amount");
|
||||
if (amtField is null)
|
||||
{
|
||||
form.Fields.Add(new Field
|
||||
amtField = new Field
|
||||
{
|
||||
Name = $"{FormDataService.InvoiceParameterPrefix}amount",
|
||||
Type = "hidden",
|
||||
Value = price?.ToString(),
|
||||
Constant = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
amtField.Value = price?.ToString();
|
||||
};
|
||||
form.Fields.Add(amtField);
|
||||
}
|
||||
amtField.Value = order.Calculate().PriceTaxExcluded.ToString(CultureInfo.InvariantCulture);
|
||||
formResponseJObject = FormDataService.GetValues(form);
|
||||
|
||||
var invoiceRequest = FormDataService.GenerateInvoiceParametersFromForm(form);
|
||||
if (invoiceRequest.Amount is not null)
|
||||
{
|
||||
price = invoiceRequest.Amount.Value;
|
||||
order.AddLine(new("", 1, invoiceRequest.Amount.Value, settings.DefaultTaxRate));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var receiptData = new PosReceiptData();
|
||||
var summary = order.Calculate();
|
||||
|
||||
bool isTopup = summary.PriceTaxIncludedWithTips == 0 && currentView == PosViewType.Static;
|
||||
if (!isTopup)
|
||||
{
|
||||
jposData.ItemsTotal = summary.ItemsTotal;
|
||||
jposData.DiscountAmount = summary.Discount;
|
||||
jposData.Subtotal = summary.PriceTaxExcluded;
|
||||
jposData.Tax = summary.Tax;
|
||||
jposData.Tip = summary.Tip;
|
||||
jposData.Total = summary.PriceTaxIncludedWithTips;
|
||||
receiptData.Subtotal = _displayFormatter.Currency(jposData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
|
||||
if (jposData.DiscountAmount > 0)
|
||||
{
|
||||
var discountFormatted = _displayFormatter.Currency(jposData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Discount = jposData.DiscountPercentage > 0 ? $"{discountFormatted} ({jposData.DiscountPercentage}%)" : discountFormatted;
|
||||
}
|
||||
|
||||
if (jposData.Tip > 0)
|
||||
{
|
||||
var tipFormatted = _displayFormatter.Currency(jposData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Tip = jposData.TipPercentage > 0 ? $"{tipFormatted} ({jposData.TipPercentage}%)" : tipFormatted;
|
||||
}
|
||||
|
||||
if (jposData.Tax > 0)
|
||||
{
|
||||
var taxFormatted = _displayFormatter.Currency(jposData.Tax, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Tax = taxFormatted;
|
||||
}
|
||||
|
||||
if (jposData.ItemsTotal > 0)
|
||||
{
|
||||
var itemsTotal = _displayFormatter.Currency(jposData.ItemsTotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.ItemsTotal = itemsTotal;
|
||||
}
|
||||
|
||||
receiptData.Total = _displayFormatter.Currency(jposData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
if (receiptData.ItemsTotal == receiptData.Subtotal)
|
||||
receiptData.ItemsTotal = null;
|
||||
if (receiptData.Subtotal == receiptData.Total)
|
||||
receiptData.Subtotal = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
|
||||
{
|
||||
Amount = price,
|
||||
Amount = isTopup ? null : summary.PriceTaxIncludedWithTips,
|
||||
Currency = settings.Currency,
|
||||
Metadata = new InvoiceMetadata
|
||||
{
|
||||
ItemCode = choice?.Id,
|
||||
ItemDesc = title,
|
||||
ItemCode = selectedChoices is [{} c1] ? c1.Id : null,
|
||||
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
|
||||
BuyerEmail = email,
|
||||
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
|
||||
OrderId = orderId ?? AppService.GetRandomOrderId()
|
||||
}.ToJObject(),
|
||||
Checkout = new InvoiceDataBase.CheckoutOptions()
|
||||
@@ -369,59 +393,37 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
entity.FullNotifications = true;
|
||||
entity.ExtendedNotifications = true;
|
||||
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
||||
entity.Metadata.PosData = jposData;
|
||||
var receiptData = new JObject();
|
||||
if (choice is not null)
|
||||
entity.Metadata.PosData = JObject.FromObject(jposData);
|
||||
|
||||
if (selectedChoices.Count == 1)
|
||||
{
|
||||
var dict = new Dictionary<string, string> { { "Title", choice.Title } };
|
||||
if (!string.IsNullOrEmpty(choice.Description))
|
||||
dict["Description"] = choice.Description;
|
||||
receiptData = JObject.FromObject(dict);
|
||||
receiptData.Title = selectedChoices[0].Title;
|
||||
if (!string.IsNullOrEmpty(selectedChoices[0].Description))
|
||||
receiptData.Description = selectedChoices[0].Description;
|
||||
}
|
||||
else if (jposData is not null)
|
||||
|
||||
Dictionary<string,string> cartData = null;
|
||||
foreach (var cartItem in jposData.Cart)
|
||||
{
|
||||
var appPosData = jposData.ToObject<PosAppData>();
|
||||
receiptData = new JObject();
|
||||
if (cartItems is not null && choices is not null)
|
||||
{
|
||||
var posCartItems = cartItems.ToList();
|
||||
var selectedChoices = choices
|
||||
.Where(item => posCartItems.Any(cartItem => cartItem.Id == item.Id))
|
||||
.ToDictionary(item => item.Id);
|
||||
var cartData = new JObject();
|
||||
foreach (AppCartItem cartItem in posCartItems)
|
||||
{
|
||||
if (!selectedChoices.TryGetValue(cartItem.Id, out var selectedChoice))
|
||||
var selectedChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
|
||||
if (selectedChoice is null)
|
||||
continue;
|
||||
var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var ident = selectedChoice.Title ?? selectedChoice.Id;
|
||||
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
|
||||
cartData ??= new();
|
||||
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
|
||||
}
|
||||
|
||||
if (jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray)
|
||||
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
|
||||
{
|
||||
for (var i = 0; i < amountsArray.Count; i++)
|
||||
{
|
||||
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(amountsArray[i].ToObject<decimal>(), settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
}
|
||||
}
|
||||
receiptData.Add("Cart", cartData);
|
||||
}
|
||||
receiptData.Add("Subtotal", _displayFormatter.Currency(appPosData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
if (appPosData.DiscountAmount > 0)
|
||||
{
|
||||
var discountFormatted = _displayFormatter.Currency(appPosData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Add("Discount", appPosData.DiscountPercentage > 0 ? $"{appPosData.DiscountPercentage}% = {discountFormatted}" : discountFormatted);
|
||||
}
|
||||
if (appPosData.Tip > 0)
|
||||
{
|
||||
var tipFormatted = _displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Add("Tip", appPosData.TipPercentage > 0 ? $"{appPosData.TipPercentage}% = {tipFormatted}" : tipFormatted);
|
||||
}
|
||||
receiptData.Add("Total", _displayFormatter.Currency(appPosData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
cartData ??= new();
|
||||
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(jposData.Amounts[i], settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
}
|
||||
|
||||
receiptData.Cart = cartData;
|
||||
|
||||
entity.Metadata.SetAdditionalData("receiptData", receiptData);
|
||||
|
||||
if (formResponseJObject is null)
|
||||
@@ -433,7 +435,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
var data = new { invoiceId = invoice.Id };
|
||||
if (wantsJson)
|
||||
return Json(data);
|
||||
if (price is 0 && storeBlob.ReceiptOptions?.Enabled is true)
|
||||
if (!isTopup && summary.PriceTaxIncludedWithTips is 0 && storeBlob.ReceiptOptions?.Enabled is true)
|
||||
return RedirectToAction(nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", data);
|
||||
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", data);
|
||||
}
|
||||
@@ -606,6 +608,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
Archived = app.Archived,
|
||||
AppName = app.Name,
|
||||
Title = settings.Title,
|
||||
DefaultTaxRate = settings.DefaultTaxRate,
|
||||
DefaultView = settings.DefaultView,
|
||||
ShowItems = settings.ShowItems,
|
||||
ShowCustomAmount = settings.ShowCustomAmount,
|
||||
@@ -697,11 +700,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
return View("PointOfSale/UpdatePointOfSale", vm);
|
||||
}
|
||||
|
||||
bool wasHtmlModified;
|
||||
var settings = new PointOfSaleSettings
|
||||
{
|
||||
Title = vm.Title,
|
||||
DefaultView = vm.DefaultView,
|
||||
DefaultTaxRate = vm.DefaultTaxRate ?? 0,
|
||||
ShowItems = vm.ShowItems,
|
||||
ShowCustomAmount = vm.ShowCustomAmount,
|
||||
ShowDiscount = vm.ShowDiscount,
|
||||
@@ -717,7 +720,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
NotificationUrl = vm.NotificationUrl,
|
||||
RedirectUrl = vm.RedirectUrl,
|
||||
HtmlLang = vm.HtmlLang,
|
||||
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out wasHtmlModified),
|
||||
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out bool wasHtmlModified),
|
||||
Description = vm.Description,
|
||||
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically),
|
||||
FormId = vm.FormId
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
75
BTCPayServer/Plugins/PointOfSale/PoSOrder.cs
Normal file
75
BTCPayServer/Plugins/PointOfSale/PoSOrder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
19
BTCPayServer/Services/Invoices/PosReceiptData.cs
Normal file
19
BTCPayServer/Services/Invoices/PosReceiptData.cs
Normal 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; }
|
||||
}
|
||||
27
BTCPayServer/Services/Invoices/WellKnownPosData.cs
Normal file
27
BTCPayServer/Services/Invoices/WellKnownPosData.cs
Normal 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; }
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -60,16 +60,6 @@ public class ProductsReportProvider : ReportProvider
|
||||
{
|
||||
values = values.ToList();
|
||||
values.Add(appId);
|
||||
if (i.Metadata?.ItemCode is string code)
|
||||
{
|
||||
values.Add(code);
|
||||
values.Add(1);
|
||||
values.Add(i.Price);
|
||||
values.Add(i.Currency);
|
||||
queryContext.Data.Add(values);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (AppService.TryParsePosCartItems(i.Metadata?.PosData, out var items))
|
||||
{
|
||||
foreach (var item in items)
|
||||
@@ -82,6 +72,13 @@ public class ProductsReportProvider : ReportProvider
|
||||
queryContext.Data.Add(copy);
|
||||
}
|
||||
}
|
||||
else if (i.Metadata?.ItemCode is string code)
|
||||
{
|
||||
values.Add(code);
|
||||
values.Add(1);
|
||||
values.Add(i.Price);
|
||||
values.Add(i.Currency);
|
||||
queryContext.Data.Add(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> <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> <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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -116,10 +116,7 @@
|
||||
@if (hasCart)
|
||||
{
|
||||
_ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart);
|
||||
var hasTotal = Model.CartData.TryGetValue("total", out var total) || Model.CartData.TryGetValue("Total", out total);
|
||||
var hasSubtotal = Model.CartData.TryGetValue("subtotal", out var subtotal) || Model.CartData.TryGetValue("subTotal", out subtotal) || Model.CartData.TryGetValue("Subtotal", out subtotal);
|
||||
var hasDiscount = Model.CartData.TryGetValue("discount", out var discount) || Model.CartData.TryGetValue("Discount", out discount);
|
||||
var hasTip = Model.CartData.TryGetValue("tip", out var tip) || Model.CartData.TryGetValue("Tip", out tip);
|
||||
var posData = WellKnownPosData.TryParse(Model.CartData) ?? new();
|
||||
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
|
||||
{
|
||||
@foreach (var (key, value) in cartDict)
|
||||
@@ -139,38 +136,53 @@
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
if (hasSubtotal && (hasDiscount || hasTip))
|
||||
|
||||
@if (posData.ItemsTotal != null)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||
</tr>
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Subtotal</td>
|
||||
<td class="val text-end">@subtotal</td>
|
||||
<td class="key text-secondary">Items total</td>
|
||||
<td class="val text-end">@posData.ItemsTotal</td>
|
||||
</tr>
|
||||
}
|
||||
if (hasDiscount)
|
||||
@if (posData.Discount != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Discount</td>
|
||||
<td class="val text-end">@discount</td>
|
||||
<td class="val text-end">@posData.Discount</td>
|
||||
</tr>
|
||||
}
|
||||
if (hasTip)
|
||||
@if (posData.Subtotal != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Subtotal</td>
|
||||
<td class="val text-end">@posData.Subtotal</td>
|
||||
</tr>
|
||||
}
|
||||
@if (posData.Tax != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Tax</td>
|
||||
<td class="val text-end">@posData.Tax</td>
|
||||
</tr>
|
||||
}
|
||||
@if (posData.Tip != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Tip</td>
|
||||
<td class="val text-end">@tip</td>
|
||||
<td class="val text-end">@posData.Tip</td>
|
||||
</tr>
|
||||
}
|
||||
if (hasTotal)
|
||||
@if (posData.Total != null)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||
</tr>
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Total</td>
|
||||
<td class="val text-end fw-semibold">@total</td>
|
||||
<td class="val text-end fw-semibold">@posData.Total</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user