diff --git a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs index 20fe02bbe..414e7a86a 100644 --- a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs +++ b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs @@ -663,7 +663,7 @@ donation: Assert.Equal(3, vmview.Items.Length); Assert.Equal("good apple", vmview.Items[0].Title); Assert.Equal("orange", vmview.Items[1].Title); - Assert.Equal(10.0m, vmview.Items[1].Price.Value); + Assert.Equal(10.0m, vmview.Items[1].Price); Assert.Equal("{0} Purchase", vmview.ButtonText); Assert.Equal("Nicolas Sexy Hair", vmview.CustomButtonText); Assert.Equal("Wanna tip?", vmview.CustomTipText); @@ -680,7 +680,7 @@ donation: Assert.IsType(publicApps .ViewPointOfSale(app.Id, PosViewType.Cart, 0, choiceKey: "apple").Result); - invoices = user.BitPay.GetInvoices(); + invoices = await user.BitPay.GetInvoicesAsync(); var appleInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("apple")); Assert.NotNull(appleInvoice); Assert.Equal("good apple", appleInvoice.ItemDesc); @@ -689,7 +689,7 @@ donation: var action = Assert.IsType(publicApps .ViewPointOfSale(app.Id, PosViewType.Cart, 6.6m, choiceKey: "donation").Result); Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName); - invoices = user.BitPay.GetInvoices(); + invoices = await user.BitPay.GetInvoicesAsync(); var donationInvoice = invoices.Single(i => i.Price == 6.6m); Assert.NotNull(donationInvoice); Assert.Equal("CAD", donationInvoice.Currency); diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 3aba6ac15..1d16dca03 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -2064,7 +2064,6 @@ namespace BTCPayServer.Tests using var s = CreateSeleniumTester(); s.Server.ActivateLightning(); await s.StartAsync(); - await s.Server.EnsureChannelsSetup(); s.RegisterNewUser(true); @@ -2101,7 +2100,6 @@ namespace BTCPayServer.Tests using var s = CreateSeleniumTester(); s.Server.ActivateLightning(); await s.StartAsync(); - await s.Server.EnsureChannelsSetup(); s.RegisterNewUser(true); @@ -2176,7 +2174,6 @@ namespace BTCPayServer.Tests using var s = CreateSeleniumTester(); s.Server.ActivateLightning(); await s.StartAsync(); - await s.Server.EnsureChannelsSetup(); s.RegisterNewUser(true); @@ -2199,6 +2196,7 @@ namespace BTCPayServer.Tests 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(); @@ -2207,34 +2205,81 @@ namespace BTCPayServer.Tests Thread.Sleep(250); Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr"))); - // Select items - s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click(); - Thread.Sleep(250); + // 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% = 0,30 €", s.Driver.FindElement(By.Id("CartDiscount")).Text); - Assert.Equal("2,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text); + 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,27 €", s.Driver.FindElement(By.Id("CartTip")).Text); - Assert.Equal("2,97 €", s.Driver.FindElement(By.Id("CartTotal")).Text); + Assert.Contains("10% = 0,90 €", s.Driver.FindElement(By.Id("CartTip")).Text); + Assert.Equal("9,90 €", s.Driver.FindElement(By.Id("CartTotal")).Text); - // Pay + // Check values on checkout page s.Driver.FindElement(By.Id("CartSubmit")).Click(); s.Driver.WaitUntilAvailable(By.Id("Checkout-v2")); s.Driver.FindElement(By.Id("DetailsToggle")).Click(); s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat")); - Assert.Contains("2,97 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text); + Assert.Contains("9,90 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text); + + // Pay + s.PayInvoice(); + + // 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); } [Fact] diff --git a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs index b212c3b73..fc22734f0 100644 --- a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs +++ b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -50,45 +51,47 @@ namespace BTCPayServer.HostedServices } }).Where(tuple => tuple.Data != null && tuple.Items.Any(item => item.Inventory.HasValue && - updateAppInventory.Items.ContainsKey(item.Id))); - foreach (var valueTuple in apps) + updateAppInventory.Items.FirstOrDefault(i => i.Id == item.Id) != null)); + foreach (var app in apps) { - foreach (var item1 in valueTuple.Items.Where(item => - updateAppInventory.Items.ContainsKey(item.Id))) + foreach (var cartItem in updateAppInventory.Items) { + var item = app.Items.FirstOrDefault(item => item.Id == cartItem.Id); + if (item == null) continue; + if (updateAppInventory.Deduct) { - item1.Inventory -= updateAppInventory.Items[item1.Id]; + item.Inventory -= cartItem.Count; } else { - item1.Inventory += updateAppInventory.Items[item1.Id]; + item.Inventory += cartItem.Count; } } - switch (valueTuple.Data.AppType) + switch (app.Data.AppType) { case PointOfSaleAppType.AppType: - ((PointOfSaleSettings)valueTuple.Settings).Template = - AppService.SerializeTemplate(valueTuple.Items); + ((PointOfSaleSettings)app.Settings).Template = + AppService.SerializeTemplate(app.Items); break; case CrowdfundAppType.AppType: - ((CrowdfundSettings)valueTuple.Settings).PerksTemplate = - AppService.SerializeTemplate(valueTuple.Items); + ((CrowdfundSettings)app.Settings).PerksTemplate = + AppService.SerializeTemplate(app.Items); break; default: throw new InvalidOperationException(); } - valueTuple.Data.SetSettings(valueTuple.Settings); - await _appService.UpdateOrCreateApp(valueTuple.Data); + app.Data.SetSettings(app.Settings); + await _appService.UpdateOrCreateApp(app.Data); } } else if (evt is InvoiceEvent invoiceEvent) { - Dictionary cartItems = null; + List cartItems = null; bool deduct; switch (invoiceEvent.Name) { @@ -104,8 +107,8 @@ namespace BTCPayServer.HostedServices return; } - if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) || - AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems))) + if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode) || + AppService.TryParsePosCartItems(invoiceEvent.Invoice.Metadata.PosData, out cartItems)) { var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice); @@ -114,13 +117,18 @@ namespace BTCPayServer.HostedServices return; } - var items = cartItems ?? new Dictionary(); + var items = cartItems?.ToList() ?? new List(); if (!string.IsNullOrEmpty(invoiceEvent.Invoice.Metadata.ItemCode)) { - items.TryAdd(invoiceEvent.Invoice.Metadata.ItemCode, 1); + items.Add(new PosCartItem + { + Id = invoiceEvent.Invoice.Metadata.ItemCode, + Count = 1, + Price = invoiceEvent.Invoice.Price + }); } - _eventAggregator.Publish(new UpdateAppInventory() + _eventAggregator.Publish(new UpdateAppInventory { Deduct = deduct, Items = items, @@ -134,7 +142,7 @@ namespace BTCPayServer.HostedServices public class UpdateAppInventory { public string[] AppId { get; set; } - public Dictionary Items { get; set; } + public List Items { get; set; } public bool Deduct { get; set; } public override string ToString() diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index 772cc5f5b..2eb09384a 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -171,7 +171,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers decimal? price; Dictionary paymentMethods = null; ViewPointOfSaleViewModel.Item choice = null; - Dictionary cartItems = null; + List cartItems = null; ViewPointOfSaleViewModel.Item[] choices = null; if (!string.IsNullOrEmpty(choiceKey)) { @@ -208,16 +208,15 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers return NotFound(); title = settings.Title; - //if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items + // if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items price = amount; - if (currentView == PosViewType.Cart && - AppService.TryParsePosCartItems(jposData, out cartItems)) + if (currentView == PosViewType.Cart && AppService.TryParsePosCartItems(jposData, out cartItems)) { price = 0.0m; choices = AppService.Parse(settings.Template, false); foreach (var cartItem in cartItems) { - var itemChoice = choices.FirstOrDefault(c => c.Id == cartItem.Key); + var itemChoice = choices.FirstOrDefault(item => item.Id == cartItem.Id); if (itemChoice == null) return NotFound(); @@ -225,20 +224,21 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers { switch (itemChoice.Inventory) { - case int i when i <= 0: + case <= 0: return RedirectToAction(nameof(ViewPointOfSale), new { appId }); - case int inventory when inventory < cartItem.Value: + case { } inventory when inventory < cartItem.Count: return RedirectToAction(nameof(ViewPointOfSale), new { appId }); } } - decimal expectedCartItemPrice = 0; - if (itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup) - { - expectedCartItemPrice = itemChoice.Price ?? 0; - } + var expectedCartItemPrice = itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup + ? itemChoice.Price ?? 0 + : 0; + + if (cartItem.Price < expectedCartItemPrice) + cartItem.Price = expectedCartItemPrice; - price += expectedCartItemPrice * cartItem.Value; + price += cartItem.Price * cartItem.Count; } if (customAmount is { } c) price += c; @@ -315,7 +315,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers { Amount = price, Currency = settings.Currency, - Metadata = new InvoiceMetadata() + Metadata = new InvoiceMetadata { ItemCode = choice?.Id, ItemDesc = title, @@ -358,17 +358,19 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers receiptData = new JObject(); if (cartItems is not null && choices is not null) { - var selectedChoices = choices.Where(item => cartItems.Keys.Contains(item.Id)) + 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 (KeyValuePair cartItem in cartItems) + foreach (PosCartItem cartItem in posCartItems) { - if (selectedChoices.TryGetValue(cartItem.Key, out var selectedChoice)) - { - cartData.Add(selectedChoice.Title ?? selectedChoice.Id, - $"{(selectedChoice.Price is null ? "Any price" : $"{_displayFormatter.Currency((decimal)selectedChoice.Price.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")} x {cartItem.Value} = {(selectedChoice.Price is null ? "Any price" : $"{_displayFormatter.Currency(((decimal)selectedChoice.Price.Value) * cartItem.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")}"); - - } + 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 == ViewPointOfSaleViewModel.ItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})"; + cartData.Add(key, $"{singlePrice} x {cartItem.Count} = {totalPrice}"); } receiptData.Add("Cart", cartData); } @@ -621,7 +623,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers return View("PointOfSale/UpdatePointOfSale", vm); } - var storeBlob = GetCurrentStore().GetStoreBlob(); var settings = new PointOfSaleSettings { Title = vm.Title, @@ -640,11 +641,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers RedirectUrl = vm.RedirectUrl, Description = vm.Description, EmbeddedCSS = vm.EmbeddedCSS, - RedirectAutomatically = - string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically) + RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically), + FormId = vm.FormId }; - settings.FormId = vm.FormId; app.Name = vm.AppName; app.SetSettings(settings); await _appService.UpdateOrCreateApp(app); diff --git a/BTCPayServer/Services/Apps/AppService.cs b/BTCPayServer/Services/Apps/AppService.cs index e900ebb51..ab6f8a691 100644 --- a/BTCPayServer/Services/Apps/AppService.cs +++ b/BTCPayServer/Services/Apps/AppService.cs @@ -411,7 +411,6 @@ namespace BTCPayServer.Services.Apps return false; if (cartObject is null) return false; - cartItems = new(); foreach (var o in cartObject.OfType()) { @@ -427,6 +426,29 @@ namespace BTCPayServer.Services.Apps } return true; } + + public static bool TryParsePosCartItems(JObject? posData, [MaybeNullWhen(false)] out List cartItems) + { + cartItems = null; + if (posData is null) + return false; + if (!posData.TryGetValue("cart", out var cartObject)) + return false; + + cartItems = new List(); + foreach (var o in cartObject.OfType()) + { + var id = o.GetValue("id", StringComparison.InvariantCulture)?.ToString(); + if (id == null) continue; + var countStr = o.GetValue("count", StringComparison.InvariantCulture)?.ToString() ?? string.Empty; + var price = o.GetValue("price")?.Value() ?? 0m; + if (int.TryParse(countStr, out var count)) + { + cartItems.Add(new PosCartItem { Id = id, Count = count, Price = price }); + } + } + return true; + } public async Task SetDefaultSettings(AppData appData, string defaultCurrency) { @@ -449,6 +471,13 @@ namespace BTCPayServer.Services.Apps #nullable restore } + public class PosCartItem + { + public string Id { get; set; } + public int Count { get; set; } + public decimal Price { get; set; } + } + public class ItemStats { public string ItemCode { get; set; } diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml index 3e6b7e0b8..68e3294c2 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml @@ -83,7 +83,7 @@ } @if (item.Inventory.HasValue) { - + @(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out") } @@ -95,11 +95,18 @@ @if (inStock) { - +
} @@ -141,7 +148,7 @@
- + {{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
diff --git a/BTCPayServer/wwwroot/pos/cart.js b/BTCPayServer/wwwroot/pos/cart.js index ce1477e7a..aecd020b7 100644 --- a/BTCPayServer/wwwroot/pos/cart.js +++ b/BTCPayServer/wwwroot/pos/cart.js @@ -121,7 +121,17 @@ document.addEventListener("DOMContentLoaded",function () { if (!this.inStock(index)) return false; const item = this.items[index]; - let itemInCart = this.cart.find(lineItem => lineItem.id === item.id); + const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index]; + + // Check if price is needed + const isFixedPrice = item.priceType.toLowerCase() === 'fixed'; + if (!isFixedPrice) { + const $amount = $posItem.querySelector('input[name="amount"]'); + if (!$amount.reportValidity()) return false; + item.price = parseFloat($amount.value); + } + + let itemInCart = this.cart.find(lineItem => lineItem.id === item.id && lineItem.price === item.price); // Add new item because it doesn't exist yet if (!itemInCart) { @@ -138,7 +148,6 @@ document.addEventListener("DOMContentLoaded",function () { itemInCart.count += 1; // Animate - const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index]; if(!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS); return true;