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;
using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Threading; using System.Threading;
using System.Threading.Tasks; 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); 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) public virtual async Task DeleteApp(string appId, CancellationToken token = default)
{ {
if (appId == null) throw new ArgumentNullException(nameof(appId)); 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.NTag424;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors; using BTCPayServer.PayoutProcessors;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
@ -31,6 +35,7 @@ using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using Xunit.Sdk; using Xunit.Sdk;
using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
@ -679,6 +684,93 @@ namespace BTCPayServer.Tests
Assert.False(apps[2].Archived); 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)] [Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task CanDeleteUsersViaApi() public async Task CanDeleteUsersViaApi()

View file

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

View file

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

View file

@ -226,6 +226,30 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(); 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() private IActionResult AppNotFound()
{ {
return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found"); 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.Data;
using BTCPayServer.Security; using BTCPayServer.Security;
using BTCPayServer.Security.Greenfield; using BTCPayServer.Security.Greenfield;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@ -1142,6 +1143,18 @@ namespace BTCPayServer.Controllers.Greenfield
await GetController<GreenfieldAppsController>().GetAllApps()); 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) public override async Task DeleteApp(string appId, CancellationToken token = default)
{ {
HandleActionResult(await GetController<GreenfieldAppsController>().DeleteApp(appId)); HandleActionResult(await GetController<GreenfieldAppsController>().DeleteApp(appId));

View file

@ -76,14 +76,14 @@ namespace BTCPayServer.Plugins.Crowdfund
"UICrowdfund", new { appId = app.Id }, _options.Value.RootPath)!); "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 cfS = app.GetSettings<CrowdfundSettings>();
var items = AppService.Parse(cfS.PerksTemplate); var items = AppService.Parse(cfS.PerksTemplate);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays); 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 settings = appData.GetSettings<CrowdfundSettings>();
var perks = AppService.Parse(settings.PerksTemplate); var perks = AppService.Parse(settings.PerksTemplate);
@ -97,7 +97,7 @@ namespace BTCPayServer.Plugins.Crowdfund
var total = entities.Sum(entity => entity.PaidAmount.Net); var total = entities.Sum(entity => entity.PaidAmount.Net);
var itemCode = entities.Key; var itemCode = entities.Key;
var perk = perks.FirstOrDefault(p => p.Id == itemCode); var perk = perks.FirstOrDefault(p => p.Id == itemCode);
return new ItemStats return new AppItemStats
{ {
ItemCode = itemCode, ItemCode = itemCode,
Title = perk?.Title ?? itemCode, Title = perk?.Title ?? itemCode,
@ -108,7 +108,7 @@ namespace BTCPayServer.Plugins.Crowdfund
}) })
.OrderByDescending(stats => stats.SalesCount); .OrderByDescending(stats => stats.SalesCount);
return Task.FromResult<IEnumerable<ItemStats>>(perkCount); return Task.FromResult<IEnumerable<AppItemStats>>(perkCount);
} }
public override async Task<object?> GetInfo(AppData appData) 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.Contracts;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services; using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Plugins.PointOfSale.Controllers; using BTCPayServer.Plugins.PointOfSale.Controllers;
@ -75,14 +76,14 @@ namespace BTCPayServer.Plugins.PointOfSale
return Task.FromResult<object?>(null); 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 posS = app.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(posS.Template); var items = AppService.Parse(posS.Template);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays); 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 settings = appData.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(settings.Template); var items = AppService.Parse(settings.Template);
@ -100,7 +101,7 @@ namespace BTCPayServer.Plugins.PointOfSale
var total = entities.Sum(entity => entity.FiatPrice); var total = entities.Sum(entity => entity.FiatPrice);
var itemCode = entities.Key; var itemCode = entities.Key;
var item = items.FirstOrDefault(p => p.Id == itemCode); var item = items.FirstOrDefault(p => p.Id == itemCode);
return new ItemStats return new AppItemStats
{ {
ItemCode = itemCode, ItemCode = itemCode,
Title = item?.Title ?? itemCode, Title = item?.Title ?? itemCode,
@ -111,7 +112,7 @@ namespace BTCPayServer.Plugins.PointOfSale
}) })
.OrderByDescending(stats => stats.SalesCount); .OrderByDescending(stats => stats.SalesCount);
return Task.FromResult<IEnumerable<ItemStats>>(itemCount); return Task.FromResult<IEnumerable<AppItemStats>>(itemCount);
} }
public override Task SetDefaultSettings(AppData appData, string defaultCurrency) public override Task SetDefaultSettings(AppData appData, string defaultCurrency)

View file

@ -1,9 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Configuration;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client; using BTCPayServer.Client;
@ -17,8 +15,6 @@ using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Dapper; using Dapper;
using Ganss.Xss;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NBitcoin; using NBitcoin;
using NBitcoin.DataEncoders; using NBitcoin.DataEncoders;
@ -87,26 +83,25 @@ namespace BTCPayServer.Services.Apps
return await appType.GetInfo(appData); 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) if (GetAppType(appData.AppType) is not IHasItemStatsAppType salesType)
throw new InvalidOperationException("This app isn't a SalesAppBaseType"); throw new InvalidOperationException("This app isn't a SalesAppBaseType");
var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, appData, var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, appData, null,
null, new[] [
{ InvoiceStatus.Processing.ToString(),
InvoiceStatus.Processing.ToString(), InvoiceStatus.Settled.ToString()
InvoiceStatus.Settled.ToString() ]);
});
return await salesType.GetItemStats(appData, paidInvoices); 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) InvoiceEntity[] paidInvoices, int numberOfDays)
{ {
var series = paidInvoices var series = paidInvoices
.Aggregate(new List<InvoiceStatsItem>(), AggregateInvoiceEntitiesForStats(items)) .Aggregate([], AggregateInvoiceEntitiesForStats(items))
.GroupBy(entity => entity.Date) .GroupBy(entity => entity.Date)
.Select(entities => new SalesStatsItem .Select(entities => new AppSalesStatsItem
{ {
Date = entities.Key, Date = entities.Key,
Label = entities.Key.ToString("MMM dd", CultureInfo.InvariantCulture), Label = entities.Key.ToString("MMM dd", CultureInfo.InvariantCulture),
@ -119,7 +114,7 @@ namespace BTCPayServer.Services.Apps
var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date; var date = (DateTimeOffset.UtcNow - TimeSpan.FromDays(i)).Date;
if (!series.Any(e => e.Date == date)) if (!series.Any(e => e.Date == date))
{ {
series = series.Append(new SalesStatsItem series = series.Append(new AppSalesStatsItem
{ {
Date = date, Date = date,
Label = date.ToString("MMM dd", CultureInfo.InvariantCulture) 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), SalesCount = series.Sum(i => i.SalesCount),
Series = series.OrderBy(i => i.Label) 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) if (GetAppType(app.AppType) is not IHasSaleStatsAppType salesType)
throw new InvalidOperationException("This app isn't a SalesAppBaseType"); throw new InvalidOperationException("This app isn't a SalesAppBaseType");
var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, app, DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays), var paidInvoices = await GetInvoicesForApp(_InvoiceRepository, app, DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays));
new[]
{
InvoiceStatus.Processing.ToString(),
InvoiceStatus.Settled.ToString()
});
return await salesType.GetSalesStats(app, paidInvoices, numberOfDays); return await salesType.GetSalesStats(app, paidInvoices, numberOfDays);
} }
@ -538,26 +528,4 @@ retry:
public int Count { get; set; } public int Count { get; set; }
public decimal Price { 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 #nullable enable
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using Microsoft.AspNetCore.Http;
namespace BTCPayServer.Services.Apps namespace BTCPayServer.Services.Apps
{ {
@ -19,10 +18,10 @@ namespace BTCPayServer.Services.Apps
} }
public interface IHasSaleStatsAppType public interface IHasSaleStatsAppType
{ {
Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays); Task<AppSalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays);
} }
public interface IHasItemStatsAppType 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": { "/api/v1/stores/{storeId}/apps": {
"parameters": [ "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"
}
}
} }
} }
}, },