From f74ea14d8bfc22e8537705ded237aa0594913ade Mon Sep 17 00:00:00 2001 From: Andrew Camilleri Date: Fri, 17 Mar 2023 03:56:32 +0100 Subject: [PATCH] Plugins can now build apps (#4608) * Plugins can now build apps * fix tests * fixup * pluginize existing apps * Test fixes part 1 * Test fixes part 2 * Fix Crowdfund namespace * Syntax * More namespace fixes * Markup * Test fix * upstream fixes * Add plugin icon * Fix nullable build warnings * allow pre popualting app creation * Fixes after merge * Make link methods async * Use AppData as parameter for ConfigureLink * GetApps by AppType * Use ConfigureLink on dashboard * Rename method * Add properties to indicate stats support * Property updates * Test fixes * Clean up imports * Fixes after merge --------- Co-authored-by: Dennis Reimann --- .../AltcoinTests/AltcoinTests.cs | 24 +- BTCPayServer.Tests/CrowdfundTests.cs | 45 +- BTCPayServer.Tests/POSTests.cs | 8 +- BTCPayServer.Tests/UnitTest1.cs | 15 +- BTCPayServer/Components/AppSales/AppSales.cs | 10 +- .../Components/AppSales/AppSalesViewModel.cs | 3 +- .../Components/AppSales/Default.cshtml | 19 +- .../Components/AppTopItems/AppTopItems.cs | 12 +- .../AppTopItems/AppTopItemsViewModel.cs | 3 +- .../Components/AppTopItems/Default.cshtml | 100 ++-- BTCPayServer/Components/MainNav/MainNav.cs | 2 +- .../Components/MainNav/MainNavViewModel.cs | 2 +- .../GreenField/GreenfieldAppsController.cs | 15 +- BTCPayServer/Controllers/UIAppsController.cs | 54 +- .../Controllers/UIInvoiceController.cs | 3 +- BTCPayServer/Controllers/UILNURLController.cs | 55 +- .../Controllers/UIPublicController.cs | 1 - .../Controllers/UIServerController.cs | 6 +- .../DomainMappingConstraintAttribute.cs | 4 +- .../AppInventoryUpdaterHostedService.cs | 16 +- BTCPayServer/Hosting/MigrationStartupTask.cs | 14 +- .../AppViewModels/CreateAppViewModel.cs | 26 +- .../Models/AppViewModels/ListAppsViewModel.cs | 2 + .../PaymentRequest/PaymentRequestService.cs | 9 +- .../Controllers/UICrowdfundController.cs | 26 +- .../Plugins/Crowdfund/CrowdfundPlugin.cs | 260 ++++++++- .../Models/ViewCrowdfundViewModel.cs | 16 +- .../Controllers/UIPointOfSaleController.cs | 17 +- .../Models/UpdatePointOfSaleViewModel.cs | 2 - .../Plugins/PointOfSale/PointOfSalePlugin.cs | 117 ++++- BTCPayServer/Services/Apps/AppHubStreamer.cs | 2 +- BTCPayServer/Services/Apps/AppService.cs | 497 ++++++------------ BTCPayServer/Services/Apps/AppType.cs | 31 +- .../Services/Apps/PointOfSaleSettings.cs | 1 + .../Services/Invoices/InvoiceRepository.cs | 77 +++ BTCPayServer/Services/PoliciesSettings.cs | 5 +- .../Shared/Crowdfund/NavExtension.cshtml | 8 +- .../Shared/PointOfSale/NavExtension.cshtml | 8 +- .../PointOfSale/UpdatePointOfSale.cshtml | 3 +- BTCPayServer/Views/UIApps/ListApps.cshtml | 17 +- BTCPayServer/Views/UIStores/Dashboard.cshtml | 15 +- BTCPayServer/wwwroot/img/icon-sprite.svg | 1 + 42 files changed, 899 insertions(+), 652 deletions(-) diff --git a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs index 35521fd03..7bc6f7938 100644 --- a/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs +++ b/BTCPayServer.Tests/AltcoinTests/AltcoinTests.cs @@ -11,6 +11,7 @@ using BTCPayServer.Models.AppViewModels; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Controllers; using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Services.Apps; @@ -386,7 +387,7 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("BOLT11Expiration")).SendKeys("5" + Keys.Enter); s.GoToInvoice(invoice.Id); s.Driver.FindElement(By.Id("IssueRefund")).Click(); - + if (multiCurrency) { s.Driver.WaitUntilAvailable(By.Id("RefundForm"), TimeSpan.FromSeconds(1)); @@ -584,7 +585,7 @@ namespace BTCPayServer.Tests Assert.True(invoice.SupportedTransactionCurrencies["LTC"].Enabled); Assert.True(invoice.PaymentSubtotals.ContainsKey("LTC")); Assert.True(invoice.PaymentTotals.ContainsKey("LTC")); - + // Check if we can disable LTC invoice = await user.BitPay.CreateInvoiceAsync( new Invoice @@ -622,10 +623,11 @@ namespace BTCPayServer.Tests var apps = user.GetController(); var pos = user.GetController(); var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId)).Model); - var appType = AppType.PointOfSale.ToString(); + var appType = PointOfSaleApp.AppType; vm.AppName = "test"; vm.SelectedAppType = appType; - Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + var redirect = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.EndsWith("/settings/pos", redirect.Url); var appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); var app = appList.Apps[0]; var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType }; @@ -680,7 +682,7 @@ donation: var appleInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("apple")); Assert.NotNull(appleInvoice); Assert.Equal("good apple", appleInvoice.ItemDesc); - + // testing custom amount var action = Assert.IsType(publicApps .ViewPointOfSale(app.Id, PosViewType.Cart, 6.6m, null, null, null, null, "donation").Result); @@ -735,7 +737,7 @@ donation: Assert.Equal(test.ExpectedDivisibility, vmview.CurrencyInfo.Divisibility); Assert.Equal(test.ExpectedSymbolSpace, vmview.CurrencyInfo.SymbolSpace); } - + //test inventory related features vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync(); vmpos.Title = "hello"; @@ -756,7 +758,7 @@ noninventoryitem: .ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "inventoryitem").Result); return Task.CompletedTask; }); - + //we already bought all available stock so this should fail await Task.Delay(100); Assert.IsType(publicApps @@ -819,13 +821,13 @@ normal: normalInvoice.CryptoInfo, s => PaymentTypes.BTCLike.ToString() == s.PaymentType && new[] { "BTC", "LTC" }.Contains( s.CryptoCode)); - + //test topup option vmpos.Template = @" a: price: 1000.0 title: good apple - + b: price: 10.0 custom: false @@ -843,7 +845,7 @@ f: g: custom: topup "; - + Assert.IsType(pos.UpdatePointOfSale(app.Id, vmpos).Result); vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync(); Assert.DoesNotContain("custom", vmpos.Template); @@ -855,7 +857,7 @@ g: 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); - + Assert.IsType(publicApps .ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result); invoices = user.BitPay.GetInvoices(); diff --git a/BTCPayServer.Tests/CrowdfundTests.cs b/BTCPayServer.Tests/CrowdfundTests.cs index 0e72b6086..f84cbf1c6 100644 --- a/BTCPayServer.Tests/CrowdfundTests.cs +++ b/BTCPayServer.Tests/CrowdfundTests.cs @@ -4,11 +4,11 @@ using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Models.AppViewModels; +using BTCPayServer.Plugins.Crowdfund; using BTCPayServer.Plugins.Crowdfund.Controllers; using BTCPayServer.Plugins.Crowdfund.Models; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; -using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Mvc; using NBitcoin; using NBitpayClient; @@ -34,18 +34,16 @@ namespace BTCPayServer.Tests await user.GrantAccessAsync(); var user2 = tester.NewAccount(); await user2.GrantAccessAsync(); - var stores = user.GetController(); var apps = user.GetController(); var apps2 = user2.GetController(); var crowdfund = user.GetController(); - var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId)).Model); - var appType = AppType.Crowdfund.ToString(); - Assert.NotNull(vm.SelectedAppType); + var appType = CrowdfundApp.AppType; + var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId, appType)).Model); + Assert.Equal(appType, vm.SelectedAppType); Assert.Null(vm.AppName); vm.AppName = "test"; - vm.SelectedAppType = appType; - var redirectToAction = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); - Assert.Equal(nameof(crowdfund.UpdateCrowdfund), redirectToAction.ActionName); + var redirect = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.EndsWith("/settings/crowdfund", redirect.Url); var appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); var app = appList.Apps[0]; var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType }; @@ -61,8 +59,8 @@ namespace BTCPayServer.Tests Assert.Equal(user.StoreId, appList.Apps[0].StoreId); Assert.IsType(apps2.DeleteApp(appList.Apps[0].Id)); Assert.IsType(apps.DeleteApp(appList.Apps[0].Id)); - redirectToAction = Assert.IsType(apps.DeleteAppPost(appList.Apps[0].Id).Result); - Assert.Equal(nameof(stores.Dashboard), redirectToAction.ActionName); + var redirectToAction = Assert.IsType(apps.DeleteAppPost(appList.Apps[0].Id).Result); + Assert.Equal(nameof(UIStoresController.Dashboard), redirectToAction.ActionName); appList = await apps.ListApps(user.StoreId).AssertViewModelAsync(); Assert.Empty(appList.Apps); } @@ -79,10 +77,11 @@ namespace BTCPayServer.Tests var apps = user.GetController(); var crowdfund = user.GetController(); var vm = apps.CreateApp(user.StoreId).AssertViewModel(); - var appType = AppType.Crowdfund.ToString(); + var appType = CrowdfundApp.AppType; vm.AppName = "test"; vm.SelectedAppType = appType; - Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + var redirect = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.EndsWith("/settings/crowdfund", redirect.Url); var appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); var app = appList.Apps[0]; var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType }; @@ -105,7 +104,7 @@ namespace BTCPayServer.Tests Amount = new decimal(0.01) }, default)); - Assert.IsType(await anonAppPubsController.ViewCrowdfund(app.Id, string.Empty)); + Assert.IsType(await anonAppPubsController.ViewCrowdfund(app.Id)); //Scenario 2: Not Enabled But Admin - Allowed Assert.IsType(await crowdfundController.ContributeToCrowdfund(app.Id, new ContributeToCrowdfund() @@ -113,8 +112,8 @@ namespace BTCPayServer.Tests RedirectToCheckout = false, Amount = new decimal(0.01) }, default)); - Assert.IsType(await crowdfundController.ViewCrowdfund(app.Id, string.Empty)); - Assert.IsType(await anonAppPubsController.ViewCrowdfund(app.Id, string.Empty)); + Assert.IsType(await crowdfundController.ViewCrowdfund(app.Id)); + Assert.IsType(await anonAppPubsController.ViewCrowdfund(app.Id)); //Scenario 3: Enabled But Start Date > Now - Not Allowed crowdfundViewModel.StartDate = DateTime.Today.AddDays(2); @@ -170,10 +169,10 @@ namespace BTCPayServer.Tests var apps = user.GetController(); var crowdfund = user.GetController(); var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId)).Model); - var appType = AppType.Crowdfund.ToString(); + var appType = CrowdfundApp.AppType; vm.AppName = "test"; vm.SelectedAppType = appType; - Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); var appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); var app = appList.Apps[0]; var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType }; @@ -193,7 +192,7 @@ namespace BTCPayServer.Tests var publicApps = user.GetController(); var model = Assert.IsType(Assert - .IsType(publicApps.ViewCrowdfund(app.Id, String.Empty).Result).Model); + .IsType(publicApps.ViewCrowdfund(app.Id).Result).Model); Assert.Equal(crowdfundViewModel.TargetAmount, model.TargetAmount); Assert.Equal(crowdfundViewModel.EndDate, model.EndDate); @@ -217,7 +216,7 @@ namespace BTCPayServer.Tests }, Facade.Merchant); model = Assert.IsType(Assert - .IsType(publicApps.ViewCrowdfund(app.Id, string.Empty).Result).Model); + .IsType(publicApps.ViewCrowdfund(app.Id).Result).Model); Assert.Equal(0m, model.Info.CurrentAmount); Assert.Equal(1m, model.Info.CurrentPendingAmount); @@ -226,12 +225,12 @@ namespace BTCPayServer.Tests TestLogs.LogInformation("Let's check current amount change once payment is confirmed"); var invoiceAddress = BitcoinAddress.Create(invoice.CryptoInfo[0].Address, tester.ExplorerNode.Network); - tester.ExplorerNode.SendToAddress(invoiceAddress, invoice.BtcDue); - tester.ExplorerNode.Generate(1); // By default invoice confirmed at 1 block + await tester.ExplorerNode.SendToAddressAsync(invoiceAddress, invoice.BtcDue); + await tester.ExplorerNode.GenerateAsync(1); // By default invoice confirmed at 1 block TestUtils.Eventually(() => { model = Assert.IsType(Assert - .IsType(publicApps.ViewCrowdfund(app.Id, String.Empty).Result).Model); + .IsType(publicApps.ViewCrowdfund(app.Id).Result).Model); Assert.Equal(1m, model.Info.CurrentAmount); Assert.Equal(0m, model.Info.CurrentPendingAmount); }); @@ -279,7 +278,7 @@ namespace BTCPayServer.Tests TestUtils.Eventually(() => { model = Assert.IsType(Assert - .IsType(publicApps.ViewCrowdfund(app.Id, string.Empty).Result).Model); + .IsType(publicApps.ViewCrowdfund(app.Id).Result).Model); Assert.Equal(0.7m, model.Info.CurrentPendingAmount); }); } diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs index 70892b9e6..0ba186197 100644 --- a/BTCPayServer.Tests/POSTests.cs +++ b/BTCPayServer.Tests/POSTests.cs @@ -2,10 +2,9 @@ using System.Threading.Tasks; using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Models.AppViewModels; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Controllers; using BTCPayServer.Plugins.PointOfSale.Models; -using BTCPayServer.Services.Apps; -using BTCPayServer.Tests.Logging; using Microsoft.AspNetCore.Mvc; using Xunit; using Xunit.Abstractions; @@ -32,10 +31,11 @@ namespace BTCPayServer.Tests var apps = user.GetController(); var pos = user.GetController(); var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId)).Model); - var appType = AppType.PointOfSale.ToString(); + var appType = PointOfSaleApp.AppType; vm.AppName = "test"; vm.SelectedAppType = appType; - Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + var redirect = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.EndsWith("/settings/pos", redirect.Url); var appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); var app = appList.Apps[0]; var appData = new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType }; diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 44b7386ee..bfc7150b8 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -35,6 +35,8 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.PayJoin.Sender; +using BTCPayServer.Plugins.PayButton; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Controllers; using BTCPayServer.Security.Bitpay; using BTCPayServer.Services; @@ -1953,14 +1955,13 @@ namespace BTCPayServer.Tests var apps = user.GetController(); var apps2 = user2.GetController(); var pos = user.GetController(); - var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId)).Model); - var appType = AppType.PointOfSale.ToString(); - Assert.NotNull(vm.SelectedAppType); + var appType = PointOfSaleApp.AppType; + var vm = Assert.IsType(Assert.IsType(apps.CreateApp(user.StoreId, appType)).Model); + Assert.Equal(appType, vm.SelectedAppType); Assert.Null(vm.AppName); vm.AppName = "test"; - vm.SelectedAppType = appType; - var redirectToAction = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); - Assert.Equal(nameof(pos.UpdatePointOfSale), redirectToAction.ActionName); + var redirect = Assert.IsType(apps.CreateApp(user.StoreId, vm).Result); + Assert.EndsWith("/settings/pos", redirect.Url); var appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); var appList2 = Assert.IsType(Assert.IsType(apps2.ListApps(user2.StoreId).Result).Model); @@ -1976,7 +1977,7 @@ namespace BTCPayServer.Tests Assert.Equal(user.StoreId, appList.Apps[0].StoreId); Assert.IsType(apps2.DeleteApp(appList.Apps[0].Id)); Assert.IsType(apps.DeleteApp(appList.Apps[0].Id)); - redirectToAction = Assert.IsType(apps.DeleteAppPost(appList.Apps[0].Id).Result); + var redirectToAction = Assert.IsType(apps.DeleteAppPost(appList.Apps[0].Id).Result); Assert.Equal(nameof(stores.Dashboard), redirectToAction.ActionName); appList = Assert.IsType(Assert.IsType(apps.ListApps(user.StoreId).Result).Model); Assert.Empty(appList.Apps); diff --git a/BTCPayServer/Components/AppSales/AppSales.cs b/BTCPayServer/Components/AppSales/AppSales.cs index 890308c02..39faeb9b9 100644 --- a/BTCPayServer/Components/AppSales/AppSales.cs +++ b/BTCPayServer/Components/AppSales/AppSales.cs @@ -27,20 +27,22 @@ public class AppSales : ViewComponent public async Task InvokeAsync(string appId, string appType) { - var vm = new AppSalesViewModel() + var vm = new AppSalesViewModel { Id = appId, AppType = appType, - Url = Url.Action("AppSales", "UIApps", new { appId = appId }), + DataUrl = Url.Action("AppSales", "UIApps", new { appId }), InitialRendering = HttpContext.GetAppData()?.Id != appId }; if (vm.InitialRendering) return View(vm); + var app = HttpContext.GetAppData(); - vm.AppType = app.AppType; - var stats = await _appService.GetSalesStats(HttpContext.GetAppData()); + var stats = await _appService.GetSalesStats(app); vm.SalesCount = stats.SalesCount; vm.Series = stats.Series; + vm.AppType = app.AppType; + vm.AppUrl = await _appService.ConfigureLink(app, app.AppType); return View(vm); } diff --git a/BTCPayServer/Components/AppSales/AppSalesViewModel.cs b/BTCPayServer/Components/AppSales/AppSalesViewModel.cs index c0490383e..c0c1e1bb0 100644 --- a/BTCPayServer/Components/AppSales/AppSalesViewModel.cs +++ b/BTCPayServer/Components/AppSales/AppSalesViewModel.cs @@ -10,7 +10,8 @@ public class AppSalesViewModel public string Name { get; set; } public string AppType { get; set; } public AppSalesPeriod Period { get; set; } - public string Url { get; set; } + public string AppUrl { get; set; } + public string DataUrl { get; set; } public long SalesCount { get; set; } public IEnumerable Series { get; set; } public bool InitialRendering { get; set; } diff --git a/BTCPayServer/Components/AppSales/Default.cshtml b/BTCPayServer/Components/AppSales/Default.cshtml index bee4f8fd3..5f7326259 100644 --- a/BTCPayServer/Components/AppSales/Default.cshtml +++ b/BTCPayServer/Components/AppSales/Default.cshtml @@ -1,17 +1,18 @@ -@using BTCPayServer.Services.Apps @using BTCPayServer.Components.AppSales -@inject BTCPayServer.Security.ContentSecurityPolicies Csp +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Plugins.Crowdfund @model BTCPayServer.Components.AppSales.AppSalesViewModel @{ - var controller = $"UI{Model.AppType}"; - var action = $"Update{Model.AppType}"; - var label = Model.AppType == "Crowdfund" ? "Contributions" : "Sales"; + var label = Model.AppType == CrowdfundApp.AppType ? "Contributions" : "Sales"; }

@Model.Name @label

- Manage + @if (!string.IsNullOrEmpty(Model.AppUrl)) + { + Manage + }
@if (Model.InitialRendering) { @@ -20,16 +21,16 @@ Loading...
- + diff --git a/BTCPayServer/Components/AppTopItems/AppTopItems.cs b/BTCPayServer/Components/AppTopItems/AppTopItems.cs index e985caaa8..44dce7bd2 100644 --- a/BTCPayServer/Components/AppTopItems/AppTopItems.cs +++ b/BTCPayServer/Components/AppTopItems/AppTopItems.cs @@ -21,24 +21,22 @@ public class AppTopItems : ViewComponent public async Task InvokeAsync(string appId, string appType = null) { - var vm = new AppTopItemsViewModel() + var vm = new AppTopItemsViewModel { Id = appId, AppType = appType, - Url = Url.Action("AppTopItems", "UIApps", new { appId = appId }), + DataUrl = Url.Action("AppTopItems", "UIApps", new { appId }), InitialRendering = HttpContext.GetAppData()?.Id != appId }; if (vm.InitialRendering) return View(vm); var app = HttpContext.GetAppData(); - vm.AppType = app.AppType; - var entries = Enum.Parse(vm.AppType) == AppType.Crowdfund - ? await _appService.GetPerkStats(app) - : await _appService.GetItemStats(app); - + var entries = await _appService.GetItemStats(app); vm.SalesCount = entries.Select(e => e.SalesCount).ToList(); vm.Entries = entries.ToList(); + vm.AppType = app.AppType; + vm.AppUrl = await _appService.ConfigureLink(app, app.AppType); return View(vm); } diff --git a/BTCPayServer/Components/AppTopItems/AppTopItemsViewModel.cs b/BTCPayServer/Components/AppTopItems/AppTopItemsViewModel.cs index 18620ede1..16cf6f2a2 100644 --- a/BTCPayServer/Components/AppTopItems/AppTopItemsViewModel.cs +++ b/BTCPayServer/Components/AppTopItems/AppTopItemsViewModel.cs @@ -9,7 +9,8 @@ public class AppTopItemsViewModel public string Id { get; set; } public string Name { get; set; } public string AppType { get; set; } - public string Url { get; set; } + public string AppUrl { get; set; } + public string DataUrl { get; set; } public List Entries { get; set; } public List SalesCount { get; set; } public bool InitialRendering { get; set; } diff --git a/BTCPayServer/Components/AppTopItems/Default.cshtml b/BTCPayServer/Components/AppTopItems/Default.cshtml index b8ad1707d..b0b209174 100644 --- a/BTCPayServer/Components/AppTopItems/Default.cshtml +++ b/BTCPayServer/Components/AppTopItems/Default.cshtml @@ -1,28 +1,28 @@ -@using BTCPayServer.Services.Apps -@inject BTCPayServer.Security.ContentSecurityPolicies Csp +@using BTCPayServer.Plugins.Crowdfund @model BTCPayServer.Components.AppTopItems.AppTopItemsViewModel @{ - var controller = $"UI{Model.AppType}"; - var action = $"Update{Model.AppType}"; - var label = Model.AppType == nameof(AppType.Crowdfund) ? "contribution" : "sale"; + var label = Model.AppType == CrowdfundApp.AppType ? "contribution" : "sale"; }
-
-

Top @(Model.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")

- View All -
- @if (Model.InitialRendering) - { -
-
- Loading... -
-
- - + - } - else if (Model.Entries.Any()) - { -
- -
- @for (var i = 0; i < Model.Entries.Count; i++) - { - var entry = Model.Entries[i]; -
- - - @entry.Title - - - @entry.SalesCount @($"{label}{(entry.SalesCount == 1 ? "" : "s")}"), - @entry.TotalFormatted - -
- } -
- } - else - { -

- No @($"{label}s") have been made yet. -

- } + + } + else if (Model.Entries.Any()) + { +
+ +
+ @for (var i = 0; i < Model.Entries.Count; i++) + { + var entry = Model.Entries[i]; +
+ + + @entry.Title + + + @entry.SalesCount @($"{label}{(entry.SalesCount == 1 ? "" : "s")}"), + @entry.TotalFormatted + +
+ } +
+ } + else + { +

+ No @($"{label}s") have been made yet. +

+ }
diff --git a/BTCPayServer/Components/MainNav/MainNav.cs b/BTCPayServer/Components/MainNav/MainNav.cs index 571df033b..f5986b7b4 100644 --- a/BTCPayServer/Components/MainNav/MainNav.cs +++ b/BTCPayServer/Components/MainNav/MainNav.cs @@ -74,7 +74,7 @@ namespace BTCPayServer.Components.MainNav Id = a.Id, IsOwner = a.IsOwner, AppName = a.AppName, - AppType = Enum.Parse(a.AppType) + AppType = a.AppType }).ToList(); if (PoliciesSettings.Experimental) diff --git a/BTCPayServer/Components/MainNav/MainNavViewModel.cs b/BTCPayServer/Components/MainNav/MainNavViewModel.cs index 46beb8dc6..6ec1c51c2 100644 --- a/BTCPayServer/Components/MainNav/MainNavViewModel.cs +++ b/BTCPayServer/Components/MainNav/MainNavViewModel.cs @@ -19,7 +19,7 @@ namespace BTCPayServer.Components.MainNav { public string Id { get; set; } public string AppName { get; set; } - public AppType AppType { get; set; } + public string AppType { get; set; } public bool IsOwner { get; set; } } } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs index 5698524e3..35d27af76 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs @@ -7,6 +7,8 @@ using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Plugins.Crowdfund; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; @@ -15,6 +17,7 @@ using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; +using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType; namespace BTCPayServer.Controllers.Greenfield { @@ -63,7 +66,7 @@ namespace BTCPayServer.Controllers.Greenfield { StoreDataId = storeId, Name = request.AppName, - AppType = AppType.Crowdfund.ToString() + AppType = CrowdfundApp.AppType }; appData.SetSettings(ToCrowdfundSettings(request)); @@ -94,7 +97,7 @@ namespace BTCPayServer.Controllers.Greenfield { StoreDataId = storeId, Name = request.AppName, - AppType = AppType.PointOfSale.ToString() + AppType = PointOfSaleApp.AppType }; appData.SetSettings(ToPointOfSaleSettings(request)); @@ -108,7 +111,7 @@ namespace BTCPayServer.Controllers.Greenfield [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task UpdatePointOfSaleApp(string appId, CreatePointOfSaleAppRequest request) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, PointOfSaleApp.AppType); if (app == null) { return AppNotFound(); @@ -181,7 +184,7 @@ namespace BTCPayServer.Controllers.Greenfield [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task GetPosApp(string appId) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, PointOfSaleApp.AppType); if (app == null) { return AppNotFound(); @@ -194,7 +197,7 @@ namespace BTCPayServer.Controllers.Greenfield [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task GetCrowdfundApp(string appId) { - var app = await _appService.GetApp(appId, AppType.Crowdfund); + var app = await _appService.GetApp(appId, CrowdfundApp.AppType); if (app == null) { return AppNotFound(); @@ -264,7 +267,7 @@ namespace BTCPayServer.Controllers.Greenfield return new PointOfSaleSettings() { Title = request.Title, - DefaultView = (Services.Apps.PosViewType)request.DefaultView, + DefaultView = (PosViewType) request.DefaultView, ShowCustomAmount = request.ShowCustomAmount, ShowDiscount = request.ShowDiscount, EnableTips = request.EnableTips, diff --git a/BTCPayServer/Controllers/UIAppsController.cs b/BTCPayServer/Controllers/UIAppsController.cs index e58afe85c..9e65c5f57 100644 --- a/BTCPayServer/Controllers/UIAppsController.cs +++ b/BTCPayServer/Controllers/UIAppsController.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; @@ -6,8 +5,6 @@ using BTCPayServer.Abstractions.Models; using BTCPayServer.Client; using BTCPayServer.Data; using BTCPayServer.Models.AppViewModels; -using BTCPayServer.Plugins.Crowdfund.Controllers; -using BTCPayServer.Plugins.PointOfSale.Controllers; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; @@ -57,13 +54,14 @@ namespace BTCPayServer.Controllers var app = await _appService.GetApp(appId, null); if (app is null) return NotFound(); - - return app.AppType switch + + var res = await _appService.ViewLink(app); + if (res is null) { - nameof(AppType.Crowdfund) => RedirectToAction(nameof(UICrowdfundController.ViewCrowdfund), "UICrowdfund", new { appId }), - nameof(AppType.PointOfSale) => RedirectToAction(nameof(UIPointOfSaleController.ViewPointOfSale), "UIPointOfSale", new { appId }), - _ => NotFound() - }; + return NotFound(); + } + + return Redirect(res); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] @@ -114,12 +112,10 @@ namespace BTCPayServer.Controllers [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpGet("/stores/{storeId}/apps/create")] - public IActionResult CreateApp(string storeId) + public IActionResult CreateApp(string storeId, string appType = null) { - return View(new CreateAppViewModel - { - StoreId = GetCurrentStore().Id - }); + var vm = new CreateAppViewModel (_appService){StoreId = GetCurrentStore().Id, SelectedAppType = appType}; + return View(vm); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] @@ -128,8 +124,8 @@ namespace BTCPayServer.Controllers { var store = GetCurrentStore(); vm.StoreId = store.Id; - - if (!Enum.TryParse(vm.SelectedAppType, out AppType appType)) + var types = _appService.GetAvailableAppTypes(); + if (!types.ContainsKey(vm.SelectedAppType)) ModelState.AddModelError(nameof(vm.SelectedAppType), "Invalid App Type"); if (!ModelState.IsValid) @@ -141,34 +137,18 @@ namespace BTCPayServer.Controllers { StoreDataId = store.Id, Name = vm.AppName, - AppType = appType.ToString() + AppType = vm.SelectedAppType }; var defaultCurrency = await GetStoreDefaultCurrentIfEmpty(appData.StoreDataId, null); - switch (appType) - { - case AppType.Crowdfund: - var emptyCrowdfund = new CrowdfundSettings { TargetCurrency = defaultCurrency }; - appData.SetSettings(emptyCrowdfund); - break; - case AppType.PointOfSale: - var empty = new PointOfSaleSettings { Currency = defaultCurrency }; - appData.SetSettings(empty); - break; - default: - throw new ArgumentOutOfRangeException(); - } - + await _appService.SetDefaultSettings(appData, defaultCurrency); await _appService.UpdateOrCreateApp(appData); + TempData[WellKnownTempData.SuccessMessage] = "App successfully created"; CreatedAppId = appData.Id; - return appType switch - { - AppType.PointOfSale => RedirectToAction(nameof(UIPointOfSaleController.UpdatePointOfSale), "UIPointOfSale", new { appId = appData.Id }), - AppType.Crowdfund => RedirectToAction(nameof(UICrowdfundController.UpdateCrowdfund), "UICrowdfund", new { appId = appData.Id }), - _ => throw new ArgumentOutOfRangeException() - }; + var url = await _appService.ConfigureLink(appData, vm.SelectedAppType); + return Redirect(url); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] diff --git a/BTCPayServer/Controllers/UIInvoiceController.cs b/BTCPayServer/Controllers/UIInvoiceController.cs index 296ef295f..caaee46f6 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.cs @@ -213,7 +213,8 @@ namespace BTCPayServer.Controllers return await CreateInvoiceCoreRaw(invoiceRequest, storeData, request.GetAbsoluteRoot(), additionalTags, cancellationToken); } - internal async Task CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List? additionalTags = null, CancellationToken cancellationToken = default, Action? entityManipulator = null) + [NonAction] + public async Task CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List? additionalTags = null, CancellationToken cancellationToken = default, Action? entityManipulator = null) { var storeBlob = store.GetStoreBlob(); var entity = _InvoiceRepository.CreateNewInvoice(); diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index 6e3dd634d..0091f2079 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -18,6 +18,8 @@ using BTCPayServer.HostedServices; using BTCPayServer.Lightning; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Plugins.Crowdfund; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Services; using BTCPayServer.Services.Apps; @@ -47,7 +49,6 @@ namespace BTCPayServer private readonly LightningLikePaymentHandler _lightningLikePaymentHandler; private readonly StoreRepository _storeRepository; private readonly AppService _appService; - private readonly UIInvoiceController _invoiceController; private readonly LinkGenerator _linkGenerator; private readonly LightningAddressService _lightningAddressService; @@ -155,6 +156,7 @@ namespace BTCPayServer if (claimResponse.Result != ClaimRequest.ClaimResult.Ok) return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Payment request could not be paid" }); + switch (claimResponse.PayoutData.State) { case PayoutState.AwaitingPayment: @@ -249,37 +251,49 @@ namespace BTCPayServer return NotFound(); } - ViewPointOfSaleViewModel.Item[] items = null; - string currencyCode = null; + ViewPointOfSaleViewModel.Item[] items; + string currencyCode; + PointOfSaleSettings posS = null; switch (app.AppType) { - case nameof(AppType.Crowdfund): + case CrowdfundApp.AppType: var cfS = app.GetSettings(); currencyCode = cfS.TargetCurrency; items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency); break; - case nameof(AppType.PointOfSale): - var posS = app.GetSettings(); + case PointOfSaleApp.AppType: + posS = app.GetSettings(); currencyCode = posS.Currency; items = _appService.Parse(posS.Template, posS.Currency); break; + default: + //TODO: Allow other apps to define lnurl support + return NotFound(); } - var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode); - var item = items.FirstOrDefault(item1 => - item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) || - item1.Id.Equals(escapedItemId, StringComparison.InvariantCultureIgnoreCase)); + ViewPointOfSaleViewModel.Item item = null; + if (!string.IsNullOrEmpty(itemCode)) + { + var escapedItemId = Extensions.UnescapeBackSlashUriString(itemCode); + item = items.FirstOrDefault(item1 => + 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 || + (item.PaymentMethods?.Any() is true && + item.PaymentMethods?.Any(s => PaymentMethodId.Parse(s) == pmi) is false)) + { + return NotFound(); + } + } + else if (app.AppType == PointOfSaleApp.AppType && posS?.ShowCustomAmount is not true) { return NotFound(); } - + return await GetLNURL(cryptoCode, app.StoreDataId, currencyCode, null, null, - () => (null, app, item, new List { AppService.GetAppInternalTag(appId) }, item.Price.Value, true)); + () => (null, app, item, new List { AppService.GetAppInternalTag(appId) }, item?.Price.Value, true)); } public class EditLightningAddressVM @@ -311,11 +325,8 @@ namespace BTCPayServer public decimal? Max { get; set; } } - public ConcurrentDictionary Items { get; set; } = - new ConcurrentDictionary(); - - public ConcurrentDictionary StoreToItemMap { get; set; } = - new ConcurrentDictionary(); + public ConcurrentDictionary Items { get; } = new (); + public ConcurrentDictionary StoreToItemMap { get; } = new (); public override string ToString() { @@ -389,7 +400,7 @@ namespace BTCPayServer var redirectUrl = app?.AppType switch { - nameof(AppType.PointOfSale) => app.GetSettings().RedirectUrl ?? + PointOfSaleApp.AppType => app.GetSettings().RedirectUrl ?? HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"), _ => null }; diff --git a/BTCPayServer/Controllers/UIPublicController.cs b/BTCPayServer/Controllers/UIPublicController.cs index 22d5cb53b..cc6e2f95c 100644 --- a/BTCPayServer/Controllers/UIPublicController.cs +++ b/BTCPayServer/Controllers/UIPublicController.cs @@ -5,7 +5,6 @@ using System.Web; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Data; using BTCPayServer.Models; -using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Plugins.PayButton.Models; using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Cors; diff --git a/BTCPayServer/Controllers/UIServerController.cs b/BTCPayServer/Controllers/UIServerController.cs index 72d3ae327..9a069f52e 100644 --- a/BTCPayServer/Controllers/UIServerController.cs +++ b/BTCPayServer/Controllers/UIServerController.cs @@ -347,7 +347,7 @@ namespace BTCPayServer.Controllers if (appIdsToFetch.Any()) { var apps = (await _AppService.GetApps(appIdsToFetch.ToArray())) - .ToDictionary(data => data.Id, data => Enum.Parse(data.AppType)); + .ToDictionary(data => data.Id, data => data.AppType); ; if (!string.IsNullOrEmpty(settings.RootAppId)) { @@ -422,8 +422,10 @@ namespace BTCPayServer.Controllers private async Task> GetAppSelectList() { + var types = _AppService.GetAvailableAppTypes(); var apps = (await _AppService.GetAllApps(null, true)) - .Select(a => new SelectListItem($"{typeof(AppType).DisplayName(a.AppType)} - {a.AppName} - {a.StoreName}", a.Id)).ToList(); + .Select(a => + new SelectListItem($"{types[a.AppType]} - {a.AppName} - {a.StoreName}", a.Id)).ToList(); apps.Insert(0, new SelectListItem("(None)", null)); return apps; } diff --git a/BTCPayServer/Filters/DomainMappingConstraintAttribute.cs b/BTCPayServer/Filters/DomainMappingConstraintAttribute.cs index 0f7dca3f5..407a8a645 100644 --- a/BTCPayServer/Filters/DomainMappingConstraintAttribute.cs +++ b/BTCPayServer/Filters/DomainMappingConstraintAttribute.cs @@ -14,13 +14,13 @@ namespace BTCPayServer.Filters { } - public DomainMappingConstraintAttribute(AppType appType) + public DomainMappingConstraintAttribute(string appType) { AppType = appType; } public int Order => 100; - private AppType? AppType { get; } + private string AppType { get; } public bool Accept(ActionConstraintContext context) { diff --git a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs index fe7b6116c..92744375e 100644 --- a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs +++ b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs @@ -3,9 +3,10 @@ using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using BTCPayServer.Controllers; using BTCPayServer.Events; using BTCPayServer.Logging; +using BTCPayServer.Plugins.Crowdfund; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Services.Apps; namespace BTCPayServer.HostedServices @@ -34,13 +35,13 @@ namespace BTCPayServer.HostedServices //get all apps that were tagged that have manageable inventory that has an item that matches the item code in the invoice var apps = (await _appService.GetApps(updateAppInventory.AppId)).Select(data => { - switch (Enum.Parse(data.AppType)) + switch (data.AppType) { - case AppType.PointOfSale: + case PointOfSaleApp.AppType: var possettings = data.GetSettings(); return (Data: data, Settings: (object)possettings, Items: _appService.Parse(possettings.Template, possettings.Currency)); - case AppType.Crowdfund: + case CrowdfundApp.AppType: var cfsettings = data.GetSettings(); return (Data: data, Settings: (object)cfsettings, Items: _appService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency)); @@ -65,14 +66,13 @@ namespace BTCPayServer.HostedServices } } - switch (Enum.Parse(valueTuple.Data.AppType)) + switch (valueTuple.Data.AppType) { - case AppType.PointOfSale: - + case PointOfSaleApp.AppType: ((PointOfSaleSettings)valueTuple.Settings).Template = _appService.SerializeTemplate(valueTuple.Items); break; - case AppType.Crowdfund: + case CrowdfundApp.AppType: ((CrowdfundSettings)valueTuple.Settings).PerksTemplate = _appService.SerializeTemplate(valueTuple.Items); break; diff --git a/BTCPayServer/Hosting/MigrationStartupTask.cs b/BTCPayServer/Hosting/MigrationStartupTask.cs index 02e0d35d2..20866af21 100644 --- a/BTCPayServer/Hosting/MigrationStartupTask.cs +++ b/BTCPayServer/Hosting/MigrationStartupTask.cs @@ -7,36 +7,30 @@ using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Client.Models; using BTCPayServer.Configuration; -using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Fido2; using BTCPayServer.Fido2.Models; using BTCPayServer.Logging; -using BTCPayServer.Models.AppViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; +using BTCPayServer.Plugins.Crowdfund; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Services; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Stores; using BTCPayServer.Storage.Models; using BTCPayServer.Storage.Services.Providers.FileSystemStorage.Configuration; -using ExchangeSharp; using Fido2NetLib.Objects; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using NBitcoin; -using NBitcoin.DataEncoders; using NBXplorer; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using PeterO.Cbor; using LightningAddressData = BTCPayServer.Data.LightningAddressData; -using PayoutData = BTCPayServer.Data.PayoutData; -using PullPaymentData = BTCPayServer.Data.PullPaymentData; -using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Hosting { @@ -476,7 +470,7 @@ WHERE cte.""Id""=p.""Id"" string newTemplate; switch (app.AppType) { - case nameof(AppType.Crowdfund): + case CrowdfundApp.AppType: var settings1 = app.GetSettings(); if (string.IsNullOrEmpty(settings1.TargetCurrency)) { @@ -492,7 +486,7 @@ WHERE cte.""Id""=p.""Id"" }; break; - case nameof(AppType.PointOfSale): + case PointOfSaleApp.AppType: var settings2 = app.GetSettings(); if (string.IsNullOrEmpty(settings2.Currency)) diff --git a/BTCPayServer/Models/AppViewModels/CreateAppViewModel.cs b/BTCPayServer/Models/AppViewModels/CreateAppViewModel.cs index a0cdb90e3..717a6af76 100644 --- a/BTCPayServer/Models/AppViewModels/CreateAppViewModel.cs +++ b/BTCPayServer/Models/AppViewModels/CreateAppViewModel.cs @@ -1,7 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Reflection; -using BTCPayServer.Data; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Services.Apps; using Microsoft.AspNetCore.Mvc.Rendering; @@ -11,17 +10,16 @@ namespace BTCPayServer.Models.AppViewModels { public CreateAppViewModel() { - SetApps(); } - class Format + + public CreateAppViewModel(AppService appService) { - public string Name { get; set; } - public string Value { get; set; } + SetApps(appService); } + [Required] [MaxLength(50)] [MinLength(1)] - [Display(Name = "App Name")] public string AppName { get; set; } @@ -33,16 +31,14 @@ namespace BTCPayServer.Models.AppViewModels public SelectList AppTypes { get; set; } - void SetApps() + private void SetApps(AppService appService) { - var defaultAppType = AppType.PointOfSale.ToString(); - var choices = typeof(AppType).GetEnumNames().Select(o => new Format - { - Name = typeof(AppType).DisplayName(o), - Value = o - }).ToArray(); + var defaultAppType = PointOfSaleApp.AppType; + var choices = appService.GetAvailableAppTypes().Select(pair => + new SelectListItem(pair.Value, pair.Key, pair.Key == defaultAppType)); + var chosen = choices.FirstOrDefault(f => f.Value == defaultAppType) ?? choices.FirstOrDefault(); - AppTypes = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); + AppTypes = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Text), chosen); SelectedAppType = chosen.Value; } diff --git a/BTCPayServer/Models/AppViewModels/ListAppsViewModel.cs b/BTCPayServer/Models/AppViewModels/ListAppsViewModel.cs index b837422d3..b5bc0fca9 100644 --- a/BTCPayServer/Models/AppViewModels/ListAppsViewModel.cs +++ b/BTCPayServer/Models/AppViewModels/ListAppsViewModel.cs @@ -1,4 +1,5 @@ using System; +using BTCPayServer.Data; namespace BTCPayServer.Models.AppViewModels @@ -18,6 +19,7 @@ namespace BTCPayServer.Models.AppViewModels public string UpdateAction { get { return "Update" + AppType; } } public string ViewAction { get { return "View" + AppType; } } public DateTimeOffset Created { get; set; } + public AppData App { get; set; } } public ListAppViewModel[] Apps { get; set; } diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index feed74464..bb1522493 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -19,20 +19,21 @@ namespace BTCPayServer.PaymentRequest { private readonly PaymentRequestRepository _PaymentRequestRepository; private readonly BTCPayNetworkProvider _BtcPayNetworkProvider; - private readonly AppService _AppService; + private readonly InvoiceRepository _invoiceRepository; private readonly CurrencyNameTable _currencies; private readonly DisplayFormatter _displayFormatter; public PaymentRequestService( PaymentRequestRepository paymentRequestRepository, BTCPayNetworkProvider btcPayNetworkProvider, + InvoiceRepository invoiceRepository, AppService appService, DisplayFormatter displayFormatter, CurrencyNameTable currencies) { _PaymentRequestRepository = paymentRequestRepository; _BtcPayNetworkProvider = btcPayNetworkProvider; - _AppService = appService; + _invoiceRepository = invoiceRepository; _currencies = currencies; _displayFormatter = displayFormatter; } @@ -60,7 +61,7 @@ namespace BTCPayServer.PaymentRequest if (currentStatus != Client.Models.PaymentRequestData.PaymentRequestStatus.Expired) { var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id); - var contributions = _AppService.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); + var contributions = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); currentStatus = contributions.TotalCurrency >= blob.Amount ? Client.Models.PaymentRequestData.PaymentRequestStatus.Completed @@ -86,7 +87,7 @@ namespace BTCPayServer.PaymentRequest var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id); - var paymentStats = _AppService.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); + var paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); var amountDue = blob.Amount - paymentStats.TotalCurrency; var pendingInvoice = invoices.OrderByDescending(entity => entity.InvoiceTime) .FirstOrDefault(entity => entity.Status == InvoiceStatusLegacy.New); diff --git a/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs b/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs index a8ab7735b..bbcd41cc3 100644 --- a/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs +++ b/BTCPayServer/Plugins/Crowdfund/Controllers/UICrowdfundController.cs @@ -35,11 +35,13 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers EventAggregator eventAggregator, StoreRepository storeRepository, UIInvoiceController invoiceController, - UserManager userManager) + UserManager userManager, + CrowdfundApp app) { _currencies = currencies; _appService = appService; _userManager = userManager; + _app = app; _storeRepository = storeRepository; _eventAggregator = eventAggregator; _invoiceController = invoiceController; @@ -51,20 +53,21 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers private readonly AppService _appService; private readonly UIInvoiceController _invoiceController; private readonly UserManager _userManager; + private readonly CrowdfundApp _app; [HttpGet("/")] [HttpGet("/apps/{appId}/crowdfund")] [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)] - [DomainMappingConstraint(AppType.Crowdfund)] - public async Task ViewCrowdfund(string appId, string statusMessage) + [DomainMappingConstraint(CrowdfundApp.AppType)] + public async Task ViewCrowdfund(string appId) { - var app = await _appService.GetApp(appId, AppType.Crowdfund, true); + var app = await _appService.GetApp(appId, CrowdfundApp.AppType, true); if (app == null) return NotFound(); var settings = app.GetSettings(); - var isAdmin = await _appService.GetAppDataIfOwner(GetUserId(), appId, AppType.Crowdfund) != null; + var isAdmin = await _appService.GetAppDataIfOwner(GetUserId(), appId, CrowdfundApp.AppType) != null; var hasEnoughSettingsToLoad = !string.IsNullOrEmpty(settings.TargetCurrency); if (!hasEnoughSettingsToLoad) @@ -89,17 +92,17 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)] [IgnoreAntiforgeryToken] [EnableCors(CorsPolicies.All)] - [DomainMappingConstraint(AppType.Crowdfund)] + [DomainMappingConstraint(CrowdfundApp.AppType)] [RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)] public async Task ContributeToCrowdfund(string appId, ContributeToCrowdfund request, CancellationToken cancellationToken) { - var app = await _appService.GetApp(appId, AppType.Crowdfund, true); + var app = await _appService.GetApp(appId, CrowdfundApp.AppType, true); if (app == null) return NotFound(); var settings = app.GetSettings(); - var isAdmin = await _appService.GetAppDataIfOwner(GetUserId(), appId, AppType.Crowdfund) != null; + var isAdmin = await _appService.GetAppDataIfOwner(GetUserId(), appId, CrowdfundApp.AppType) != null; if (!settings.Enabled && !isAdmin) { @@ -395,7 +398,12 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers private async Task GetAppInfo(string appId) { - var info = (ViewCrowdfundViewModel)await _appService.GetAppInfo(appId); + var app = await _appService.GetApp(appId, CrowdfundApp.AppType, true); + if (app is null) + { + return null; + } + var info = (ViewCrowdfundViewModel) await _app.GetInfo(app); info.HubPath = AppHub.GetHubPath(Request); info.SimpleDisplay = Request.Query.ContainsKey("simple"); return info; diff --git a/BTCPayServer/Plugins/Crowdfund/CrowdfundPlugin.cs b/BTCPayServer/Plugins/Crowdfund/CrowdfundPlugin.cs index c012149c5..3a3f11ed1 100644 --- a/BTCPayServer/Plugins/Crowdfund/CrowdfundPlugin.cs +++ b/BTCPayServer/Plugins/Crowdfund/CrowdfundPlugin.cs @@ -1,9 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Services; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.Plugins.Crowdfund.Controllers; +using BTCPayServer.Plugins.Crowdfund.Models; +using BTCPayServer.Services; +using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using Ganss.XSS; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; -namespace BTCPayServer.Plugins.PayButton +namespace BTCPayServer.Plugins.Crowdfund { public class CrowdfundPlugin : BaseBTCPayServerPlugin { @@ -14,7 +29,250 @@ namespace BTCPayServer.Plugins.PayButton public override void Execute(IServiceCollection services) { services.AddSingleton(new UIExtension("Crowdfund/NavExtension", "apps-nav")); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); + base.Execute(services); } } + + public class CrowdfundApp: IApp + { + private readonly LinkGenerator _linkGenerator; + private readonly IOptions _options; + private readonly DisplayFormatter _displayFormatter; + private readonly CurrencyNameTable _currencyNameTable; + private readonly HtmlSanitizer _htmlSanitizer; + private readonly InvoiceRepository _invoiceRepository; + public const string AppType = "Crowdfund"; + public string Description => AppType; + public string Type => AppType; + public bool SupportsSalesStats => true; + public bool SupportsItemStats => true; + + public CrowdfundApp( + LinkGenerator linkGenerator, + IOptions options, + InvoiceRepository invoiceRepository, + DisplayFormatter displayFormatter, + CurrencyNameTable currencyNameTable, + HtmlSanitizer htmlSanitizer) + { + _linkGenerator = linkGenerator; + _options = options; + _displayFormatter = displayFormatter; + _currencyNameTable = currencyNameTable; + _htmlSanitizer = htmlSanitizer; + _invoiceRepository = invoiceRepository; + } + + public Task ConfigureLink(AppData app) + { + return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UICrowdfundController.UpdateCrowdfund), + "UICrowdfund", new { appId = app.Id }, _options.Value.RootPath)); + } + + public Task GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays) + { + var cfS = app.GetSettings(); + var items = AppService.Parse(_htmlSanitizer, _displayFormatter, cfS.PerksTemplate, cfS.TargetCurrency); + return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays); + } + + public Task> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices) + { + var settings = appData.GetSettings(); + var perks = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency); + 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 + !string.IsNullOrEmpty(entity.Metadata.ItemCode)) + .GroupBy(entity => entity.Metadata.ItemCode) + .Select(entities => + { + var total = entities + .Sum(entity => entity.GetPayments(true) + .Sum(pay => + { + var paymentMethodId = pay.GetPaymentMethodId(); + var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee; + var rate = entity.GetPaymentMethod(paymentMethodId).Rate; + return rate * value; + })); + var itemCode = entities.Key; + var perk = perks.FirstOrDefault(p => p.Id == itemCode); + return new ItemStats + { + ItemCode = itemCode, + Title = perk?.Title ?? itemCode, + SalesCount = entities.Count(), + Total = total, + TotalFormatted = _displayFormatter.Currency(total, settings.TargetCurrency) + }; + }) + .OrderByDescending(stats => stats.SalesCount); + + return Task.FromResult>(perkCount); + } + + public async Task GetInfo(AppData appData) + { + var settings = appData.GetSettings(); + var resetEvery = settings.StartDate.HasValue ? settings.ResetEvery : CrowdfundResetEvery.Never; + DateTime? lastResetDate = null; + DateTime? nextResetDate = null; + if (resetEvery != CrowdfundResetEvery.Never) + { + lastResetDate = settings.StartDate.Value; + + nextResetDate = lastResetDate.Value; + while (DateTime.UtcNow >= nextResetDate) + { + lastResetDate = nextResetDate; + switch (resetEvery) + { + case CrowdfundResetEvery.Hour: + nextResetDate = lastResetDate.Value.AddHours(settings.ResetEveryAmount); + break; + case CrowdfundResetEvery.Day: + nextResetDate = lastResetDate.Value.AddDays(settings.ResetEveryAmount); + break; + case CrowdfundResetEvery.Month: + nextResetDate = lastResetDate.Value.AddMonths(settings.ResetEveryAmount); + break; + case CrowdfundResetEvery.Year: + nextResetDate = lastResetDate.Value.AddYears(settings.ResetEveryAmount); + break; + } + } + } + + var invoices = await AppService.GetInvoicesForApp(_invoiceRepository,appData, lastResetDate); + var completeInvoices = invoices.Where(IsComplete).ToArray(); + var pendingInvoices = invoices.Where(IsPending).ToArray(); + var paidInvoices = invoices.Where(IsPaid).ToArray(); + + var pendingPayments = _invoiceRepository.GetContributionsByPaymentMethodId(settings.TargetCurrency, pendingInvoices, !settings.EnforceTargetAmount); + var currentPayments = _invoiceRepository.GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount); + + var perkCount = paidInvoices + .Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode)) + .GroupBy(entity => entity.Metadata.ItemCode) + .ToDictionary(entities => entities.Key, entities => entities.Count()); + + Dictionary perkValue = new(); + if (settings.DisplayPerksValue) + { + perkValue = paidInvoices + .Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrEmpty(entity.Metadata.ItemCode)) + .GroupBy(entity => entity.Metadata.ItemCode) + .ToDictionary(entities => entities.Key, entities => + entities.Sum(entity => entity.GetPayments(true).Sum(pay => + { + var paymentMethodId = pay.GetPaymentMethodId(); + var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee; + var rate = entity.GetPaymentMethod(paymentMethodId).Rate; + return rate * value; + }))); + } + + var perks = AppService.GetPOSItems(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency); + if (settings.SortPerksByPopularity) + { + var ordered = perkCount.OrderByDescending(pair => pair.Value); + var newPerksOrder = ordered + .Select(keyValuePair => perks.SingleOrDefault(item => item.Id == keyValuePair.Key)) + .Where(matchingPerk => matchingPerk != null) + .ToList(); + var remainingPerks = perks.Where(item => !newPerksOrder.Contains(item)); + newPerksOrder.AddRange(remainingPerks); + perks = newPerksOrder.ToArray(); + } + + var store = appData.StoreData; + var storeBlob = store.GetStoreBlob(); + + return new ViewCrowdfundViewModel + { + Title = settings.Title, + Tagline = settings.Tagline, + Description = settings.Description, + CustomCSSLink = settings.CustomCSSLink, + MainImageUrl = settings.MainImageUrl, + EmbeddedCSS = settings.EmbeddedCSS, + StoreName = store.StoreName, + CssFileId = storeBlob.CssFileId, + LogoFileId = storeBlob.LogoFileId, + BrandColor = storeBlob.BrandColor, + StoreId = appData.StoreDataId, + AppId = appData.Id, + StartDate = settings.StartDate?.ToUniversalTime(), + EndDate = settings.EndDate?.ToUniversalTime(), + TargetAmount = settings.TargetAmount, + TargetCurrency = settings.TargetCurrency, + EnforceTargetAmount = settings.EnforceTargetAmount, + Perks = perks, + Enabled = settings.Enabled, + DisqusEnabled = settings.DisqusEnabled, + SoundsEnabled = settings.SoundsEnabled, + DisqusShortname = settings.DisqusShortname, + AnimationsEnabled = settings.AnimationsEnabled, + ResetEveryAmount = settings.ResetEveryAmount, + ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery), + DisplayPerksRanking = settings.DisplayPerksRanking, + PerkCount = perkCount, + PerkValue = perkValue, + NeverReset = settings.ResetEvery == CrowdfundResetEvery.Never, + Sounds = settings.Sounds, + AnimationColors = settings.AnimationColors, + CurrencyData = _currencyNameTable.GetCurrencyData(settings.TargetCurrency, true), + CurrencyDataPayments = currentPayments.Select(pair => pair.Key) + .Concat(pendingPayments.Select(pair => pair.Key)) + .Select(id => _currencyNameTable.GetCurrencyData(id.CryptoCode, true)).DistinctBy(data => data.Code) + .ToDictionary(data => data.Code, data => data), + Info = new ViewCrowdfundViewModel.CrowdfundInfo + { + TotalContributors = paidInvoices.Length, + ProgressPercentage = (currentPayments.TotalCurrency / settings.TargetAmount) * 100, + PendingProgressPercentage = (pendingPayments.TotalCurrency / settings.TargetAmount) * 100, + LastUpdated = DateTime.UtcNow, + PaymentStats = currentPayments.ToDictionary(c => c.Key.ToString(), c => c.Value.Value), + PendingPaymentStats = pendingPayments.ToDictionary(c => c.Key.ToString(), c => c.Value.Value), + LastResetDate = lastResetDate, + NextResetDate = nextResetDate, + CurrentPendingAmount = pendingPayments.TotalCurrency, + CurrentAmount = currentPayments.TotalCurrency + } + }; + } + + public Task SetDefaultSettings(AppData appData, string defaultCurrency) + { + var emptyCrowdfund = new CrowdfundSettings { TargetCurrency = defaultCurrency }; + appData.SetSettings(emptyCrowdfund); + return Task.CompletedTask; + } + + public Task ViewLink(AppData app) + { + return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UICrowdfundController.ViewCrowdfund), + "UICrowdfund", new {appId = app.Id}, _options.Value.RootPath)); + } + + private static bool IsPaid(InvoiceEntity entity) + { + return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed || entity.Status == InvoiceStatusLegacy.Paid; + } + + private static bool IsPending(InvoiceEntity entity) + { + return !(entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed); + } + + private static bool IsComplete(InvoiceEntity entity) + { + return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed; + } + } } diff --git a/BTCPayServer/Plugins/Crowdfund/Models/ViewCrowdfundViewModel.cs b/BTCPayServer/Plugins/Crowdfund/Models/ViewCrowdfundViewModel.cs index a5302151d..b599ec9e5 100644 --- a/BTCPayServer/Plugins/Crowdfund/Models/ViewCrowdfundViewModel.cs +++ b/BTCPayServer/Plugins/Crowdfund/Models/ViewCrowdfundViewModel.cs @@ -60,20 +60,8 @@ namespace BTCPayServer.Plugins.Crowdfund.Models public DateTime? LastResetDate { get; set; } public DateTime? NextResetDate { get; set; } } - public class Contribution - { - public PaymentMethodId PaymentMethodId { get; set; } - public decimal Value { get; set; } - public decimal CurrencyValue { get; set; } - } - public class Contributions : Dictionary - { - public Contributions(IEnumerable> collection) : base(collection) - { - TotalCurrency = Values.Select(v => v.CurrencyValue).Sum(); - } - public decimal TotalCurrency { get; } - } + + public bool Started => !StartDate.HasValue || DateTime.UtcNow > StartDate; diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index 7d8e587b2..d7d265d30 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -64,11 +64,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers [HttpGet("/")] [HttpGet("/apps/{appId}/pos")] [HttpGet("/apps/{appId}/pos/{viewType?}")] - [DomainMappingConstraint(AppType.PointOfSale)] + [DomainMappingConstraint(PointOfSaleApp.AppType)] [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)] public async Task ViewPointOfSale(string appId, PosViewType? viewType = null) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, PointOfSaleApp.AppType); if (app == null) return NotFound(); var settings = app.GetSettings(); @@ -121,7 +121,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers [HttpPost("/apps/{appId}/pos/{viewType?}")] [IgnoreAntiforgeryToken] [EnableCors(CorsPolicies.All)] - [DomainMappingConstraint(AppType.PointOfSale)] + [DomainMappingConstraint(PointOfSaleApp.AppType)] [RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)] [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)] public async Task ViewPointOfSale(string appId, @@ -137,7 +137,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore, CancellationToken cancellationToken = default) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, PointOfSaleApp.AppType); if (string.IsNullOrEmpty(choiceKey) && amount <= 0) { return RedirectToAction(nameof(ViewPointOfSale), new { appId }); @@ -334,7 +334,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)] public async Task POSForm(string appId, PosViewType? viewType = null) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, PointOfSaleApp.AppType); if (app == null) return NotFound(); @@ -349,7 +349,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers var formParameters = Request.Form .Where(pair => pair.Key != "__RequestVerificationToken") .ToMultiValueDictionary(p => p.Key, p => p.Value.ToString()); - var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);; + var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture); var store = await _appService.GetStore(app); var storeBlob = store.GetStoreBlob(); var form = Form.Parse(formData.Config); @@ -380,7 +380,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers [XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)] public async Task POSFormSubmit(string appId, FormViewModel viewModel, PosViewType? viewType = null) { - var app = await _appService.GetApp(appId, AppType.PointOfSale); + var app = await _appService.GetApp(appId, PointOfSaleApp.AppType); if (app == null) return NotFound(); @@ -403,8 +403,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers if (FormDataService.Validate(form, ModelState)) { - - var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);; + var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture); var redirectUrl = Request.GetAbsoluteUri(Url.Action(nameof(ViewPointOfSale), controller, new {appId, viewType})); formParameters.Add("formResponse", form.GetValues().ToString()); diff --git a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs index 893df0c3b..d7fb95cd9 100644 --- a/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs +++ b/BTCPayServer/Plugins/PointOfSale/Models/UpdatePointOfSaleViewModel.cs @@ -1,7 +1,5 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using BTCPayServer.Services.Apps; -using BTCPayServer.Services.Stores; using BTCPayServer.Validation; using Microsoft.AspNetCore.Mvc.Rendering; diff --git a/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs b/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs index 9a1a30d3a..60ed171a3 100644 --- a/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs +++ b/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs @@ -1,9 +1,23 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Services; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.Plugins.PointOfSale.Controllers; +using BTCPayServer.Services; +using BTCPayServer.Services.Apps; +using BTCPayServer.Services.Invoices; +using Ganss.XSS; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; -namespace BTCPayServer.Plugins.PayButton +namespace BTCPayServer.Plugins.PointOfSale { public class PointOfSalePlugin : BaseBTCPayServerPlugin { @@ -14,7 +28,108 @@ namespace BTCPayServer.Plugins.PayButton public override void Execute(IServiceCollection services) { services.AddSingleton(new UIExtension("PointOfSale/NavExtension", "apps-nav")); + services.AddSingleton(); base.Execute(services); } } + + public enum PosViewType + { + [Display(Name = "Product list")] + Static, + [Display(Name = "Product list with cart")] + Cart, + [Display(Name = "Keypad only")] + Light, + [Display(Name = "Print display")] + Print + } + + public class PointOfSaleApp: IApp + { + private readonly LinkGenerator _linkGenerator; + private readonly IOptions _btcPayServerOptions; + private readonly DisplayFormatter _displayFormatter; + private readonly HtmlSanitizer _htmlSanitizer; + public const string AppType = "PointOfSale"; + public string Description => "Point of Sale"; + public string Type => AppType; + public bool SupportsSalesStats => true; + public bool SupportsItemStats => true; + + public PointOfSaleApp( + LinkGenerator linkGenerator, + IOptions btcPayServerOptions, + DisplayFormatter displayFormatter, + HtmlSanitizer htmlSanitizer) + { + _linkGenerator = linkGenerator; + _btcPayServerOptions = btcPayServerOptions; + _displayFormatter = displayFormatter; + _htmlSanitizer = htmlSanitizer; + } + + public Task ConfigureLink(AppData app) + { + return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIPointOfSaleController.UpdatePointOfSale), + "UIPointOfSale", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)); + } + + public Task GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays) + { + var posS = app.GetSettings(); + var items = AppService.Parse(_htmlSanitizer, _displayFormatter, posS.Template, posS.Currency); + return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays); + } + + public Task> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices) + { + var settings = appData.GetSettings(); + var items = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.Template, settings.Currency); + 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 + entity.Metadata.PosData is not null || + // The item code should be present for all types other than the cart and keypad + !string.IsNullOrEmpty(entity.Metadata.ItemCode) + )) + .Aggregate(new List(), AppService.AggregateInvoiceEntitiesForStats(items)) + .GroupBy(entity => entity.ItemCode) + .Select(entities => + { + var total = entities.Sum(entity => entity.FiatPrice); + var itemCode = entities.Key; + var item = items.FirstOrDefault(p => p.Id == itemCode); + return new ItemStats + { + ItemCode = itemCode, + Title = item?.Title ?? itemCode, + SalesCount = entities.Count(), + Total = total, + TotalFormatted = _displayFormatter.Currency(total, settings.Currency) + }; + }) + .OrderByDescending(stats => stats.SalesCount); + + return Task.FromResult>(itemCount); + } + + public Task GetInfo(AppData appData) + { + throw new NotImplementedException(); + } + + public Task SetDefaultSettings(AppData appData, string defaultCurrency) + { + var empty = new PointOfSaleSettings { Currency = defaultCurrency }; + appData.SetSettings(empty); + return Task.CompletedTask; + } + + public Task ViewLink(AppData app) + { + return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIPointOfSaleController.ViewPointOfSale), + "UIPointOfSale", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)); + } + } } diff --git a/BTCPayServer/Services/Apps/AppHubStreamer.cs b/BTCPayServer/Services/Apps/AppHubStreamer.cs index 03fcbadce..b8fe95aa7 100644 --- a/BTCPayServer/Services/Apps/AppHubStreamer.cs +++ b/BTCPayServer/Services/Apps/AppHubStreamer.cs @@ -55,7 +55,7 @@ namespace BTCPayServer.Services.Apps private async Task InfoUpdated(string appId) { - var info = await _appService.GetAppInfo(appId); + var info = await _appService.GetInfo(appId); await _HubContext.Clients.Group(appId).SendCoreAsync(AppHub.InfoUpdated, new object[] { info }); } } diff --git a/BTCPayServer/Services/Apps/AppService.cs b/BTCPayServer/Services/Apps/AppService.cs index 344025547..6d9b8bc0a 100644 --- a/BTCPayServer/Services/Apps/AppService.cs +++ b/BTCPayServer/Services/Apps/AppService.cs @@ -5,11 +5,10 @@ using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; -using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Models.AppViewModels; -using BTCPayServer.Payments; -using BTCPayServer.Plugins.Crowdfund.Models; +using BTCPayServer.Plugins.Crowdfund; +using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; @@ -25,13 +24,14 @@ using Newtonsoft.Json.Linq; using YamlDotNet.Core; using YamlDotNet.RepresentationModel; using YamlDotNet.Serialization; -using static BTCPayServer.Plugins.Crowdfund.Models.ViewCrowdfundViewModel; +using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType; using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Services.Apps { public class AppService { + private readonly IEnumerable _apps; readonly ApplicationDbContextFactory _ContextFactory; private readonly InvoiceRepository _InvoiceRepository; readonly CurrencyNameTable _Currencies; @@ -39,13 +39,16 @@ namespace BTCPayServer.Services.Apps private readonly StoreRepository _storeRepository; private readonly HtmlSanitizer _HtmlSanitizer; public CurrencyNameTable Currencies => _Currencies; - public AppService(ApplicationDbContextFactory contextFactory, - InvoiceRepository invoiceRepository, - CurrencyNameTable currencies, - DisplayFormatter displayFormatter, - StoreRepository storeRepository, - HtmlSanitizer htmlSanitizer) + public AppService( + IEnumerable apps, + ApplicationDbContextFactory contextFactory, + InvoiceRepository invoiceRepository, + CurrencyNameTable currencies, + DisplayFormatter displayFormatter, + StoreRepository storeRepository, + HtmlSanitizer htmlSanitizer) { + _apps = apps; _ContextFactory = contextFactory; _InvoiceRepository = invoiceRepository; _Currencies = currencies; @@ -54,251 +57,53 @@ namespace BTCPayServer.Services.Apps _displayFormatter = displayFormatter; } - public async Task GetAppInfo(string appId) + public Dictionary GetAvailableAppTypes() { - var app = await GetApp(appId, AppType.Crowdfund, true); - if (app != null) - { - return await GetInfo(app); - } - return null; + return _apps.ToDictionary(app => app.Type, app => app.Description); + } + + public Task ConfigureLink(AppData app, string vmSelectedAppType) + { + return GetAppForType(vmSelectedAppType).ConfigureLink(app); } - private async Task GetInfo(AppData appData) + private IApp GetAppForType(string appType) { - var settings = appData.GetSettings(); - var resetEvery = settings.StartDate.HasValue ? settings.ResetEvery : CrowdfundResetEvery.Never; - DateTime? lastResetDate = null; - DateTime? nextResetDate = null; - if (resetEvery != CrowdfundResetEvery.Never) + return _apps.First(app => app.Type == appType); + } + + public async Task GetInfo(string appId) + { + var appData = await GetApp(appId, null); + if (appData is null) { - lastResetDate = settings.StartDate.Value; - - nextResetDate = lastResetDate.Value; - while (DateTime.UtcNow >= nextResetDate) - { - lastResetDate = nextResetDate; - switch (resetEvery) - { - case CrowdfundResetEvery.Hour: - nextResetDate = lastResetDate.Value.AddHours(settings.ResetEveryAmount); - break; - case CrowdfundResetEvery.Day: - nextResetDate = lastResetDate.Value.AddDays(settings.ResetEveryAmount); - break; - case CrowdfundResetEvery.Month: - nextResetDate = lastResetDate.Value.AddMonths(settings.ResetEveryAmount); - break; - case CrowdfundResetEvery.Year: - nextResetDate = lastResetDate.Value.AddYears(settings.ResetEveryAmount); - break; - } - } + return null; + } + var app = GetAppForType(appData.AppType); + if (app is null) + { + return null; } - var invoices = await GetInvoicesForApp(appData, lastResetDate); - var completeInvoices = invoices.Where(IsComplete).ToArray(); - var pendingInvoices = invoices.Where(IsPending).ToArray(); - var paidInvoices = invoices.Where(IsPaid).ToArray(); - - var pendingPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, pendingInvoices, !settings.EnforceTargetAmount); - var currentPayments = GetContributionsByPaymentMethodId(settings.TargetCurrency, completeInvoices, !settings.EnforceTargetAmount); - - var perkCount = paidInvoices - .Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode)) - .GroupBy(entity => entity.Metadata.ItemCode) - .ToDictionary(entities => entities.Key, entities => entities.Count()); - - Dictionary perkValue = new(); - if (settings.DisplayPerksValue) - { - perkValue = paidInvoices - .Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) && - !string.IsNullOrEmpty(entity.Metadata.ItemCode)) - .GroupBy(entity => entity.Metadata.ItemCode) - .ToDictionary(entities => entities.Key, entities => - entities.Sum(entity => entity.GetPayments(true).Sum(pay => - { - var paymentMethodId = pay.GetPaymentMethodId(); - var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee; - var rate = entity.GetPaymentMethod(paymentMethodId).Rate; - return rate * value; - }))); - } - - var perks = Parse(settings.PerksTemplate, settings.TargetCurrency); - if (settings.SortPerksByPopularity) - { - var ordered = perkCount.OrderByDescending(pair => pair.Value); - var newPerksOrder = ordered - .Select(keyValuePair => perks.SingleOrDefault(item => item.Id == keyValuePair.Key)) - .Where(matchingPerk => matchingPerk != null) - .ToList(); - var remainingPerks = perks.Where(item => !newPerksOrder.Contains(item)); - newPerksOrder.AddRange(remainingPerks); - perks = newPerksOrder.ToArray(); - } - - var store = appData.StoreData; - var storeBlob = store.GetStoreBlob(); - - return new ViewCrowdfundViewModel - { - Title = settings.Title, - Tagline = settings.Tagline, - Description = settings.Description, - CustomCSSLink = settings.CustomCSSLink, - MainImageUrl = settings.MainImageUrl, - EmbeddedCSS = settings.EmbeddedCSS, - StoreName = store.StoreName, - CssFileId = storeBlob.CssFileId, - LogoFileId = storeBlob.LogoFileId, - BrandColor = storeBlob.BrandColor, - StoreId = appData.StoreDataId, - AppId = appData.Id, - StartDate = settings.StartDate?.ToUniversalTime(), - EndDate = settings.EndDate?.ToUniversalTime(), - TargetAmount = settings.TargetAmount, - TargetCurrency = settings.TargetCurrency, - EnforceTargetAmount = settings.EnforceTargetAmount, - Perks = perks, - Enabled = settings.Enabled, - DisqusEnabled = settings.DisqusEnabled, - SoundsEnabled = settings.SoundsEnabled, - DisqusShortname = settings.DisqusShortname, - AnimationsEnabled = settings.AnimationsEnabled, - ResetEveryAmount = settings.ResetEveryAmount, - ResetEvery = Enum.GetName(typeof(CrowdfundResetEvery), settings.ResetEvery), - DisplayPerksRanking = settings.DisplayPerksRanking, - PerkCount = perkCount, - PerkValue = perkValue, - NeverReset = settings.ResetEvery == CrowdfundResetEvery.Never, - Sounds = settings.Sounds, - AnimationColors = settings.AnimationColors, - CurrencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true), - CurrencyDataPayments = Enumerable.DistinctBy(currentPayments.Select(pair => pair.Key) - .Concat(pendingPayments.Select(pair => pair.Key)) - .Select(id => _Currencies.GetCurrencyData(id.CryptoCode, true)), data => data.Code) - .ToDictionary(data => data.Code, data => data), - Info = new CrowdfundInfo - { - TotalContributors = paidInvoices.Length, - ProgressPercentage = (currentPayments.TotalCurrency / settings.TargetAmount) * 100, - PendingProgressPercentage = (pendingPayments.TotalCurrency / settings.TargetAmount) * 100, - LastUpdated = DateTime.UtcNow, - PaymentStats = currentPayments.ToDictionary(c => c.Key.ToString(), c => c.Value.Value), - PendingPaymentStats = pendingPayments.ToDictionary(c => c.Key.ToString(), c => c.Value.Value), - LastResetDate = lastResetDate, - NextResetDate = nextResetDate, - CurrentPendingAmount = pendingPayments.TotalCurrency, - CurrentAmount = currentPayments.TotalCurrency - } - }; + return app.GetInfo(appData); } - - private static bool IsPending(InvoiceEntity entity) - { - return !(entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed); - } - - private static bool IsComplete(InvoiceEntity entity) - { - return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed; - } - - public async Task> GetPerkStats(AppData appData) - { - var settings = appData.GetSettings(); - var invoices = await GetInvoicesForApp(appData); - var paidInvoices = invoices.Where(IsPaid).ToArray(); - var currencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true); - var perks = Parse(settings.PerksTemplate, settings.TargetCurrency); - 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 - !string.IsNullOrEmpty(entity.Metadata.ItemCode)) - .GroupBy(entity => entity.Metadata.ItemCode) - .Select(entities => - { - var total = entities - .Sum(entity => entity.GetPayments(true) - .Sum(pay => - { - var paymentMethodId = pay.GetPaymentMethodId(); - var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee; - var rate = entity.GetPaymentMethod(paymentMethodId).Rate; - return rate * value; - })); - var itemCode = entities.Key; - var perk = perks.FirstOrDefault(p => p.Id == itemCode); - return new ItemStats - { - ItemCode = itemCode, - Title = perk?.Title ?? itemCode, - SalesCount = entities.Count(), - Total = total, - TotalFormatted = $"{total.ShowMoney(currencyData.Divisibility)} {settings.TargetCurrency}" - }; - }) - .OrderByDescending(stats => stats.SalesCount); - - return perkCount; - } - + public async Task> GetItemStats(AppData appData) { - var settings = appData.GetSettings(); - var invoices = await GetInvoicesForApp(appData); - var paidInvoices = invoices.Where(IsPaid).ToArray(); - var currencyData = _Currencies.GetCurrencyData(settings.Currency, true); - var items = Parse(settings.Template, settings.Currency); - 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 - entity.Metadata.PosData != null || - // The item code should be present for all types other than the cart and keypad - !string.IsNullOrEmpty(entity.Metadata.ItemCode) - )) - .Aggregate(new List(), AggregateInvoiceEntitiesForStats(items)) - .GroupBy(entity => entity.ItemCode) - .Select(entities => + var paidInvoices = await GetInvoicesForApp(_InvoiceRepository,appData, + null, new [] { - var total = entities.Sum(entity => entity.FiatPrice); - var itemCode = entities.Key; - var item = items.FirstOrDefault(p => p.Id == itemCode); - return new ItemStats - { - ItemCode = itemCode, - Title = item?.Title ?? itemCode, - SalesCount = entities.Count(), - Total = total, - TotalFormatted = $"{total.ShowMoney(currencyData.Divisibility)} {settings.Currency}" - }; - }) - .OrderByDescending(stats => stats.SalesCount); - - return itemCount; + InvoiceState.ToString(InvoiceStatusLegacy.Paid), + InvoiceState.ToString(InvoiceStatusLegacy.Confirmed), + InvoiceState.ToString(InvoiceStatusLegacy.Complete) + }); + return await GetAppForType(appData.AppType).GetItemStats(appData, paidInvoices); } - public async Task GetSalesStats(AppData app, int numberOfDays = 7) + public static Task GetSalesStatswithPOSItems(ViewPointOfSaleViewModel.Item[] items, + InvoiceEntity[] paidInvoices, int numberOfDays) { - ViewPointOfSaleViewModel.Item[] items = null; - switch (app.AppType) - { - case nameof(AppType.Crowdfund): - var cfS = app.GetSettings(); - items = Parse(cfS.PerksTemplate, cfS.TargetCurrency); - break; - case nameof(AppType.PointOfSale): - var posS = app.GetSettings(); - items = Parse(posS.Template, posS.Currency); - break; - } - - var invoices = await GetInvoicesForApp(app); - var paidInvoices = invoices.Where(IsPaid).ToArray(); var series = paidInvoices - .Where(entity => entity.InvoiceTime > DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays)) .Aggregate(new List(), AggregateInvoiceEntitiesForStats(items)) .GroupBy(entity => entity.Date) .Select(entities => new SalesStatsItem @@ -322,21 +127,33 @@ namespace BTCPayServer.Services.Apps } } - return new SalesStats + return Task.FromResult(new SalesStats { SalesCount = series.Sum(i => i.SalesCount), Series = series.OrderBy(i => i.Label) - }; + }); + } + + public async Task GetSalesStats(AppData app, int numberOfDays = 7) + { + var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, app, DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays), + new [] + { + InvoiceState.ToString(InvoiceStatusLegacy.Paid), + InvoiceState.ToString(InvoiceStatusLegacy.Confirmed), + InvoiceState.ToString(InvoiceStatusLegacy.Complete) + }); + return await GetAppForType(app.AppType).GetSalesStats(app, paidInvoices, numberOfDays); } - private class InvoiceStatsItem + public class InvoiceStatsItem { public string ItemCode { get; set; } public decimal FiatPrice { get; set; } public DateTime Date { get; set; } } - private static Func, InvoiceEntity, List> AggregateInvoiceEntitiesForStats(ViewPointOfSaleViewModel.Item[] items) + public static Func, InvoiceEntity, List> AggregateInvoiceEntitiesForStats(ViewPointOfSaleViewModel.Item[] items) { return (res, e) => { @@ -382,18 +199,14 @@ namespace BTCPayServer.Services.Apps return res; }; } - - private static bool IsPaid(InvoiceEntity entity) - { - return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed || entity.Status == InvoiceStatusLegacy.Paid; - } - - public static string GetAppOrderId(AppData app) => - app.AppType switch + + public static string GetAppOrderId(AppData app) => GetAppOrderId(app.AppType, app.Id); + public static string GetAppOrderId(string appType, string appId) => + appType switch { - nameof(AppType.Crowdfund) => $"crowdfund-app_{app.Id}", - nameof(AppType.PointOfSale) => $"pos-app_{app.Id}", - _ => throw new ArgumentOutOfRangeException(nameof(app), app.AppType) + CrowdfundApp.AppType => $"crowdfund-app_{appId}", + PointOfSaleApp.AppType => $"pos-app_{appId}", + _ => $"{appType}_{appId}" }; public static string GetAppInternalTag(string appId) => $"APP#{appId}"; @@ -402,13 +215,13 @@ namespace BTCPayServer.Services.Apps return invoice.GetInternalTags("APP#"); } - private async Task GetInvoicesForApp(AppData appData, DateTime? startDate = null) + public static async Task GetInvoicesForApp(InvoiceRepository invoiceRepository, AppData appData, DateTimeOffset? startDate = null, string[] status = null) { - var invoices = await _InvoiceRepository.GetInvoices(new InvoiceQuery() + var invoices = await invoiceRepository.GetInvoices(new InvoiceQuery { - StoreId = new[] { appData.StoreData.Id }, + StoreId = new[] { appData.StoreDataId }, OrderId = appData.TagAllInvoices ? null : new[] { GetAppOrderId(appData) }, - Status = new[]{ + Status = status?? new[]{ InvoiceState.ToString(InvoiceStatusLegacy.New), InvoiceState.ToString(InvoiceStatusLegacy.Paid), InvoiceState.ToString(InvoiceStatusLegacy.Confirmed), @@ -424,7 +237,7 @@ namespace BTCPayServer.Services.Apps public async Task GetOwnedStores(string userId) { - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); return await ctx.UserStore .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) .Select(u => u.StoreData) @@ -433,7 +246,7 @@ namespace BTCPayServer.Services.Apps public async Task DeleteApp(AppData appData) { - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); ctx.Apps.Add(appData); ctx.Entry(appData).State = EntityState.Deleted; return await ctx.SaveChangesAsync() == 1; @@ -441,7 +254,7 @@ namespace BTCPayServer.Services.Apps public async Task GetAllApps(string userId, bool allowNoUser = false, string storeId = null) { - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); var listApps = await ctx.UserStore .Where(us => (allowNoUser && string.IsNullOrEmpty(userId) || us.ApplicationUserId == userId) && @@ -457,6 +270,7 @@ namespace BTCPayServer.Services.Apps AppType = app.AppType, Id = app.Id, Created = app.Created, + App = app }) .OrderBy(b => b.Created) .ToArrayAsync(); @@ -469,28 +283,23 @@ namespace BTCPayServer.Services.Apps foreach (ListAppsViewModel.ListAppViewModel app in listApps) { - app.ViewStyle = await GetAppViewStyleAsync(app.Id, app.AppType); + app.ViewStyle = GetAppViewStyle(app.App, app.AppType); } return listApps; } - public async Task GetAppViewStyleAsync(string appId, string appType) + public string GetAppViewStyle(AppData app, string appType) { - AppType appTypeEnum = Enum.Parse(appType); - AppData appData = await GetApp(appId, appTypeEnum, false); - var settings = appData.GetSettings(); - string style; - switch (appTypeEnum) + switch (appType) { - case AppType.PointOfSale: + case PointOfSaleApp.AppType: + var settings = app.GetSettings(); string posViewStyle = (settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView).ToString(); style = typeof(PosViewType).DisplayName(posViewStyle); break; - case AppType.Crowdfund: - style = string.Empty; - break; + default: style = string.Empty; break; @@ -501,10 +310,9 @@ namespace BTCPayServer.Services.Apps public async Task> GetApps(string[] appIds, bool includeStore = false) { - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); var query = ctx.Apps - .Where(us => appIds.Contains(us.Id)); - + .Where(app => appIds.Contains(app.Id)); if (includeStore) { query = query.Include(data => data.StoreData); @@ -512,12 +320,20 @@ namespace BTCPayServer.Services.Apps return await query.ToListAsync(); } - public async Task GetApp(string appId, AppType? appType, bool includeStore = false) + public async Task> GetApps(string appType) { - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); + var query = ctx.Apps + .Where(app => app.AppType == appType); + return await query.ToListAsync(); + } + + public async Task GetApp(string appId, string appType, bool includeStore = false) + { + await using var ctx = _ContextFactory.CreateContext(); var query = ctx.Apps .Where(us => us.Id == appId && - (appType == null || us.AppType == appType.ToString())); + (appType == null || us.AppType == appType)); if (includeStore) { @@ -573,21 +389,32 @@ namespace BTCPayServer.Services.Apps var serializer = new SerializerBuilder().Build(); return serializer.Serialize(mappingNode); } - public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency) + + 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) { if (string.IsNullOrWhiteSpace(template)) return Array.Empty(); using var input = new StringReader(template); - YamlStream stream = new YamlStream(); + 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 }) + .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 ViewPointOfSaleViewModel.Item.ItemPrice(); + ViewPointOfSaleViewModel.Item.ItemPrice price = new (); var pValue = c.GetDetail("price")?.FirstOrDefault(); switch (c.GetDetailString("custom") ?? c.GetDetailString("price_type")?.ToLowerInvariant()) @@ -599,10 +426,10 @@ namespace BTCPayServer.Services.Apps case "true": case "minimum": price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum; - if (pValue != null) + 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); + price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol); } break; case "fixed": @@ -610,11 +437,11 @@ namespace BTCPayServer.Services.Apps case null: price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed; price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture); - price.Formatted = _displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol); + price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol); break; } - return new ViewPointOfSaleViewModel.Item() + return new ViewPointOfSaleViewModel.Item { Description = c.GetDetailString("description"), Id = c.Key, @@ -624,7 +451,7 @@ namespace BTCPayServer.Services.Apps BuyButtonText = c.GetDetailString("buyButtonText"), Inventory = string.IsNullOrEmpty(c.GetDetailString("inventory")) - ? (int?)null + ? null : int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture), PaymentMethods = c.GetDetailStringList("payment_methods"), Disabled = c.GetDetailString("disabled") == "true" @@ -633,65 +460,11 @@ namespace BTCPayServer.Services.Apps .ToArray(); } - public ViewPointOfSaleViewModel.Item[] GetPOSItems(string template, string currency) + public static ViewPointOfSaleViewModel.Item[] GetPOSItems(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency) { - return Parse(template, currency).Where(c => !c.Disabled).ToArray(); + return Parse(htmlSanitizer, displayFormatter, template, currency).Where(c => !c.Disabled).ToArray(); } - public Contributions GetContributionsByPaymentMethodId(string currency, InvoiceEntity[] invoices, bool softcap) - { - var contributions = invoices - .Where(p => p.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)) - .SelectMany(p => - { - var contribution = new Contribution(); - contribution.PaymentMethodId = new PaymentMethodId(p.Currency, PaymentTypes.BTCLike); - contribution.CurrencyValue = p.Price; - contribution.Value = contribution.CurrencyValue; - - // For hardcap, we count newly created invoices as part of the contributions - if (!softcap && p.Status == InvoiceStatusLegacy.New) - return new[] { contribution }; - - // If the user get a donation via other mean, he can register an invoice manually for such amount - // then mark the invoice as complete - var payments = p.GetPayments(true); - if (payments.Count == 0 && - p.ExceptionStatus == InvoiceExceptionStatus.Marked && - p.Status == InvoiceStatusLegacy.Complete) - return new[] { contribution }; - - contribution.CurrencyValue = 0m; - contribution.Value = 0m; - - // If an invoice has been marked invalid, remove the contribution - if (p.ExceptionStatus == InvoiceExceptionStatus.Marked && - p.Status == InvoiceStatusLegacy.Invalid) - return new[] { contribution }; - - - // Else, we just sum the payments - return payments - .Select(pay => - { - var paymentMethodContribution = new Contribution(); - paymentMethodContribution.PaymentMethodId = pay.GetPaymentMethodId(); - paymentMethodContribution.Value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee; - var rate = p.GetPaymentMethod(paymentMethodContribution.PaymentMethodId).Rate; - paymentMethodContribution.CurrencyValue = rate * paymentMethodContribution.Value; - return paymentMethodContribution; - }) - .ToArray(); - }) - .GroupBy(p => p.PaymentMethodId) - .ToDictionary(p => p.Key, p => new Contribution() - { - PaymentMethodId = p.Key, - Value = p.Select(v => v.Value).Sum(), - CurrencyValue = p.Select(v => v.CurrencyValue).Sum() - }); - return new Contributions(contributions); - } private class PosHolder { @@ -734,25 +507,25 @@ namespace BTCPayServer.Services.Apps public YamlScalarNode Value { get; set; } } - public async Task GetAppDataIfOwner(string userId, string appId, AppType? type = null) + public async Task GetAppDataIfOwner(string userId, string appId, string type = null) { if (userId == null || appId == null) return null; - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); var app = await ctx.UserStore .Where(us => us.ApplicationUserId == userId && us.Role == StoreRoles.Owner) .SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId)) .FirstOrDefaultAsync(); if (app == null) return null; - if (type != null && type.Value.ToString() != app.AppType) + if (type != null && type != app.AppType) return null; return app; } public async Task UpdateOrCreateApp(AppData app) { - using var ctx = _ContextFactory.CreateContext(); + await using var ctx = _ContextFactory.CreateContext(); if (string.IsNullOrEmpty(app.Id)) { app.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); @@ -808,7 +581,35 @@ namespace BTCPayServer.Services.Apps } return true; } + + public async Task SetDefaultSettings(AppData appData, string defaultCurrency) + { + var app = GetAppForType(appData.AppType); + if (app is null) + { + appData.SetSettings(null); + } + else + { + await app.SetDefaultSettings(appData, defaultCurrency); + } + } + + public async Task ViewLink(AppData app) + { + var appType = GetAppForType(app.AppType); + return await appType?.ViewLink(app)!; + } #nullable restore + public bool SupportsSalesStats(AppData app) + { + return GetAppForType(app.AppType).SupportsSalesStats; + } + + public bool SupportsItemStats(AppData app) + { + return GetAppForType(app.AppType).SupportsItemStats; + } } public class ItemStats diff --git a/BTCPayServer/Services/Apps/AppType.cs b/BTCPayServer/Services/Apps/AppType.cs index 66621e45e..dfa9b2bc4 100644 --- a/BTCPayServer/Services/Apps/AppType.cs +++ b/BTCPayServer/Services/Apps/AppType.cs @@ -1,24 +1,23 @@ +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Services.Invoices; namespace BTCPayServer.Services.Apps { - public enum AppType + public interface IApp { - [Display(Name = "Point of Sale")] - PointOfSale, - Crowdfund - } - - public enum PosViewType - { - [Display(Name = "Product list")] - Static, - [Display(Name = "Product list with cart")] - Cart, - [Display(Name = "Keypad only")] - Light, - [Display(Name = "Print display")] - Print + public string Description { get; } + public string Type { get; } + public bool SupportsSalesStats { get; } + public bool SupportsItemStats { get; } + Task ConfigureLink(AppData app); + Task ViewLink(AppData app); + Task SetDefaultSettings(AppData appData, string defaultCurrency); + Task GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays); + Task> GetItemStats(AppData appData, InvoiceEntity[] invoiceEntities); + Task GetInfo(AppData appData); } public enum RequiresRefundEmail diff --git a/BTCPayServer/Services/Apps/PointOfSaleSettings.cs b/BTCPayServer/Services/Apps/PointOfSaleSettings.cs index 908fa90da..5c4e8634b 100644 --- a/BTCPayServer/Services/Apps/PointOfSaleSettings.cs +++ b/BTCPayServer/Services/Apps/PointOfSaleSettings.cs @@ -1,5 +1,6 @@ using BTCPayServer.Client.Models; using BTCPayServer.Services.Stores; +using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType; namespace BTCPayServer.Services.Apps { diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index b47f174e7..a4b9fbb46 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -775,6 +775,67 @@ namespace BTCPayServer.Services.Invoices ? JsonConvert.DeserializeObject(ZipUtils.Unzip(blob), DefaultSerializerSettings) : network.ToObject(ZipUtils.Unzip(blob)); } + + public static string ToJsonString(T data, BTCPayNetworkBase network) + { + return network == null ? JsonConvert.SerializeObject(data, DefaultSerializerSettings) : network.ToString(data); + } + + + public InvoiceStatistics GetContributionsByPaymentMethodId(string currency, InvoiceEntity[] invoices, bool softcap) + { + var contributions = invoices + .Where(p => p.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase)) + .SelectMany(p => + { + var contribution = new InvoiceStatistics.Contribution(); + contribution.PaymentMethodId = new PaymentMethodId(p.Currency, PaymentTypes.BTCLike); + contribution.CurrencyValue = p.Price; + contribution.Value = contribution.CurrencyValue; + + // For hardcap, we count newly created invoices as part of the contributions + if (!softcap && p.Status == InvoiceStatusLegacy.New) + return new[] { contribution }; + + // If the user get a donation via other mean, he can register an invoice manually for such amount + // then mark the invoice as complete + var payments = p.GetPayments(true); + if (payments.Count == 0 && + p.ExceptionStatus == InvoiceExceptionStatus.Marked && + p.Status == InvoiceStatusLegacy.Complete) + return new[] { contribution }; + + contribution.CurrencyValue = 0m; + contribution.Value = 0m; + + // If an invoice has been marked invalid, remove the contribution + if (p.ExceptionStatus == InvoiceExceptionStatus.Marked && + p.Status == InvoiceStatusLegacy.Invalid) + return new[] { contribution }; + + + // Else, we just sum the payments + return payments + .Select(pay => + { + var paymentMethodContribution = new InvoiceStatistics.Contribution(); + paymentMethodContribution.PaymentMethodId = pay.GetPaymentMethodId(); + paymentMethodContribution.Value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee; + var rate = p.GetPaymentMethod(paymentMethodContribution.PaymentMethodId).Rate; + paymentMethodContribution.CurrencyValue = rate * paymentMethodContribution.Value; + return paymentMethodContribution; + }) + .ToArray(); + }) + .GroupBy(p => p.PaymentMethodId) + .ToDictionary(p => p.Key, p => new InvoiceStatistics.Contribution() + { + PaymentMethodId = p.Key, + Value = p.Select(v => v.Value).Sum(), + CurrencyValue = p.Select(v => v.CurrencyValue).Sum() + }); + return new InvoiceStatistics(contributions); + } } public class InvoiceQuery @@ -844,4 +905,20 @@ namespace BTCPayServer.Services.Invoices public bool IncludeArchived { get; set; } = true; public bool IncludeRefunds { get; set; } } + + public class InvoiceStatistics : Dictionary + { + public InvoiceStatistics(IEnumerable> collection) : base(collection) + { + TotalCurrency = Values.Select(v => v.CurrencyValue).Sum(); + } + public decimal TotalCurrency { get; } + + public class Contribution + { + public PaymentMethodId PaymentMethodId { get; set; } + public decimal Value { get; set; } + public decimal CurrencyValue { get; set; } + } + } } diff --git a/BTCPayServer/Services/PoliciesSettings.cs b/BTCPayServer/Services/PoliciesSettings.cs index f0f9d3619..9f7739ca2 100644 --- a/BTCPayServer/Services/PoliciesSettings.cs +++ b/BTCPayServer/Services/PoliciesSettings.cs @@ -44,8 +44,7 @@ namespace BTCPayServer.Services [Display(Name = "Display app on website root")] public string RootAppId { get; set; } - public AppType? RootAppType { get; set; } - + public string RootAppType { get; set; } [Display(Name = "Override the block explorers used")] public List BlockExplorerLinks { get; set; } = new List(); @@ -65,7 +64,7 @@ namespace BTCPayServer.Services [Display(Name = "Domain")] [Required] [HostName] public string Domain { get; set; } [Display(Name = "App")] [Required] public string AppId { get; set; } - public AppType AppType { get; set; } + public string AppType { get; set; } } } } diff --git a/BTCPayServer/Views/Shared/Crowdfund/NavExtension.cshtml b/BTCPayServer/Views/Shared/Crowdfund/NavExtension.cshtml index 7123ecba8..b841fb1b0 100644 --- a/BTCPayServer/Views/Shared/Crowdfund/NavExtension.cshtml +++ b/BTCPayServer/Views/Shared/Crowdfund/NavExtension.cshtml @@ -1,18 +1,18 @@ @using BTCPayServer.Client @using Microsoft.AspNetCore.Mvc.TagHelpers -@using BTCPayServer.TagHelpers @using BTCPayServer.Views.Apps @using BTCPayServer.Abstractions.Extensions -@using BTCPayServer.Services.Apps +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Plugins.Crowdfund @model BTCPayServer.Components.MainNav.StoreApp @{ var store = Context.GetStoreData(); } -@if (store != null && Model.AppType == AppType.Crowdfund) +@if (store != null && Model.AppType == CrowdfundApp.AppType) { diff --git a/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml b/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml index 5492aa607..dbbc2e859 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml @@ -1,18 +1,18 @@ @using BTCPayServer.Client @using Microsoft.AspNetCore.Mvc.TagHelpers -@using BTCPayServer.TagHelpers @using BTCPayServer.Views.Apps @using BTCPayServer.Abstractions.Extensions -@using BTCPayServer.Services.Apps +@using BTCPayServer.Abstractions.TagHelpers +@using BTCPayServer.Plugins.PointOfSale @model BTCPayServer.Components.MainNav.StoreApp @{ var store = Context.GetStoreData(); } -@if (store != null && Model.AppType == AppType.PointOfSale) +@if (store != null && Model.AppType == PointOfSaleApp.AppType) { diff --git a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml index 958851fe7..5235df5a4 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml @@ -1,9 +1,8 @@ -@using BTCPayServer.Services.Apps @using BTCPayServer.Abstractions.Models @using BTCPayServer.Views.Apps @using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Plugins.PointOfSale @using BTCPayServer.Forms -@using BTCPayServer.Services.Stores @inject FormDataService FormDataService @inject BTCPayServer.Security.ContentSecurityPolicies Csp @model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel diff --git a/BTCPayServer/Views/UIApps/ListApps.cshtml b/BTCPayServer/Views/UIApps/ListApps.cshtml index 26af73a6f..8bf95a9c6 100644 --- a/BTCPayServer/Views/UIApps/ListApps.cshtml +++ b/BTCPayServer/Views/UIApps/ListApps.cshtml @@ -1,6 +1,7 @@ @using BTCPayServer.Services.Apps @using BTCPayServer.Abstractions.Models @model ListAppsViewModel +@inject AppService AppService @{ ViewData.SetActivePage(AppsNavPages.Index, "Apps"); var storeNameSortOrder = (string)ViewData["StoreNameSortOrder"]; @@ -89,13 +90,15 @@ @app.AppName - @typeof(AppType).DisplayName(app.AppType) - @if (app.AppType != AppType.Crowdfund.ToString()) - { - - + @AppService.GetAvailableAppTypes()[app.AppType] + @{ + var viewStyle = @app.ViewStyle; + } + @if (!string.IsNullOrEmpty(viewStyle)) + { + - + @Safe.Raw(viewStyle) } - - @app.ViewStyle @if (app.IsOwner) @@ -103,7 +106,7 @@ Settings - } - Delete + Delete } diff --git a/BTCPayServer/Views/UIStores/Dashboard.cshtml b/BTCPayServer/Views/UIStores/Dashboard.cshtml index cf22aca16..22b95ac41 100644 --- a/BTCPayServer/Views/UIStores/Dashboard.cshtml +++ b/BTCPayServer/Views/UIStores/Dashboard.cshtml @@ -6,8 +6,9 @@ @using BTCPayServer.Components.StoreWalletBalance @using BTCPayServer.Components.AppSales @using BTCPayServer.Components.AppTopItems -@model StoreDashboardViewModel; - +@using BTCPayServer.Services.Apps +@inject AppService AppService +@model StoreDashboardViewModel @{ ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId); var store = ViewContext.HttpContext.GetStoreData(); @@ -118,8 +119,14 @@ @foreach (var app in Model.Apps) { - - + @if (AppService.SupportsSalesStats(app)) + { + + } + @if (AppService.SupportsItemStats(app)) + { + + } } } diff --git a/BTCPayServer/wwwroot/img/icon-sprite.svg b/BTCPayServer/wwwroot/img/icon-sprite.svg index 5783866f9..4091f1086 100644 --- a/BTCPayServer/wwwroot/img/icon-sprite.svg +++ b/BTCPayServer/wwwroot/img/icon-sprite.svg @@ -37,6 +37,7 @@ +