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 <mail@dennisreimann.de>
This commit is contained in:
Andrew Camilleri 2023-03-17 03:56:32 +01:00 committed by GitHub
parent a671632fde
commit f74ea14d8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 899 additions and 652 deletions

View file

@ -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<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
var appType = PointOfSaleApp.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(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<RedirectToActionResult>(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<UpdatePointOfSaleViewModel>();
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<RedirectToActionResult>(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<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
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<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result);
invoices = user.BitPay.GetInvoices();

View file

@ -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<UIStoresController>();
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.Crowdfund.ToString();
Assert.NotNull(vm.SelectedAppType);
var appType = CrowdfundApp.AppType;
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(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<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.Equal(nameof(crowdfund.UpdateCrowdfund), redirectToAction.ActionName);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/crowdfund", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(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<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal(nameof(stores.Dashboard), redirectToAction.ActionName);
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal(nameof(UIStoresController.Dashboard), redirectToAction.ActionName);
appList = await apps.ListApps(user.StoreId).AssertViewModelAsync<ListAppsViewModel>();
Assert.Empty(appList.Apps);
}
@ -79,10 +77,11 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
var vm = apps.CreateApp(user.StoreId).AssertViewModel<CreateAppViewModel>();
var appType = AppType.Crowdfund.ToString();
var appType = CrowdfundApp.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/crowdfund", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(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<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id, string.Empty));
Assert.IsType<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id));
//Scenario 2: Not Enabled But Admin - Allowed
Assert.IsType<OkObjectResult>(await crowdfundController.ContributeToCrowdfund(app.Id, new ContributeToCrowdfund()
@ -113,8 +112,8 @@ namespace BTCPayServer.Tests
RedirectToCheckout = false,
Amount = new decimal(0.01)
}, default));
Assert.IsType<ViewResult>(await crowdfundController.ViewCrowdfund(app.Id, string.Empty));
Assert.IsType<NotFoundResult>(await anonAppPubsController.ViewCrowdfund(app.Id, string.Empty));
Assert.IsType<ViewResult>(await crowdfundController.ViewCrowdfund(app.Id));
Assert.IsType<NotFoundResult>(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<UIAppsController>();
var crowdfund = user.GetController<UICrowdfundController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.Crowdfund.ToString();
var appType = CrowdfundApp.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(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<UICrowdfundController>();
var model = Assert.IsType<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, String.Empty).Result).Model);
.IsType<ViewResult>(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<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, string.Empty).Result).Model);
.IsType<ViewResult>(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<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, String.Empty).Result).Model);
.IsType<ViewResult>(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<ViewCrowdfundViewModel>(Assert
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id, string.Empty).Result).Model);
.IsType<ViewResult>(publicApps.ViewCrowdfund(app.Id).Result).Model);
Assert.Equal(0.7m, model.Info.CurrentPendingAmount);
});
}

View file

@ -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<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
var appType = PointOfSaleApp.AppType;
vm.AppName = "test";
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(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 };

View file

@ -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<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var pos = user.GetController<UIPointOfSaleController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
Assert.NotNull(vm.SelectedAppType);
var appType = PointOfSaleApp.AppType;
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(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<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.Equal(nameof(pos.UpdatePointOfSale), redirectToAction.ActionName);
var redirect = Assert.IsType<RedirectResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.EndsWith("/settings/pos", redirect.Url);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var appList2 =
Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps2.ListApps(user2.StoreId).Result).Model);
@ -1976,7 +1977,7 @@ namespace BTCPayServer.Tests
Assert.Equal(user.StoreId, appList.Apps[0].StoreId);
Assert.IsType<NotFoundResult>(apps2.DeleteApp(appList.Apps[0].Id));
Assert.IsType<ViewResult>(apps.DeleteApp(appList.Apps[0].Id));
redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.DeleteAppPost(appList.Apps[0].Id).Result);
Assert.Equal(nameof(stores.Dashboard), redirectToAction.ActionName);
appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
Assert.Empty(appList.Apps);

View file

@ -27,20 +27,22 @@ public class AppSales : ViewComponent
public async Task<IViewComponentResult> 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);
}

View file

@ -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<SalesStatsItem> Series { get; set; }
public bool InitialRendering { get; set; }

View file

@ -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";
}
<div id="AppSales-@Model.Id" class="widget app-sales">
<header class="mb-3">
<h3>@Model.Name @label</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.Id">Manage</a>
@if (!string.IsNullOrEmpty(Model.AppUrl))
{
<a href="@Model.AppUrl">Manage</a>
}
</header>
@if (Model.InitialRendering)
{
@ -20,16 +21,16 @@
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppSales/Default.cshtml.js" asp-append-version="true"></script>
<script src="~/Components/AppSales/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Model.Url);
const url = @Safe.Json(Model.DataUrl);
const appId = @Safe.Json(Model.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppSales-${appId}`).outerHTML = await response.text();
const data = document.querySelector(`#AppSales-${appId} template`);
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>

View file

@ -21,24 +21,22 @@ public class AppTopItems : ViewComponent
public async Task<IViewComponentResult> 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<AppType>(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);
}

View file

@ -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<ItemStats> Entries { get; set; }
public List<int> SalesCount { get; set; }
public bool InitialRendering { get; set; }

View file

@ -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";
}
<div id="AppTopItems-@Model.Id" class="widget app-top-items">
<header class="mb-3">
<h3>Top @(Model.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")</h3>
<a asp-controller="@controller" asp-action="@action" asp-route-appId="@Model.Id">View All</a>
</header>
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppTopItems/Default.cshtml.js" asp-append-version="true"></script>
<script>
<header class="mb-3">
<h3>Top @(Model.AppType == CrowdfundApp.AppType ? "Perks" : "Items")</h3>
@if (!string.IsNullOrEmpty(Model.AppUrl))
{
<a href="@Model.AppUrl">View All</a>
}
</header>
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="~/Components/AppTopItems/Default.cshtml.js" asp-append-version="true"></script>
<script>
(async () => {
const url = @Safe.Json(Model.Url);
const url = @Safe.Json(Model.DataUrl);
const appId = @Safe.Json(Model.Id);
const response = await fetch(url);
if (response.ok) {
@ -31,35 +31,35 @@
if (data) window.appSales.dataLoaded(JSON.parse(data.innerHTML));
}
})();
</script>
}
else if (Model.Entries.Any())
{
<div class="ct-chart mb-3"></div>
<template>
@Safe.Json(Model)
</template>
<div class="app-items">
@for (var i = 0; i < Model.Entries.Count; i++)
{
var entry = Model.Entries[i];
<div class="app-item ct-series-@i">
<span class="app-item-name">
<span class="app-item-point ct-point"></span>
@entry.Title
</span>
<span class="app-item-value">
<span class="text-muted">@entry.SalesCount @($"{label}{(entry.SalesCount == 1 ? "" : "s")}"),</span>
@entry.TotalFormatted
</span>
</div>
}
</div>
}
else
{
<p class="text-secondary mt-3">
No @($"{label}s") have been made yet.
</p>
}
</script>
}
else if (Model.Entries.Any())
{
<div class="ct-chart mb-3"></div>
<template>
@Safe.Json(Model)
</template>
<div class="app-items">
@for (var i = 0; i < Model.Entries.Count; i++)
{
var entry = Model.Entries[i];
<div class="app-item ct-series-@i">
<span class="app-item-name">
<span class="app-item-point ct-point"></span>
@entry.Title
</span>
<span class="app-item-value">
<span class="text-muted">@entry.SalesCount @($"{label}{(entry.SalesCount == 1 ? "" : "s")}"),</span>
@entry.TotalFormatted
</span>
</div>
}
</div>
}
else
{
<p class="text-secondary mt-3">
No @($"{label}s") have been made yet.
</p>
}
</div>

View file

@ -74,7 +74,7 @@ namespace BTCPayServer.Components.MainNav
Id = a.Id,
IsOwner = a.IsOwner,
AppName = a.AppName,
AppType = Enum.Parse<AppType>(a.AppType)
AppType = a.AppType
}).ToList();
if (PoliciesSettings.Experimental)

View file

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

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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,

View file

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

View file

@ -213,7 +213,8 @@ namespace BTCPayServer.Controllers
return await CreateInvoiceCoreRaw(invoiceRequest, storeData, request.GetAbsoluteRoot(), additionalTags, cancellationToken);
}
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
[NonAction]
public async Task<InvoiceEntity> CreateInvoiceCoreRaw(CreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
{
var storeBlob = store.GetStoreBlob();
var entity = _InvoiceRepository.CreateNewInvoice();

View file

@ -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<CrowdfundSettings>();
currencyCode = cfS.TargetCurrency;
items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency);
break;
case nameof(AppType.PointOfSale):
var posS = app.GetSettings<PointOfSaleSettings>();
case PointOfSaleApp.AppType:
posS = app.GetSettings<PointOfSaleSettings>();
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<string> { AppService.GetAppInternalTag(appId) }, item.Price.Value, true));
() => (null, app, item, new List<string> { AppService.GetAppInternalTag(appId) }, item?.Price.Value, true));
}
public class EditLightningAddressVM
@ -311,11 +325,8 @@ namespace BTCPayServer
public decimal? Max { get; set; }
}
public ConcurrentDictionary<string, LightningAddressItem> Items { get; set; } =
new ConcurrentDictionary<string, LightningAddressItem>();
public ConcurrentDictionary<string, string[]> StoreToItemMap { get; set; } =
new ConcurrentDictionary<string, string[]>();
public ConcurrentDictionary<string, LightningAddressItem> Items { get; } = new ();
public ConcurrentDictionary<string, string[]> StoreToItemMap { get; } = new ();
public override string ToString()
{
@ -389,7 +400,7 @@ namespace BTCPayServer
var redirectUrl = app?.AppType switch
{
nameof(AppType.PointOfSale) => app.GetSettings<PointOfSaleSettings>().RedirectUrl ??
PointOfSaleApp.AppType => app.GetSettings<PointOfSaleSettings>().RedirectUrl ??
HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"),
_ => null
};

View file

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

View file

@ -347,7 +347,7 @@ namespace BTCPayServer.Controllers
if (appIdsToFetch.Any())
{
var apps = (await _AppService.GetApps(appIdsToFetch.ToArray()))
.ToDictionary(data => data.Id, data => Enum.Parse<AppType>(data.AppType));
.ToDictionary(data => data.Id, data => data.AppType);
;
if (!string.IsNullOrEmpty(settings.RootAppId))
{
@ -422,8 +422,10 @@ namespace BTCPayServer.Controllers
private async Task<List<SelectListItem>> 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;
}

View file

@ -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)
{

View file

@ -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<AppType>(data.AppType))
switch (data.AppType)
{
case AppType.PointOfSale:
case PointOfSaleApp.AppType:
var possettings = data.GetSettings<PointOfSaleSettings>();
return (Data: data, Settings: (object)possettings,
Items: _appService.Parse(possettings.Template, possettings.Currency));
case AppType.Crowdfund:
case CrowdfundApp.AppType:
var cfsettings = data.GetSettings<CrowdfundSettings>();
return (Data: data, Settings: (object)cfsettings,
Items: _appService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency));
@ -65,14 +66,13 @@ namespace BTCPayServer.HostedServices
}
}
switch (Enum.Parse<AppType>(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;

View file

@ -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<CrowdfundSettings>();
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<PointOfSaleSettings>();
if (string.IsNullOrEmpty(settings2.Currency))

View file

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

View file

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

View file

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

View file

@ -35,11 +35,13 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
EventAggregator eventAggregator,
StoreRepository storeRepository,
UIInvoiceController invoiceController,
UserManager<ApplicationUser> userManager)
UserManager<ApplicationUser> 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<ApplicationUser> _userManager;
private readonly CrowdfundApp _app;
[HttpGet("/")]
[HttpGet("/apps/{appId}/crowdfund")]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[DomainMappingConstraint(AppType.Crowdfund)]
public async Task<IActionResult> ViewCrowdfund(string appId, string statusMessage)
[DomainMappingConstraint(CrowdfundApp.AppType)]
public async Task<IActionResult> 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<CrowdfundSettings>();
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<IActionResult> 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<CrowdfundSettings>();
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<ViewCrowdfundViewModel> 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;

View file

@ -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<IUIExtension>(new UIExtension("Crowdfund/NavExtension", "apps-nav"));
services.AddSingleton<CrowdfundApp>();
services.AddSingleton<IApp>(provider => provider.GetRequiredService<CrowdfundApp>());
base.Execute(services);
}
}
public class CrowdfundApp: IApp
{
private readonly LinkGenerator _linkGenerator;
private readonly IOptions<BTCPayServerOptions> _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<BTCPayServerOptions> options,
InvoiceRepository invoiceRepository,
DisplayFormatter displayFormatter,
CurrencyNameTable currencyNameTable,
HtmlSanitizer htmlSanitizer)
{
_linkGenerator = linkGenerator;
_options = options;
_displayFormatter = displayFormatter;
_currencyNameTable = currencyNameTable;
_htmlSanitizer = htmlSanitizer;
_invoiceRepository = invoiceRepository;
}
public Task<string> ConfigureLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UICrowdfundController.UpdateCrowdfund),
"UICrowdfund", new { appId = app.Id }, _options.Value.RootPath));
}
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var cfS = app.GetSettings<CrowdfundSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, cfS.PerksTemplate, cfS.TargetCurrency);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var perks = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
var 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<IEnumerable<ItemStats>>(perkCount);
}
public async Task<object> GetInfo(AppData appData)
{
var settings = appData.GetSettings<CrowdfundSettings>();
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<string, decimal> 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<string> 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;
}
}
}

View file

@ -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<PaymentMethodId, Contribution>
{
public Contributions(IEnumerable<KeyValuePair<PaymentMethodId, Contribution>> collection) : base(collection)
{
TotalCurrency = Values.Select(v => v.CurrencyValue).Sum();
}
public decimal TotalCurrency { get; }
}
public bool Started => !StartDate.HasValue || DateTime.UtcNow > StartDate;

View file

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

View file

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

View file

@ -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<IUIExtension>(new UIExtension("PointOfSale/NavExtension", "apps-nav"));
services.AddSingleton<IApp,PointOfSaleApp>();
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> _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> btcPayServerOptions,
DisplayFormatter displayFormatter,
HtmlSanitizer htmlSanitizer)
{
_linkGenerator = linkGenerator;
_btcPayServerOptions = btcPayServerOptions;
_displayFormatter = displayFormatter;
_htmlSanitizer = htmlSanitizer;
}
public Task<string> ConfigureLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIPointOfSaleController.UpdatePointOfSale),
"UIPointOfSale", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath));
}
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var posS = app.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, posS.Template, posS.Currency);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.Template, settings.Currency);
var 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.InvoiceStatsItem>(), 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<IEnumerable<ItemStats>>(itemCount);
}
public Task<object> 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<string> ViewLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIPointOfSaleController.ViewPointOfSale),
"UIPointOfSale", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath));
}
}
}

View file

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

View file

@ -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<IApp> _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<IApp> 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<object> GetAppInfo(string appId)
public Dictionary<string, string> 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<string> ConfigureLink(AppData app, string vmSelectedAppType)
{
return GetAppForType(vmSelectedAppType).ConfigureLink(app);
}
private async Task<ViewCrowdfundViewModel> GetInfo(AppData appData)
private IApp GetAppForType(string appType)
{
var settings = appData.GetSettings<CrowdfundSettings>();
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<object> 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<string, decimal> 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<IEnumerable<ItemStats>> GetPerkStats(AppData appData)
{
var settings = appData.GetSettings<CrowdfundSettings>();
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<IEnumerable<ItemStats>> GetItemStats(AppData appData)
{
var settings = appData.GetSettings<PointOfSaleSettings>();
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<InvoiceStatsItem>(), 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<SalesStats> GetSalesStats(AppData app, int numberOfDays = 7)
public static Task<SalesStats> GetSalesStatswithPOSItems(ViewPointOfSaleViewModel.Item[] items,
InvoiceEntity[] paidInvoices, int numberOfDays)
{
ViewPointOfSaleViewModel.Item[] items = null;
switch (app.AppType)
{
case nameof(AppType.Crowdfund):
var cfS = app.GetSettings<CrowdfundSettings>();
items = Parse(cfS.PerksTemplate, cfS.TargetCurrency);
break;
case nameof(AppType.PointOfSale):
var posS = app.GetSettings<PointOfSaleSettings>();
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<InvoiceStatsItem>(), 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<SalesStats> 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<List<InvoiceStatsItem>, InvoiceEntity, List<InvoiceStatsItem>> AggregateInvoiceEntitiesForStats(ViewPointOfSaleViewModel.Item[] items)
public static Func<List<InvoiceStatsItem>, InvoiceEntity, List<InvoiceStatsItem>> 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<InvoiceEntity[]> GetInvoicesForApp(AppData appData, DateTime? startDate = null)
public static async Task<InvoiceEntity[]> 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<StoreData[]> 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<bool> 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<ListAppsViewModel.ListAppViewModel[]> 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<string> GetAppViewStyleAsync(string appId, string appType)
public string GetAppViewStyle(AppData app, string appType)
{
AppType appTypeEnum = Enum.Parse<AppType>(appType);
AppData appData = await GetApp(appId, appTypeEnum, false);
var settings = appData.GetSettings<PointOfSaleSettings>();
string style;
switch (appTypeEnum)
switch (appType)
{
case AppType.PointOfSale:
case PointOfSaleApp.AppType:
var settings = app.GetSettings<PointOfSaleSettings>();
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<List<AppData>> 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<AppData> GetApp(string appId, AppType? appType, bool includeStore = false)
public async Task<List<AppData>> 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<AppData> 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<ViewPointOfSaleViewModel.Item>();
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<AppData> GetAppDataIfOwner(string userId, string appId, AppType? type = null)
public async Task<AppData> 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<string?> 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

View file

@ -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<string> ConfigureLink(AppData app);
Task<string> ViewLink(AppData app);
Task SetDefaultSettings(AppData appData, string defaultCurrency);
Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays);
Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] invoiceEntities);
Task<object> GetInfo(AppData appData);
}
public enum RequiresRefundEmail

View file

@ -1,5 +1,6 @@
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Stores;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Services.Apps
{

View file

@ -775,6 +775,67 @@ namespace BTCPayServer.Services.Invoices
? JsonConvert.DeserializeObject<T>(ZipUtils.Unzip(blob), DefaultSerializerSettings)
: network.ToObject<T>(ZipUtils.Unzip(blob));
}
public static string ToJsonString<T>(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<PaymentMethodId, InvoiceStatistics.Contribution>
{
public InvoiceStatistics(IEnumerable<KeyValuePair<PaymentMethodId, Contribution>> 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; }
}
}
}

View file

@ -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<BlockExplorerOverrideItem> BlockExplorerLinks { get; set; } = new List<BlockExplorerOverrideItem>();
@ -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; }
}
}
}

View file

@ -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)
{
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UICrowdfund" asp-action="UpdateCrowdfund" asp-route-appId="@Model.Id" class="nav-link @ViewData.IsActivePage(AppsNavPages.Update, Model.Id)" id="@($"StoreNav-App-{Model.Id}")">
<vc:icon symbol="@Model.AppType.ToString().ToLower()"/>
<vc:icon symbol="@Model.AppType.ToLower()"/>
<span>@Model.AppName</span>
</a>
</li>

View file

@ -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)
{
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIPointOfSale" asp-action="UpdatePointOfSale" asp-route-appId="@Model.Id" class="nav-link @ViewData.IsActivePage(AppsNavPages.Update, Model.Id)" id="@($"StoreNav-App-{Model.Id}")">
<vc:icon symbol="@Model.AppType.ToString().ToLower()"/>
<vc:icon symbol="@Model.AppType.ToLower()"/>
<span>@Model.AppName</span>
</a>
</li>

View file

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

View file

@ -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 @@
</td>
<td>@app.AppName</td>
<td>
@typeof(AppType).DisplayName(app.AppType)
@if (app.AppType != AppType.Crowdfund.ToString())
{
<span>-</span>
@AppService.GetAvailableAppTypes()[app.AppType]
@{
var viewStyle = @app.ViewStyle;
}
@if (!string.IsNullOrEmpty(viewStyle))
{
<span>-</span>
@Safe.Raw(viewStyle)
}
@app.ViewStyle
</td>
<td class="text-end">
@if (app.IsOwner)
@ -103,7 +106,7 @@
<a asp-action="@app.UpdateAction" asp-controller="UIApps" asp-route-appId="@app.Id" asp-route-storeId="@app.StoreId">Settings</a>
<span> - </span>
}
<a asp-action="DeleteApp" asp-route-appId="@app.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The app <strong>@Html.Encode(app.AppName)</strong> and its settings will be permanently deleted from your store <strong>@Html.Encode(app.StoreName)</strong>." data-confirm-input="DELETE">Delete</a>
<a asp-action="DeleteApp" asp-route-appId="@app.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The app <strong>@Html.Encode(app.AppName)</strong> and its settings will be permanently deleted from your store <strong>@Html.Encode(app.StoreName)</strong>." data-confirm-input="DELETE">Delete</a>
</td>
</tr>
}

View file

@ -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 @@
<vc:store-recent-invoices vm="@(new StoreRecentInvoicesViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })"/>
@foreach (var app in Model.Apps)
{
<vc:app-sales app-id="@app.Id" app-type="@app.AppType" />
<vc:app-top-items app-id="@app.Id" app-type="@app.AppType" />
@if (AppService.SupportsSalesStats(app))
{
<vc:app-sales app-id="@app.Id" app-type="@app.AppType" />
}
@if (AppService.SupportsItemStats(app))
{
<vc:app-top-items app-id="@app.Id" app-type="@app.AppType" />
}
}
</div>
}

View file

@ -37,6 +37,7 @@
<symbol id="payment-requests" viewBox="0 0 24 24" fill="none"><path d="M12 19.3845C16.0784 19.3845 19.3846 16.0783 19.3846 11.9999C19.3846 7.92144 16.0784 4.61523 12 4.61523C7.92156 4.61523 4.61536 7.92144 4.61536 11.9999C4.61536 16.0783 7.92156 19.3845 12 19.3845Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M9.53845 14.216L14.2769 9.41602" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.53845 10.707V14.2147H13.2308" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="payment-sent" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="22.5" stroke="currentColor" stroke-width="3"/><path d="M24 16v16m5.71-10.29L24 16l-6.29 6.29" stroke="currentColor" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="payouts" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8.30774 8.92383H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 12H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 15.0156H12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="plugin" viewBox="0 0 24 24" fill="none"><path d="M12.0002 10.2354L4.73633 7.38747M12.0002 10.2354L19.2642 7.38747M12.0002 10.2354V19.5M5.21166 7.01614L11.2783 4.6375C11.7412 4.45417 12.2566 4.45417 12.7196 4.6375L18.7862 7.01614C19.0023 7.1083 19.1858 7.26312 19.3131 7.46062C19.4404 7.65812 19.5055 7.88923 19.5002 8.12413V15.876C19.5058 16.1106 19.441 16.3415 19.3142 16.539C19.1874 16.7365 19.0045 16.8915 18.7888 16.984L12.7222 19.3633C12.259 19.5453 11.7441 19.5453 11.2809 19.3633L5.21433 16.984C4.9982 16.8919 4.81466 16.737 4.68739 16.5395C4.56012 16.342 4.49496 16.1109 4.50033 15.876V8.12413C4.49475 7.88953 4.55951 7.65864 4.68628 7.46117C4.81305 7.26371 4.99603 7.10871 5.21166 7.01614Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pointofsale" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.4303H7.12002C6.79748 19.4322 6.47817 19.3659 6.18309 19.2356C5.88802 19.1054 5.62385 18.9141 5.40795 18.6745C5.19206 18.4349 5.02933 18.1522 4.93046 17.8452C4.83159 17.5382 4.79882 17.2137 4.83431 16.8931L5.60002 10.1617C5.6311 9.88087 5.76509 9.62152 5.97615 9.43368C6.1872 9.24584 6.46035 9.14284 6.74288 9.14455H17.2572C17.5397 9.14284 17.8129 9.24584 18.0239 9.43368C18.235 9.62152 18.369 9.88087 18.4 10.1617L19.1429 16.8931C19.1782 17.2118 19.146 17.5343 19.0485 17.8398C18.951 18.1452 18.7903 18.4267 18.5769 18.666C18.3634 18.9053 18.1021 19.097 17.8097 19.2286C17.5174 19.3603 17.2006 19.429 16.88 19.4303Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.42859 9.14369C7.42859 7.93128 7.91022 6.76852 8.76753 5.91121C9.62484 5.0539 10.7876 4.57227 12 4.57227C13.2124 4.57227 14.3752 5.0539 15.2325 5.91121C16.0898 6.76852 16.5714 7.93128 16.5714 9.14369" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.14282 12.5723H14.8571" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pos-cart" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.426H7.12a2.286 2.286 0 0 1-2.286-2.537l.766-6.731A1.143 1.143 0 0 1 6.743 9.14h10.514a1.143 1.143 0 0 1 1.143 1.017l.743 6.731a2.286 2.286 0 0 1-2.263 2.537Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.43 9.142a4.571 4.571 0 1 1 9.143 0M9.14 12.57h5.715" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="pos-light" viewBox="0 0 24 24" fill="none"><path d="M8 4h8c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4H8c-2.2 0-4-1.8-4-4V8c0-2.2 1.8-4 4-4Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8 13h8M8 16.25h8" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><rect x="7" y="7" width="10" height="3.5" rx="1" fill="currentColor"/></symbol>

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 45 KiB