Switch Apps to json not YML (#4792)

This commit is contained in:
Andrew Camilleri 2023-05-23 02:18:57 +02:00 committed by GitHub
parent 97e7e60cea
commit 8860eec254
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 432 additions and 435 deletions

View File

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data;
@ -41,4 +43,7 @@ public class LightningAddressDataBlob
public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; }
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.Lightning;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.StoreViewModels;
@ -651,6 +652,7 @@ donation:
price: 1.02
custom: true
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.Equal("hello", vmpos.Title);
@ -662,7 +664,6 @@ donation:
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("$5.00", vmview.Items[0].Price.Formatted);
Assert.Equal("{0} Purchase", vmview.ButtonText);
Assert.Equal("Nicolas Sexy Hair", vmview.CustomButtonText);
Assert.Equal("Wanna tip?", vmview.CustomTipText);
@ -723,6 +724,7 @@ donation:
price: 1.02
custom: true
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
publicApps = user.GetController<UIPointOfSaleController>();
vmview = await publicApps.ViewPointOfSale(app.Id, PosViewType.Cart).AssertViewModelAsync<ViewPointOfSaleViewModel>();
@ -750,6 +752,8 @@ inventoryitem:
inventory: 1
noninventoryitem:
price: 10.0";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
//inventoryitem has 1 item available
@ -780,15 +784,13 @@ noninventoryitem:
//let's mark the inventoryitem invoice as invalid, this should return the item to back in stock
var controller = tester.PayTester.GetController<UIInvoiceController>(user.UserId, user.StoreId);
var appService = tester.PayTester.GetService<AppService>();
var eventAggregator = tester.PayTester.GetService<EventAggregator>();
Assert.IsType<JsonResult>(await controller.ChangeInvoiceState(inventoryItemInvoice.Id, "invalid"));
//check that item is back in stock
await TestUtils.EventuallyAsync(async () =>
{
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.Equal(1,
appService.Parse(vmpos.Template, "BTC").Single(item => item.Id == "inventoryitem").Inventory);
AppService.Parse(vmpos.Template).Single(item => item.Id == "inventoryitem").Inventory);
}, 10000);
//test payment methods option
@ -803,6 +805,8 @@ btconly:
- 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, null, null, null, null, "btconly").Result);
@ -847,18 +851,19 @@ g:
custom: topup
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.DoesNotContain("custom", vmpos.Template);
var items = appService.Parse(vmpos.Template, vmpos.Currency);
Assert.Contains(items, item => item.Id == "a" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "b" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "c" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "d" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "e" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
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.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result);
invoices = user.BitPay.GetInvoices();

View File

@ -1,10 +1,12 @@
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Hosting;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Mvc;
using Xunit;
using Xunit.Abstractions;
@ -19,6 +21,74 @@ namespace BTCPayServer.Tests
{
}
[Fact]
[Trait("Fast", "Fast")]
public void CanParseOldYmlCorrectly()
{
var testOriginalDefaultYmlTemplate = @"
green tea:
price: 1
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
black tea:
price: 1
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
rooibos:
price: 1.2
title: Rooibos
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
pu erh:
price: 2
title: Pu Erh
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
herbal tea:
price: 1.8
title: Herbal Tea
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
custom: true
fruit tea:
price: 1.5
title: Fruit Tea
description: The Tibetan Himalayas, the land is majestic and beautifula spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!
image: ~/img/pos-sample/fruit-tea.jpg
inventory: 5
custom: true
";
var parsedDefault = MigrationStartupTask.ParsePOSYML(testOriginalDefaultYmlTemplate);
Assert.Equal(6, parsedDefault.Length);
Assert.Equal( "Green Tea" ,parsedDefault[0].Title);
Assert.Equal( "green tea" ,parsedDefault[0].Id);
Assert.Equal( "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." ,parsedDefault[0].Description);
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.Null( parsedDefault[0].AdditionalData);
Assert.Null( parsedDefault[0].PaymentMethods);
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
Assert.Equal( "herbal tea" ,parsedDefault[4].Id);
Assert.Equal( "Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!" ,parsedDefault[4].Description);
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.Null( parsedDefault[4].AdditionalData);
Assert.Null( parsedDefault[4].PaymentMethods);
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUsePoSApp1()
@ -53,6 +123,7 @@ donation:
price: 1.02
custom: true
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
var publicApps = user.GetController<UIPointOfSaleController>();

View File

@ -964,7 +964,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("ToggleRawEditor")).Click();
var template = s.Driver.FindElement(By.Id("Template")).GetAttribute("value");
Assert.Contains("buyButtonText: Take my money", template);
Assert.Contains("\"buyButtonText\":\"Take my money\"", template);
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);

View File

@ -45,6 +45,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.24" />
@ -75,7 +76,6 @@
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Google" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.9" />
</ItemGroup>

View File

@ -245,7 +245,7 @@ namespace BTCPayServer.Controllers.Greenfield
EmbeddedCSS = request.EmbeddedCSS?.Trim(),
NotificationUrl = request.NotificationUrl?.Trim(),
Tagline = request.Tagline?.Trim(),
PerksTemplate = request.PerksTemplate is not null ? _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate.Trim(), request.TargetCurrency!)) : null,
PerksTemplate = request.PerksTemplate is not null ? AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate.Trim())) : null,
// If Disqus shortname is not null or empty we assume that Disqus should be enabled
DisqusEnabled = !string.IsNullOrEmpty(request.DisqusShortname?.Trim()),
DisqusShortname = request.DisqusShortname?.Trim(),
@ -272,7 +272,7 @@ namespace BTCPayServer.Controllers.Greenfield
ShowDiscount = request.ShowDiscount,
EnableTips = request.EnableTips,
Currency = request.Currency,
Template = request.Template != null ? _appService.SerializeTemplate(_appService.Parse(request.Template, request.Currency)) : null,
Template = request.Template != null ? AppService.SerializeTemplate(AppService.Parse(request.Template)) : null,
ButtonText = request.FixedAmountPayButtonText ?? PointOfSaleSettings.BUTTON_TEXT_DEF,
CustomButtonText = request.CustomAmountPayButtonText ?? PointOfSaleSettings.CUSTOM_BUTTON_TEXT_DEF,
CustomTipText = request.TipText ?? PointOfSaleSettings.CUSTOM_TIP_TEXT_DEF,
@ -331,7 +331,7 @@ namespace BTCPayServer.Controllers.Greenfield
Currency = settings.Currency,
Items = JsonConvert.DeserializeObject(
JsonConvert.SerializeObject(
_appService.Parse(settings.Template, settings.Currency),
AppService.Parse(settings.Template),
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
@ -363,8 +363,8 @@ namespace BTCPayServer.Controllers.Greenfield
{
try
{
// Just checking if we can serialize, we don't care about the currency
_appService.SerializeTemplate(_appService.Parse(request.Template, "USD"));
// Just checking if we can serialize
AppService.SerializeTemplate(AppService.Parse(request.Template));
}
catch
{
@ -406,7 +406,7 @@ namespace BTCPayServer.Controllers.Greenfield
Tagline = settings.Tagline,
Perks = JsonConvert.DeserializeObject(
JsonConvert.SerializeObject(
_appService.Parse(settings.PerksTemplate, settings.TargetCurrency),
AppService.Parse(settings.PerksTemplate),
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
@ -453,8 +453,8 @@ namespace BTCPayServer.Controllers.Greenfield
try
{
// Just checking if we can serialize, we don't care about the currency
_appService.SerializeTemplate(_appService.Parse(request.PerksTemplate, "USD"));
// Just checking if we can serialize
AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate));
}
catch
{

View File

@ -257,12 +257,12 @@ namespace BTCPayServer
case CrowdfundAppType.AppType:
var cfS = app.GetSettings<CrowdfundSettings>();
currencyCode = cfS.TargetCurrency;
items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency);
items = AppService.Parse(cfS.PerksTemplate);
break;
case PointOfSaleAppType.AppType:
posS = app.GetSettings<PointOfSaleSettings>();
currencyCode = posS.Currency;
items = _appService.Parse(posS.Template, posS.Currency);
items = AppService.Parse(posS.Template);
break;
default:
//TODO: Allow other apps to define lnurl support

View File

@ -40,11 +40,11 @@ namespace BTCPayServer.HostedServices
case PointOfSaleAppType.AppType:
var possettings = data.GetSettings<PointOfSaleSettings>();
return (Data: data, Settings: (object)possettings,
Items: _appService.Parse(possettings.Template, possettings.Currency));
Items: AppService.Parse(possettings.Template));
case CrowdfundAppType.AppType:
var cfsettings = data.GetSettings<CrowdfundSettings>();
return (Data: data, Settings: (object)cfsettings,
Items: _appService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency));
Items: AppService.Parse(cfsettings.PerksTemplate));
default:
return (null, null, null);
}
@ -70,11 +70,11 @@ namespace BTCPayServer.HostedServices
{
case PointOfSaleAppType.AppType:
((PointOfSaleSettings)valueTuple.Settings).Template =
_appService.SerializeTemplate(valueTuple.Items);
AppService.SerializeTemplate(valueTuple.Items);
break;
case CrowdfundAppType.AppType:
((CrowdfundSettings)valueTuple.Settings).PerksTemplate =
_appService.SerializeTemplate(valueTuple.Items);
AppService.SerializeTemplate(valueTuple.Items);
break;
default:
throw new InvalidOperationException();

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -26,11 +27,14 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBXplorer;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PeterO.Cbor;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using LightningAddressData = BTCPayServer.Data.LightningAddressData;
using Serializer = NBXplorer.Serializer;
namespace BTCPayServer.Hosting
{
@ -246,6 +250,11 @@ namespace BTCPayServer.Hosting
{
await FixMappedDomainAppType();
settings.FixMappedDomainAppType = true;
}
if (!settings.MigrateAppYmlToJson)
{
await MigrateAppYmlToJson();
settings.MigrateAppYmlToJson = true;
await _Settings.UpdateSetting(settings);
}
}
@ -304,6 +313,134 @@ namespace BTCPayServer.Hosting
return;
await ToPostgresMigrationStartupTask.UpdateSequenceInvoiceSearch(ctx);
}
private async Task MigrateAppYmlToJson()
{
await using var ctx = _DBContextFactory.CreateContext();
var apps = await ctx.Apps.Where(data => CrowdfundAppType.AppType == data.AppType || PointOfSaleAppType.AppType == data.AppType)
.ToListAsync();
foreach (var app in apps)
{
switch (app.AppType)
{
case CrowdfundAppType.AppType :
var cfSettings = app.GetSettings<CrowdfundSettings>();
if (!string.IsNullOrEmpty(cfSettings?.PerksTemplate))
{
cfSettings.PerksTemplate = AppService.SerializeTemplate(ParsePOSYML(cfSettings?.PerksTemplate));
app.SetSettings(cfSettings);
}
break;
case PointOfSaleAppType.AppType:
var pSettings = app.GetSettings<PointOfSaleSettings>();
if (!string.IsNullOrEmpty(pSettings?.Template))
{
pSettings.Template = AppService.SerializeTemplate(ParsePOSYML(pSettings?.Template));
app.SetSettings(pSettings);
}
break;
}
}
await ctx.SaveChangesAsync();
}
public static ViewPointOfSaleViewModel.Item[] ParsePOSYML(string yaml)
{
var items = new List<ViewPointOfSaleViewModel.Item>();
var stream = new YamlStream();
stream.Load(new StringReader(yaml));
var root = stream.Documents.First().RootNode as YamlMappingNode;
foreach (var posItem in root.Children)
{
var trimmedKey = ((YamlScalarNode)posItem.Key).Value?.Trim();
if (string.IsNullOrEmpty(trimmedKey))
{
continue;
}
var currentItem = new ViewPointOfSaleViewModel.Item
{
Id = trimmedKey, Title = trimmedKey, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed
};
var itemSpecs = (YamlMappingNode)posItem.Value;
foreach (var spec in itemSpecs)
{
if (spec.Key is not YamlScalarNode {Value: string keyString} || string.IsNullOrEmpty(keyString))
continue;
var scalarValue = spec.Value as YamlScalarNode;
switch (keyString)
{
case "title":
currentItem.Title = scalarValue?.Value ?? trimmedKey;
break;
case "inventory":
if (int.TryParse(scalarValue?.Value, out var inv))
{
currentItem.Inventory = inv;
}
break;
case "description":
currentItem.Description = scalarValue?.Value;
break;
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))
{
if (customBoolValue)
{
currentItem.PriceType = currentItem.Price is null or 0
? ViewPointOfSaleViewModel.ItemPriceType.Topup
: ViewPointOfSaleViewModel.ItemPriceType.Minimum;
}
else
{
currentItem.PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed;
}
}
else if (Enum.TryParse<ViewPointOfSaleViewModel.ItemPriceType>(scalarValue?.Value, true,
out var customPriceType))
{
currentItem.PriceType = customPriceType;
}
break;
case "price":
if (decimal.TryParse(scalarValue?.Value, out var price))
{
currentItem.Price = price;
}
break;
case "buybuttontext":
currentItem.BuyButtonText = scalarValue?.Value;
break;
case "disabled":
if (bool.TryParse(scalarValue?.Value, out var disabled))
{
currentItem.Disabled = disabled;
}
break;
}
}
items.Add(currentItem);
}
return items.ToArray();
}
#pragma warning disable CS0612 // Type or member is obsolete
@ -521,8 +658,8 @@ WHERE cte.""Id""=p.""Id""
settings1.TargetCurrency = app.StoreData.GetStoreBlob().DefaultCurrency;
app.SetSettings(settings1);
}
items = _appService.Parse(settings1.PerksTemplate, settings1.TargetCurrency);
newTemplate = _appService.SerializeTemplate(items);
items = AppService.Parse(settings1.PerksTemplate);
newTemplate = AppService.SerializeTemplate(items);
if (settings1.PerksTemplate != newTemplate)
{
settings1.PerksTemplate = newTemplate;
@ -538,8 +675,8 @@ WHERE cte.""Id""=p.""Id""
settings2.Currency = app.StoreData.GetStoreBlob().DefaultCurrency;
app.SetSettings(settings2);
}
items = _appService.Parse(settings2.Template, settings2.Currency);
newTemplate = _appService.SerializeTemplate(items);
items = AppService.Parse(settings2.Template);
newTemplate = AppService.SerializeTemplate(items);
if (settings2.Template != newTemplate)
{
settings2.Template = newTemplate;

View File

@ -127,13 +127,13 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
ViewPointOfSaleViewModel.Item choice = null;
if (!string.IsNullOrEmpty(request.ChoiceKey))
{
var choices = _appService.GetPOSItems(settings.PerksTemplate, settings.TargetCurrency);
var choices = AppService.Parse(settings.PerksTemplate, false);
choice = choices?.FirstOrDefault(c => c.Id == request.ChoiceKey);
if (choice == null)
return NotFound("Incorrect option provided");
title = choice.Title;
if (choice.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
price = null;
}
@ -273,7 +273,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
try
{
vm.PerksTemplate = _appService.SerializeTemplate(_appService.Parse(vm.PerksTemplate, vm.TargetCurrency));
vm.PerksTemplate = AppService.SerializeTemplate(AppService.Parse(vm.PerksTemplate));
}
catch
{

View File

@ -74,14 +74,14 @@ namespace BTCPayServer.Plugins.Crowdfund
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var cfS = app.GetSettings<CrowdfundSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, cfS.PerksTemplate, cfS.TargetCurrency);
var items = AppService.Parse( cfS.PerksTemplate);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var perks = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
var perks = AppService.Parse( settings.PerksTemplate);
var perkCount = paidInvoices
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) &&
// we need the item code to know which perk it is and group by that
@ -176,7 +176,7 @@ namespace BTCPayServer.Plugins.Crowdfund
})));
}
var perks = AppService.GetPOSItems(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
var perks = AppService.Parse( settings.PerksTemplate, false);
if (settings.SortPerksByPopularity)
{
var ordered = perkCount.OrderByDescending(pair => pair.Value);

View File

@ -106,7 +106,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern),
SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern)
},
Items = _appService.GetPOSItems(settings.Template, settings.Currency),
Items = AppService.Parse(settings.Template, false),
ButtonText = settings.ButtonText,
CustomButtonText = settings.CustomButtonText,
CustomTipText = settings.CustomTipText,
@ -165,12 +165,12 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
ViewPointOfSaleViewModel.Item[] choices = null;
if (!string.IsNullOrEmpty(choiceKey))
{
choices = _appService.GetPOSItems(settings.Template, settings.Currency);
choices = AppService.Parse(settings.Template, false);
choice = choices.FirstOrDefault(c => c.Id == choiceKey);
if (choice == null)
return NotFound();
title = choice.Title;
if (choice.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
price = null;
}
@ -204,7 +204,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (currentView == PosViewType.Cart &&
AppService.TryParsePosCartItems(jposData, out cartItems))
{
choices = _appService.GetPOSItems(settings.Template, settings.Currency);
choices = AppService.Parse(settings.Template, false);
var expectedMinimumAmount = 0m;
foreach (var cartItem in cartItems)
{
@ -224,9 +224,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
decimal expectedCartItemPrice = 0;
if (itemChoice.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
if (itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
expectedCartItemPrice = itemChoice.Price.Value ?? 0;
expectedCartItemPrice = itemChoice.Price ?? 0;
}
expectedMinimumAmount += expectedCartItemPrice * cartItem.Value;
@ -327,7 +327,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (selectedChoices.TryGetValue(cartItem.Key, out var selectedChoice))
{
cartData.Add(selectedChoice.Title ?? selectedChoice.Id,
$"{(selectedChoice.Price.Value is null ? "Any price" : $"{_displayFormatter.Currency((decimal)selectedChoice.Price.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")} x {cartItem.Value} = {(selectedChoice.Price.Value is null ? "Any price" : $"{_displayFormatter.Currency(((decimal)selectedChoice.Price.Value) * cartItem.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")}");
$"{(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)}")}");
}
}
@ -533,7 +533,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
try
{
var items = _appService.Parse(settings.Template, settings.Currency);
var items = AppService.Parse(settings.Template);
var builder = new StringBuilder();
builder.AppendLine(CultureInfo.InvariantCulture, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">");
builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />");
@ -568,7 +568,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
ModelState.AddModelError(nameof(vm.Currency), "Invalid currency");
try
{
vm.Template = _appService.SerializeTemplate(_appService.Parse(vm.Template, vm.Currency));
vm.Template = AppService.SerializeTemplate(AppService.Parse(vm.Template));
}
catch
{

View File

@ -1,34 +1,45 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.JsonConverters;
using BTCPayServer.Services.Apps;
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
{
public class ItemPrice
{
public enum ItemPriceType
{
Topup,
Minimum,
Fixed
}
public ItemPriceType Type { get; set; }
public string Formatted { get; set; }
public decimal? Value { get; set; }
}
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Description { get; set; }
public string Id { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Image { get; set; }
public ItemPrice Price { 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

View File

@ -82,14 +82,14 @@ namespace BTCPayServer.Plugins.PointOfSale
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var posS = app.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, posS.Template, posS.Currency);
var items = AppService.Parse(posS.Template);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.Template, settings.Currency);
var items = AppService.Parse( settings.Template);
var itemCount = paidInvoices
.Where(entity => entity.Currency.Equals(settings.Currency, StringComparison.OrdinalIgnoreCase) && (
// The POS data is present for the cart view, where multiple items can be bought

View File

@ -13,18 +13,12 @@ using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using ExchangeSharp;
using Ganss.XSS;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Services.Apps
@ -32,6 +26,17 @@ namespace BTCPayServer.Services.Apps
public class AppService
{
private readonly Dictionary<string, AppBaseType> _appTypes;
static AppService()
{
_defaultSerializer = new JsonSerializerSettings()
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
Formatting = Formatting.None
};
}
private static JsonSerializerSettings _defaultSerializer;
readonly ApplicationDbContextFactory _ContextFactory;
private readonly InvoiceRepository _InvoiceRepository;
readonly CurrencyNameTable _Currencies;
@ -342,169 +347,20 @@ namespace BTCPayServer.Services.Apps
{
return _storeRepository.FindStore(app.StoreDataId);
}
public string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
public static string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
{
var mappingNode = new YamlMappingNode();
foreach (var item in items)
{
var itemNode = new YamlMappingNode();
itemNode.Add("title", new YamlScalarNode(item.Title));
if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup && item.Price.Value is not null)
itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant()));
if (!string.IsNullOrEmpty(item.Description))
{
itemNode.Add("description", new YamlScalarNode(item.Description)
{
Style = ScalarStyle.DoubleQuoted
});
}
if (!string.IsNullOrEmpty(item.Image))
{
itemNode.Add("image", new YamlScalarNode(item.Image));
}
itemNode.Add("price_type", new YamlScalarNode(item.Price.Type.ToStringLowerInvariant()));
itemNode.Add("disabled", new YamlScalarNode(item.Disabled.ToStringLowerInvariant()));
if (item.Inventory.HasValue)
{
itemNode.Add("inventory", new YamlScalarNode(item.Inventory.ToString()));
}
if (!string.IsNullOrEmpty(item.BuyButtonText))
{
itemNode.Add("buyButtonText", new YamlScalarNode(item.BuyButtonText));
}
if (item.PaymentMethods?.Any() is true)
{
itemNode.Add("payment_methods", new YamlSequenceNode(item.PaymentMethods.Select(s => new YamlScalarNode(s))));
}
mappingNode.Add(item.Id, itemNode);
}
var serializer = new SerializerBuilder().Build();
return serializer.Serialize(mappingNode);
return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer);
}
public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency)
{
return Parse(_HtmlSanitizer, _displayFormatter, template, currency);
}
public ViewPointOfSaleViewModel.Item[] GetPOSItems(string template, string currency)
{
return GetPOSItems(_HtmlSanitizer, _displayFormatter, template, currency);
}
public static ViewPointOfSaleViewModel.Item[] Parse(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency)
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true)
{
if (string.IsNullOrWhiteSpace(template))
return Array.Empty<ViewPointOfSaleViewModel.Item>();
using var input = new StringReader(template);
YamlStream stream = new();
stream.Load(input);
var root = (YamlMappingNode)stream.Documents[0].RootNode;
return root
.Children
.Select(kv => new PosHolder(htmlSanitizer) { Key = htmlSanitizer.Sanitize((kv.Key as YamlScalarNode)?.Value), Value = kv.Value as YamlMappingNode })
.Where(kv => kv.Value != null)
.Select(c =>
{
ViewPointOfSaleViewModel.Item.ItemPrice price = new();
var pValue = c.GetDetail("price")?.FirstOrDefault();
switch (c.GetDetailString("custom") ?? c.GetDetailString("price_type")?.ToLowerInvariant())
{
case "topup":
case null when pValue is null:
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup;
break;
case "true":
case "minimum":
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum;
if (pValue != null && !string.IsNullOrEmpty(pValue.Value?.Value))
{
price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture);
price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol);
}
break;
case "fixed":
case "false":
case null:
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed;
if (pValue?.Value.Value is not null)
{
price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture);
price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol);
}
break;
}
return new ViewPointOfSaleViewModel.Item
{
Description = c.GetDetailString("description"),
Id = c.Key,
Image = c.GetDetailString("image"),
Title = c.GetDetailString("title") ?? c.Key,
Price = price,
BuyButtonText = c.GetDetailString("buyButtonText"),
Inventory =
string.IsNullOrEmpty(c.GetDetailString("inventory"))
? null
: int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture),
PaymentMethods = c.GetDetailStringList("payment_methods"),
Disabled = c.GetDetailString("disabled") == "true"
};
})
.ToArray();
}
public static ViewPointOfSaleViewModel.Item[] GetPOSItems(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency)
{
return Parse(htmlSanitizer, displayFormatter, template, currency).Where(c => !c.Disabled).ToArray();
return JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!.Where(item => includeDisabled || !item.Disabled).ToArray();
}
#nullable restore
private class PosHolder
{
private readonly HtmlSanitizer _htmlSanitizer;
public PosHolder(
HtmlSanitizer htmlSanitizer)
{
_htmlSanitizer = htmlSanitizer;
}
public string Key { get; set; }
public YamlMappingNode Value { get; set; }
public IEnumerable<PosScalar> GetDetail(string field)
{
var res = Value.Children
.Where(kv => kv.Value != null)
.Select(kv => new PosScalar { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlScalarNode })
.Where(cc => cc.Key == field);
return res;
}
public string GetDetailString(string field)
{
var raw = GetDetail(field).FirstOrDefault()?.Value?.Value;
return raw is null ? null : _htmlSanitizer.Sanitize(raw);
}
public string[] GetDetailStringList(string field)
{
if (!Value.Children.ContainsKey(field) || !(Value.Children[field] is YamlSequenceNode sequenceNode))
{
return null;
}
return sequenceNode.Children.Select(node => (node as YamlScalarNode)?.Value).Where(s => s != null).Select(s => _htmlSanitizer.Sanitize(s)).ToArray();
}
}
private class PosScalar
{
public string Key { get; set; }
public YamlScalarNode Value { get; set; }
}
#nullable enable
public async Task<AppData?> GetAppDataIfOwner(string userId, string appId, string? type = null)
{

View File

@ -1,5 +1,5 @@
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Stores;
using BTCPayServer.Plugins.PointOfSale.Models;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Services.Apps
@ -9,40 +9,71 @@ namespace BTCPayServer.Services.Apps
public PointOfSaleSettings()
{
Title = "Tea shop";
Template =
"green tea:\n" +
" price: 1\n" +
" title: Green Tea\n" +
" 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.\n" +
" image: ~/img/pos-sample/green-tea.jpg\n\n" +
"black tea:\n" +
" price: 1\n" +
" title: Black Tea\n" +
" 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.\n" +
" image: ~/img/pos-sample/black-tea.jpg\n\n" +
"rooibos:\n" +
" price: 1.2\n" +
" title: Rooibos\n" +
" 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.\n" +
" image: ~/img/pos-sample/rooibos.jpg\n\n" +
"pu erh:\n" +
" price: 2\n" +
" title: Pu Erh\n" +
" 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.\n" +
" image: ~/img/pos-sample/pu-erh.jpg\n\n" +
"herbal tea:\n" +
" price: 1.8\n" +
" title: Herbal Tea\n" +
" 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!\n" +
" image: ~/img/pos-sample/herbal-tea.jpg\n" +
" custom: true\n\n" +
"fruit tea:\n" +
" price: 1.5\n" +
" title: Fruit Tea\n" +
" 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!\n" +
" image: ~/img/pos-sample/fruit-tea.jpg\n" +
" inventory: 5\n" +
" custom: true";
Template = AppService.SerializeTemplate(new ViewPointOfSaleViewModel.Item[]
{
new()
{
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,
Price = 1
},
new()
{
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,
Price = 1
},
new()
{
Id = "rooibos",
Title = "Rooibos",
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,
Price = 1.2m
},
new()
{
Id = "pu-erh",
Title = "Pu Erh",
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,
Price = 2
},
new()
{
Id = "herbal-tea",
Title = "Herbal Tea",
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,
Price = 1.8m,
Disabled = true
},
new()
{
Id = "fruit-tea",
Title = "Fruit Tea",
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,
Inventory = 5,
Disabled = true
}
});
DefaultView = PosViewType.Static;
ShowCustomAmount = false;
ShowDiscount = true;

View File

@ -58,5 +58,5 @@ public class PosAppCartItemPrice
public decimal Value { get; set; }
[JsonProperty(PropertyName = "type")]
public ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType Type { get; set; }
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
}

View File

@ -37,5 +37,6 @@ namespace BTCPayServer.Services
public bool FileSystemStorageAsDefault { get; set; }
public bool FixSeqAfterSqliteMigration { get; set; }
public bool FixMappedDomainAppType { get; set; }
public bool MigrateAppYmlToJson { get; set; }
}
}

View File

@ -33,15 +33,15 @@
<span>@item.Price.Value</span>
<span>@vm.TargetCurrency</span>
if (item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum)
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
{
@Safe.Raw("or more")
}
}
else if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed )
else if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed )
{
@Safe.Raw("Any amount")
}else if (item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed)
}else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)
{
@Safe.Raw("Free")
}
@ -56,7 +56,7 @@
{
case null:
break;
case var i when i <= 0:
case <= 0:
<span>Sold out</span>
break;
default:

View File

@ -1,5 +1,7 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
var customTipPercentages = Model.CustomTipPercentages;
@ -253,11 +255,12 @@
<span class="text-muted small">
@{
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
buttonText = buttonText.Replace("{0}",item.Price.Formatted)
?.Replace("{Price}",item.Price.Formatted);
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
buttonText = buttonText.Replace("{0}",formatted)
?.Replace("{Price}",formatted);
}
}
@Safe.Raw(buttonText)

View File

@ -1,11 +1,12 @@
@using BTCPayServer.Payments.Lightning
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using BTCPayServer.Services.Stores
@using LNURL
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@inject StoreRepository StoreRepository
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
var store = await StoreRepository.FindStore(Model.StoreId);
Layout = "PointOfSale/Public/_Layout";
@ -68,11 +69,7 @@ else
Description = "Create invoice to pay custom amount",
Title = "Custom amount",
BuyButtonText = Model.CustomButtonText,
Price = new ViewPointOfSaleViewModel.Item.ItemPrice()
{
Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup,
}
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup
}
}).ToArray();
}
@ -90,16 +87,19 @@ else
<p class="card-title text-center">@Safe.Raw(item.Description)</p>
}
<div class="w-100 mb-3 fs-5 text-center">
@switch (item.Price.Type)
@{
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
}
@switch (item.PriceType)
{
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup:
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum:
<span>@item.Price.Formatted minimum</span>
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed:
@item.Price.Formatted
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
@formatted
break;
default:
throw new ArgumentOutOfRangeException();

View File

@ -1,5 +1,7 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
@ -17,8 +19,9 @@
@for (var x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
buttonText = buttonText.Replace("{0}", item.Price.Formatted).Replace("{Price}", item.Price.Formatted);
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
<div class="card px-0" data-id="@x">
@if (!string.IsNullOrWhiteSpace(item.Image))
@ -29,11 +32,11 @@
<div class="card-footer bg-transparent border-0 pb-3">
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
{
@if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy autocomplete="off">
<input type="hidden" name="choiceKey" value="@item.Id" />
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.Price.Type, item.Price.Value, item.Price.Value);}
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.PriceType, item.Price.Value, item.Price.Value);}
</form>
}
else
@ -72,7 +75,7 @@
@{CardBody("Custom Amount", "Create invoice to pay custom amount");}
<div class="card-footer bg-transparent border-0 pb-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);}
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.ItemPriceType.Minimum);}
</form>
@if (anyInventoryItems)
{
@ -91,9 +94,9 @@
</div>
@functions {
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
{
if (itemPriceType == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed && priceValue == 0)
if (itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && priceValue == 0)
{
<div class="input-group">
<input class="form-control" type="text" readonly value="Free"/>
@ -105,7 +108,7 @@
<div class="input-group">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed)">
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)">
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>
}

View File

@ -61,11 +61,11 @@
<div class="form-group row">
<div class="col-sm-6">
<label class="form-label">Price</label>
<select class="form-select" v-model="editingItem.custom">
<select class="form-select" v-model="editingItem.priceType">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
</div>
<div class="col-sm-6" v-show="editingItem.custom !== 'topup'">
<div class="col-sm-6" v-show="editingItem.priceType !== 'Topup'">
<label class="form-label">&nbsp;</label>
<div class="input-group mb-2">
<input class="form-control"
@ -129,9 +129,9 @@ document.addEventListener("DOMContentLoaded", function () {
items: [],
editingItem: null,
customPriceOptions: [
{ text: 'Fixed', value: "fixed" },
{ text: 'Minimum', value: "minimum" },
{ text: 'Custom', value: 'topup' },
{ text: 'Fixed', value: "Fixed" },
{ text: 'Minimum', value: "Minimum" },
{ text: 'Custom', value: 'Topup' },
],
elementId: "@Model.templateId"
},
@ -141,8 +141,8 @@ document.addEventListener("DOMContentLoaded", function () {
}
},
mounted: function() {
this.loadYml();
this.getInputElement().on("input change", this.loadYml.bind(this));
this.load();
this.getInputElement().on("input change", this.load.bind(this));
this.getModalElement().on("hide.bs.modal", this.clearEditingItem.bind(this));
},
methods: {
@ -156,148 +156,22 @@ document.addEventListener("DOMContentLoaded", function () {
},
getInputElement : function(){ return $("#" + this.elementId); },
getModalElement : function(){ return $("#product-modal"); },
loadYml: function(){
var result = [];
var template = this.getInputElement().val().trim();
var lines = [];
var items = template.split("\n");
for (var i = 0; i < items.length; i++) {
if (items[i] === ""){
continue;
}
if (items[i].startsWith(" ")){
lines[lines.length-1]+=items[i] + "\n";
} else {
lines.push(items[i] + "\n");
}
load: function(){
const template = this.getInputElement().val().trim();
if (!template){
this.items = [];
} else {
this.items = JSON.parse(template);
}
// Split products from the template
for (var kl in lines) {
var line = lines[kl], product = line.split("\n"), goingThroughMethods = false,
id = null, price = null, title = null, description = null, image = null,
custom = null, buyButtonText = null, inventory = null, paymentMethods = [],
disabled = false;
for (var kp in product) {
var productProperty = product[kp].trim();
if (kp == 0) {
id = productProperty.replace(":", "");
}
if (productProperty.startsWith("-") && goingThroughMethods) {
paymentMethods.push(productProperty.substr(1));
} else {
goingThroughMethods = false;
}
if (productProperty.indexOf('price:') !== -1) {
price = parseFloat(productProperty.replace('price:', '').trim()).noExponents();
}
if (productProperty.indexOf('title:') !== -1) {
title = productProperty.replace('title:', '').trim();
}
if (productProperty.indexOf('description:') !== -1) {
description =productProperty.replace('description:', '').trim();
if (description.startsWith('"') && description.endsWith('"')){
description = description.substr(1, description.length-2);
}
description = description
.replaceAll("<br>", "\n")
.replaceAll("<br/>", "\n")
.replaceAll('\\"', '"')
}
if (productProperty.indexOf('image:') !== -1) {
image = productProperty.replace('image:', '').trim();
}
if (productProperty.indexOf('price_type:') !== -1) {
custom = productProperty.replace('price_type:', '').trim();
}
if (productProperty.indexOf('buyButtonText:') !== -1) {
buyButtonText = productProperty.replace('buyButtonText:', '').trim();
}
if (productProperty.indexOf('inventory:') !== -1) {
inventory = parseInt(productProperty.replace('inventory:', '').trim(),10);
}
if (productProperty.indexOf('payment_methods:') !== -1) {
goingThroughMethods = true;
}
if (productProperty.indexOf('disabled:') !== -1) {
disabled = productProperty.replace('disabled:', '').trim() === "true";
}
}
if (title != null) {
// Add product to the list
result.push({
id: id,
title: title,
price: price,
image: image || null,
description: description || '',
custom: custom || "fixed",
buyButtonText: buyButtonText,
inventory: isNaN(inventory)? null: inventory,
paymentMethods: paymentMethods,
disabled: disabled
});
}
}
this.items = result;
},
toYml: function(){
let template = '';
// Construct template from the product list
for (const key in this.items) {
const product = this.items[key],
id = product.id,
title = product.title,
price = product.custom === 'topup'? null : product.price??0,
image = product.image,
description = product.description,
custom = product.custom,
buyButtonText = product.buyButtonText,
inventory = product.inventory,
paymentMethods = product.paymentMethods,
disabled = product.disabled;
let itemTemplate = id+":\n";
itemTemplate += ( product.custom === 'topup'? '' : (' price: ' + parseFloat(price).noExponents() + '\n'));
itemTemplate+= ' title: ' + title + '\n';
if (description) {
itemTemplate += ' description: "' + description.replaceAll("\n", "<br/>").replaceAll('"', '\\"') + '"\n';
}
if (image) {
itemTemplate += ' image: ' + image + '\n';
}
if (inventory) {
itemTemplate += ' inventory: ' + inventory + '\n';
}
if (custom != null) {
itemTemplate += ' price_type: "' + custom + '" \n';
}
if (buyButtonText != null && buyButtonText.length > 0) {
itemTemplate += ' buyButtonText: ' + buyButtonText + '\n';
}
if (disabled != null) {
itemTemplate += ' disabled: ' + disabled.toString() + '\n';
}
if(paymentMethods != null && paymentMethods.length > 0){
itemTemplate+= ' payment_methods:\n';
for (var method of paymentMethods){
itemTemplate+= ' - '+method+'\n';
}
}
itemTemplate += '\n';
template+=itemTemplate;
}
this.getInputElement().val(template);
save: function(){
let template = JSON.stringify(this.items);
this.getInputElement().val(template);
},
editItem: function(index){
this.errors = [];
if(index < 0){
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", custom: "fixed", inventory: null, paymentMethods: [], disabled: false};
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", priceType: "Fixed", inventory: null, disabled: false};
}else{
this.editingItem = {...this.items[index], index};
}
@ -307,7 +181,7 @@ document.addEventListener("DOMContentLoaded", function () {
},
removeItem: function(index){
this.items.splice(index,1);
this.toYml();
this.save();
},
clearEditingItem: function(){
this.editingItem = null;
@ -343,7 +217,7 @@ document.addEventListener("DOMContentLoaded", function () {
this.errors.push("Image cannot start with \"- \"");
}
if (this.editingItem.custom !== "topup" && !this.$refs.txtPrice.checkValidity()) {
if (this.editingItem["priceType"] !== "Topup" && !this.$refs.txtPrice.checkValidity()) {
this.errors.push("Price must be a valid number");
}
if (!this.$refs.txtTitle.checkValidity()) {
@ -375,7 +249,7 @@ document.addEventListener("DOMContentLoaded", function () {
}else{
this.items.splice(this.editingItem.index,1,this.editingItem);
}
this.toYml();
this.save();
this.getModalElement().modal("hide");
},
escape: function(item) {