mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-11 01:35:22 +01:00
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:
parent
9428347cb6
commit
6d3e1bb40a
19 changed files with 328 additions and 63 deletions
|
@ -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";
|
||||
|
|
|
@ -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>();
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Apps;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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),
|
||||
|
|
61
BTCPayServer/Services/Invoices/PosAppData.cs
Normal file
61
BTCPayServer/Services/Invoices/PosAppData.cs
Normal 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; }
|
||||
}
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue