POS: Handle flexible price items in cart view (#5238)

This commit is contained in:
d11n 2023-08-09 10:31:19 +03:00 committed by GitHub
parent 19d360a543
commit d67ebd957e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 166 additions and 68 deletions

View file

@ -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<RedirectToActionResult>(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<RedirectToActionResult>(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);

View file

@ -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]

View file

@ -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<string, int> cartItems = null;
List<PosCartItem> 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<string, int>();
var items = cartItems?.ToList() ?? new List<PosCartItem>();
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<string, int> Items { get; set; }
public List<PosCartItem> Items { get; set; }
public bool Deduct { get; set; }
public override string ToString()

View file

@ -171,7 +171,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
decimal? price;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null;
Dictionary<string, int> cartItems = null;
List<PosCartItem> 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<string, int> 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);

View file

@ -411,7 +411,6 @@ namespace BTCPayServer.Services.Apps
return false;
if (cartObject is null)
return false;
cartItems = new();
foreach (var o in cartObject.OfType<JObject>())
{
@ -427,6 +426,29 @@ namespace BTCPayServer.Services.Apps
}
return true;
}
public static bool TryParsePosCartItems(JObject? posData, [MaybeNullWhen(false)] out List<PosCartItem> cartItems)
{
cartItems = null;
if (posData is null)
return false;
if (!posData.TryGetValue("cart", out var cartObject))
return false;
cartItems = new List<PosCartItem>();
foreach (var o in cartObject.OfType<JObject>())
{
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<decimal>() ?? 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; }

View file

@ -83,7 +83,7 @@
}
@if (item.Inventory.HasValue)
{
<span class="badge text-bg-warning" v-text="inventoryText(@index)">
<span class="badge text-bg-warning inventory" v-text="inventoryText(@index)">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
@ -95,11 +95,18 @@
</div>
@if (inStock)
{
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<form class="card-footer bg-transparent border-0 pt-0 pb-3">
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed)
{
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input class="form-control" type="number" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@item.Price" required v-on:click.stop>
</div>
}
<button type="button" class="btn btn-primary w-100" :disabled="!inStock(@index)">
@Safe.RawEncode(buttonText)
</button>
</div>
</form>
<div class="posItem-added"><vc:icon symbol="checkmark" /></div>
}
</div>
@ -141,7 +148,7 @@
</td>
<td class="align-middle">
<div class="d-flex align-items-center gap-2 justify-content-end quantity">
<span class="badge text-bg-warning" v-if="item.inventory">
<span class="badge text-bg-warning inventory" v-if="item.inventory">
{{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
</span>
<div class="d-flex align-items-center gap-2">

View file

@ -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;