Refactoring: Move AppItem to Client lib and use the class for item list (#6258)

* Refactoring: Move AppItem to Client lib and use the class for item list

This makes it available for the app, which would otherwise have to replicate the model. Also uses the proper class for the item/perk list of the app models.

* Remove unused app item payment methods property

* Do not ignore nullable values in JSON

* Revert to use Newtonsoft types
This commit is contained in:
d11n 2024-11-05 03:49:30 +01:00 committed by GitHub
parent 225264a283
commit ff79a31066
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 219 additions and 245 deletions

View File

@ -0,0 +1,35 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public enum AppItemPriceType
{
Fixed,
Topup,
Minimum
}
public class AppItem
{
public string Id { get; set; }
public string Title { get; set; }
public bool Disabled { get; set; }
public string Description { get; set; }
public string[] Categories { get; set; }
public string Image { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public AppItemPriceType PriceType { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? Price { get; set; }
public string BuyButtonText { get; set; }
public int? Inventory { get; set; }
[JsonExtensionData]
public Dictionary<string, JToken> AdditionalData { get; set; }
}

View File

@ -37,7 +37,7 @@ public abstract class CrowdfundBaseData : AppBaseData
public class CrowdfundAppData : CrowdfundBaseData
{
public object? Perks { get; set; }
public AppItem[]? Perks { get; set; }
}
public class CrowdfundAppRequest : CrowdfundBaseData, IAppRequest

View File

@ -1,8 +1,6 @@
#nullable enable
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
@ -31,7 +29,7 @@ public abstract class PointOfSaleBaseData : AppBaseData
public class PointOfSaleAppData : PointOfSaleBaseData
{
public object? Items { get; set; }
public AppItem[]? Items { get; set; }
}
public class PointOfSaleAppRequest : PointOfSaleBaseData, IAppRequest

View File

@ -3,10 +3,9 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.Lightning;
using BTCPayServer.Models.AppViewModels;
@ -25,7 +24,7 @@ using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
using WalletSettingsViewModel = BTCPayServer.Models.StoreViewModels.WalletSettingsViewModel;
namespace BTCPayServer.Tests
@ -759,39 +758,6 @@ noninventoryitem:
AppService.Parse(vmpos.Template).Single(item => item.Id == "inventoryitem").Inventory);
}, 10000);
//test payment methods option
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "hello";
vmpos.Currency = "BTC";
vmpos.Template = @"
btconly:
price: 1.0
title: good apple
payment_methods:
- BTC
normal:
price: 1.0";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "btconly").Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "normal").Result);
invoices = user.BitPay.GetInvoices();
var normalInvoice = invoices.Single(invoice => invoice.ItemCode == "normal");
var btcOnlyInvoice = invoices.Single(invoice => invoice.ItemCode == "btconly");
Assert.Single(btcOnlyInvoice.CryptoInfo);
Assert.Equal("BTC",
btcOnlyInvoice.CryptoInfo.First().CryptoCode);
Assert.Equal("BTC-CHAIN",
btcOnlyInvoice.CryptoInfo.First().PaymentType);
Assert.Equal(2, normalInvoice.CryptoInfo.Length);
Assert.Contains(
normalInvoice.CryptoInfo,
s => "BTC-CHAIN" == s.PaymentType && new[] { "BTC", "LTC" }.Contains(
s.CryptoCode));
//test topup option
vmpos.Template = @"
a:
@ -821,13 +787,13 @@ g:
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.DoesNotContain("custom", vmpos.Template);
var items = AppService.Parse(vmpos.Template);
Assert.Contains(items, item => item.Id == "a" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "b" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "c" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "d" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "e" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "a" && item.PriceType == AppItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "b" && item.PriceType == AppItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "c" && item.PriceType == AppItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "d" && item.PriceType == AppItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "e" && item.PriceType == AppItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.PriceType == AppItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.PriceType == AppItemPriceType.Topup);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, choiceKey: "g").Result);

View File

@ -745,9 +745,9 @@ namespace BTCPayServer.Tests
await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient();
var item1 = new ViewPointOfSaleViewModel.Item { Id = "item1", Title = "Item 1", Price = 1, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item2 = new ViewPointOfSaleViewModel.Item { Id = "item2", Title = "Item 2", Price = 2, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item3 = new ViewPointOfSaleViewModel.Item { Id = "item3", Title = "Item 3", Price = 3, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item1 = new AppItem { Id = "item1", Title = "Item 1", Price = 1, PriceType = AppItemPriceType.Fixed };
var item2 = new AppItem { Id = "item2", Title = "Item 2", Price = 2, PriceType = AppItemPriceType.Fixed };
var item3 = new AppItem { Id = "item3", Title = "Item 3", Price = 3, PriceType = AppItemPriceType.Fixed };
var posItems = AppService.SerializeTemplate([item1, item2, item3]);
var posApp = await client.CreatePointOfSaleApp(user.StoreId, new PointOfSaleAppRequest { AppName = "test pos", Template = posItems, });
var crowdfundApp = await client.CreateCrowdfundApp(user.StoreId, new CrowdfundAppRequest { AppName = "test crowdfund" });

View File

@ -1,6 +1,7 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Hosting;
@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Mvc;
using Xunit;
using Xunit.Abstractions;
using static BTCPayServer.Tests.UnitTest1;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests
{
@ -76,9 +78,8 @@ fruit tea:
Assert.Null( parsedDefault[0].BuyButtonText);
Assert.Equal( "~/img/pos-sample/green-tea.jpg" ,parsedDefault[0].Image);
Assert.Equal( 1 ,parsedDefault[0].Price);
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Fixed ,parsedDefault[0].PriceType);
Assert.Equal( AppItemPriceType.Fixed ,parsedDefault[0].PriceType);
Assert.Null( parsedDefault[0].AdditionalData);
Assert.Null( parsedDefault[0].PaymentMethods);
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
@ -87,9 +88,8 @@ fruit tea:
Assert.Null( parsedDefault[4].BuyButtonText);
Assert.Equal( "~/img/pos-sample/herbal-tea.jpg" ,parsedDefault[4].Image);
Assert.Equal( 1.8m ,parsedDefault[4].Price);
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Minimum ,parsedDefault[4].PriceType);
Assert.Equal( AppItemPriceType.Minimum ,parsedDefault[4].PriceType);
Assert.Null( parsedDefault[4].AdditionalData);
Assert.Null( parsedDefault[4].PaymentMethods);
}
[Fact]

View File

@ -355,6 +355,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
var settings = appData.GetSettings<PointOfSaleSettings>();
Enum.TryParse<PosViewType>(settings.DefaultView.ToString(), true, out var defaultView);
var items = AppService.Parse(settings.Template);
return new PointOfSaleAppData
{
@ -382,16 +383,7 @@ namespace BTCPayServer.Controllers.Greenfield
RedirectUrl = settings.RedirectUrl,
Description = settings.Description,
RedirectAutomatically = settings.RedirectAutomatically,
Items = JsonConvert.DeserializeObject(
JsonConvert.SerializeObject(
AppService.Parse(settings.Template),
new JsonSerializerSettings
{
ContractResolver =
new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
}
)
)
Items = items
};
}
@ -420,6 +412,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
var settings = appData.GetSettings<CrowdfundSettings>();
Enum.TryParse<CrowdfundResetEvery>(settings.ResetEvery.ToString(), true, out var resetEvery);
var perks = AppService.Parse(settings.PerksTemplate);
return new CrowdfundAppData
{
@ -451,15 +444,7 @@ namespace BTCPayServer.Controllers.Greenfield
SortPerksByPopularity = settings.SortPerksByPopularity,
Sounds = settings.Sounds,
AnimationColors = settings.AnimationColors,
Perks = JsonConvert.DeserializeObject(
JsonConvert.SerializeObject(
AppService.Parse(settings.PerksTemplate),
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
}
)
)
Perks = perks
};
}

View File

@ -26,7 +26,6 @@ using BTCPayServer.Payouts;
using BTCPayServer.Plugins;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@ -290,7 +289,7 @@ namespace BTCPayServer
return NotFound();
}
ViewPointOfSaleViewModel.Item[] items;
AppItem[] items;
string currencyCode;
PointOfSaleSettings posS = null;
switch (app.AppType)
@ -310,7 +309,7 @@ namespace BTCPayServer
return NotFound();
}
ViewPointOfSaleViewModel.Item item = null;
AppItem item = null;
if (!string.IsNullOrEmpty(itemCode))
{
var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _);
@ -321,13 +320,8 @@ namespace BTCPayServer
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
item1.Id.Equals(escapedItemId, StringComparison.InvariantCultureIgnoreCase));
if (item is null ||
item.Inventory <= 0 ||
(item.PaymentMethods?.Any() is true &&
item.PaymentMethods?.Any(s => PaymentMethodId.Parse(s) == pmi) is false))
{
if (item is null || item.Inventory <= 0)
return NotFound();
}
}
else if (app.AppType == PointOfSaleAppType.AppType && posS?.ShowCustomAmount is not true)
{
@ -336,7 +330,7 @@ namespace BTCPayServer
var createInvoice = new CreateInvoiceRequest
{
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? null : item?.Price,
Amount = item?.PriceType == AppItemPriceType.Topup ? null : item?.Price,
Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions
{
@ -350,7 +344,7 @@ namespace BTCPayServer
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
};
var allowOverpay = item?.PriceType is not ViewPointOfSaleViewModel.ItemPriceType.Fixed;
var allowOverpay = item?.PriceType is not AppItemPriceType.Fixed;
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
if (item != null)
{

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
@ -12,11 +13,9 @@ using BTCPayServer.Fido2.Models;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Payouts;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@ -398,9 +397,10 @@ namespace BTCPayServer.Hosting
await ctx.SaveChangesAsync();
}
public static ViewPointOfSaleViewModel.Item[] ParsePOSYML(string yaml)
public static AppItem[] ParsePOSYML(string yaml)
{
var items = new List<ViewPointOfSaleViewModel.Item>();
var items = new List<AppItem>();
var stream = new YamlStream();
if (string.IsNullOrEmpty(yaml))
return items.ToArray();
@ -417,11 +417,11 @@ namespace BTCPayServer.Hosting
continue;
}
var currentItem = new ViewPointOfSaleViewModel.Item
var currentItem = new AppItem
{
Id = trimmedKey,
Title = trimmedKey,
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed
PriceType = AppItemPriceType.Fixed
};
var itemSpecs = (YamlMappingNode)posItem.Value;
foreach (var spec in itemSpecs)
@ -446,12 +446,6 @@ namespace BTCPayServer.Hosting
case "image":
currentItem.Image = scalarValue?.Value;
break;
case "payment_methods" when spec.Value is YamlSequenceNode pmSequenceNode:
currentItem.PaymentMethods = pmSequenceNode.Children
.Select(node => (node as YamlScalarNode)?.Value?.Trim())
.Where(node => !string.IsNullOrEmpty(node)).ToArray();
break;
case "price_type":
case "custom":
if (bool.TryParse(scalarValue?.Value, out var customBoolValue))
@ -459,15 +453,15 @@ namespace BTCPayServer.Hosting
if (customBoolValue)
{
currentItem.PriceType = currentItem.Price is null or 0
? ViewPointOfSaleViewModel.ItemPriceType.Topup
: ViewPointOfSaleViewModel.ItemPriceType.Minimum;
? AppItemPriceType.Topup
: AppItemPriceType.Minimum;
}
else
{
currentItem.PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed;
currentItem.PriceType = AppItemPriceType.Fixed;
}
}
else if (Enum.TryParse<ViewPointOfSaleViewModel.ItemPriceType>(scalarValue?.Value, true,
else if (Enum.TryParse<AppItemPriceType>(scalarValue?.Value, true,
out var customPriceType))
{
currentItem.PriceType = customPriceType;

View File

@ -17,7 +17,6 @@ using BTCPayServer.Forms;
using BTCPayServer.Forms.Models;
using BTCPayServer.Models;
using BTCPayServer.Plugins.Crowdfund.Models;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
@ -149,36 +148,28 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
decimal? price = request.Amount;
var title = settings.Title;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null;
if (!string.IsNullOrEmpty(request.ChoiceKey))
{
var choices = AppService.Parse(settings.PerksTemplate, false);
choice = choices?.FirstOrDefault(c => c.Id == request.ChoiceKey);
AppItem choice = choices.FirstOrDefault(c => c.Id == request.ChoiceKey);
if (choice == null)
return NotFound("Incorrect option provided");
title = choice.Title;
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
if (choice.PriceType == AppItemPriceType.Topup)
{
price = null;
}
else
{
price = choice.Price.Value;
if (choice.Price.HasValue)
price = choice.Price.Value;
if (request.Amount > price)
price = request.Amount;
}
if (choice.Inventory.HasValue)
if (choice.Inventory is <= 0)
{
if (choice.Inventory <= 0)
{
return NotFound("Option was out of stock");
}
}
if (choice?.PaymentMethods?.Any() is true)
{
paymentMethods = choice?.PaymentMethods.ToDictionary(s => s,
s => new InvoiceSupportedTransactionCurrency() { Enabled = true });
return NotFound("Option was out of stock");
}
}
else
@ -231,8 +222,6 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
}
}
if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price >
(info.TargetAmount - (info.Info.CurrentAmount + info.Info.CurrentPendingAmount))))
{

View File

@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Client.Models;
using BTCPayServer.Models;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Rates;
namespace BTCPayServer.Plugins.Crowdfund.Models
@ -26,7 +26,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Models
public CrowdfundInfo Info { get; set; }
public string Tagline { get; set; }
public StoreBrandingViewModel StoreBranding { get; set; }
public ViewPointOfSaleViewModel.Item[] Perks { get; set; }
public AppItem[] Perks { get; set; }
public bool SimpleDisplay { get; set; }
public bool DisqusEnabled { get; set; }
public bool SoundsEnabled { get; set; }

View File

@ -176,9 +176,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string title;
decimal? price;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null;
AppItem choice = null;
List<PosCartItem> cartItems = null;
ViewPointOfSaleViewModel.Item[] choices = null;
AppItem[] choices = null;
if (!string.IsNullOrEmpty(choiceKey))
{
choices = AppService.Parse(settings.Template, false);
@ -186,7 +186,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (choice == null)
return NotFound();
title = choice.Title;
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
if (choice.PriceType == AppItemPriceType.Topup)
{
price = null;
}
@ -201,12 +201,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{
return RedirectToAction(nameof(ViewPointOfSale), new { appId });
}
if (choice?.PaymentMethods?.Any() is true)
{
paymentMethods = choice?.PaymentMethods.ToDictionary(s => s,
s => new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
else
{
@ -239,7 +233,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
}
var expectedCartItemPrice = itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup
var expectedCartItemPrice = itemChoice.PriceType != AppItemPriceType.Topup
? itemChoice.Price ?? 0
: 0;
@ -373,7 +367,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
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})";
var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
}

View File

@ -2,52 +2,14 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.JsonConverters;
using BTCPayServer.Client.Models;
using BTCPayServer.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.PointOfSale.Models
{
public class ViewPointOfSaleViewModel
{
public enum ItemPriceType
{
Topup,
Minimum,
Fixed
}
public class Item
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Description { get; set; }
public string Id { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string[] Categories { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Image { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public ItemPriceType PriceType { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? Price { get; set; }
public string Title { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string BuyButtonText { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public int? Inventory { get; set; } = null;
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string[] PaymentMethods { get; set; }
public bool Disabled { get; set; } = false;
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}
public class CurrencyInfoData
{
public bool Prefixed { get; set; }
@ -70,8 +32,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public bool EnableTips { get; set; }
public string Step { get; set; }
public string Title { get; set; }
Item[] _Items;
public Item[] Items
AppItem[] _Items;
public AppItem[] Items
{
get
{

View File

@ -10,7 +10,6 @@ using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
@ -95,7 +94,7 @@ namespace BTCPayServer.Services.Apps
return await salesType.GetItemStats(appData, paidInvoices);
}
public static Task<AppSalesStats> GetSalesStatswithPOSItems(ViewPointOfSaleViewModel.Item[] items,
public static Task<AppSalesStats> GetSalesStatswithPOSItems(AppItem[] items,
InvoiceEntity[] paidInvoices, int numberOfDays)
{
var series = paidInvoices
@ -145,7 +144,7 @@ namespace BTCPayServer.Services.Apps
public DateTime Date { get; set; }
}
public static Func<List<InvoiceStatsItem>, InvoiceEntity, List<InvoiceStatsItem>> AggregateInvoiceEntitiesForStats(ViewPointOfSaleViewModel.Item[] items)
public static Func<List<InvoiceStatsItem>, InvoiceEntity, List<InvoiceStatsItem>> AggregateInvoiceEntitiesForStats(AppItem[] items)
{
return (res, e) =>
{
@ -344,14 +343,14 @@ namespace BTCPayServer.Services.Apps
return _storeRepository.FindStore(app.StoreDataId);
}
public static string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
public static string SerializeTemplate(AppItem[] items)
{
return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer);
}
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true, bool throws = false)
public static AppItem[] Parse(string template, bool includeDisabled = true, bool throws = false)
{
if (string.IsNullOrWhiteSpace(template)) return [];
var allItems = JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!;
var allItems = JsonConvert.DeserializeObject<AppItem[]>(template, _defaultSerializer)!;
// ensure all items have an id, which is also unique
var itemsWithoutId = allItems.Where(i => string.IsNullOrEmpty(i.Id)).ToList();
if (itemsWithoutId.Any() && throws) throw new ArgumentException($"Missing ID for item \"{itemsWithoutId.First().Title}\".");

View File

@ -1,4 +1,4 @@
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Client.Models;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Services.Apps
@ -8,71 +8,70 @@ namespace BTCPayServer.Services.Apps
public PointOfSaleSettings()
{
Title = "Tea shop";
Template = AppService.SerializeTemplate(new ViewPointOfSaleViewModel.Item[]
{
new()
Template = AppService.SerializeTemplate([
new AppItem
{
Id = "green-tea",
Title = "Green Tea",
Description =
"Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
Image = "~/img/pos-sample/green-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
PriceType = AppItemPriceType.Fixed,
Price = 1
},
new()
new AppItem
{
Id = "black-tea",
Title = "Black Tea",
Description =
"Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
Image = "~/img/pos-sample/black-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
PriceType = AppItemPriceType.Fixed,
Price = 1
},
new()
new AppItem
{
Id = "rooibos",
Title = "Rooibos (limited)",
Description =
"Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.",
Image = "~/img/pos-sample/rooibos.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
PriceType = AppItemPriceType.Fixed,
Price = 1.2m,
Inventory = 5,
},
new()
new AppItem
{
Id = "pu-erh",
Title = "Pu Erh (free)",
Description =
"This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.",
Image = "~/img/pos-sample/pu-erh.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
PriceType = AppItemPriceType.Fixed,
Price = 0
},
new()
new AppItem
{
Id = "herbal-tea",
Title = "Herbal Tea (minimum)",
Description =
"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!",
Image = "~/img/pos-sample/herbal-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Minimum,
PriceType = AppItemPriceType.Minimum,
Price = 1.8m,
Disabled = false
},
new()
new AppItem
{
Id = "fruit-tea",
Title = "Fruit Tea (any amount)",
Description =
"The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!",
Image = "~/img/pos-sample/fruit-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup,
PriceType = AppItemPriceType.Topup,
Disabled = false
}
});
]);
DefaultView = PosViewType.Static;
ShowCustomAmount = false;
ShowDiscount = false;

View File

@ -1,6 +1,5 @@
using System;
using System.Globalization;
using BTCPayServer.Plugins.PointOfSale.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

View File

@ -1,4 +1,5 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Client.Models
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.Crowdfund.Models.ContributeToCrowdfund
@{ var vm = Model.ViewCrowdfundViewModel; }
@ -32,16 +33,16 @@
<span>@item.Price.Value</span>
<span>@vm.TargetCurrency</span>
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
if (item.PriceType == AppItemPriceType.Minimum)
{
@Safe.Raw(StringLocalizer["or more"])
}
}
else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup )
else if (item.PriceType == AppItemPriceType.Topup)
{
@Safe.Raw(StringLocalizer["Any amount"])
}
else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)
else if (item.PriceType == AppItemPriceType.Fixed)
{
@Safe.Raw(StringLocalizer["Free"])
}

View File

@ -1,9 +1,9 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@using BTCPayServer.Client
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client.Models
@inject DisplayFormatter DisplayFormatter
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@ -21,12 +21,12 @@
<script src="~/pos/cart.js" asp-append-version="true"></script>
}
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
private string GetItemPriceFormatted(AppItem item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.PriceType == AppItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
return item.PriceType == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
@ -73,7 +73,7 @@
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
? item.PriceType == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
var categories = new JArray(item.Categories ?? new object[] { });
@ -86,7 +86,7 @@
<div class="card-body d-flex flex-column gap-2 mb-auto">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
@if (item.PriceType == AppItemPriceType.Topup || item.Price == 0)
{
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}
@ -116,7 +116,7 @@
@if (inStock)
{
<form class="card-footer">
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed)
@if (item.PriceType != AppItemPriceType.Fixed)
{
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>

View File

@ -1,15 +1,17 @@
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Payments
@using BTCPayServer.Payments.Lightning
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using BTCPayServer.Services.Invoices
@using BTCPayServer.Services.Stores
@using LNURL
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@using Microsoft.AspNetCore.Mvc.TagHelpers
@inject StoreRepository StoreRepository
@inject PaymentMethodHandlerDictionary Handlers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
var store = await StoreRepository.FindStore(Model.StoreId);
Layout = "PointOfSale/Public/_Layout";
@ -58,16 +60,15 @@ else
{
if (Model.ShowCustomAmount)
{
Model.Items = Model.Items.Concat(new[]
{
new ViewPointOfSaleViewModel.Item()
Model.Items = Model.Items.Concat([
new AppItem
{
Description = "Create invoice to pay custom amount",
Title = "Custom amount",
BuyButtonText = Model.CustomButtonText,
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup
PriceType = AppItemPriceType.Topup
}
}).ToArray();
]).ToArray();
}
}
@ -76,7 +77,7 @@ else
{
var item = Model.Items[x];
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && item.Price == 0) continue;
if (item.PriceType == AppItemPriceType.Fixed && item.Price == 0) continue;
<div class="d-flex flex-wrap">
<div class="tile card w-100" data-id="@x">
<div class="card-body pt-0 d-flex flex-column gap-2">
@ -85,13 +86,13 @@ else
<span class="fw-semibold">
@switch (item.PriceType)
{
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
case AppItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
case AppItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
case AppItemPriceType.Fixed:
@formatted
break;
default:

View File

@ -1,18 +1,18 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Client.Models
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
Layout = "PointOfSale/Public/_Layout";
}
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
private string GetItemPriceFormatted(AppItem item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.PriceType == AppItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
return item.PriceType == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
@ -31,7 +31,7 @@
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
? item.PriceType == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
@ -44,7 +44,7 @@
<div class="card-body d-flex flex-column gap-2 mb-auto">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
@if (item.PriceType == AppItemPriceType.Topup || item.Price == 0)
{
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}
@ -76,7 +76,7 @@
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
<input type="hidden" name="choiceKey" value="@item.Id" />
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
@if (item.PriceType == AppItemPriceType.Minimum)
{
<div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span>

View File

@ -3,15 +3,16 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@using BTCPayServer.Client
@using BTCPayServer.Client.Models
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
private string GetItemPriceFormatted(AppItem item)
{
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
if (item.PriceType == AppItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
return item.PriceType == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
}
}
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
@ -115,7 +116,7 @@
var item = Model.Items[index];
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var displayed = item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && inStock ? "true" : "false";
var displayed = item.PriceType == AppItemPriceType.Fixed && inStock ? "true" : "false";
var categories = new JArray(item.Categories ?? new object[] { });
<div class="posItem p-3" :class="{ 'posItem--inStock': inStock(@index), 'posItem--displayed': @displayed }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)' v-show="@displayed">
<div class="d-flex align-items-start w-100 gap-3">
@ -128,7 +129,7 @@
<div class="d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
@if (item.PriceType == AppItemPriceType.Topup || item.Price == 0)
{
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
}

View File

@ -747,34 +747,31 @@
"type": "object",
"properties": {
"items": {
"type": "object",
"type": "array",
"items": {
"$ref": "#/components/schemas/AppItem"
},
"description": "JSON object of app items",
"example": [
{
"description": "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
"id": "green tea",
"image": "~/img/pos-sample/green-tea.jpg",
"price": {
"type": 2,
"formatted": "$1.00",
"value": 1.0
},
"title": "Green Tea",
"description": "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
"image": "~/img/pos-sample/green-tea.jpg",
"price": "1.0",
"priceType": "Fixed",
"buyButtonText": null,
"inventory": 5,
"paymentMethods": null,
"disabled": false
},
{
"description": "Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
"id": "black tea",
"image": "~/img/pos-sample/black-tea.jpg",
"price": {
"type": 2,
"formatted": "$1.00",
"value": 1.0
},
"title": "Black Tea",
"description": "Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
"image": "~/img/pos-sample/black-tea.jpg",
"price": "2.0",
"priceType": "Fixed",
"buyButtonText": "Test Buy Button Text",
"inventory": null,
"paymentMethods": null,
@ -1012,6 +1009,66 @@
}
]
},
"AppItem": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "green-tea",
"description": "Unique ID of the item"
},
"title": {
"type": "string",
"example": "Green Tea",
"description": "The display name of the item"
},
"description": {
"type": "string",
"example": "Lovely, fresh and tender.",
"description": "A description text for the item"
},
"image": {
"type": "string",
"example": "http://teashop.com/img/green-tea.jpg",
"description": "An image URL for the item"
},
"price": {
"type": "string",
"format": "decimal",
"nullable": true,
"example": "21.0"
},
"priceType": {
"type": "string",
"x-enumNames": [
"Fixed",
"Topup",
"Minimum"
],
"enum": [
"Fixed",
"Topup",
"Minimum"
]
},
"buyButtonText": {
"type": "string",
"example": "Buy me!",
"description": "A custom text for the buy button for the item"
},
"inventory": {
"type": "integer",
"nullable": true,
"example": 21,
"description": "The remaining stock the item"
},
"disabled": {
"type": "boolean",
"description": "If true, the item does not appear in the list by default.",
"default": false
}
}
},
"AppSalesStats": {
"type": "object",
"properties": {