Dashboard: Add Point Of Sale data (#3897)

* Dashboard: Add Point Of Sale data

Closes #3675.

* LNURL: Add POS redirect URL

* POS: Fix invoices link

* Fix integration tests

* Simplify data aggregation

* Improve chart display
This commit is contained in:
d11n 2022-06-28 07:05:02 +02:00 committed by GitHub
parent 9428347cb6
commit 6d3e1bb40a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 328 additions and 63 deletions

View file

@ -618,12 +618,13 @@ namespace BTCPayServer.Tests
user.RegisterDerivationScheme("LTC");
var apps = user.GetController<UIAppsController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
vm.AppName = "test";
vm.SelectedAppType = AppType.PointOfSale.ToString();
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(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];
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName });
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType });
var vmpos = await apps.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "hello";
vmpos.Currency = "CAD";

View file

@ -35,15 +35,16 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.Crowdfund.ToString();
Assert.NotNull(vm.SelectedAppType);
Assert.Null(vm.AppName);
vm.AppName = "test";
vm.SelectedAppType = AppType.Crowdfund.ToString();
vm.SelectedAppType = appType;
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.Equal(nameof(apps.UpdateCrowdfund), redirectToAction.ActionName);
var appList = Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps.ListApps(user.StoreId).Result).Model);
var app = appList.Apps[0];
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName });
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType});
var appList2 =
Assert.IsType<ListAppsViewModel>(Assert.IsType<ViewResult>(apps2.ListApps(user2.StoreId).Result).Model);
Assert.Single(appList.Apps);
@ -71,12 +72,13 @@ namespace BTCPayServer.Tests
user.RegisterDerivationScheme("BTC");
var apps = user.GetController<UIAppsController>();
var vm = apps.CreateApp(user.StoreId).AssertViewModel<CreateAppViewModel>();
var appType = AppType.Crowdfund.ToString();
vm.AppName = "test";
vm.SelectedAppType = AppType.Crowdfund.ToString();
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(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];
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName });
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType });
//Scenario 1: Not Enabled - Not Allowed
var crowdfundViewModel = await apps.UpdateCrowdfund(app.Id).AssertViewModelAsync<UpdateCrowdfundViewModel>();
@ -158,12 +160,13 @@ namespace BTCPayServer.Tests
await user.SetNetworkFeeMode(NetworkFeeMode.Never);
var apps = user.GetController<UIAppsController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.Crowdfund.ToString();
vm.AppName = "test";
vm.SelectedAppType = AppType.Crowdfund.ToString();
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(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];
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName });
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType });
TestLogs.LogInformation("We create an invoice with a hardcap");
var crowdfundViewModel = await apps.UpdateCrowdfund(app.Id).AssertViewModelAsync<UpdateCrowdfundViewModel>();

View file

@ -29,12 +29,13 @@ namespace BTCPayServer.Tests
user.RegisterDerivationScheme("BTC");
var apps = user.GetController<UIAppsController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
vm.AppName = "test";
vm.SelectedAppType = AppType.PointOfSale.ToString();
vm.SelectedAppType = appType;
Assert.IsType<RedirectToActionResult>(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];
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName });
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType });
var vmpos = await apps.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Template = @"
apple:

View file

@ -1953,17 +1953,18 @@ namespace BTCPayServer.Tests
var apps = user.GetController<UIAppsController>();
var apps2 = user2.GetController<UIAppsController>();
var vm = Assert.IsType<CreateAppViewModel>(Assert.IsType<ViewResult>(apps.CreateApp(user.StoreId)).Model);
var appType = AppType.PointOfSale.ToString();
Assert.NotNull(vm.SelectedAppType);
Assert.Null(vm.AppName);
vm.AppName = "test";
vm.SelectedAppType = AppType.PointOfSale.ToString();
vm.SelectedAppType = appType;
var redirectToAction = Assert.IsType<RedirectToActionResult>(apps.CreateApp(user.StoreId, vm).Result);
Assert.Equal(nameof(apps.UpdatePointOfSale), redirectToAction.ActionName);
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);
var app = appList.Apps[0];
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName });
apps.HttpContext.SetAppData(new AppData { Id = app.Id, StoreDataId = app.StoreId, Name = app.AppName, AppType = appType });
Assert.Single(appList.Apps);
Assert.Empty(appList2.Apps);
Assert.Equal("test", appList.Apps[0].AppName);

View file

@ -1,4 +1,3 @@
using System.Collections;
using System.Collections.Generic;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;

View file

@ -1,19 +1,23 @@
@using BTCPayServer.Services.Apps
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Components.AppSales.AppSalesViewModel
@{
var action = $"Update{Model.App.AppType}";
var label = Model.App.AppType == nameof(AppType.Crowdfund) ? "Contributions" : "Sales";
}
<div id="AppSales-@Model.App.Id" class="widget app-sales">
<header class="mb-3">
<h3>@Model.App.Name Contributions</h3>
<h3>@Model.App.Name @label</h3>
<a asp-controller="UIApps" asp-action="@action" asp-route-appId="@Model.App.Id">Manage</a>
</header>
<p>@Model.SalesCount Total Contributions</p>
<div class="ct-chart ct-major-octave"></div>
<p>@Model.SalesCount Total @label</p>
<div class="ct-chart"></div>
<script>
(function () {
const id = 'AppSales-@Model.App.Id';
const id = @Safe.Json($"AppSales-{Model.App.Id}");
const labels = @Safe.Json(Model.Series.Select(i => i.Label));
const series = @Safe.Json(Model.Series.Select(i => i.SalesCount));
const min = Math.min(...series);

View file

@ -1,3 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Apps;
@ -20,11 +22,13 @@ public class AppTopItems : ViewComponent
public async Task<IViewComponentResult> InvokeAsync(AppData app)
{
var entries = await _appService.GetPerkStats(app);
var entries = Enum.Parse<AppType>(app.AppType) == AppType.Crowdfund
? await _appService.GetPerkStats(app)
: await _appService.GetItemStats(app);
var vm = new AppTopItemsViewModel
{
App = app,
Entries = entries
Entries = entries.ToList()
};
return View(vm);

View file

@ -7,5 +7,5 @@ namespace BTCPayServer.Components.AppTopItems;
public class AppTopItemsViewModel
{
public AppData App { get; set; }
public IEnumerable<ItemStats> Entries { get; set; }
public List<ItemStats> Entries { get; set; }
}

View file

@ -1,24 +1,46 @@
@using BTCPayServer.Services.Apps
@model BTCPayServer.Components.AppTopItems.AppTopItemsViewModel
@{
var action = $"Update{Model.App.AppType}";
var label = Model.App.AppType == nameof(AppType.Crowdfund) ? "contribution" : "sale";
}
<div class="widget app-top-items">
<div id="AppTopItems-@Model.App.Id" class="widget app-top-items">
<header class="mb-3">
<h3>Top Perks</h3>
<h3>Top @(Model.App.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")</h3>
<a asp-controller="UIApps" asp-action="@action" asp-route-appId="@Model.App.Id">View All</a>
</header>
@if (Model.Entries.Any())
{
<div class="ct-chart mb-3"></div>
<script>
(function () {
const id = @Safe.Json($"AppTopItems-{Model.App.Id}");
const series = @Safe.Json(Model.Entries.Select(i => i.SalesCount));
new Chartist.Bar(`#${id} .ct-chart`, { series }, {
distributeSeries: true,
horizontalBars: true,
showLabel: false,
stackBars: true,
axisY: {
offset: 0
}
});
})();
</script>
<div class="app-items">
@foreach (var entry in Model.Entries)
@for (var i = 0; i < Model.Entries.Count; i++)
{
<div class="app-item">
<span class="app-item-name">@entry.Title</span>
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">
@entry.SalesCount sale@(entry.SalesCount == 1 ? "" : "s"),
@entry.TotalFormatted total
<span class="text-muted">@entry.SalesCount @($"{label}{(entry.SalesCount == 1 ? "" : "s")}"),</span>
@entry.TotalFormatted
</span>
</div>
}
@ -27,7 +49,7 @@
else
{
<p class="text-secondary mt-3">
No contributions have been made yet.
No @($"{label}s") have been made yet.
</p>
}
</div>

View file

@ -62,7 +62,7 @@ namespace BTCPayServer.Controllers
IsRecurring = resetEvery != nameof(CrowdfundResetEvery.Never),
UseAllStoreInvoices = app.TagAllInvoices,
AppId = appId,
SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetCrowdfundOrderId(appId)}",
SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}",
DisplayPerksRanking = settings.DisplayPerksRanking,
DisplayPerksValue = settings.DisplayPerksValue,
SortPerksByPopularity = settings.SortPerksByPopularity,

View file

@ -49,7 +49,7 @@ namespace BTCPayServer.Controllers
Description = settings.Description,
NotificationUrl = settings.NotificationUrl,
RedirectUrl = settings.RedirectUrl,
SearchTerm = $"storeid:{app.StoreDataId}",
SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}",
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
RequiresRefundEmail = settings.RequiresRefundEmail
};

View file

@ -223,7 +223,7 @@ namespace BTCPayServer.Controllers
Currency = settings.Currency,
Price = price,
BuyerEmail = email,
OrderId = orderId ?? AppService.GetPosOrderId(appId),
OrderId = orderId ?? AppService.GetAppOrderId(app),
NotificationURL =
string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl,
RedirectURL = !string.IsNullOrEmpty(redirectUrl) ? redirectUrl
@ -378,7 +378,7 @@ namespace BTCPayServer.Controllers
{
var invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
{
OrderId = AppService.GetCrowdfundOrderId(appId),
OrderId = AppService.GetAppOrderId(app),
Currency = settings.TargetCurrency,
ItemCode = request.ChoiceKey ?? string.Empty,
ItemDesc = title,

View file

@ -237,6 +237,12 @@ namespace BTCPayServer
var lnAddress = username is null ? null : $"{username}@{Request.Host}";
List<string[]> lnurlMetadata = new();
var redirectUrl = app?.AppType switch
{
nameof(AppType.PointOfSale) => app.GetSettings<PointOfSaleSettings>().RedirectUrl ??
HttpContext.Request.GetAbsoluteUri($"/apps/{app.Id}/pos"),
_ => null
};
var invoiceRequest = new CreateInvoiceRequest
{
Amount = invoiceAmount,
@ -245,7 +251,8 @@ namespace BTCPayServer
PaymentMethods = new[] { pmi.ToStringNormalized() },
Expiration = blob.InvoiceExpiration < TimeSpan.FromMinutes(2)
? blob.InvoiceExpiration
: TimeSpan.FromMinutes(2)
: TimeSpan.FromMinutes(2),
RedirectURL = redirectUrl
},
Currency = currencyCode,
Type = invoiceAmount is null ? InvoiceType.TopUp : InvoiceType.Standard,
@ -258,7 +265,7 @@ namespace BTCPayServer
{
ItemCode = item.Id,
ItemDesc = item.Description,
OrderId = AppService.GetPosOrderId(app.Id)
OrderId = AppService.GetAppOrderId(app)
}.ToJObject();
}

View file

@ -158,17 +158,16 @@ namespace BTCPayServer.Controllers
var userId = GetUserId();
var apps = await _appService.GetAllApps(userId, false, store.Id);
vm.Apps = apps
.Where(a => a.AppType == AppType.Crowdfund.ToString())
.Select(a =>
{
var appData = _appService.GetAppDataIfOwner(userId, a.Id, AppType.Crowdfund).Result;
var appData = _appService.GetAppDataIfOwner(userId, a.Id).Result;
appData.StoreData = store;
return appData;
})
.ToList();
}
return View("Dashboard", vm);
return View(vm);
}
[HttpPost]

View file

@ -5,7 +5,6 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Payments;
@ -17,6 +16,7 @@ using Ganss.XSS;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
@ -46,7 +46,7 @@ namespace BTCPayServer.Services.Apps
_storeRepository = storeRepository;
_HtmlSanitizer = htmlSanitizer;
}
public async Task<object> GetAppInfo(string appId)
{
var app = await GetApp(appId, AppType.Crowdfund, true);
@ -200,8 +200,9 @@ namespace BTCPayServer.Services.Apps
var currencyData = _Currencies.GetCurrencyData(settings.TargetCurrency, true);
var perks = Parse(settings.PerksTemplate, settings.TargetCurrency);
var perkCount = paidInvoices
.Where(entity => !string.IsNullOrEmpty(entity.Metadata.ItemCode) &&
entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase))
.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 =>
{
@ -229,14 +230,67 @@ namespace BTCPayServer.Services.Apps
return perkCount;
}
public async Task<SalesStats> GetSalesStats(AppData appData, int numberOfDays = 7)
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
!string.IsNullOrEmpty(entity.Metadata.PosData) ||
// 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 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;
}
public async Task<SalesStats> GetSalesStats(AppData app, int numberOfDays = 7)
{
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 => !string.IsNullOrEmpty(entity.Metadata.ItemCode) &&
entity.InvoiceTime > DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays))
.GroupBy(entity => entity.InvoiceTime.Date)
.Where(entity => entity.InvoiceTime > DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays) && (
// The POS data is present for the cart view, where multiple items can be bought
!string.IsNullOrEmpty(entity.Metadata.PosData) ||
// 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.Date)
.Select(entities => new SalesStatsItem
{
Date = entities.Key,
@ -260,18 +314,79 @@ namespace BTCPayServer.Services.Apps
return new SalesStats
{
SalesCount = paidInvoices.Length,
SalesCount = series.Sum(i => i.SalesCount),
Series = series.OrderBy(i => i.Label)
};
}
private 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)
{
return (res, e) =>
{
if (!string.IsNullOrEmpty(e.Metadata.ItemCode))
{
var item = items.FirstOrDefault(p => p.Id == e.Metadata.ItemCode);
if (item == null) return res;
var fiatPrice = e.GetPayments(true).Sum(pay =>
{
var paymentMethodId = pay.GetPaymentMethodId();
var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee;
var rate = e.GetPaymentMethod(paymentMethodId).Rate;
return rate * value;
});
res.Add(new InvoiceStatsItem
{
ItemCode = e.Metadata.ItemCode,
FiatPrice = fiatPrice,
Date = e.InvoiceTime.Date
});
}
else if (!string.IsNullOrEmpty(e.Metadata.PosData))
{
// flatten single items from POS data
var data = JsonConvert.DeserializeObject<PosAppData>(e.Metadata.PosData);
if (data is not { Cart.Length: > 0 }) return res;
foreach (var lineItem in data.Cart)
{
var item = items.FirstOrDefault(p => p.Id == lineItem.Id);
if (item == null) continue;
for (var i = 0; i < lineItem.Count; i++)
{
res.Add(new InvoiceStatsItem
{
ItemCode = item.Id,
FiatPrice = lineItem.Price.Value,
Date = e.InvoiceTime.Date
});
}
}
}
return res;
};
}
private static bool IsPaid(InvoiceEntity entity)
{
return entity.Status == InvoiceStatusLegacy.Complete || entity.Status == InvoiceStatusLegacy.Confirmed || entity.Status == InvoiceStatusLegacy.Paid;
}
public static string GetPosOrderId(string appId) => $"pos-app_{appId}";
public static string GetCrowdfundOrderId(string appId) => $"crowdfund-app_{appId}";
public static string GetAppOrderId(AppData app) =>
app.AppType switch
{
nameof(AppType.Crowdfund) => $"crowdfund-app_{app.Id}",
nameof(AppType.PointOfSale) => $"pos-app_{app.Id}",
_ => throw new ArgumentOutOfRangeException(nameof(app), app.AppType)
};
public static string GetAppInternalTag(string appId) => $"APP#{appId}";
public static string[] GetAppInternalTags(InvoiceEntity invoice)
{
@ -283,7 +398,7 @@ namespace BTCPayServer.Services.Apps
var invoices = await _InvoiceRepository.GetInvoices(new InvoiceQuery()
{
StoreId = new[] { appData.StoreData.Id },
OrderId = appData.TagAllInvoices ? null : new[] { GetCrowdfundOrderId(appData.Id) },
OrderId = appData.TagAllInvoices ? null : new[] { GetAppOrderId(appData) },
Status = new[]{
InvoiceState.ToString(InvoiceStatusLegacy.New),
InvoiceState.ToString(InvoiceStatusLegacy.Paid),

View file

@ -0,0 +1,61 @@
using BTCPayServer.Models.AppViewModels;
using Newtonsoft.Json;
namespace BTCPayServer.Services.Invoices;
public class PosAppData
{
[JsonProperty(PropertyName = "cart")]
public PosAppCartItem[] Cart { get; set; }
[JsonProperty(PropertyName = "customAmount")]
public decimal CustomAmount { get; set; }
[JsonProperty(PropertyName = "discountPercentage")]
public decimal DiscountPercentage { get; set; }
[JsonProperty(PropertyName = "discountAmount")]
public decimal DiscountAmount { get; set; }
[JsonProperty(PropertyName = "tip")]
public decimal Tip { get; set; }
[JsonProperty(PropertyName = "subTotal")]
public decimal Subtotal { get; set; }
[JsonProperty(PropertyName = "total")]
public decimal Total { get; set; }
}
public class PosAppCartItem
{
[JsonProperty(PropertyName = "id")]
public string Id { get; set; }
[JsonProperty(PropertyName = "price")]
public PosAppCartItemPrice Price { get; set; }
[JsonProperty(PropertyName = "title")]
public string Title { get; set; }
[JsonProperty(PropertyName = "count")]
public int Count { get; set; }
[JsonProperty(PropertyName = "inventory")]
public int? Inventory { get; set; }
[JsonProperty(PropertyName = "image")]
public string Image { get; set; }
}
public class PosAppCartItemPrice
{
[JsonProperty(PropertyName = "formatted")]
public string Formatted { get; set; }
[JsonProperty(PropertyName = "value")]
public decimal Value { get; set; }
[JsonProperty(PropertyName = "type")]
public ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType Type { get; set; }
}

View file

@ -256,7 +256,7 @@
</form>
<div class="d-flex gap-3 mt-3">
<a class="btn btn-secondary" asp-action="ListInvoices" asp-controller="UIInvoice" asp-route-searchterm="@Model.SearchTerm">Invoices</a>
<a class="btn btn-secondary" asp-action="ListInvoices" asp-controller="UIInvoice" asp-route-storeId="@Model.StoreId" asp-route-searchterm="@Model.SearchTerm">Invoices</a>
<a id="DeleteApp" class="btn btn-outline-danger" asp-action="DeleteApp" asp-route-appId="@Model.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The app <strong>@Model.AppName</strong> and its settings will be permanently deleted." data-confirm-input="DELETE">Delete this app</a>
</div>

View file

@ -1,3 +1,13 @@
/* Variables */
:root {
--chart-main-rgb: 68, 164, 49;
--chart-series-a-rgb: var(--chart-main-rgb);
--chart-series-b-rgb: 245, 0, 0;
--chart-series-c-rgb: 0, 109, 242;
--chart-series-d-rgb: 255, 188, 4;
--chart-series-e-rgb: 160, 98, 75;
}
/* General and site-wide Bootstrap modifications */
p {
margin-bottom: 1.5rem;
@ -103,7 +113,6 @@ h2 small .fa-question-circle-o {
font-size: var(--btcpay-font-size-l);
}
/* Invoices */
.invoice-payments {
padding: var(--btcpay-space-m) var(--btcpay-space-l);
@ -391,6 +400,27 @@ svg.icon-note {
font-weight: var(--btcpay-font-weight-semibold);
}
.widget.app-top-items .ct-chart,
.widget.app-top-items .ct-chart .ct-chart-bar {
height: 30px;
}
.widget.app-top-items .ct-chart .ct-chart-bar {
margin-left: -.4rem;
margin-right: -.5rem;
width: calc(100% + 1rem) !important;
}
.widget.app-top-items .ct-bar {
stroke-linecap: round;
stroke-width: 10px;
}
.widget.app-top-items .ct-grids,
.widget.app-top-items .ct-labels {
display: none;
}
.widget.app-top-items .app-items {
display: flex;
flex-direction: column;
@ -399,10 +429,20 @@ svg.icon-note {
.widget.app-top-items .app-item {
display: flex;
align-items: end;
flex-wrap: wrap;
gap: var(--btcpay-space-xs);
align-items: center;
justify-content: space-between;
}
.widget.app-top-items .app-item-point {
display: inline-block;
width: var(--btcpay-space-s);
height: var(--btcpay-space-s);
margin-right: var(--btcpay-space-s);
border-radius: 50%;
}
.widget.app-top-items .app-item-value {
font-weight: var(--btcpay-font-weight-semibold);
}

View file

@ -1,9 +1,12 @@
.ct-label {
fill: rgba(128, 128, 128, .4);
color: var(--btcpay-body-text-muted);
font-size: 0.75rem;
line-height: 1; }
.ct-chart-pie .ct-label {
fill: rgba(var(--btcpay-body-text-rgb), 1);
}
.ct-label.ct-horizontal {
min-width: 3rem; }
@ -168,35 +171,40 @@
fill: none;
stroke-width: 60px; }
.ct-series-0 .ct-point { background: rgb(var(--chart-series-a-rgb)); }
.ct-series-a .ct-point, .ct-series-a .ct-line, .ct-series-a .ct-bar, .ct-series-a .ct-slice-donut {
stroke: rgba(68,164,49, 1); }
stroke: rgb(var(--chart-series-a-rgb)); }
.ct-series-a .ct-slice-pie, .ct-series-a .ct-slice-donut-solid, .ct-series-a .ct-area {
fill: rgba(68,164,49, 0.75); }
fill: rgb(var(--chart-series-a-rgb)); }
.ct-series-1 .ct-point { background: rgb(var(--chart-series-b-rgb)); }
.ct-series-b .ct-point, .ct-series-b .ct-line, .ct-series-b .ct-bar, .ct-series-b .ct-slice-donut {
stroke: #f05b4f; }
stroke: rgb(var(--chart-series-b-rgb)); }
.ct-series-b .ct-slice-pie, .ct-series-b .ct-slice-donut-solid, .ct-series-b .ct-area {
fill: #f05b4f; }
fill: rgb(var(--chart-series-c-rgb)); }
.ct-series-2 .ct-point { background: rgb(var(--chart-series-c-rgb)); }
.ct-series-c .ct-point, .ct-series-c .ct-line, .ct-series-c .ct-bar, .ct-series-c .ct-slice-donut {
stroke: #f4c63d; }
stroke: rgb(var(--chart-series-c-rgb)); }
.ct-series-c .ct-slice-pie, .ct-series-c .ct-slice-donut-solid, .ct-series-c .ct-area {
fill: #f4c63d; }
fill: rgb(var(--chart-series-c-rgb)); }
.ct-series-3 .ct-point { background: rgb(var(--chart-series-d-rgb)); }
.ct-series-d .ct-point, .ct-series-d .ct-line, .ct-series-d .ct-bar, .ct-series-d .ct-slice-donut {
stroke: #d17905; }
stroke: rgb(var(--chart-series-d-rgb)); }
.ct-series-d .ct-slice-pie, .ct-series-d .ct-slice-donut-solid, .ct-series-d .ct-area {
fill: #d17905; }
fill: rgb(var(--chart-series-d-rgb)); }
.ct-series-4 .ct-point { background: rgb(var(--chart-series-e-rgb)); }
.ct-series-e .ct-point, .ct-series-e .ct-line, .ct-series-e .ct-bar, .ct-series-e .ct-slice-donut {
stroke: #453d3f; }
stroke: rgb(var(--chart-series-e-rgb)); }
.ct-series-e .ct-slice-pie, .ct-series-e .ct-slice-donut-solid, .ct-series-e .ct-area {
fill: #453d3f; }
fill: rgb(var(--chart-series-e-rgb)); }
.ct-square {
display: block;