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.Integer:
|
||||||
case JTokenType.String:
|
case JTokenType.String:
|
||||||
if (objectType == typeof(decimal) || objectType == typeof(decimal?))
|
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);
|
return decimal.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
|
||||||
if (objectType == typeof(double) || objectType == typeof(double?))
|
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);
|
return double.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
throw new JsonSerializationException("Unexpected object type: " + objectType);
|
throw new JsonSerializationException("Unexpected object type: " + objectType);
|
||||||
case JTokenType.Null when objectType == typeof(decimal?) || objectType == typeof(double?):
|
case JTokenType.Null when objectType == typeof(decimal?) || objectType == typeof(double?):
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
using BTCPayServer.JsonConverters;
|
using BTCPayServer.JsonConverters;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Client.Models;
|
namespace BTCPayServer.Client.Models;
|
||||||
|
|
||||||
@@ -10,4 +12,7 @@ public class AppCartItem
|
|||||||
public int Count { get; set; }
|
public int Count { get; set; }
|
||||||
[JsonConverter(typeof(NumericStringJsonConverter))]
|
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||||
public decimal Price { get; set; }
|
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 string BuyButtonText { get; set; }
|
||||||
public int? Inventory { get; set; }
|
public int? Inventory { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||||
|
public decimal? TaxRate { get; set; }
|
||||||
|
|
||||||
[JsonExtensionData]
|
[JsonExtensionData]
|
||||||
public Dictionary<string, JToken> AdditionalData { get; set; }
|
public Dictionary<string, JToken> AdditionalData { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public static class PosDataParser
|
|||||||
result.TryAdd(item.Key, ParsePosData(item.Value));
|
result.TryAdd(item.Key, ParsePosData(item.Value));
|
||||||
break;
|
break;
|
||||||
case null:
|
case null:
|
||||||
|
case JTokenType.Null:
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
result.TryAdd(item.Key, item.Value.ToString());
|
result.TryAdd(item.Key, item.Value.ToString());
|
||||||
|
|||||||
@@ -802,65 +802,5 @@ g:
|
|||||||
Assert.Equal("new", topupInvoice.Status);
|
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;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -59,6 +60,8 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.DoesNotContain("Error", driver.Title, StringComparison.OrdinalIgnoreCase);
|
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)
|
public static async Task AssertNoError(this IPage page)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
@@ -11,6 +13,8 @@ using BTCPayServer.Plugins.PointOfSale.Controllers;
|
|||||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using Xunit.Abstractions;
|
using Xunit.Abstractions;
|
||||||
using static BTCPayServer.Tests.UnitTest1;
|
using static BTCPayServer.Tests.UnitTest1;
|
||||||
@@ -19,12 +23,8 @@ using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
|
|||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
[Collection(nameof(NonParallelizableCollectionDefinition))]
|
[Collection(nameof(NonParallelizableCollectionDefinition))]
|
||||||
public class POSTests : UnitTestBase
|
public class POSTests(ITestOutputHelper helper) : UnitTestBase(helper)
|
||||||
{
|
{
|
||||||
public POSTests(ITestOutputHelper helper) : base(helper)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Fast", "Fast")]
|
[Trait("Fast", "Fast")]
|
||||||
public void CanParseOldYmlCorrectly()
|
public void CanParseOldYmlCorrectly()
|
||||||
@@ -79,8 +79,8 @@ fruit tea:
|
|||||||
Assert.Equal( 1 ,parsedDefault[0].Price);
|
Assert.Equal( 1 ,parsedDefault[0].Price);
|
||||||
Assert.Equal( AppItemPriceType.Fixed ,parsedDefault[0].PriceType);
|
Assert.Equal( AppItemPriceType.Fixed ,parsedDefault[0].PriceType);
|
||||||
Assert.Null( parsedDefault[0].AdditionalData);
|
Assert.Null( parsedDefault[0].AdditionalData);
|
||||||
|
|
||||||
|
|
||||||
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
|
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
|
||||||
Assert.Equal( "herbal tea" ,parsedDefault[4].Id);
|
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);
|
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);
|
items = AppService.Parse(missingId);
|
||||||
Assert.Single(items);
|
Assert.Single(items);
|
||||||
Assert.Equal("black-tea", items[0].Id);
|
Assert.Equal("black-tea", items[0].Id);
|
||||||
|
|
||||||
// Throws for missing ID
|
// Throws for missing ID
|
||||||
Assert.Throws<ArgumentException>(() => AppService.Parse(missingId, true, true));
|
Assert.Throws<ArgumentException>(() => AppService.Parse(missingId, true, true));
|
||||||
|
|
||||||
@@ -134,11 +134,11 @@ fruit tea:
|
|||||||
var duplicateId = template.Replace(@"""id"": ""green-tea"",", @"""id"": ""black-tea"",");
|
var duplicateId = template.Replace(@"""id"": ""green-tea"",", @"""id"": ""black-tea"",");
|
||||||
items = AppService.Parse(duplicateId);
|
items = AppService.Parse(duplicateId);
|
||||||
Assert.Empty(items);
|
Assert.Empty(items);
|
||||||
|
|
||||||
// Throws for duplicate IDs
|
// Throws for duplicate IDs
|
||||||
Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true));
|
Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact(Timeout = LongRunningTestTimeout)]
|
[Fact(Timeout = LongRunningTestTimeout)]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task CanUsePoSApp1()
|
public async Task CanUsePoSApp1()
|
||||||
@@ -191,7 +191,7 @@ donation:
|
|||||||
// apple is not found
|
// apple is not found
|
||||||
Assert.IsType<NotFoundResult>(publicApps
|
Assert.IsType<NotFoundResult>(publicApps
|
||||||
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
|
.ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result);
|
||||||
|
|
||||||
// List
|
// List
|
||||||
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
|
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
|
||||||
app = appList.Apps[0];
|
app = appList.Apps[0];
|
||||||
@@ -227,5 +227,521 @@ donation:
|
|||||||
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
|
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
|
||||||
Assert.Empty(appList.Apps);
|
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();
|
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)
|
public async Task GoToInvoices(string storeId = null)
|
||||||
{
|
{
|
||||||
if (storeId is 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)
|
public async Task<ILocator> FindAlertMessage(StatusMessageModel.StatusSeverity severity = StatusMessageModel.StatusSeverity.Success, string partialText = null)
|
||||||
{
|
{
|
||||||
var locator = await FindAlertMessage(new[] { severity });
|
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)
|
public async Task<string> RegisterNewUser(bool isAdmin = false)
|
||||||
@@ -333,6 +302,18 @@ namespace BTCPayServer.Tests
|
|||||||
}
|
}
|
||||||
else { await GoToUrl("/"); }
|
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")
|
public async Task LogIn(string user, string password = "123456")
|
||||||
{
|
{
|
||||||
await Page.FillAsync("#Email", user);
|
await Page.FillAsync("#Email", user);
|
||||||
@@ -424,6 +405,8 @@ namespace BTCPayServer.Tests
|
|||||||
StoreId = storeId;
|
StoreId = storeId;
|
||||||
if (WalletId != null)
|
if (WalletId != null)
|
||||||
WalletId = new WalletId(storeId, WalletId.CryptoCode);
|
WalletId = new WalletId(storeId, WalletId.CryptoCode);
|
||||||
|
if (storeNavPage != StoreNavPages.General)
|
||||||
|
await Page.Locator($"#StoreNav-{StoreNavPages.General}").ClickAsync();
|
||||||
}
|
}
|
||||||
await Page.Locator($"#StoreNav-{storeNavPage}").ClickAsync();
|
await Page.Locator($"#StoreNav-{storeNavPage}").ClickAsync();
|
||||||
}
|
}
|
||||||
@@ -502,5 +485,59 @@ namespace BTCPayServer.Tests
|
|||||||
{
|
{
|
||||||
await Page.ClickAsync(".modal.fade.show .modal-confirm");
|
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.FindAlertMessage(partialText: "App updated");
|
||||||
await s.Page.ClickAsync("#ViewApp");
|
await s.Page.ClickAsync("#ViewApp");
|
||||||
var popOutPage = await s.Page.Context.WaitForPageAsync();
|
var popOutPage = await s.Page.Context.WaitForPageAsync();
|
||||||
await popOutPage.Locator("button[type='submit']").First.ClickAsync();
|
string invoiceId;
|
||||||
await popOutPage.FillAsync("[name='buyerEmail']", "aa@aa.com");
|
await using (var o = await s.SwitchPage(popOutPage))
|
||||||
await popOutPage.ClickAsync("input[type='submit']");
|
{
|
||||||
await s.PayInvoiceAsync(popOutPage, true);
|
await s.Page.Locator("button[type='submit']").First.ClickAsync();
|
||||||
var invoiceId = popOutPage.Url[(popOutPage.Url.LastIndexOf("/", StringComparison.Ordinal) + 1)..];
|
await s.Page.FillAsync("[name='buyerEmail']", "aa@aa.com");
|
||||||
await popOutPage.CloseAsync();
|
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.Page.Context.Pages.First().BringToFrontAsync();
|
||||||
await s.GoToUrl($"/invoices/{invoiceId}/");
|
await s.GoToUrl($"/invoices/{invoiceId}/");
|
||||||
@@ -109,7 +112,7 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.Contains("CustomFormInputTest", await s.Page.ContentAsync());
|
Assert.Contains("CustomFormInputTest", await s.Page.ContentAsync());
|
||||||
await s.Page.FillAsync("[name='buyerEmail']", "aa@aa.com");
|
await s.Page.FillAsync("[name='buyerEmail']", "aa@aa.com");
|
||||||
await s.Page.ClickAsync("input[type='submit']");
|
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);
|
var result = await s.Server.PayTester.HttpClient.GetAsync(formUrl);
|
||||||
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
|
Assert.Equal(HttpStatusCode.NotFound, result.StatusCode);
|
||||||
await s.GoToHome();
|
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]
|
[Fact]
|
||||||
[Trait("Selenium", "Selenium")]
|
[Trait("Selenium", "Selenium")]
|
||||||
[Trait("Lightning", "Lightning")]
|
[Trait("Lightning", "Lightning")]
|
||||||
|
|||||||
@@ -238,16 +238,15 @@ namespace BTCPayServer.Controllers
|
|||||||
if (additionalData.TryGetValue("receiptData", out object? combinedReceiptData))
|
if (additionalData.TryGetValue("receiptData", out object? combinedReceiptData))
|
||||||
{
|
{
|
||||||
var receiptData = new Dictionary<string, object>((Dictionary<string, object>)combinedReceiptData, StringComparer.OrdinalIgnoreCase);
|
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
|
// 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>();
|
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
|
// 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);
|
receiptData.Remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,6 +260,7 @@ namespace BTCPayServer.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders, _handlers);
|
var payments = ViewPaymentRequestViewModel.PaymentRequestInvoicePayment.GetViewModels(i, _displayFormatter, _transactionLinkProviders, _handlers);
|
||||||
|
vm.TaxIncluded = i.Metadata?.TaxIncluded ?? 0.0m;
|
||||||
vm.Amount = i.PaidAmount.Net;
|
vm.Amount = i.PaidAmount.Net;
|
||||||
vm.Payments = receipt.ShowPayments is false ? null : payments;
|
vm.Payments = receipt.ShowPayments is false ? null : payments;
|
||||||
|
|
||||||
@@ -929,6 +929,12 @@ namespace BTCPayServer.Controllers
|
|||||||
|
|
||||||
model.PaymentMethodId = paymentMethodId.ToString();
|
model.PaymentMethodId = paymentMethodId.ToString();
|
||||||
model.OrderAmountFiat = OrderAmountFromInvoice(model.PaymentMethodCurrency, invoice, DisplayFormatter.CurrencyFormat.Symbol);
|
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)
|
if (storeBlob.PlaySoundOnPayment)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||||||
{
|
{
|
||||||
public class CheckoutModel
|
public class CheckoutModel
|
||||||
{
|
{
|
||||||
|
public class Amount
|
||||||
|
{
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
public string Formatted { get; set; }
|
||||||
|
}
|
||||||
public string CheckoutBodyComponentName { get; set; }
|
public string CheckoutBodyComponentName { get; set; }
|
||||||
public class AvailablePaymentMethod
|
public class AvailablePaymentMethod
|
||||||
{
|
{
|
||||||
@@ -52,6 +57,7 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||||||
public string Rate { get; set; }
|
public string Rate { get; set; }
|
||||||
public string OrderAmount { get; set; }
|
public string OrderAmount { get; set; }
|
||||||
public string OrderAmountFiat { get; set; }
|
public string OrderAmountFiat { get; set; }
|
||||||
|
public Amount TaxIncluded { get; set; }
|
||||||
public string InvoiceBitcoinUrl { get; set; }
|
public string InvoiceBitcoinUrl { get; set; }
|
||||||
public string InvoiceBitcoinUrlQR { get; set; }
|
public string InvoiceBitcoinUrlQR { get; set; }
|
||||||
public int TxCount { get; set; }
|
public int TxCount { get; set; }
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||||||
public ReceiptOptions ReceiptOptions { get; set; }
|
public ReceiptOptions ReceiptOptions { get; set; }
|
||||||
public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; }
|
public List<ViewPaymentRequestViewModel.PaymentRequestInvoicePayment> Payments { get; set; }
|
||||||
public string RedirectUrl { get; set; }
|
public string RedirectUrl { get; set; }
|
||||||
|
public decimal TaxIncluded { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
CustomButtonText = settings.CustomButtonText,
|
CustomButtonText = settings.CustomButtonText,
|
||||||
CustomTipText = settings.CustomTipText,
|
CustomTipText = settings.CustomTipText,
|
||||||
CustomTipPercentages = settings.CustomTipPercentages,
|
CustomTipPercentages = settings.CustomTipPercentages,
|
||||||
|
DefaultTaxRate = settings.DefaultTaxRate,
|
||||||
AppId = appId,
|
AppId = appId,
|
||||||
StoreId = store.Id,
|
StoreId = store.Id,
|
||||||
HtmlLang = settings.HtmlLang,
|
HtmlLang = settings.HtmlLang,
|
||||||
@@ -171,22 +172,31 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
// Distinguish JSON requests coming via the mobile app
|
// Distinguish JSON requests coming via the mobile app
|
||||||
var wantsJson = Request.Headers.Accept.FirstOrDefault()?.StartsWith("application/json") is true;
|
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);
|
var app = await _appService.GetApp(appId, PointOfSaleAppType.AppType);
|
||||||
if (app == null)
|
if (app == null)
|
||||||
return wantsJson
|
return wantsJson
|
||||||
? Json(new { error = "App not found" })
|
? Json(new { error = StringLocalizer["App not found"].Value })
|
||||||
: NotFound();
|
: NotFound();
|
||||||
|
|
||||||
// not allowing negative tips or discounts
|
// not allowing negative tips or discounts
|
||||||
if (tip < 0 || discount < 0)
|
if (tip < 0 || discount < 0)
|
||||||
return wantsJson
|
return Error(StringLocalizer["Negative tip or discount is not allowed"].Value);
|
||||||
? Json(new { error = "Negative tip or discount is not allowed" })
|
|
||||||
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(choiceKey) && amount <= 0)
|
if (string.IsNullOrEmpty(choiceKey) && (amount <= 0 || customAmount <= 0))
|
||||||
return wantsJson
|
return Error(StringLocalizer["Negative amount is not allowed"].Value);
|
||||||
? Json(new { error = "Negative amount is not allowed" })
|
|
||||||
: RedirectToAction(nameof(ViewPointOfSale), new { appId });
|
|
||||||
|
|
||||||
var settings = app.GetSettings<PointOfSaleSettings>();
|
var settings = app.GetSettings<PointOfSaleSettings>();
|
||||||
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
|
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
|
||||||
@@ -196,86 +206,56 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
{
|
{
|
||||||
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
|
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
|
||||||
}
|
}
|
||||||
|
var choices = AppService.Parse(settings.Template, false);
|
||||||
var jposData = TryParseJObject(posData);
|
var jposData = PosAppData.TryParse(posData) ?? new();
|
||||||
string title;
|
PoSOrder order = new(_currencies.GetNumberFormatInfo(settings.Currency, true).CurrencyDecimalDigits);
|
||||||
decimal? price;
|
|
||||||
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
|
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
|
||||||
AppItem choice = null;
|
List<AppItem> selectedChoices = new();
|
||||||
List<AppCartItem> cartItems = null;
|
|
||||||
AppItem[] choices = null;
|
|
||||||
if (!string.IsNullOrEmpty(choiceKey))
|
if (!string.IsNullOrEmpty(choiceKey))
|
||||||
{
|
{
|
||||||
choices = AppService.Parse(settings.Template, false);
|
jposData.Cart = new PosAppCartItem[] { new() { Id = choiceKey, Count = 1, Price = amount ?? 0 } };
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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)
|
order.AddLine(new("", 1, o, settings.DefaultTaxRate));
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 store = await _appService.GetStore(app);
|
||||||
var storeBlob = store.GetStoreBlob();
|
var storeBlob = store.GetStoreBlob();
|
||||||
@@ -317,38 +297,82 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
var amtField = form.GetFieldByFullName($"{FormDataService.InvoiceParameterPrefix}amount");
|
var amtField = form.GetFieldByFullName($"{FormDataService.InvoiceParameterPrefix}amount");
|
||||||
if (amtField is null)
|
if (amtField is null)
|
||||||
{
|
{
|
||||||
form.Fields.Add(new Field
|
amtField = new Field
|
||||||
{
|
{
|
||||||
Name = $"{FormDataService.InvoiceParameterPrefix}amount",
|
Name = $"{FormDataService.InvoiceParameterPrefix}amount",
|
||||||
Type = "hidden",
|
Type = "hidden",
|
||||||
Value = price?.ToString(),
|
|
||||||
Constant = true
|
Constant = true
|
||||||
});
|
};
|
||||||
}
|
form.Fields.Add(amtField);
|
||||||
else
|
|
||||||
{
|
|
||||||
amtField.Value = price?.ToString();
|
|
||||||
}
|
}
|
||||||
|
amtField.Value = order.Calculate().PriceTaxExcluded.ToString(CultureInfo.InvariantCulture);
|
||||||
formResponseJObject = FormDataService.GetValues(form);
|
formResponseJObject = FormDataService.GetValues(form);
|
||||||
|
|
||||||
var invoiceRequest = FormDataService.GenerateInvoiceParametersFromForm(form);
|
var invoiceRequest = FormDataService.GenerateInvoiceParametersFromForm(form);
|
||||||
if (invoiceRequest.Amount is not null)
|
if (invoiceRequest.Amount is not null)
|
||||||
{
|
{
|
||||||
price = invoiceRequest.Amount.Value;
|
order.AddLine(new("", 1, invoiceRequest.Amount.Value, settings.DefaultTaxRate));
|
||||||
}
|
}
|
||||||
break;
|
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
|
try
|
||||||
{
|
{
|
||||||
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
|
var invoice = await _invoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest
|
||||||
{
|
{
|
||||||
Amount = price,
|
Amount = isTopup ? null : summary.PriceTaxIncludedWithTips,
|
||||||
Currency = settings.Currency,
|
Currency = settings.Currency,
|
||||||
Metadata = new InvoiceMetadata
|
Metadata = new InvoiceMetadata
|
||||||
{
|
{
|
||||||
ItemCode = choice?.Id,
|
ItemCode = selectedChoices is [{} c1] ? c1.Id : null,
|
||||||
ItemDesc = title,
|
ItemDesc = selectedChoices is [{} c2] ? c2.Title : null,
|
||||||
BuyerEmail = email,
|
BuyerEmail = email,
|
||||||
|
TaxIncluded = summary.Tax == 0m ? null : summary.Tax,
|
||||||
OrderId = orderId ?? AppService.GetRandomOrderId()
|
OrderId = orderId ?? AppService.GetRandomOrderId()
|
||||||
}.ToJObject(),
|
}.ToJObject(),
|
||||||
Checkout = new InvoiceDataBase.CheckoutOptions()
|
Checkout = new InvoiceDataBase.CheckoutOptions()
|
||||||
@@ -369,59 +393,37 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
entity.FullNotifications = true;
|
entity.FullNotifications = true;
|
||||||
entity.ExtendedNotifications = true;
|
entity.ExtendedNotifications = true;
|
||||||
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
entity.Metadata.OrderUrl = Request.GetDisplayUrl();
|
||||||
entity.Metadata.PosData = jposData;
|
entity.Metadata.PosData = JObject.FromObject(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}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray)
|
if (selectedChoices.Count == 1)
|
||||||
{
|
{
|
||||||
for (var i = 0; i < amountsArray.Count; i++)
|
receiptData.Title = selectedChoices[0].Title;
|
||||||
{
|
if (!string.IsNullOrEmpty(selectedChoices[0].Description))
|
||||||
cartData.Add($"Custom Amount {i + 1}", _displayFormatter.Currency(amountsArray[i].ToObject<decimal>(), settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
receiptData.Description = selectedChoices[0].Description;
|
||||||
}
|
|
||||||
}
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
entity.Metadata.SetAdditionalData("receiptData", receiptData);
|
||||||
|
|
||||||
if (formResponseJObject is null)
|
if (formResponseJObject is null)
|
||||||
@@ -433,7 +435,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
var data = new { invoiceId = invoice.Id };
|
var data = new { invoiceId = invoice.Id };
|
||||||
if (wantsJson)
|
if (wantsJson)
|
||||||
return Json(data);
|
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.InvoiceReceipt), "UIInvoice", data);
|
||||||
return RedirectToAction(nameof(UIInvoiceController.Checkout), "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);
|
viewModel.StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob);
|
||||||
return View("Views/UIForms/View", viewModel);
|
return View("Views/UIForms/View", viewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanViewInvoices, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
[HttpGet("/apps/{appId}/pos/recent-transactions")]
|
[HttpGet("/apps/{appId}/pos/recent-transactions")]
|
||||||
public async Task<IActionResult> RecentTransactions(string appId)
|
public async Task<IActionResult> RecentTransactions(string appId)
|
||||||
@@ -606,6 +608,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
Archived = app.Archived,
|
Archived = app.Archived,
|
||||||
AppName = app.Name,
|
AppName = app.Name,
|
||||||
Title = settings.Title,
|
Title = settings.Title,
|
||||||
|
DefaultTaxRate = settings.DefaultTaxRate,
|
||||||
DefaultView = settings.DefaultView,
|
DefaultView = settings.DefaultView,
|
||||||
ShowItems = settings.ShowItems,
|
ShowItems = settings.ShowItems,
|
||||||
ShowCustomAmount = settings.ShowCustomAmount,
|
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}";
|
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);
|
await FillUsers(vm);
|
||||||
return View("PointOfSale/UpdatePointOfSale", vm);
|
return View("PointOfSale/UpdatePointOfSale", vm);
|
||||||
}
|
}
|
||||||
@@ -697,11 +700,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
return View("PointOfSale/UpdatePointOfSale", vm);
|
return View("PointOfSale/UpdatePointOfSale", vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool wasHtmlModified;
|
|
||||||
var settings = new PointOfSaleSettings
|
var settings = new PointOfSaleSettings
|
||||||
{
|
{
|
||||||
Title = vm.Title,
|
Title = vm.Title,
|
||||||
DefaultView = vm.DefaultView,
|
DefaultView = vm.DefaultView,
|
||||||
|
DefaultTaxRate = vm.DefaultTaxRate ?? 0,
|
||||||
ShowItems = vm.ShowItems,
|
ShowItems = vm.ShowItems,
|
||||||
ShowCustomAmount = vm.ShowCustomAmount,
|
ShowCustomAmount = vm.ShowCustomAmount,
|
||||||
ShowDiscount = vm.ShowDiscount,
|
ShowDiscount = vm.ShowDiscount,
|
||||||
@@ -717,7 +720,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
|||||||
NotificationUrl = vm.NotificationUrl,
|
NotificationUrl = vm.NotificationUrl,
|
||||||
RedirectUrl = vm.RedirectUrl,
|
RedirectUrl = vm.RedirectUrl,
|
||||||
HtmlLang = vm.HtmlLang,
|
HtmlLang = vm.HtmlLang,
|
||||||
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out wasHtmlModified),
|
HtmlMetaTags = _safe.RawMeta(vm.HtmlMetaTags, out bool wasHtmlModified),
|
||||||
Description = vm.Description,
|
Description = vm.Description,
|
||||||
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically),
|
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically),
|
||||||
FormId = vm.FormId
|
FormId = vm.FormId
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
|
|||||||
public bool ShowCategories { get; set; }
|
public bool ShowCategories { get; set; }
|
||||||
[Display(Name = "Enable tips")]
|
[Display(Name = "Enable tips")]
|
||||||
public bool EnableTips { get; set; }
|
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 Example1 { get; internal set; }
|
||||||
public string Example2 { get; internal set; }
|
public string Example2 { get; internal set; }
|
||||||
public string ExampleCallback { get; internal set; }
|
public string ExampleCallback { get; internal set; }
|
||||||
|
|||||||
@@ -73,5 +73,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
|
|||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
public SelectList AllCategories { get; set; }
|
public SelectList AllCategories { get; set; }
|
||||||
public string StoreId { 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 BTCPayServer.Client.Models;
|
||||||
|
using Newtonsoft.Json;
|
||||||
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
|
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
|
||||||
|
|
||||||
namespace BTCPayServer.Services.Apps
|
namespace BTCPayServer.Services.Apps
|
||||||
@@ -83,6 +84,8 @@ namespace BTCPayServer.Services.Apps
|
|||||||
public string Currency { get; set; }
|
public string Currency { get; set; }
|
||||||
public string Template { get; set; }
|
public string Template { get; set; }
|
||||||
public bool EnableShoppingCart { get; set; }
|
public bool EnableShoppingCart { get; set; }
|
||||||
|
[JsonConverter(typeof(JsonConverters.NumericStringJsonConverter))]
|
||||||
|
public decimal DefaultTaxRate { get; set; }
|
||||||
public PosViewType DefaultView { get; set; }
|
public PosViewType DefaultView { get; set; }
|
||||||
public bool ShowItems { get; set; }
|
public bool ShowItems { get; set; }
|
||||||
public bool ShowCustomAmount { get; set; }
|
public bool ShowCustomAmount { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -7,6 +8,18 @@ namespace BTCPayServer.Services.Invoices;
|
|||||||
|
|
||||||
public class PosAppData
|
public class PosAppData
|
||||||
{
|
{
|
||||||
|
public static PosAppData TryParse(string posData)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JObject.Parse(posData).ToObject<PosAppData>();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
[JsonProperty(PropertyName = "cart")]
|
[JsonProperty(PropertyName = "cart")]
|
||||||
public PosAppCartItem[] Cart { get; set; }
|
public PosAppCartItem[] Cart { get; set; }
|
||||||
|
|
||||||
@@ -19,18 +32,19 @@ public class PosAppData
|
|||||||
[JsonProperty(PropertyName = "discountPercentage")]
|
[JsonProperty(PropertyName = "discountPercentage")]
|
||||||
public decimal DiscountPercentage { get; set; }
|
public decimal DiscountPercentage { get; set; }
|
||||||
|
|
||||||
[JsonProperty(PropertyName = "discountAmount")]
|
|
||||||
public decimal DiscountAmount { get; set; }
|
|
||||||
|
|
||||||
[JsonProperty(PropertyName = "tipPercentage")]
|
[JsonProperty(PropertyName = "tipPercentage")]
|
||||||
public decimal TipPercentage { get; set; }
|
public decimal TipPercentage { get; set; }
|
||||||
|
|
||||||
[JsonProperty(PropertyName = "tip")]
|
[JsonProperty(PropertyName = "itemsTotal")]
|
||||||
public decimal Tip { get; set; }
|
public decimal ItemsTotal { get; set; }
|
||||||
|
[JsonProperty(PropertyName = "discountAmount")]
|
||||||
|
public decimal DiscountAmount { get; set; }
|
||||||
[JsonProperty(PropertyName = "subTotal")]
|
[JsonProperty(PropertyName = "subTotal")]
|
||||||
public decimal Subtotal { get; set; }
|
public decimal Subtotal { get; set; }
|
||||||
|
[JsonProperty(PropertyName = "tax")]
|
||||||
|
public decimal Tax { get; set; }
|
||||||
|
[JsonProperty(PropertyName = "tip")]
|
||||||
|
public decimal Tip { get; set; }
|
||||||
[JsonProperty(PropertyName = "total")]
|
[JsonProperty(PropertyName = "total")]
|
||||||
public decimal Total { get; set; }
|
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("InvoiceCurrency", "text"),
|
||||||
new("InvoiceDue", "number"),
|
new("InvoiceDue", "number"),
|
||||||
new("InvoicePrice", "number"),
|
new("InvoicePrice", "number"),
|
||||||
|
new("InvoiceTaxIncluded", "number"),
|
||||||
new("InvoiceItemCode", "text"),
|
new("InvoiceItemCode", "text"),
|
||||||
new("InvoiceItemDesc", "text"),
|
new("InvoiceItemDesc", "text"),
|
||||||
new("InvoiceFullStatus", "text"),
|
new("InvoiceFullStatus", "text"),
|
||||||
@@ -91,6 +92,7 @@ public class LegacyInvoiceExportReportProvider : ReportProvider
|
|||||||
data.Add(invoiceEntity.Currency);
|
data.Add(invoiceEntity.Currency);
|
||||||
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits));
|
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits));
|
||||||
data.Add(invoiceEntity.Price);
|
data.Add(invoiceEntity.Price);
|
||||||
|
data.Add(invoiceEntity.Metadata.TaxIncluded ?? 0.0m);
|
||||||
data.Add(invoiceEntity.Metadata.ItemCode);
|
data.Add(invoiceEntity.Metadata.ItemCode);
|
||||||
data.Add(invoiceEntity.Metadata.ItemDesc);
|
data.Add(invoiceEntity.Metadata.ItemDesc);
|
||||||
data.Add(invoiceEntity.GetInvoiceState().ToString());
|
data.Add(invoiceEntity.GetInvoiceState().ToString());
|
||||||
@@ -125,6 +127,7 @@ public class LegacyInvoiceExportReportProvider : ReportProvider
|
|||||||
data.Add(invoiceEntity.Currency);
|
data.Add(invoiceEntity.Currency);
|
||||||
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits)); // InvoiceDue
|
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits)); // InvoiceDue
|
||||||
data.Add(invoiceEntity.Price);
|
data.Add(invoiceEntity.Price);
|
||||||
|
data.Add(invoiceEntity.Metadata.TaxIncluded ?? 0.0m);
|
||||||
data.Add(invoiceEntity.Metadata.ItemCode);
|
data.Add(invoiceEntity.Metadata.ItemCode);
|
||||||
data.Add(invoiceEntity.Metadata.ItemDesc);
|
data.Add(invoiceEntity.Metadata.ItemDesc);
|
||||||
data.Add(invoiceEntity.GetInvoiceState().ToString());
|
data.Add(invoiceEntity.GetInvoiceState().ToString());
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class ProductsReportProvider : ReportProvider
|
|||||||
_displayFormatter = displayFormatter;
|
_displayFormatter = displayFormatter;
|
||||||
Apps = apps;
|
Apps = apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly DisplayFormatter _displayFormatter;
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
private InvoiceRepository InvoiceRepository { get; }
|
private InvoiceRepository InvoiceRepository { get; }
|
||||||
private AppService Apps { get; }
|
private AppService Apps { get; }
|
||||||
@@ -60,7 +60,19 @@ public class ProductsReportProvider : ReportProvider
|
|||||||
{
|
{
|
||||||
values = values.ToList();
|
values = values.ToList();
|
||||||
values.Add(appId);
|
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(code);
|
||||||
values.Add(1);
|
values.Add(1);
|
||||||
@@ -68,21 +80,6 @@ public class ProductsReportProvider : ReportProvider
|
|||||||
values.Add(i.Currency);
|
values.Add(i.Currency);
|
||||||
queryContext.Data.Add(values);
|
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
|
// Round the currency amount
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="@StringLocalizer["Close"]">
|
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="@StringLocalizer["Close"]">
|
||||||
<vc:icon symbol="cross" />
|
<vc:icon symbol="cross" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div class="offcanvas-body py-0">
|
<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">
|
<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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<table class="table table-borderless mt-4 mb-0">
|
<table class="table table-borderless mt-4 mb-0">
|
||||||
<tr>
|
<tr v-if="itemsTotalNumeric">
|
||||||
<td class="align-middle" text-translate="true">Subtotal</td>
|
<td class="align-middle h6 border-0" text-translate="true">Items total</td>
|
||||||
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
|
<td class="align-middle h6 border-0 text-end" id="CartItemsTotal">{{ formatCurrency(itemsTotalNumeric, true) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="discountNumeric">
|
<tr v-if="discountNumeric">
|
||||||
<td class="align-middle" text-translate="true">Discount</td>
|
<td class="align-middle" text-translate="true">Discount</td>
|
||||||
<td class="align-middle text-end" id="CartDiscount">
|
<td class="align-middle text-end" id="CartDiscount">
|
||||||
<span v-if="discountPercent">{{discountPercent}}% =</span>
|
<span>{{ formatCurrency(discountNumeric, true) }}</span> <span v-if="discountPercent">({{discountPercent}}%)</span>
|
||||||
{{ formatCurrency(discountNumeric, true) }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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">
|
<tr v-if="tipNumeric">
|
||||||
<td class="align-middle" text-translate="true">Tip</td>
|
<td class="align-middle" text-translate="true">Tip</td>
|
||||||
<td class="align-middle text-end" id="CartTip">
|
<td class="align-middle text-end" id="CartTip">
|
||||||
<span v-if="tipPercent">{{tipPercent}}% =</span>
|
<span>{{ formatCurrency(tipNumeric, true) }}</span> <span v-if="tipPercent">({{tipPercent}}%)</span>
|
||||||
{{ formatCurrency(tipNumeric, true) }}
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<input type="hidden" name="posdata" :value="posdata">
|
<input type="hidden" name="posdata" :value="posdata">
|
||||||
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
|
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
|
||||||
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
|
<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 class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
|
<div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</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">
|
<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">
|
<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>
|
<label :for="`ModeTablist-${m.type}`">{{ m.title }}</label>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,6 +164,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<fieldset id="discounts" class="mt-2">
|
||||||
<legend class="h5 mb-3 fw-semibold" text-translate="true">Discounts</legend>
|
<legend class="h5 mb-3 fw-semibold" text-translate="true">Discounts</legend>
|
||||||
<div class="form-group d-flex align-items-center">
|
<div class="form-group d-flex align-items-center">
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
|
@using BTCPayServer.Services.Invoices
|
||||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
@model (Dictionary<string, object> Items, int Level)
|
@model (Dictionary<string, object> Items, int Level)
|
||||||
|
|
||||||
@if (Model.Items.Any())
|
@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>
|
<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);
|
_ = 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)
|
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
|
||||||
{
|
{
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -35,33 +30,48 @@
|
|||||||
}
|
}
|
||||||
</tbody>
|
</tbody>
|
||||||
}
|
}
|
||||||
|
var posData = WellKnownPosData.TryParse(Model.Items) ?? new();
|
||||||
<tfoot style="border-top-width:0">
|
<tfoot style="border-top-width:0">
|
||||||
@if (hasSubtotal && (hasDiscount || hasTip))
|
@if (posData.ItemsTotal != null)
|
||||||
{
|
{
|
||||||
<tr style="border-top-width:3px">
|
<tr style="border-top-width:3px">
|
||||||
<th>Subtotal</th>
|
<th text-translate="true">Items total</th>
|
||||||
<td class="text-end">@subtotal</td>
|
<td class="text-end">@posData.ItemsTotal</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@if (hasDiscount)
|
@if (posData.Discount != null)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<th>Discount</th>
|
<th text-translate="true">Discount</th>
|
||||||
<td class="text-end">@discount</td>
|
<td class="text-end">@posData.Discount</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@if (hasTip)
|
@if (posData.Subtotal != null)
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<th>Tip</th>
|
|
||||||
<td class="text-end">@tip</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
@if (hasTotal)
|
|
||||||
{
|
{
|
||||||
<tr style="border-top-width:3px">
|
<tr style="border-top-width:3px">
|
||||||
<th>Total</th>
|
<th text-translate="true">Subtotal</th>
|
||||||
<td class="text-end">@total</td>
|
<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>
|
</tr>
|
||||||
}
|
}
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
@@ -50,6 +50,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-danger" v-if="errors.price">{{errors.price}}</div>
|
<div class="text-danger" v-if="errors.price">{{errors.price}}</div>
|
||||||
</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">
|
<div class="form-group">
|
||||||
<label for="EditorImageUrl" class="form-label" text-translate="true">Image Url</label>
|
<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" />
|
<input id="EditorImageUrl" class="form-control mb-2" v-model="editingItem && editingItem.image" ref="txtImage" />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@using BTCPayServer.Abstractions.Contracts
|
@using BTCPayServer.Abstractions.Contracts
|
||||||
@inject LanguageService LangService
|
@inject LanguageService LangService
|
||||||
@inject BTCPayServerEnvironment Env
|
@inject BTCPayServerEnvironment Env
|
||||||
@inject IEnumerable<IUIExtension> UiExtensions
|
@inject IEnumerable<IUIExtension> UiExtensions
|
||||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||||
@model CheckoutModel
|
@model CheckoutModel
|
||||||
@{
|
@{
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</noscript>
|
</noscript>
|
||||||
<script type="text/x-template" id="payment-details">
|
<script type="text/x-template" id="payment-details">
|
||||||
<dl>
|
<dl>
|
||||||
<div v-if="orderAmount > 0" id="PaymentDetails-TotalPrice" key="TotalPrice">
|
<div v-if="orderAmount > 0" id="PaymentDetails-TotalPrice" key="TotalPrice">
|
||||||
<dt v-t="'total_price'"></dt>
|
<dt v-t="'total_price'"></dt>
|
||||||
@@ -256,6 +256,10 @@
|
|||||||
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
|
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
|
||||||
<dt v-t="'total_fiat'"></dt>
|
<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>
|
<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>
|
||||||
<div v-if="srvModel.rate && srvModel.paymentMethodCurrency" id="PaymentDetails-ExchangeRate" key="ExchangeRate">
|
<div v-if="srvModel.rate && srvModel.paymentMethodCurrency" id="PaymentDetails-ExchangeRate" key="ExchangeRate">
|
||||||
<dt v-t="'exchange_rate'"></dt>
|
<dt v-t="'exchange_rate'"></dt>
|
||||||
|
|||||||
@@ -70,6 +70,13 @@
|
|||||||
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
|
<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>
|
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
|
||||||
</div>
|
</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">
|
<div class="d-flex flex-column">
|
||||||
<dd class="text-muted mb-0 fw-semibold">Date</dd>
|
<dd class="text-muted mb-0 fw-semibold">Date</dd>
|
||||||
<dt class="fs-5 mb-0 text-nowrap fw-semibold">@Model.Timestamp.ToBrowserDate()</dt>
|
<dt class="fs-5 mb-0 text-nowrap fw-semibold">@Model.Timestamp.ToBrowserDate()</dt>
|
||||||
@@ -86,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (isProcessing)
|
@if (isProcessing)
|
||||||
{
|
{
|
||||||
<small class="d-block text-muted text-center px-4">This page will refresh periodically until the invoice is settled.</small>
|
<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)
|
@if (hasCart)
|
||||||
{
|
{
|
||||||
_ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart);
|
_ = 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 posData = WellKnownPosData.TryParse(Model.CartData) ?? new();
|
||||||
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);
|
|
||||||
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
|
if (cart is Dictionary<string, object> { Keys.Count: > 0 } cartDict)
|
||||||
{
|
{
|
||||||
@foreach (var (key, value) in cartDict)
|
@foreach (var (key, value) in cartDict)
|
||||||
@@ -130,8 +127,8 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
|
else if (cart is ICollection<object> { Count: > 0 } cartCollection)
|
||||||
{
|
{
|
||||||
@foreach (var value in cartCollection)
|
@foreach (var value in cartCollection)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
@@ -139,38 +136,53 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hasSubtotal && (hasDiscount || hasTip))
|
|
||||||
|
@if (posData.ItemsTotal != null)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="sums-data">
|
<tr class="sums-data">
|
||||||
<td class="key text-secondary">Subtotal</td>
|
<td class="key text-secondary">Items total</td>
|
||||||
<td class="val text-end">@subtotal</td>
|
<td class="val text-end">@posData.ItemsTotal</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
if (hasDiscount)
|
@if (posData.Discount != null)
|
||||||
{
|
{
|
||||||
<tr class="sums-data">
|
<tr class="sums-data">
|
||||||
<td class="key text-secondary">Discount</td>
|
<td class="key text-secondary">Discount</td>
|
||||||
<td class="val text-end">@discount</td>
|
<td class="val text-end">@posData.Discount</td>
|
||||||
</tr>
|
</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">
|
<tr class="sums-data">
|
||||||
<td class="key text-secondary">Tip</td>
|
<td class="key text-secondary">Tip</td>
|
||||||
<td class="val text-end">@tip</td>
|
<td class="val text-end">@posData.Tip</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
if (hasTotal)
|
@if (posData.Total != null)
|
||||||
{
|
{
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><hr class="w-100 my-0"/></td>
|
<td colspan="2"><hr class="w-100 my-0"/></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="sums-data">
|
<tr class="sums-data">
|
||||||
<td class="key text-secondary">Total</td>
|
<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>
|
</tr>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ function initApp() {
|
|||||||
await this.setupNFC();
|
await this.setupNFC();
|
||||||
}
|
}
|
||||||
updateLanguageSelect();
|
updateLanguageSelect();
|
||||||
|
|
||||||
window.parent.postMessage('loaded', '*');
|
window.parent.postMessage('loaded', '*');
|
||||||
},
|
},
|
||||||
beforeDestroy () {
|
beforeDestroy () {
|
||||||
@@ -329,7 +329,7 @@ function initApp() {
|
|||||||
},
|
},
|
||||||
async fetchData () {
|
async fetchData () {
|
||||||
if (this.isPluginPaymentMethod) return;
|
if (this.isPluginPaymentMethod) return;
|
||||||
|
|
||||||
const url = `${statusUrl}&paymentMethodId=${this.pmId}`;
|
const url = `${statusUrl}&paymentMethodId=${this.pmId}`;
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -346,7 +346,7 @@ function initApp() {
|
|||||||
const newEnd = new Date();
|
const newEnd = new Date();
|
||||||
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
|
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
|
||||||
this.endDate = newEnd;
|
this.endDate = newEnd;
|
||||||
|
|
||||||
// updating ui
|
// updating ui
|
||||||
this.srvModel = data;
|
this.srvModel = data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"invoice_id": "Invoice ID",
|
"invoice_id": "Invoice ID",
|
||||||
"order_id": "Order ID",
|
"order_id": "Order ID",
|
||||||
"total_price": "Total Price",
|
"total_price": "Total Price",
|
||||||
|
"tax_included": "Total Taxes",
|
||||||
"total_fiat": "Total Fiat",
|
"total_fiat": "Total Fiat",
|
||||||
"exchange_rate": "Exchange Rate",
|
"exchange_rate": "Exchange Rate",
|
||||||
"amount_paid": "Amount Paid",
|
"amount_paid": "Amount Paid",
|
||||||
@@ -42,4 +43,4 @@
|
|||||||
"copy_confirm": "Copied",
|
"copy_confirm": "Copied",
|
||||||
"powered_by": "Powered by",
|
"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."
|
"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';
|
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) {
|
function storageKey(name) {
|
||||||
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
|
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
|
||||||
}
|
}
|
||||||
@@ -39,7 +131,7 @@ const posCommon = {
|
|||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
...srvModel,
|
...srvModel,
|
||||||
amount: null,
|
posOrder: new PoSOrder(srvModel.currencyInfo.divisibility),
|
||||||
tip: null,
|
tip: null,
|
||||||
tipPercent: null,
|
tipPercent: null,
|
||||||
discount: null,
|
discount: null,
|
||||||
@@ -56,20 +148,30 @@ const posCommon = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
amountNumeric () {
|
summary() {
|
||||||
const { divisibility } = this.currencyInfo
|
return this.posOrder.calculate();
|
||||||
const cart = this.cart.reduce((res, item) => res + (item.price || 0) * item.count, 0).toFixed(divisibility)
|
},
|
||||||
const value = parseFloat(this.amount || 0) + parseFloat(cart)
|
itemsTotalNumeric() {
|
||||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(divisibility))
|
// 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 () {
|
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
|
const amounts = this.amounts.filter(e => e) // clear empty or zero values
|
||||||
if (amounts) data.amounts = amounts.map(parseFloat)
|
if (amounts) data.amounts = amounts.map(parseFloat)
|
||||||
if (this.cart) data.cart = this.cart
|
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.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
|
if (this.tipPercent > 0) data.tipPercentage = this.tipPercent
|
||||||
return JSON.stringify(data)
|
return JSON.stringify(data)
|
||||||
},
|
},
|
||||||
@@ -78,29 +180,13 @@ const posCommon = {
|
|||||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||||
},
|
},
|
||||||
discountNumeric () {
|
discountNumeric () {
|
||||||
return this.amountNumeric && this.discountPercentNumeric
|
return this.summary.discount;
|
||||||
? parseFloat((this.amountNumeric * (this.discountPercentNumeric / 100)).toFixed(this.currencyInfo.divisibility))
|
|
||||||
: 0.0;
|
|
||||||
},
|
|
||||||
amountMinusDiscountNumeric () {
|
|
||||||
return parseFloat((this.amountNumeric - this.discountNumeric).toFixed(this.currencyInfo.divisibility))
|
|
||||||
},
|
},
|
||||||
tipNumeric () {
|
tipNumeric () {
|
||||||
if (this.tipPercent) {
|
return this.summary.tip;
|
||||||
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
|
|
||||||
},
|
},
|
||||||
totalNumeric () {
|
totalNumeric () {
|
||||||
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
|
return this.summary.priceTaxIncludedWithTips;
|
||||||
},
|
},
|
||||||
cartCount() {
|
cartCount() {
|
||||||
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
|
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 < 0) this.discountPercent = '0'
|
||||||
else if (value > 100) this.discountPercent = '100'
|
else if (value > 100) this.discountPercent = '100'
|
||||||
else this.discountPercent = value.toString()
|
else this.discountPercent = value.toString()
|
||||||
|
this.posOrder.addDiscountRate(isNaN(value) ? null : value)
|
||||||
},
|
},
|
||||||
tip(val) {
|
tip(val) {
|
||||||
this.tipPercent = null
|
this.tipPercent = null
|
||||||
|
this.posOrder.setTip(val)
|
||||||
},
|
},
|
||||||
cart: {
|
cart: {
|
||||||
handler(newCart) {
|
handler(newCart) {
|
||||||
@@ -132,11 +220,12 @@ const posCommon = {
|
|||||||
if (this.persistState) {
|
if (this.persistState) {
|
||||||
saveState('cart', newCart)
|
saveState('cart', newCart)
|
||||||
}
|
}
|
||||||
|
this.posOrder.setCart(newCart, this.amounts, this.defaultTaxRate)
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true
|
||||||
},
|
},
|
||||||
amounts (values) {
|
amounts (values) {
|
||||||
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
|
this.posOrder.setCart(this.cart, values, this.defaultTaxRate)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -155,6 +244,7 @@ const posCommon = {
|
|||||||
this.tipPercent = this.tipPercent !== percentage
|
this.tipPercent = this.tipPercent !== percentage
|
||||||
? percentage
|
? percentage
|
||||||
: null;
|
: null;
|
||||||
|
this.posOrder.setTipPercent(this.tipPercent)
|
||||||
},
|
},
|
||||||
formatCrypto(value, withSymbol) {
|
formatCrypto(value, withSymbol) {
|
||||||
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
|
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
|
||||||
@@ -207,10 +297,7 @@ const posCommon = {
|
|||||||
// Add new item because it doesn't exist yet
|
// Add new item because it doesn't exist yet
|
||||||
if (!itemInCart) {
|
if (!itemInCart) {
|
||||||
itemInCart = {
|
itemInCart = {
|
||||||
id: item.id,
|
...item,
|
||||||
title: item.title,
|
|
||||||
price: item.price,
|
|
||||||
inventory: item.inventory,
|
|
||||||
count
|
count
|
||||||
}
|
}
|
||||||
this.cart.push(itemInCart);
|
this.cart.push(itemInCart);
|
||||||
@@ -221,11 +308,13 @@ const posCommon = {
|
|||||||
// Animate
|
// Animate
|
||||||
if (!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
|
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;
|
return itemInCart;
|
||||||
},
|
},
|
||||||
removeFromCart(id) {
|
removeFromCart(id) {
|
||||||
const index = this.cart.findIndex(lineItem => lineItem.id === id);
|
const index = this.cart.findIndex(lineItem => lineItem.id === id);
|
||||||
this.cart.splice(index, 1);
|
this.cart.splice(index, 1);
|
||||||
|
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||||
},
|
},
|
||||||
getQuantity(id) {
|
getQuantity(id) {
|
||||||
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
|
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
|
||||||
@@ -245,6 +334,7 @@ const posCommon = {
|
|||||||
if (itemInCart && itemInCart.count <= 0 && addOrRemove) {
|
if (itemInCart && itemInCart.count <= 0 && addOrRemove) {
|
||||||
this.removeFromCart(itemInCart.id);
|
this.removeFromCart(itemInCart.id);
|
||||||
}
|
}
|
||||||
|
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
this.cart = [];
|
this.cart = [];
|
||||||
@@ -305,6 +395,7 @@ const posCommon = {
|
|||||||
if (this.persistState) {
|
if (this.persistState) {
|
||||||
this.cart = loadState('cart');
|
this.cart = loadState('cart');
|
||||||
}
|
}
|
||||||
|
this.posOrder.setCart(this.cart, this.amounts, this.defaultTaxRate);
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
if (this.$refs.categories) {
|
if (this.$refs.categories) {
|
||||||
@@ -328,7 +419,7 @@ const posCommon = {
|
|||||||
});
|
});
|
||||||
adjustCategories();
|
adjustCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.forEachItem(item => {
|
this.forEachItem(item => {
|
||||||
item.addEventListener('transitionend', () => {
|
item.addEventListener('transitionend', () => {
|
||||||
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
|
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
|
||||||
@@ -336,7 +427,7 @@ const posCommon = {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.$refs.RecentTransactions) {
|
if (this.$refs.RecentTransactions) {
|
||||||
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
|
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
|
||||||
}
|
}
|
||||||
@@ -347,7 +438,7 @@ const posCommon = {
|
|||||||
localStorage.removeItem(storageKey('cart'));
|
localStorage.removeItem(storageKey('cart'));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
this.updateDisplay()
|
this.updateDisplay()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,15 +30,17 @@ document.addEventListener("DOMContentLoaded",function () {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
calculation () {
|
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 = ''
|
let calc = ''
|
||||||
const hasAmounts = this.amounts.length && this.amounts.reduce((sum, amt) => sum + parseFloat(amt || 0), 0) > 0;
|
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) 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 (this.cart.length && hasAmounts) calc += ' + '
|
||||||
if (hasAmounts) calc += this.amounts.map(amt => this.formatCurrency(amt || 0, true)).join(' + ')
|
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.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.tipPercent) calc += ` (${this.tipPercent}%)`
|
||||||
|
if (this.summary.tax) calc += ` + ${this.formatCurrency(this.summary.tax, true)}`
|
||||||
|
if (this.defaultTaxRate) calc += ` (${this.defaultTaxRate}%)`
|
||||||
return calc
|
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/=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/=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/=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/=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/=SSH/@EntryIndexedValue">SSH</s:String>
|
||||||
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=TX/@EntryIndexedValue">TX</s:String>
|
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=TX/@EntryIndexedValue">TX</s:String>
|
||||||
|
|||||||
Reference in New Issue
Block a user