Greenfield: App endpoints for sales statistics (#6103)

This commit is contained in:
d11n 2024-09-12 09:17:16 +02:00 committed by GitHub
parent 36bada8feb
commit 666445e8f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 394 additions and 59 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
@ -58,6 +59,20 @@ public partial class BTCPayServerClient
return await SendHttpRequest<CrowdfundAppData>($"api/v1/apps/crowdfund/{appId}", null, HttpMethod.Get, token);
}
public virtual async Task<AppSalesStats> GetAppSales(string appId, int numberOfDays = 7, CancellationToken token = default)
{
if (appId == null) throw new ArgumentNullException(nameof(appId));
var queryPayload = new Dictionary<string, object> { { nameof(numberOfDays), numberOfDays } };
return await SendHttpRequest<AppSalesStats>($"api/v1/apps/{appId}/sales", queryPayload, HttpMethod.Get, token);
}
public virtual async Task<List<AppItemStats>> GetAppTopItems(string appId, int offset = 0, int count = 10, CancellationToken token = default)
{
if (appId == null) throw new ArgumentNullException(nameof(appId));
var queryPayload = new Dictionary<string, object> { { nameof(offset), offset }, { nameof(count), count } };
return await SendHttpRequest<List<AppItemStats>>($"api/v1/apps/{appId}/top-items", queryPayload, HttpMethod.Get, token);
}
public virtual async Task DeleteApp(string appId, CancellationToken token = default)
{
if (appId == null) throw new ArgumentNullException(nameof(appId));

View file

@ -0,0 +1,15 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class AppItemStats
{
public string ItemCode { get; set; }
public string Title { get; set; }
public int SalesCount { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Total { get; set; }
public string TotalFormatted { get; set; }
}

View file

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class AppSalesStats
{
public int SalesCount { get; set; }
public IEnumerable<AppSalesStatsItem> Series { get; set; }
}
public class AppSalesStatsItem
{
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTime Date { get; set; }
public string Label { get; set; }
public int SalesCount { get; set; }
}

View file

@ -16,7 +16,11 @@ using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
@ -31,6 +35,7 @@ using Xunit;
using Xunit.Abstractions;
using Xunit.Sdk;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests
{
@ -679,6 +684,93 @@ namespace BTCPayServer.Tests
Assert.False(apps[2].Archived);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanGetAppStats()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.GrantAccessAsync(true);
await user.MakeAdmin();
await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient();
var item1 = new ViewPointOfSaleViewModel.Item { Id = "item1", Title = "Item 1", Price = 1, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item2 = new ViewPointOfSaleViewModel.Item { Id = "item2", Title = "Item 2", Price = 2, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var item3 = new ViewPointOfSaleViewModel.Item { Id = "item3", Title = "Item 3", Price = 3, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed };
var posItems = AppService.SerializeTemplate([item1, item2, item3]);
var posApp = await client.CreatePointOfSaleApp(user.StoreId, new PointOfSaleAppRequest { AppName = "test pos", Template = posItems, });
var crowdfundApp = await client.CreateCrowdfundApp(user.StoreId, new CrowdfundAppRequest { AppName = "test crowdfund" });
// empty states
var posSales = await client.GetAppSales(posApp.Id);
Assert.NotNull(posSales);
Assert.Equal(0, posSales.SalesCount);
Assert.Equal(7, posSales.Series.Count());
var crowdfundSales = await client.GetAppSales(crowdfundApp.Id);
Assert.NotNull(crowdfundSales);
Assert.Equal(0, crowdfundSales.SalesCount);
Assert.Equal(7, crowdfundSales.Series.Count());
var posTopItems = await client.GetAppTopItems(posApp.Id);
Assert.Empty(posTopItems);
var crowdfundItems = await client.GetAppTopItems(crowdfundApp.Id);
Assert.Empty(crowdfundItems);
// with sales - fiddle invoices via the UI controller
var uiPosController = tester.PayTester.GetController<UIPointOfSaleController>();
var action = Assert.IsType<RedirectToActionResult>(uiPosController.ViewPointOfSale(posApp.Id, PosViewType.Static, 1, choiceKey: item1.Id).GetAwaiter().GetResult());
Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName);
Assert.True(action.RouteValues!.TryGetValue("invoiceId", out var i1Id));
var cart = new JObject {
["cart"] = new JArray
{
new JObject { ["id"] = item2.Id, ["count"] = 4 },
new JObject { ["id"] = item3.Id, ["count"] = 2 }
},
["subTotal"] = 14,
["total"] = 14,
["amounts"] = new JArray()
}.ToString();
action = Assert.IsType<RedirectToActionResult>(uiPosController.ViewPointOfSale(posApp.Id, PosViewType.Cart, 7, posData: cart).GetAwaiter().GetResult());
Assert.Equal(nameof(UIInvoiceController.Checkout), action.ActionName);
Assert.True(action.RouteValues!.TryGetValue("invoiceId", out var i2Id));
await user.PayInvoice(i1Id!.ToString());
await user.PayInvoice(i2Id!.ToString());
posSales = await client.GetAppSales(posApp.Id);
Assert.Equal(7, posSales.SalesCount);
Assert.Equal(7, posSales.Series.Count());
Assert.Equal(0, posSales.Series.First().SalesCount);
Assert.Equal(7, posSales.Series.Last().SalesCount);
posTopItems = await client.GetAppTopItems(posApp.Id);
Assert.Equal(3, posTopItems.Count);
Assert.Equal(item2.Id, posTopItems[0].ItemCode);
Assert.Equal(4, posTopItems[0].SalesCount);
Assert.Equal(item3.Id, posTopItems[1].ItemCode);
Assert.Equal(2, posTopItems[1].SalesCount);
Assert.Equal(item1.Id, posTopItems[2].ItemCode);
Assert.Equal(1, posTopItems[2].SalesCount);
// with count and offset
posTopItems = await client.GetAppTopItems(posApp.Id,1, 5);
Assert.Equal(2, posTopItems.Count);
Assert.Equal(item3.Id, posTopItems[0].ItemCode);
Assert.Equal(2, posTopItems[0].SalesCount);
Assert.Equal(item1.Id, posTopItems[1].ItemCode);
Assert.Equal(1, posTopItems[1].SalesCount);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanDeleteUsersViaApi()

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppSales;
@ -12,6 +13,6 @@ public class AppSalesViewModel
public string AppUrl { get; set; }
public string DataUrl { get; set; }
public long SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
public IEnumerable<AppSalesStatsItem> Series { get; set; }
public bool InitialRendering { get; set; }
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Apps;
namespace BTCPayServer.Components.AppTopItems;
@ -10,7 +11,7 @@ public class AppTopItemsViewModel
public string AppType { get; set; }
public string AppUrl { get; set; }
public string DataUrl { get; set; }
public List<ItemStats> Entries { get; set; }
public List<AppItemStats> Entries { get; set; }
public List<int> SalesCount { get; set; }
public bool InitialRendering { get; set; }
}

View file

@ -226,6 +226,30 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok();
}
[HttpGet("~/api/v1/apps/{appId}/sales")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetAppSales(string appId, [FromQuery] int numberOfDays = 7)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null) return AppNotFound();
var stats = await _appService.GetSalesStats(app, numberOfDays);
return Ok(stats);
}
[HttpGet("~/api/v1/apps/{appId}/top-items")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> GetAppTopItems(string appId, [FromQuery] int offset = 0, [FromQuery] int count = 10)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null) return AppNotFound();
var stats = (await _appService.GetItemStats(app)).ToList();
var max = Math.Min(count, stats.Count - offset);
var items = stats.GetRange(offset, max);
return Ok(items);
}
private IActionResult AppNotFound()
{
return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found");

View file

@ -14,6 +14,7 @@ using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
@ -1142,6 +1143,18 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldAppsController>().GetAllApps());
}
public override async Task<AppSalesStats> GetAppSales(string appId, int numberOfDays = 7, CancellationToken token = default)
{
return GetFromActionResult<AppSalesStats>(
await GetController<GreenfieldAppsController>().GetAppSales(appId, numberOfDays));
}
public override async Task<List<AppItemStats>> GetAppTopItems(string appId, int offset = 0, int count = 10, CancellationToken token = default)
{
return GetFromActionResult<List<AppItemStats>>(
await GetController<GreenfieldAppsController>().GetAppTopItems(appId, offset, count));
}
public override async Task DeleteApp(string appId, CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldAppsController>().DeleteApp(appId));

View file

@ -76,14 +76,14 @@ namespace BTCPayServer.Plugins.Crowdfund
"UICrowdfund", new { appId = app.Id }, _options.Value.RootPath)!);
}
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
public Task<AppSalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var cfS = app.GetSettings<CrowdfundSettings>();
var items = AppService.Parse(cfS.PerksTemplate);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
public Task<IEnumerable<AppItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var perks = AppService.Parse(settings.PerksTemplate);
@ -97,7 +97,7 @@ namespace BTCPayServer.Plugins.Crowdfund
var total = entities.Sum(entity => entity.PaidAmount.Net);
var itemCode = entities.Key;
var perk = perks.FirstOrDefault(p => p.Id == itemCode);
return new ItemStats
return new AppItemStats
{
ItemCode = itemCode,
Title = perk?.Title ?? itemCode,
@ -108,7 +108,7 @@ namespace BTCPayServer.Plugins.Crowdfund
})
.OrderByDescending(stats => stats.SalesCount);
return Task.FromResult<IEnumerable<ItemStats>>(perkCount);
return Task.FromResult<IEnumerable<AppItemStats>>(perkCount);
}
public override async Task<object?> GetInfo(AppData appData)

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Plugins.PointOfSale.Controllers;
@ -75,14 +76,14 @@ namespace BTCPayServer.Plugins.PointOfSale
return Task.FromResult<object?>(null);
}
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
public Task<AppSalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var posS = app.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(posS.Template);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
public Task<IEnumerable<AppItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(settings.Template);
@ -100,7 +101,7 @@ namespace BTCPayServer.Plugins.PointOfSale
var total = entities.Sum(entity => entity.FiatPrice);
var itemCode = entities.Key;
var item = items.FirstOrDefault(p => p.Id == itemCode);
return new ItemStats
return new AppItemStats
{
ItemCode = itemCode,
Title = item?.Title ?? itemCode,
@ -111,7 +112,7 @@ namespace BTCPayServer.Plugins.PointOfSale
})
.OrderByDescending(stats => stats.SalesCount);
return Task.FromResult<IEnumerable<ItemStats>>(itemCount);
return Task.FromResult<IEnumerable<AppItemStats>>(itemCount);
}
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)

View file

@ -1,9 +1,7 @@
using System;
using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Client;
@ -17,8 +15,6 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Dapper;
using Ganss.Xss;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
@ -87,26 +83,25 @@ namespace BTCPayServer.Services.Apps
return await appType.GetInfo(appData);
}
public async Task<IEnumerable<ItemStats>> GetItemStats(AppData appData)
public async Task<IEnumerable<AppItemStats>> GetItemStats(AppData appData)
{
if (GetAppType(appData.AppType) is not IHasItemStatsAppType salesType)
throw new InvalidOperationException("This app isn't a SalesAppBaseType");
var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, appData,
null, new[]
{
InvoiceStatus.Processing.ToString(),
InvoiceStatus.Settled.ToString()
});
var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, appData, null,
[
InvoiceStatus.Processing.ToString(),
InvoiceStatus.Settled.ToString()
]);
return await salesType.GetItemStats(appData, paidInvoices);
}
public static Task<SalesStats> GetSalesStatswithPOSItems(ViewPointOfSaleViewModel.Item[] items,
public static Task<AppSalesStats> GetSalesStatswithPOSItems(ViewPointOfSaleViewModel.Item[] items,
InvoiceEntity[] paidInvoices, int numberOfDays)
{
var series = paidInvoices
.Aggregate(new List<InvoiceStatsItem>(), AggregateInvoiceEntitiesForStats(items))
.Aggregate([], AggregateInvoiceEntitiesForStats(items))
.GroupBy(entity => entity.Date)
.Select(entities => new SalesStatsItem
.Select(entities => new AppSalesStatsItem
{
Date = entities.Key,
Label = entities.Key.ToString("MMM dd", CultureInfo.InvariantCulture),
@ -119,7 +114,7 @@ namespace BTCPayServer.Services.Apps
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
if (!series.Any(e => e.Date == date))
{
series = series.Append(new SalesStatsItem
series = series.Append(new AppSalesStatsItem
{
Date = date,
Label = date.ToString("MMM dd", CultureInfo.InvariantCulture)
@ -127,23 +122,18 @@ namespace BTCPayServer.Services.Apps
}
}
return Task.FromResult(new SalesStats
return Task.FromResult(new AppSalesStats
{
SalesCount = series.Sum(i => i.SalesCount),
Series = series.OrderBy(i => i.Label)
});
}
public async Task<SalesStats> GetSalesStats(AppData app, int numberOfDays = 7)
public async Task<AppSalesStats> GetSalesStats(AppData app, int numberOfDays = 7)
{
if (GetAppType(app.AppType) is not IHasSaleStatsAppType salesType)
throw new InvalidOperationException("This app isn't a SalesAppBaseType");
var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, app, DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays),
new[]
{
InvoiceStatus.Processing.ToString(),
InvoiceStatus.Settled.ToString()
});
var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, app, DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays));
return await salesType.GetSalesStats(app, paidInvoices, numberOfDays);
}
@ -538,26 +528,4 @@ retry:
public int Count { get; set; }
public decimal Price { get; set; }
}
public class ItemStats
{
public string ItemCode { get; set; }
public string Title { get; set; }
public int SalesCount { get; set; }
public decimal Total { get; set; }
public string TotalFormatted { get; set; }
}
public class SalesStats
{
public int SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
}
public class SalesStatsItem
{
public DateTime Date { get; set; }
public string Label { get; set; }
public int SalesCount { get; set; }
}
}

View file

@ -1,10 +1,9 @@
#nullable enable
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Http;
namespace BTCPayServer.Services.Apps
{
@ -19,10 +18,10 @@ namespace BTCPayServer.Services.Apps
}
public interface IHasSaleStatsAppType
{
Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays);
Task<AppSalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays);
}
public interface IHasItemStatsAppType
{
Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] invoiceEntities);
Task<IEnumerable<AppItemStats>> GetItemStats(AppData appData, InvoiceEntity[] invoiceEntities);
}
}

View file

@ -345,6 +345,130 @@
]
}
},
"/api/v1/apps/{appId}/sales": {
"get": {
"tags": [
"Apps"
],
"operationId": "Apps_GetAppSales",
"summary": "Get app sales statistics",
"description": "Returns sales statistics for the app",
"parameters": [
{
"name": "appId",
"in": "path",
"required": true,
"description": "The app ID",
"schema": {
"type": "string"
}
},
{
"name": "numberOfDays",
"in": "query",
"required": false,
"description": "How many of the last days",
"schema": {
"nullable": true,
"type": "number",
"default": 7
}
}
],
"responses": {
"200": {
"description": "App sales statistics",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AppSalesStats"
}
}
}
},
"404": {
"description": "App with specified ID was not found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/apps/{appId}/top-items": {
"get": {
"tags": [
"Apps"
],
"operationId": "Apps_GetAppTopItems",
"summary": "Get app top items statistics",
"description": "Returns top items statistics for the app",
"parameters": [
{
"name": "appId",
"in": "path",
"required": true,
"description": "The app ID",
"schema": {
"type": "string"
}
},
{
"name": "count",
"in": "query",
"required": false,
"description": "How many of the items",
"schema": {
"nullable": true,
"type": "number",
"default": 5
}
},
{
"name": "offset",
"in": "query",
"required": false,
"description": "Offset for paging",
"schema": {
"nullable": true,
"type": "number",
"default": 0
}
}
],
"responses": {
"200": {
"description": "App top items statistics",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppItemStats"
}
}
}
}
},
"404": {
"description": "App with specified ID was not found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/stores/{storeId}/apps": {
"parameters": [
{
@ -887,6 +1011,69 @@
}
}
]
},
"AppSalesStats": {
"type": "object",
"properties": {
"salesCount": {
"type": "integer",
"example": 615,
"description": "Total sales in that period"
},
"series": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AppSalesStatsItem"
}
}
}
},
"AppSalesStatsItem": {
"type": "object",
"properties": {
"date": {
"type": "integer",
"description": "UNIX timestamp of the beginning of that day"
},
"label": {
"type": "string",
"description": "Short date as label",
"example": "Jul 21"
},
"salesCount": {
"type": "integer",
"example": 21,
"description": "Total sales on that day"
}
}
},
"AppItemStats": {
"type": "object",
"properties": {
"itemCode": {
"type": "string",
"description": "Item ID"
},
"title": {
"type": "string",
"description": "Item Name"
},
"salesCount": {
"type": "integer",
"example": 21,
"description": "Total sales of that item"
},
"total": {
"type": "string",
"format": "decimal",
"description": "The total amount of sales of that item"
},
"totalFormatted": {
"type": "string",
"description": "The formatted total amount of sales of that item",
"example": "615.21 USD"
}
}
}
}
},