mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +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()
|
||||
@@ -79,8 +79,8 @@ fruit tea:
|
||||
Assert.Equal( 1 ,parsedDefault[0].Price);
|
||||
Assert.Equal( AppItemPriceType.Fixed ,parsedDefault[0].PriceType);
|
||||
Assert.Null( parsedDefault[0].AdditionalData);
|
||||
|
||||
|
||||
|
||||
|
||||
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
|
||||
Assert.Equal( "herbal tea" ,parsedDefault[4].Id);
|
||||
Assert.Equal( "Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!" ,parsedDefault[4].Description);
|
||||
@@ -126,7 +126,7 @@ fruit tea:
|
||||
items = AppService.Parse(missingId);
|
||||
Assert.Single(items);
|
||||
Assert.Equal("black-tea", items[0].Id);
|
||||
|
||||
|
||||
// Throws for missing ID
|
||||
Assert.Throws<ArgumentException>(() => AppService.Parse(missingId, true, true));
|
||||
|
||||
@@ -134,11 +134,11 @@ fruit tea:
|
||||
var duplicateId = template.Replace(@"""id"": ""green-tea"",", @"""id"": ""black-tea"",");
|
||||
items = AppService.Parse(duplicateId);
|
||||
Assert.Empty(items);
|
||||
|
||||
|
||||
// Throws for duplicate IDs
|
||||
Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true));
|
||||
}
|
||||
|
||||
|
||||
[Fact(Timeout = LongRunningTestTimeout)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePoSApp1()
|
||||
@@ -191,7 +191,7 @@ donation:
|
||||
// apple is not found
|
||||
Assert.IsType<NotFoundResult>(publicApps
|
||||
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
|
||||
|
||||
|
||||
// List
|
||||
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
|
||||
app = appList.Apps[0];
|
||||
@@ -227,5 +227,521 @@ donation:
|
||||
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
|
||||
Assert.Empty(appList.Apps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Playwright", "Playwright")]
|
||||
public async Task CanUsePOSCart()
|
||||
{
|
||||
await using var s = CreatePlaywrightTester();
|
||||
await s.StartAsync();
|
||||
|
||||
// Create users
|
||||
var user = await s.RegisterNewUser();
|
||||
var userAccount = s.AsTestAccount();
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.GoToRegister();
|
||||
await s.RegisterNewUser(true);
|
||||
|
||||
// Setup store and associate user
|
||||
(_, string storeId) = await s.CreateNewStore();
|
||||
await s.GoToStore();
|
||||
await s.AddDerivationScheme();
|
||||
await s.AddUserToStore(storeId, user, "Guest");
|
||||
|
||||
// Setup POS
|
||||
await s.CreateApp("PointOfSale");
|
||||
await s.Page.ClickAsync("label[for='DefaultView_Cart']");
|
||||
await s.Page.FillAsync("#Currency", "EUR");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.ClickAsync("#EnableTips");
|
||||
Assert.True(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.FillAsync("#CustomTipPercentages", "10,21");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#ShowDiscount"));
|
||||
await s.Page.ClickAsync("#ShowDiscount");
|
||||
|
||||
|
||||
// Default tax of 8.375%, but 10% for the first item.
|
||||
await s.Page.FillAsync("#DefaultTaxRate", "8.375");
|
||||
await s.Page.Locator(".template-item").First.ClickAsync();
|
||||
await s.Page.Locator("#item-form div").Filter(new() { HasText = "Tax rate %" }).GetByRole(AriaRole.Spinbutton).FillAsync("10");
|
||||
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Apply" }).ClickAsync();
|
||||
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "App updated");
|
||||
|
||||
// View
|
||||
var o = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("#ViewApp");
|
||||
await s.SwitchPage(o);
|
||||
await s.Page.WaitForSelectorAsync("#PosItems");
|
||||
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
var posUrl = s.Page.Url;
|
||||
|
||||
// Select and clear
|
||||
await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
|
||||
Assert.Single(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
await s.Page.ClickAsync("#CartClear");
|
||||
Assert.Empty(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
|
||||
// Select simple items
|
||||
await s.Page.ClickAsync(".posItem:nth-child(1) .btn-primary");
|
||||
Assert.Single(await s.Page.QuerySelectorAllAsync("#CartItems tr"));
|
||||
await s.Page.ClickAsync(".posItem:nth-child(2) .btn-primary");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(2) .btn-primary");
|
||||
Assert.Equal(2, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "3,00€",
|
||||
Taxes = "0,27 €",
|
||||
Total = "3,27 €"
|
||||
});
|
||||
|
||||
// Select item with inventory - two of it
|
||||
Assert.Equal("5 left", await s.Page.TextContentAsync(".posItem:nth-child(3) .badge.inventory"));
|
||||
await s.Page.ClickAsync(".posItem:nth-child(3) .btn-primary");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(3) .btn-primary");
|
||||
Assert.Equal(3, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "5,40 €",
|
||||
Taxes = "0,47 €",
|
||||
Total = "5,87 €"
|
||||
});
|
||||
|
||||
// Select items with minimum amount
|
||||
await s.Page.ClickAsync(".posItem:nth-child(5) .btn-primary");
|
||||
Assert.Equal(4, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "7,20 €",
|
||||
Taxes = "0,62 €",
|
||||
Total = "7,82 €"
|
||||
});
|
||||
|
||||
// Select items with adjusted minimum amount
|
||||
await s.Page.FillAsync(".posItem:nth-child(5) input[name='amount']", "");
|
||||
await s.Page.FillAsync(".posItem:nth-child(5) input[name='amount']", "2.3");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(5) .btn-primary");
|
||||
Assert.Equal(5, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "9,50 €",
|
||||
Taxes = "0,81 €",
|
||||
Total = "10,31 €"
|
||||
});
|
||||
|
||||
// Select items with custom amount
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", "");
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", ".2");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(6) .btn-primary");
|
||||
Assert.Equal(6, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "9,70 €",
|
||||
Taxes = "0,83 €",
|
||||
Total = "10,53 €"
|
||||
});
|
||||
|
||||
// Select items with another custom amount
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", "");
|
||||
await s.Page.FillAsync(".posItem:nth-child(6) input[name='amount']", ".3");
|
||||
await s.Page.ClickAsync(".posItem:nth-child(6) .btn-primary");
|
||||
Assert.Equal(7, (await s.Page.QuerySelectorAllAsync("#CartItems tr")).Count);
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
Subtotal = "10,00 €",
|
||||
Taxes = "0,86 €",
|
||||
Total = "10,86 €"
|
||||
});
|
||||
|
||||
// Discount: 10%
|
||||
Assert.False(await s.Page.IsVisibleAsync("#CartDiscount"));
|
||||
await s.Page.FillAsync("#Discount", "10");
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
ItemsTotal = "10,00 €",
|
||||
Discount = "1,00 € (10%)",
|
||||
Subtotal = "9,00 €",
|
||||
Taxes = "0,77 €",
|
||||
Total = "9,77 €"
|
||||
});
|
||||
|
||||
// Tip: 10%
|
||||
Assert.False(await s.Page.IsVisibleAsync("#CartTip"));
|
||||
await s.Page.ClickAsync("#Tip-10");
|
||||
|
||||
await AssertCartSummary(s, new()
|
||||
{
|
||||
ItemsTotal = "10,00 €",
|
||||
Discount = "1,00 € (10%)",
|
||||
Subtotal = "9,00 €",
|
||||
Tip = "0,90 € (10%)",
|
||||
Taxes = "0,77 €",
|
||||
Total = "10,67 €"
|
||||
});
|
||||
|
||||
// Check values on checkout page
|
||||
await s.Page.ClickAsync("#CartSubmit");
|
||||
await s.Page.WaitForSelectorAsync("#Checkout");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
|
||||
Assert.Contains("0,77 €", await s.Page.TextContentAsync("#PaymentDetails-TaxIncluded"));
|
||||
Assert.Contains("10,67 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
//
|
||||
// Pay
|
||||
await s.PayInvoice(true);
|
||||
|
||||
|
||||
// Receipt
|
||||
await s.Page.ClickAsync("#ReceiptLink");
|
||||
await s.Page.WaitForSelectorAsync("#CartData table");
|
||||
await AssertReceipt(s, new()
|
||||
{
|
||||
Items = [
|
||||
new("Black Tea", "2 x 1,00 € = 2,00 €"),
|
||||
new("Green Tea", "1 x 1,00 € = 1,00 €"),
|
||||
new("Rooibos (limited)", "2 x 1,20 € = 2,40 €"),
|
||||
new("Herbal Tea (minimum) (1,80 €)", "1 x 1,80 € = 1,80 €"),
|
||||
new("Herbal Tea (minimum) (2,30 €)", "1 x 2,30 € = 2,30 €"),
|
||||
new("Fruit Tea (any amount) (0,20 €)", "1 x 0,20 € = 0,20 €"),
|
||||
new("Fruit Tea (any amount) (0,30 €)", "1 x 0,30 € = 0,30 €")
|
||||
],
|
||||
Sums = [
|
||||
new("Items total", "10,00 €"),
|
||||
new("Discount", "1,00 € (10%)"),
|
||||
new("Subtotal", "9,00 €"),
|
||||
new("Tax", "0,77 €"),
|
||||
new("Tip", "0,90 € (10%)"),
|
||||
new("Total", "10,67 €")
|
||||
]
|
||||
});
|
||||
|
||||
// Check inventory got updated and is now 3 instead of 5
|
||||
await s.GoToUrl(posUrl);
|
||||
Assert.Equal("3 left", await s.Page.TextContentAsync(".posItem:nth-child(3) .badge.inventory"));
|
||||
|
||||
// Guest user can access recent transactions
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.LogIn(user, userAccount.RegisterDetails.Password);
|
||||
await s.GoToUrl(posUrl);
|
||||
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
|
||||
// Unauthenticated user can't access recent transactions
|
||||
await s.GoToUrl(posUrl);
|
||||
Assert.False(await s.Page.IsVisibleAsync("#RecentTransactionsToggle"));
|
||||
}
|
||||
|
||||
public class CartSummaryAssertion
|
||||
{
|
||||
public string Subtotal { get; set; }
|
||||
public string Taxes { get; set; }
|
||||
public string Total { get; set; }
|
||||
public string ItemsTotal { get; set; }
|
||||
public string Discount { get; set; }
|
||||
public string Tip { get; set; }
|
||||
}
|
||||
private async Task AssertCartSummary(PlaywrightTester s, CartSummaryAssertion o)
|
||||
{
|
||||
string[] ids = ["CartItemsTotal", "CartDiscount", "CartAmount", "CartTip", "CartTax", "CartTotal"];
|
||||
string[] values = [o.ItemsTotal, o.Discount, o.Subtotal, o.Tip, o.Taxes, o.Total];
|
||||
for (int i = 0; i < ids.Length; i++)
|
||||
{
|
||||
if (values[i] != null)
|
||||
{
|
||||
var text = await s.Page.TextContentAsync("#" + ids[i]);
|
||||
Assert.Equal(values[i].NormalizeWhitespaces(), text.NormalizeWhitespaces());
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.False(await s.Page.IsVisibleAsync("#" + ids[i]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Playwright", "Playwright")]
|
||||
public async Task CanUsePOSKeypad()
|
||||
{
|
||||
await using var s = CreatePlaywrightTester();
|
||||
await s.StartAsync();
|
||||
|
||||
// Create users
|
||||
var user = await s.RegisterNewUser();
|
||||
var userAccount = s.AsTestAccount();
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.GoToRegister();
|
||||
await s.RegisterNewUser(true);
|
||||
|
||||
// Setup store and associate user
|
||||
(_, string storeId) = await s.CreateNewStore();
|
||||
await s.GoToStore();
|
||||
await s.AddDerivationScheme();
|
||||
await s.AddUserToStore(storeId, user, "Guest");
|
||||
|
||||
// Setup POS
|
||||
await s.CreateApp("PointOfSale");
|
||||
var editUrl = s.Page.Url;
|
||||
await s.Page.ClickAsync("label[for='DefaultView_Light']");
|
||||
await s.Page.FillAsync("#Currency", "EUR");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.ClickAsync("#EnableTips");
|
||||
Assert.True(await s.Page.IsCheckedAsync("#EnableTips"));
|
||||
await s.Page.FillAsync("#CustomTipPercentages", "");
|
||||
await s.Page.FillAsync("#CustomTipPercentages", "10,21");
|
||||
Assert.False(await s.Page.IsCheckedAsync("#ShowDiscount"));
|
||||
Assert.False(await s.Page.IsCheckedAsync("#ShowItems"));
|
||||
await s.Page.ClickAsync("#ShowDiscount");
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "App updated");
|
||||
|
||||
// View
|
||||
var o = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("#ViewApp");
|
||||
await s.SwitchPage(o);
|
||||
|
||||
// basic checks
|
||||
var keypadUrl = s.Page.Url;
|
||||
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
|
||||
Assert.Null(await s.Page.QuerySelectorAsync("#ItemsListToggle"));
|
||||
Assert.Contains("EUR", await s.Page.TextContentAsync("#Currency"));
|
||||
Assert.Contains("0,00", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.Equal("", await s.Page.TextContentAsync("#Calculation"));
|
||||
Assert.True(await s.Page.IsCheckedAsync("#ModeTablist-amounts"));
|
||||
Assert.False(await s.Page.IsEnabledAsync("#ModeTablist-discount"));
|
||||
Assert.False(await s.Page.IsEnabledAsync("#ModeTablist-tip"));
|
||||
|
||||
// Amount: 1234,56
|
||||
await EnterKeypad(s, "123400");
|
||||
Assert.Equal("1.234,00", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.Equal("", await s.Page.TextContentAsync("#Calculation"));
|
||||
await EnterKeypad(s, "+56");
|
||||
Assert.Equal("1.234,56", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.True(await s.Page.IsEnabledAsync("#ModeTablist-discount"));
|
||||
Assert.True(await s.Page.IsEnabledAsync("#ModeTablist-tip"));
|
||||
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 €");
|
||||
|
||||
// Discount: 10%
|
||||
await s.Page.ClickAsync("label[for='ModeTablist-discount']");
|
||||
await EnterKeypad(s, "10");
|
||||
Assert.Contains("1.111,10", await s.Page.TextContentAsync("#Amount"));
|
||||
Assert.Contains("10% discount", await s.Page.TextContentAsync("#Discount"));
|
||||
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 € - 123,46 € (10%)");
|
||||
|
||||
// Tip: 10%
|
||||
await s.Page.ClickAsync("label[for='ModeTablist-tip']");
|
||||
await s.Page.ClickAsync("#Tip-10");
|
||||
Assert.Contains("1.222,21", await s.Page.TextContentAsync("#Amount"));
|
||||
await AssertKeypadCalculation(s, "1.234,00 € + 0,56 € - 123,46 € (10%) + 111,11 € (10%)");
|
||||
|
||||
// Pay
|
||||
await s.Page.ClickAsync("#pay-button");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
Assert.Contains("1 222,21 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
await s.PayInvoice(true);
|
||||
|
||||
// Receipt
|
||||
await s.Page.ClickAsync("#ReceiptLink");
|
||||
await AssertReceipt(s, new()
|
||||
{
|
||||
Items = [
|
||||
|
||||
new("Custom Amount 1", "1 234,00 €"),
|
||||
new("Custom Amount 2", "0,56 €")
|
||||
],
|
||||
Sums = [
|
||||
|
||||
new("Items total", "1 234,56 €"),
|
||||
new("Discount", "123,46 € (10%)"),
|
||||
new("Subtotal", "1 111,10 €"),
|
||||
new("Tip", "111,11 € (10%)"),
|
||||
new("Total", "1 222,21 €")
|
||||
]
|
||||
});
|
||||
|
||||
await s.GoToUrl(editUrl);
|
||||
await s.Page.ClickAsync("#ShowItems");
|
||||
await s.Page.FillAsync("#DefaultTaxRate", "10");
|
||||
await s.ClickPagePrimary();
|
||||
await s.FindAlertMessage(partialText: "App updated");
|
||||
|
||||
await s.GoToUrl(keypadUrl);
|
||||
await s.Page.ClickAsync("#ItemsListToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PosItems");
|
||||
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(1) .btn-plus");
|
||||
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(1) .btn-plus");
|
||||
await s.Page.ClickAsync("#PosItems .posItem--displayed:nth-child(2) .btn-plus");
|
||||
await s.Page.ClickAsync("#ItemsListOffcanvas button[data-bs-dismiss='offcanvas']");
|
||||
|
||||
await EnterKeypad(s, "123");
|
||||
Assert.Contains("4,65", await s.Page.TextContentAsync("#Amount"));
|
||||
await AssertKeypadCalculation(s, "2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 € + 0,42 € (10%)");
|
||||
|
||||
// Pay
|
||||
await s.Page.ClickAsync("#pay-button");
|
||||
await s.Page.WaitForSelectorAsync("#Checkout");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
|
||||
Assert.Contains("4,65 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
await s.PayInvoice(true);
|
||||
|
||||
|
||||
// Receipt
|
||||
await s.Page.ClickAsync("#ReceiptLink");
|
||||
|
||||
await AssertReceipt(s, new()
|
||||
{
|
||||
Items = [
|
||||
new("Black Tea", "1 x 1,00 € = 1,00 €"),
|
||||
new("Green Tea", "2 x 1,00 € = 2,00 €"),
|
||||
new("Custom Amount 1", "1,23 €")
|
||||
],
|
||||
Sums = [
|
||||
new("Subtotal", "4,23 €"),
|
||||
new("Tax", "0,42 €"),
|
||||
new("Total", "4,65 €")
|
||||
]
|
||||
});
|
||||
|
||||
// Guest user can access recent transactions
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
await s.LogIn(user, userAccount.RegisterDetails.Password);
|
||||
await s.GoToUrl(keypadUrl);
|
||||
await s.Page.WaitForSelectorAsync("#RecentTransactionsToggle");
|
||||
await s.GoToHome();
|
||||
await s.Logout();
|
||||
|
||||
// Unauthenticated user can't access recent transactions
|
||||
await s.GoToUrl(keypadUrl);
|
||||
Assert.False(await s.Page.IsVisibleAsync("#RecentTransactionsToggle"));
|
||||
|
||||
// But they can generate invoices
|
||||
await EnterKeypad(s, "123");
|
||||
|
||||
await s.Page.ClickAsync("#pay-button");
|
||||
await s.Page.WaitForSelectorAsync("#Checkout");
|
||||
await s.Page.ClickAsync("#DetailsToggle");
|
||||
await s.Page.WaitForSelectorAsync("#PaymentDetails-TotalFiat");
|
||||
Assert.Contains("1,35 €", await s.Page.TextContentAsync("#PaymentDetails-TotalFiat"));
|
||||
}
|
||||
|
||||
private static async Task AssertKeypadCalculation(PlaywrightTester s, string expected)
|
||||
{
|
||||
Assert.Equal(expected.NormalizeWhitespaces(), (await s.Page.TextContentAsync("#Calculation")).NormalizeWhitespaces());
|
||||
}
|
||||
|
||||
public class AssertReceiptAssertion
|
||||
{
|
||||
public record Line(string Key, string Value);
|
||||
public Line[] Items { get; set; }
|
||||
public Line[] Sums { get; set; }
|
||||
}
|
||||
|
||||
private async Task AssertReceipt(PlaywrightTester s, AssertReceiptAssertion assertion)
|
||||
{
|
||||
await AssertReceipt(s, assertion, "#CartData table tbody tr", "#CartData table tfoot tr");
|
||||
// Receipt print
|
||||
var o = s.Page.Context.WaitForPageAsync();
|
||||
await s.Page.ClickAsync("#ReceiptLinkPrint");
|
||||
await using (await s.SwitchPage(o))
|
||||
{
|
||||
await AssertReceipt(s, assertion, "#PaymentDetails table tr.cart-data", "#PaymentDetails table tr.sums-data");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AssertReceipt(PlaywrightTester s, AssertReceiptAssertion assertion, string itemSelector, string sumsSelector)
|
||||
{
|
||||
var items = await s.Page.QuerySelectorAllAsync(itemSelector);
|
||||
var sums = await s.Page.QuerySelectorAllAsync(sumsSelector);
|
||||
Assert.Equal(assertion.Items.Length, items.Count);
|
||||
Assert.Equal(assertion.Sums.Length, sums.Count);
|
||||
for (int i = 0; i < assertion.Items.Length; i++)
|
||||
{
|
||||
var txt = (await items[i].TextContentAsync()).NormalizeWhitespaces();
|
||||
Assert.Contains(assertion.Items[i].Key.NormalizeWhitespaces(), txt);
|
||||
Assert.Contains(assertion.Items[i].Value.NormalizeWhitespaces(), txt);
|
||||
}
|
||||
|
||||
for (int i = 0; i < assertion.Sums.Length; i++)
|
||||
{
|
||||
var txt = (await sums[i].TextContentAsync()).NormalizeWhitespaces();
|
||||
Assert.Contains(assertion.Sums[i].Key.NormalizeWhitespaces(), txt);
|
||||
Assert.Contains(assertion.Sums[i].Value.NormalizeWhitespaces(), txt);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnterKeypad(PlaywrightTester tester, string text)
|
||||
{
|
||||
foreach (char c in text)
|
||||
{
|
||||
await tester.Page.ClickAsync($".keypad [data-key='{c}']");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePoSAppJsonEndpoint()
|
||||
{
|
||||
using var tester = CreateServerTester();
|
||||
await tester.StartAsync();
|
||||
var user = tester.NewAccount();
|
||||
await user.GrantAccessAsync();
|
||||
user.RegisterDerivationScheme("BTC");
|
||||
var apps = user.GetController<UIAppsController>();
|
||||
var pos = user.GetController<UIPointOfSaleController>();
|
||||
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
|
||||
var appType = PointOfSaleAppType.AppType;
|
||||
vm.AppName = "test";
|
||||
vm.SelectedAppType = appType;
|
||||
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
|
||||
Assert.EndsWith("/settings/pos", redirect.Url);
|
||||
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
|
||||
var app = appList.Apps[0];
|
||||
var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType };
|
||||
apps.HttpContext.SetAppData(appData);
|
||||
pos.HttpContext.SetAppData(appData);
|
||||
var vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
|
||||
vmpos.Title = "App POS";
|
||||
vmpos.Currency = "EUR";
|
||||
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
|
||||
|
||||
// Failing requests
|
||||
var (invoiceId1, error1) = await PosJsonRequest(tester, app.Id, "amount=-21&discount=10&tip=2");
|
||||
Assert.Null(invoiceId1);
|
||||
Assert.Equal("Negative amount is not allowed", error1);
|
||||
var (invoiceId2, error2) = await PosJsonRequest(tester, app.Id, "amount=21&discount=-10&tip=-2");
|
||||
Assert.Null(invoiceId2);
|
||||
Assert.Equal("Negative tip or discount is not allowed", error2);
|
||||
|
||||
// Successful request
|
||||
var (invoiceId3, error3) = await PosJsonRequest(tester, app.Id, "amount=21");
|
||||
Assert.NotNull(invoiceId3);
|
||||
Assert.Null(error3);
|
||||
|
||||
// Check generated invoice
|
||||
var invoices = await user.BitPay.GetInvoicesAsync();
|
||||
var invoice = invoices.First();
|
||||
Assert.Equal(invoiceId3, invoice.Id);
|
||||
Assert.Equal(21.00m, invoice.Price);
|
||||
Assert.Equal("EUR", invoice.Currency);
|
||||
}
|
||||
|
||||
private async Task<(string invoiceId, string error)> PosJsonRequest(ServerTester tester, string appId, string query)
|
||||
{
|
||||
var uriBuilder = new UriBuilder(tester.PayTester.ServerUri) { Path = $"/apps/{appId}/pos/light", Query = query };
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri);
|
||||
request.Headers.Add("Accept", "application/json");
|
||||
var response = await tester.PayTester.HttpClient.SendAsync(request);
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var json = JObject.Parse(content);
|
||||
return (json["invoiceId"]?.Value<string>(), json["error"]?.Value<string>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
if (choice.Inventory is <= 0)
|
||||
{
|
||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
jposData.Cart = new PosAppCartItem[] { new() { Id = choiceKey, Count = 1, Price = amount ?? 0 } };
|
||||
}
|
||||
else
|
||||
jposData.Cart ??= [];
|
||||
|
||||
if (currentView is PosViewType.Print)
|
||||
return NotFound();
|
||||
if (currentView is PosViewType.Cart or PosViewType.Static && jposData.Cart.Length == 0)
|
||||
return NotFound();
|
||||
|
||||
if (jposData.Amounts is null &&
|
||||
currentView == PosViewType.Light &&
|
||||
amount is { } o)
|
||||
{
|
||||
if (!settings.ShowCustomAmount && currentView != PosViewType.Cart && currentView != PosViewType.Light)
|
||||
return NotFound();
|
||||
|
||||
title = settings.Title;
|
||||
// if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
|
||||
price = amount;
|
||||
if (AppService.TryParsePosCartItems(jposData, out cartItems))
|
||||
{
|
||||
price = jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray
|
||||
? amountsArray.Values<decimal>().Sum()
|
||||
: 0.0m;
|
||||
choices = AppService.Parse(settings.Template, false);
|
||||
foreach (var cartItem in cartItems)
|
||||
{
|
||||
var itemChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
|
||||
if (itemChoice == null)
|
||||
return NotFound();
|
||||
|
||||
if (itemChoice.Inventory.HasValue)
|
||||
{
|
||||
switch (itemChoice.Inventory)
|
||||
{
|
||||
case <= 0:
|
||||
case { } inventory when inventory < cartItem.Count:
|
||||
return wantsJson
|
||||
? Json(new { error = $"Inventory for {itemChoice.Title} exhausted: {itemChoice.Inventory} available" })
|
||||
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
||||
}
|
||||
}
|
||||
|
||||
var expectedCartItemPrice = itemChoice.PriceType != AppItemPriceType.Topup
|
||||
? itemChoice.Price ?? 0
|
||||
: 0;
|
||||
|
||||
if (cartItem.Price < expectedCartItemPrice)
|
||||
cartItem.Price = expectedCartItemPrice;
|
||||
|
||||
price += cartItem.Price * cartItem.Count;
|
||||
}
|
||||
if (customAmount is { } c)
|
||||
price += c;
|
||||
if (discount is { } d)
|
||||
price -= price * d / 100.0m;
|
||||
if (tip is { } t)
|
||||
price += t;
|
||||
}
|
||||
order.AddLine(new("", 1, o, settings.DefaultTaxRate));
|
||||
}
|
||||
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
|
||||
{
|
||||
order.AddLine(new($"Custom Amount {i + 1}", 1, jposData.Amounts[i], settings.DefaultTaxRate));
|
||||
}
|
||||
foreach (var cartItem in jposData.Cart)
|
||||
{
|
||||
var itemChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
|
||||
if (itemChoice == null)
|
||||
return NotFound();
|
||||
selectedChoices.Add(itemChoice);
|
||||
if (itemChoice.Inventory is <= 0 ||
|
||||
itemChoice.Inventory is { } inv && inv < cartItem.Count)
|
||||
return Error(StringLocalizer["Inventory for {0} exhausted: {1} available", itemChoice.Title, itemChoice.Inventory]);
|
||||
|
||||
if (itemChoice.PriceType is not AppItemPriceType.Topup)
|
||||
{
|
||||
var expectedCartItemPrice = itemChoice.Price ?? 0;
|
||||
if (cartItem.Price < expectedCartItemPrice)
|
||||
cartItem.Price = expectedCartItemPrice;
|
||||
}
|
||||
order.AddLine(new(cartItem.Id, cartItem.Count, cartItem.Price, itemChoice.TaxRate ?? settings.DefaultTaxRate));
|
||||
}
|
||||
if (customAmount is { } c && settings.ShowCustomAmount)
|
||||
order.AddLine(new("", 1, c, settings.DefaultTaxRate));
|
||||
if (discount is { } d)
|
||||
order.AddDiscountRate(d);
|
||||
if (tip is { } t)
|
||||
order.AddTip(t);
|
||||
|
||||
var store = await _appService.GetStore(app);
|
||||
var storeBlob = store.GetStoreBlob();
|
||||
@@ -317,38 +297,82 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
var amtField = form.GetFieldByFullName($"{FormDataService.InvoiceParameterPrefix}amount");
|
||||
if (amtField is null)
|
||||
{
|
||||
form.Fields.Add(new Field
|
||||
amtField = new Field
|
||||
{
|
||||
Name = $"{FormDataService.InvoiceParameterPrefix}amount",
|
||||
Type = "hidden",
|
||||
Value = price?.ToString(),
|
||||
Constant = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
amtField.Value = price?.ToString();
|
||||
};
|
||||
form.Fields.Add(amtField);
|
||||
}
|
||||
amtField.Value = order.Calculate().PriceTaxExcluded.ToString(CultureInfo.InvariantCulture);
|
||||
formResponseJObject = FormDataService.GetValues(form);
|
||||
|
||||
var invoiceRequest = FormDataService.GenerateInvoiceParametersFromForm(form);
|
||||
if (invoiceRequest.Amount is not null)
|
||||
{
|
||||
price = invoiceRequest.Amount.Value;
|
||||
order.AddLine(new("", 1, invoiceRequest.Amount.Value, settings.DefaultTaxRate));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
var receiptData = new PosReceiptData();
|
||||
var summary = order.Calculate();
|
||||
|
||||
bool isTopup = summary.PriceTaxIncludedWithTips == 0 && currentView == PosViewType.Static;
|
||||
if (!isTopup)
|
||||
{
|
||||
jposData.ItemsTotal = summary.ItemsTotal;
|
||||
jposData.DiscountAmount = summary.Discount;
|
||||
jposData.Subtotal = summary.PriceTaxExcluded;
|
||||
jposData.Tax = summary.Tax;
|
||||
jposData.Tip = summary.Tip;
|
||||
jposData.Total = summary.PriceTaxIncludedWithTips;
|
||||
receiptData.Subtotal = _displayFormatter.Currency(jposData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
|
||||
if (jposData.DiscountAmount > 0)
|
||||
{
|
||||
var discountFormatted = _displayFormatter.Currency(jposData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Discount = jposData.DiscountPercentage > 0 ? $"{discountFormatted} ({jposData.DiscountPercentage}%)" : discountFormatted;
|
||||
}
|
||||
|
||||
if (jposData.Tip > 0)
|
||||
{
|
||||
var tipFormatted = _displayFormatter.Currency(jposData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Tip = jposData.TipPercentage > 0 ? $"{tipFormatted} ({jposData.TipPercentage}%)" : tipFormatted;
|
||||
}
|
||||
|
||||
if (jposData.Tax > 0)
|
||||
{
|
||||
var taxFormatted = _displayFormatter.Currency(jposData.Tax, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Tax = taxFormatted;
|
||||
}
|
||||
|
||||
if (jposData.ItemsTotal > 0)
|
||||
{
|
||||
var itemsTotal = _displayFormatter.Currency(jposData.ItemsTotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.ItemsTotal = itemsTotal;
|
||||
}
|
||||
|
||||
receiptData.Total = _displayFormatter.Currency(jposData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
if (receiptData.ItemsTotal == receiptData.Subtotal)
|
||||
receiptData.ItemsTotal = null;
|
||||
if (receiptData.Subtotal == receiptData.Total)
|
||||
receiptData.Subtotal = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
|
||||
{
|
||||
Amount = price,
|
||||
Amount = isTopup ? null : summary.PriceTaxIncludedWithTips,
|
||||
Currency = settings.Currency,
|
||||
Metadata = new InvoiceMetadata
|
||||
{
|
||||
ItemCode = choice?.Id,
|
||||
ItemDesc = title,
|
||||
ItemCode = selectedChoices is [{} c1] ? c1.Id : null,
|
||||
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
|
||||
BuyerEmail = email,
|
||||
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
|
||||
OrderId = orderId ?? AppService.GetRandomOrderId()
|
||||
}.ToJObject(),
|
||||
Checkout = new InvoiceDataBase.CheckoutOptions()
|
||||
@@ -369,59 +393,37 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
entity.FullNotifications = true;
|
||||
entity.ExtendedNotifications = true;
|
||||
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
||||
entity.Metadata.PosData = jposData;
|
||||
var receiptData = new JObject();
|
||||
if (choice is not null)
|
||||
{
|
||||
var dict = new Dictionary<string, string> { { "Title", choice.Title } };
|
||||
if (!string.IsNullOrEmpty(choice.Description))
|
||||
dict["Description"] = choice.Description;
|
||||
receiptData = JObject.FromObject(dict);
|
||||
}
|
||||
else if (jposData is not null)
|
||||
{
|
||||
var appPosData = jposData.ToObject<PosAppData>();
|
||||
receiptData = new JObject();
|
||||
if (cartItems is not null && choices is not null)
|
||||
{
|
||||
var posCartItems = cartItems.ToList();
|
||||
var selectedChoices = choices
|
||||
.Where(item => posCartItems.Any(cartItem => cartItem.Id == item.Id))
|
||||
.ToDictionary(item => item.Id);
|
||||
var cartData = new JObject();
|
||||
foreach (AppCartItem cartItem in posCartItems)
|
||||
{
|
||||
if (!selectedChoices.TryGetValue(cartItem.Id, out var selectedChoice))
|
||||
continue;
|
||||
var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var ident = selectedChoice.Title ?? selectedChoice.Id;
|
||||
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
|
||||
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
|
||||
}
|
||||
entity.Metadata.PosData = JObject.FromObject(jposData);
|
||||
|
||||
if (jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray)
|
||||
{
|
||||
for (var i = 0; i < amountsArray.Count; i++)
|
||||
{
|
||||
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(amountsArray[i].ToObject<decimal>(), settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
}
|
||||
}
|
||||
receiptData.Add("Cart", cartData);
|
||||
}
|
||||
receiptData.Add("Subtotal", _displayFormatter.Currency(appPosData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
if (appPosData.DiscountAmount > 0)
|
||||
{
|
||||
var discountFormatted = _displayFormatter.Currency(appPosData.DiscountAmount, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Add("Discount", appPosData.DiscountPercentage > 0 ? $"{appPosData.DiscountPercentage}% = {discountFormatted}" : discountFormatted);
|
||||
}
|
||||
if (appPosData.Tip > 0)
|
||||
{
|
||||
var tipFormatted = _displayFormatter.Currency(appPosData.Tip, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
receiptData.Add("Tip", appPosData.TipPercentage > 0 ? $"{appPosData.TipPercentage}% = {tipFormatted}" : tipFormatted);
|
||||
}
|
||||
receiptData.Add("Total", _displayFormatter.Currency(appPosData.Total, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
if (selectedChoices.Count == 1)
|
||||
{
|
||||
receiptData.Title = selectedChoices[0].Title;
|
||||
if (!string.IsNullOrEmpty(selectedChoices[0].Description))
|
||||
receiptData.Description = selectedChoices[0].Description;
|
||||
}
|
||||
|
||||
Dictionary<string,string> cartData = null;
|
||||
foreach (var cartItem in jposData.Cart)
|
||||
{
|
||||
var selectedChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id);
|
||||
if (selectedChoice is null)
|
||||
continue;
|
||||
var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var ident = selectedChoice.Title ?? selectedChoice.Id;
|
||||
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
|
||||
cartData ??= new();
|
||||
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
|
||||
}
|
||||
|
||||
for (var i = 0; i < (jposData.Amounts ?? []).Length; i++)
|
||||
{
|
||||
cartData ??= new();
|
||||
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(jposData.Amounts[i], settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
}
|
||||
|
||||
receiptData.Cart = cartData;
|
||||
|
||||
entity.Metadata.SetAdditionalData("receiptData", receiptData);
|
||||
|
||||
if (formResponseJObject is null)
|
||||
@@ -433,7 +435,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
var data = new { invoiceId = invoice.Id };
|
||||
if (wantsJson)
|
||||
return Json(data);
|
||||
if (price is 0 && storeBlob.ReceiptOptions?.Enabled is true)
|
||||
if (!isTopup && summary.PriceTaxIncludedWithTips is 0 && storeBlob.ReceiptOptions?.Enabled is true)
|
||||
return RedirectToAction(nameof(UIInvoiceController.InvoiceReceipt), "UIInvoice", data);
|
||||
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", data);
|
||||
}
|
||||
@@ -561,7 +563,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
viewModel.StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob);
|
||||
return View("Views/UIForms/View", viewModel);
|
||||
}
|
||||
|
||||
|
||||
[Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[HttpGet("/apps/{appId}/pos/recent-transactions")]
|
||||
public async Task<IActionResult> RecentTransactions(string appId)
|
||||
@@ -606,6 +608,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
Archived = app.Archived,
|
||||
AppName = app.Name,
|
||||
Title = settings.Title,
|
||||
DefaultTaxRate = settings.DefaultTaxRate,
|
||||
DefaultView = settings.DefaultView,
|
||||
ShowItems = settings.ShowItems,
|
||||
ShowCustomAmount = settings.ShowCustomAmount,
|
||||
@@ -663,7 +666,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
}
|
||||
|
||||
vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}";
|
||||
|
||||
|
||||
await FillUsers(vm);
|
||||
return View("PointOfSale/UpdatePointOfSale", vm);
|
||||
}
|
||||
@@ -697,11 +700,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
return View("PointOfSale/UpdatePointOfSale", vm);
|
||||
}
|
||||
|
||||
bool wasHtmlModified;
|
||||
var settings = new PointOfSaleSettings
|
||||
{
|
||||
Title = vm.Title,
|
||||
DefaultView = vm.DefaultView,
|
||||
DefaultTaxRate = vm.DefaultTaxRate ?? 0,
|
||||
ShowItems = vm.ShowItems,
|
||||
ShowCustomAmount = vm.ShowCustomAmount,
|
||||
ShowDiscount = vm.ShowDiscount,
|
||||
@@ -717,7 +720,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
NotificationUrl = vm.NotificationUrl,
|
||||
RedirectUrl = vm.RedirectUrl,
|
||||
HtmlLang = vm.HtmlLang,
|
||||
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out wasHtmlModified),
|
||||
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out bool wasHtmlModified),
|
||||
Description = vm.Description,
|
||||
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically),
|
||||
FormId = vm.FormId
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -17,7 +17,7 @@ public class ProductsReportProvider : ReportProvider
|
||||
_displayFormatter = displayFormatter;
|
||||
Apps = apps;
|
||||
}
|
||||
|
||||
|
||||
private readonly DisplayFormatter _displayFormatter;
|
||||
private InvoiceRepository InvoiceRepository { get; }
|
||||
private AppService Apps { get; }
|
||||
@@ -60,7 +60,19 @@ public class ProductsReportProvider : ReportProvider
|
||||
{
|
||||
values = values.ToList();
|
||||
values.Add(appId);
|
||||
if (i.Metadata?.ItemCode is string code)
|
||||
if (AppService.TryParsePosCartItems(i.Metadata?.PosData, out var items))
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var copy = values.ToList();
|
||||
copy.Add(item.Id);
|
||||
copy.Add(item.Count);
|
||||
copy.Add(item.Price * item.Count);
|
||||
copy.Add(i.Currency);
|
||||
queryContext.Data.Add(copy);
|
||||
}
|
||||
}
|
||||
else if (i.Metadata?.ItemCode is string code)
|
||||
{
|
||||
values.Add(code);
|
||||
values.Add(1);
|
||||
@@ -68,21 +80,6 @@ public class ProductsReportProvider : ReportProvider
|
||||
values.Add(i.Currency);
|
||||
queryContext.Data.Add(values);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (AppService.TryParsePosCartItems(i.Metadata?.PosData, out var items))
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
var copy = values.ToList();
|
||||
copy.Add(item.Id);
|
||||
copy.Add(item.Count);
|
||||
copy.Add(item.Price * item.Count);
|
||||
copy.Add(i.Currency);
|
||||
queryContext.Data.Add(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Round the currency amount
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
</button>
|
||||
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="@StringLocalizer["Close"]">
|
||||
<vc:icon symbol="cross" />
|
||||
</button>
|
||||
</button>
|
||||
</header>
|
||||
<div class="offcanvas-body py-0">
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" v-if="cartCount !== 0">
|
||||
@@ -226,22 +226,30 @@
|
||||
</tr>
|
||||
</table>
|
||||
<table class="table table-borderless mt-4 mb-0">
|
||||
<tr>
|
||||
<td class="align-middle" text-translate="true">Subtotal</td>
|
||||
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
|
||||
<tr v-if="itemsTotalNumeric">
|
||||
<td class="align-middle h6 border-0" text-translate="true">Items total</td>
|
||||
<td class="align-middle h6 border-0 text-end" id="CartItemsTotal">{{ formatCurrency(itemsTotalNumeric, true) }}</td>
|
||||
</tr>
|
||||
<tr v-if="discountNumeric">
|
||||
<td class="align-middle" text-translate="true">Discount</td>
|
||||
<td class="align-middle text-end" id="CartDiscount">
|
||||
<span v-if="discountPercent">{{discountPercent}}% =</span>
|
||||
{{ formatCurrency(discountNumeric, true) }}
|
||||
<span>{{ formatCurrency(discountNumeric, true) }}</span> <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" />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@inject LanguageService LangService
|
||||
@inject BTCPayServerEnvironment Env
|
||||
@inject IEnumerable<IUIExtension> UiExtensions
|
||||
@inject IEnumerable<IUIExtension> UiExtensions
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@model CheckoutModel
|
||||
@{
|
||||
@@ -247,7 +247,7 @@
|
||||
</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="text/x-template" id="payment-details">
|
||||
<script type="text/x-template" id="payment-details">
|
||||
<dl>
|
||||
<div v-if="orderAmount > 0" id="PaymentDetails-TotalPrice" key="TotalPrice">
|
||||
<dt v-t="'total_price'"></dt>
|
||||
@@ -256,6 +256,10 @@
|
||||
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
|
||||
<dt v-t="'total_fiat'"></dt>
|
||||
<dd class="clipboard-button clipboard-button-hover" :data-clipboard="asNumber(srvModel.orderAmountFiat)" data-clipboard-hover="start">{{srvModel.orderAmountFiat}}</dd>
|
||||
</div>
|
||||
<div v-if="srvModel.taxIncluded.value > 0 && srvModel.taxIncluded.formatted" id="PaymentDetails-TaxIncluded" key="TaxIncluded">
|
||||
<dt v-t="'tax_included'"></dt>
|
||||
<dd class="clipboard-button clipboard-button-hover" :data-clipboard="srvModel.taxIncluded.value" data-clipboard-hover="start">{{srvModel.taxIncluded.formatted}}</dd>
|
||||
</div>
|
||||
<div v-if="srvModel.rate && srvModel.paymentMethodCurrency" id="PaymentDetails-ExchangeRate" key="ExchangeRate">
|
||||
<dt v-t="'exchange_rate'"></dt>
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
|
||||
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
|
||||
</div>
|
||||
@if (Model.TaxIncluded != 0.0m)
|
||||
{
|
||||
<div class="d-flex flex-column">
|
||||
<dd class="text-muted mb-0 fw-semibold">Total Taxes</dd>
|
||||
<dt class="fs-5 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.TaxIncluded, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex flex-column">
|
||||
<dd class="text-muted mb-0 fw-semibold">Date</dd>
|
||||
<dt class="fs-5 mb-0 text-nowrap fw-semibold">@Model.Timestamp.ToBrowserDate()</dt>
|
||||
@@ -86,7 +93,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@if (isProcessing)
|
||||
{
|
||||
<small class="d-block text-muted text-center px-4">This page will refresh periodically until the invoice is settled.</small>
|
||||
|
||||
@@ -116,10 +116,7 @@
|
||||
@if (hasCart)
|
||||
{
|
||||
_ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart);
|
||||
var hasTotal = Model.CartData.TryGetValue("total", out var total) || Model.CartData.TryGetValue("Total", out total);
|
||||
var hasSubtotal = Model.CartData.TryGetValue("subtotal", out var subtotal) || Model.CartData.TryGetValue("subTotal", out subtotal) || Model.CartData.TryGetValue("Subtotal", out subtotal);
|
||||
var hasDiscount = Model.CartData.TryGetValue("discount", out var discount) || Model.CartData.TryGetValue("Discount", out discount);
|
||||
var hasTip = Model.CartData.TryGetValue("tip", out var tip) || Model.CartData.TryGetValue("Tip", out tip);
|
||||
var posData = WellKnownPosData.TryParse(Model.CartData) ?? new();
|
||||
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
|
||||
{
|
||||
@foreach (var (key, value) in cartDict)
|
||||
@@ -130,8 +127,8 @@
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
|
||||
{
|
||||
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
|
||||
{
|
||||
@foreach (var value in cartCollection)
|
||||
{
|
||||
<tr>
|
||||
@@ -139,38 +136,53 @@
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
if (hasSubtotal && (hasDiscount || hasTip))
|
||||
|
||||
@if (posData.ItemsTotal != null)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||
</tr>
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Subtotal</td>
|
||||
<td class="val text-end">@subtotal</td>
|
||||
<td class="key text-secondary">Items total</td>
|
||||
<td class="val text-end">@posData.ItemsTotal</td>
|
||||
</tr>
|
||||
}
|
||||
if (hasDiscount)
|
||||
@if (posData.Discount != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Discount</td>
|
||||
<td class="val text-end">@discount</td>
|
||||
<td class="val text-end">@posData.Discount</td>
|
||||
</tr>
|
||||
}
|
||||
if (hasTip)
|
||||
@if (posData.Subtotal != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Subtotal</td>
|
||||
<td class="val text-end">@posData.Subtotal</td>
|
||||
</tr>
|
||||
}
|
||||
@if (posData.Tax != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Tax</td>
|
||||
<td class="val text-end">@posData.Tax</td>
|
||||
</tr>
|
||||
}
|
||||
@if (posData.Tip != null)
|
||||
{
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Tip</td>
|
||||
<td class="val text-end">@tip</td>
|
||||
<td class="val text-end">@posData.Tip</td>
|
||||
</tr>
|
||||
}
|
||||
if (hasTotal)
|
||||
@if (posData.Total != null)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||
</tr>
|
||||
<tr class="sums-data">
|
||||
<td class="key text-secondary">Total</td>
|
||||
<td class="val text-end fw-semibold">@total</td>
|
||||
<td class="val text-end fw-semibold">@posData.Total</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ function initApp() {
|
||||
await this.setupNFC();
|
||||
}
|
||||
updateLanguageSelect();
|
||||
|
||||
|
||||
window.parent.postMessage('loaded', '*');
|
||||
},
|
||||
beforeDestroy () {
|
||||
@@ -329,7 +329,7 @@ function initApp() {
|
||||
},
|
||||
async fetchData () {
|
||||
if (this.isPluginPaymentMethod) return;
|
||||
|
||||
|
||||
const url = `${statusUrl}&paymentMethodId=${this.pmId}`;
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
@@ -346,7 +346,7 @@ function initApp() {
|
||||
const newEnd = new Date();
|
||||
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
|
||||
this.endDate = newEnd;
|
||||
|
||||
|
||||
// updating ui
|
||||
this.srvModel = data;
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"invoice_id": "Invoice ID",
|
||||
"order_id": "Order ID",
|
||||
"total_price": "Total Price",
|
||||
"tax_included": "Total Taxes",
|
||||
"total_fiat": "Total Fiat",
|
||||
"exchange_rate": "Exchange Rate",
|
||||
"amount_paid": "Amount Paid",
|
||||
@@ -42,4 +43,4 @@
|
||||
"copy_confirm": "Copied",
|
||||
"powered_by": "Powered by",
|
||||
"conversion_body": "This service is provided by 3rd party. Please keep in mind that we have no control over how providers will forward your funds. Invoice will only be marked paid once funds are received on the {{cryptoCode}} blockchain."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,97 @@
|
||||
const POS_ITEM_ADDED_CLASS = 'posItem--added';
|
||||
|
||||
class PoSOrder {
|
||||
constructor(decimals) {
|
||||
this._decimals = decimals;
|
||||
this._discount = 0;
|
||||
this._tip = 0;
|
||||
this._tipPercent = 0;
|
||||
this.itemLines = [];
|
||||
}
|
||||
|
||||
static ItemLine = class {
|
||||
constructor(itemId, count, unitPrice, taxRate = null) {
|
||||
this.itemId = itemId;
|
||||
this.count = count;
|
||||
this.unitPrice = unitPrice;
|
||||
this.taxRate = taxRate;
|
||||
}
|
||||
}
|
||||
|
||||
addLine(line) {
|
||||
this.itemLines.push(line);
|
||||
}
|
||||
|
||||
setTip(tip) {
|
||||
this._tip = this._round(tip);
|
||||
this._tipPercent = 0;
|
||||
}
|
||||
setTipPercent(tip) {
|
||||
this._tipPercent = tip;
|
||||
this._tip = 0;
|
||||
}
|
||||
|
||||
addDiscountRate(discount) {
|
||||
this._discount = discount;
|
||||
}
|
||||
|
||||
setCart(cart, amounts, defaultTaxRate) {
|
||||
this.itemLines = [];
|
||||
for (const item of cart) {
|
||||
this.addLine(new PoSOrder.ItemLine(item.id, item.count, item.price, item.taxRate ?? defaultTaxRate));
|
||||
}
|
||||
if (amounts) {
|
||||
var i = 1;
|
||||
for (const item of amounts) {
|
||||
if (!item) continue;
|
||||
this.addLine(new PoSOrder.ItemLine("Custom Amount " + i, 1, item, defaultTaxRate));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
calculate() {
|
||||
const ctx = {
|
||||
discount: 0,
|
||||
tax: 0,
|
||||
itemsTotal: 0,
|
||||
priceTaxExcluded: 0,
|
||||
tip: 0,
|
||||
priceTaxIncluded: 0,
|
||||
priceTaxIncludedWithTips: 0
|
||||
};
|
||||
|
||||
for (const item of this.itemLines) {
|
||||
let linePrice = item.unitPrice * item.count;
|
||||
let discount = linePrice * this._discount / 100;
|
||||
discount = this._round(discount);
|
||||
ctx.discount += discount;
|
||||
linePrice -= discount;
|
||||
|
||||
let taxRate = item.taxRate ?? 0;
|
||||
let tax = linePrice * taxRate / 100;
|
||||
tax = this._round(tax);
|
||||
ctx.tax += tax;
|
||||
ctx.priceTaxExcluded += linePrice;
|
||||
}
|
||||
|
||||
ctx.priceTaxExcluded = this._round(ctx.priceTaxExcluded);
|
||||
ctx.tip = this._round(this._tip);
|
||||
ctx.tip += this._round(ctx.priceTaxExcluded * this._tipPercent / 100);
|
||||
ctx.priceTaxIncluded = ctx.priceTaxExcluded + ctx.tax;
|
||||
ctx.priceTaxIncludedWithTips = ctx.priceTaxIncluded + ctx.tip;
|
||||
ctx.priceTaxIncludedWithTips = this._round(ctx.priceTaxIncludedWithTips);
|
||||
ctx.itemsTotal = ctx.priceTaxExcluded + ctx.discount;
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
_round(value) {
|
||||
const factor = Math.pow(10, this._decimals);
|
||||
return Math.round(value * factor + Number.EPSILON) / factor;
|
||||
}
|
||||
}
|
||||
|
||||
function storageKey(name) {
|
||||
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
|
||||
}
|
||||
@@ -39,7 +131,7 @@ const posCommon = {
|
||||
data () {
|
||||
return {
|
||||
...srvModel,
|
||||
amount: null,
|
||||
posOrder: new PoSOrder(srvModel.currencyInfo.divisibility),
|
||||
tip: null,
|
||||
tipPercent: null,
|
||||
discount: null,
|
||||
@@ -56,20 +148,30 @@ const posCommon = {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
amountNumeric () {
|
||||
const { divisibility } = this.currencyInfo
|
||||
const cart = this.cart.reduce((res, item) => res + (item.price || 0) * item.count, 0).toFixed(divisibility)
|
||||
const value = parseFloat(this.amount || 0) + parseFloat(cart)
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(divisibility))
|
||||
summary() {
|
||||
return this.posOrder.calculate();
|
||||
},
|
||||
itemsTotalNumeric() {
|
||||
// We don't want to show the items total if there is no discount or tip
|
||||
if (this.summary.itemsTotal === this.summary.priceTaxExcluded) return 0;
|
||||
return this.summary.itemsTotal;
|
||||
},
|
||||
taxNumeric() {
|
||||
return this.summary.tax;
|
||||
},
|
||||
subtotalNumeric () {
|
||||
// We don't want to show the subtotal if there is no tax or tips
|
||||
if (this.summary.priceTaxExcluded === this.summary.priceTaxIncludedWithTips) return 0;
|
||||
return this.summary.priceTaxExcluded;
|
||||
},
|
||||
posdata () {
|
||||
const data = { subTotal: this.amountNumeric, total: this.totalNumeric }
|
||||
const data = { subTotal: this.summary.priceTaxExcluded, total: this.summary.priceTaxIncludedWithTips }
|
||||
const amounts = this.amounts.filter(e => e) // clear empty or zero values
|
||||
if (amounts) data.amounts = amounts.map(parseFloat)
|
||||
if (this.cart) data.cart = this.cart
|
||||
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
|
||||
if (this.summary.discount > 0) data.discountAmount = this.summary.discount
|
||||
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
|
||||
if (this.tipNumeric > 0) data.tip = this.tipNumeric
|
||||
if (this.summary.tip > 0) data.tip = this.summary.tip
|
||||
if (this.tipPercent > 0) data.tipPercentage = this.tipPercent
|
||||
return JSON.stringify(data)
|
||||
},
|
||||
@@ -78,29 +180,13 @@ const posCommon = {
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
discountNumeric () {
|
||||
return this.amountNumeric && this.discountPercentNumeric
|
||||
? parseFloat((this.amountNumeric * (this.discountPercentNumeric / 100)).toFixed(this.currencyInfo.divisibility))
|
||||
: 0.0;
|
||||
},
|
||||
amountMinusDiscountNumeric () {
|
||||
return parseFloat((this.amountNumeric - this.discountNumeric).toFixed(this.currencyInfo.divisibility))
|
||||
return this.summary.discount;
|
||||
},
|
||||
tipNumeric () {
|
||||
if (this.tipPercent) {
|
||||
return parseFloat((this.amountMinusDiscountNumeric * (this.tipPercent / 100)).toFixed(this.currencyInfo.divisibility))
|
||||
} else {
|
||||
if (this.tip < 0) {
|
||||
this.tip = 0
|
||||
}
|
||||
const value = parseFloat(this.tip)
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||
}
|
||||
},
|
||||
total () {
|
||||
return this.amountNumeric - this.discountNumeric + this.tipNumeric
|
||||
return this.summary.tip;
|
||||
},
|
||||
totalNumeric () {
|
||||
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
|
||||
return this.summary.priceTaxIncludedWithTips;
|
||||
},
|
||||
cartCount() {
|
||||
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
|
||||
@@ -119,9 +205,11 @@ const posCommon = {
|
||||
else if (value < 0) this.discountPercent = '0'
|
||||
else if (value > 100) this.discountPercent = '100'
|
||||
else this.discountPercent = value.toString()
|
||||
this.posOrder.addDiscountRate(isNaN(value) ? null : value)
|
||||
},
|
||||
tip(val) {
|
||||
this.tipPercent = null
|
||||
this.posOrder.setTip(val)
|
||||
},
|
||||
cart: {
|
||||
handler(newCart) {
|
||||
@@ -132,11 +220,12 @@ const posCommon = {
|
||||
if (this.persistState) {
|
||||
saveState('cart', newCart)
|
||||
}
|
||||
this.posOrder.setCart(newCart, this.amounts, this.defaultTaxRate)
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
amounts (values) {
|
||||
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
|
||||
this.posOrder.setCart(this.cart, values, this.defaultTaxRate)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -155,6 +244,7 @@ const posCommon = {
|
||||
this.tipPercent = this.tipPercent !== percentage
|
||||
? percentage
|
||||
: null;
|
||||
this.posOrder.setTipPercent(this.tipPercent)
|
||||
},
|
||||
formatCrypto(value, withSymbol) {
|
||||
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
|
||||
@@ -207,10 +297,7 @@ const posCommon = {
|
||||
// Add new item because it doesn't exist yet
|
||||
if (!itemInCart) {
|
||||
itemInCart = {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
price: item.price,
|
||||
inventory: item.inventory,
|
||||
...item,
|
||||
count
|
||||
}
|
||||
this.cart.push(itemInCart);
|
||||
@@ -221,11 +308,13 @@ const posCommon = {
|
||||
// Animate
|
||||
if (!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
|
||||
|
||||
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||
return itemInCart;
|
||||
},
|
||||
removeFromCart(id) {
|
||||
const index = this.cart.findIndex(lineItem => lineItem.id === id);
|
||||
this.cart.splice(index, 1);
|
||||
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||
},
|
||||
getQuantity(id) {
|
||||
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
|
||||
@@ -245,6 +334,7 @@ const posCommon = {
|
||||
if (itemInCart && itemInCart.count <= 0 && addOrRemove) {
|
||||
this.removeFromCart(itemInCart.id);
|
||||
}
|
||||
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||
},
|
||||
clear() {
|
||||
this.cart = [];
|
||||
@@ -305,6 +395,7 @@ const posCommon = {
|
||||
if (this.persistState) {
|
||||
this.cart = loadState('cart');
|
||||
}
|
||||
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||
},
|
||||
mounted () {
|
||||
if (this.$refs.categories) {
|
||||
@@ -328,7 +419,7 @@ const posCommon = {
|
||||
});
|
||||
adjustCategories();
|
||||
}
|
||||
|
||||
|
||||
this.forEachItem(item => {
|
||||
item.addEventListener('transitionend', () => {
|
||||
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
|
||||
@@ -336,7 +427,7 @@ const posCommon = {
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
|
||||
if (this.$refs.RecentTransactions) {
|
||||
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
|
||||
}
|
||||
@@ -347,7 +438,7 @@ const posCommon = {
|
||||
localStorage.removeItem(storageKey('cart'));
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
this.updateDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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