From dc986959fd6dfc55732c54be4797e63df76bc9e5 Mon Sep 17 00:00:00 2001 From: Nicolas Dorier Date: Mon, 24 Jul 2023 09:24:32 +0900 Subject: [PATCH] Add reporting feature (#5155) * Add reporting feature * Remove nodatime * Add summaries * work... * Add chart title * Fix error * Allow to set hour in the field * UI updates * Fix fake data * ViewDefinitions can be dynamic * Add items sold * Sticky table headers * Update JS and remove jQuery usages * JS click fix * Handle tag all invoices for app * fix dup row in items report * Can cancel invoice request * Add tests * Fake data for items sold * Rename Items to Products, improve navigation F5 * Use bordered table for summaries --------- Co-authored-by: Dennis Reimann --- .../TagHelpers/PermissionTagHelper.cs | 4 +- .../Models/StoreReportRequest.cs | 62 ++++ .../Models/StoreReportsResponse.cs | 16 ++ .../ApplicationDbContextFactory.cs | 3 + BTCPayServer.Tests/TestAccount.cs | 95 ++++++- BTCPayServer.Tests/ThirdPartyTests.cs | 8 + BTCPayServer.Tests/UnitTest1.cs | 119 ++++++++ BTCPayServer/BTCPayServer.csproj | 4 + .../Components/MainNav/Default.cshtml | 6 + .../GreenField/GreenfieldReportsController.cs | 80 ++++++ .../UIReportsController.CheatMode.cs | 122 ++++++++ .../Controllers/UIReportsController.cs | 91 ++++++ BTCPayServer/Extensions.cs | 9 + BTCPayServer/Extensions/StoreExtensions.cs | 9 + BTCPayServer/Hosting/BTCPayServerServices.cs | 6 + .../StoreReportsViewModel.cs | 16 ++ .../Services/Invoices/InvoiceRepository.cs | 9 +- BTCPayServer/Services/ReportService.cs | 20 ++ .../Reporting/OnChainWalletReportProvider.cs | 122 ++++++++ .../Reporting/PaymentsReportProvider.cs | 187 ++++++++++++ .../Reporting/ProductsReportProvider.cs | 131 +++++++++ .../Services/Reporting/QueryContext.cs | 34 +++ .../Services/Reporting/ReportProvider.cs | 19 ++ .../Services/Reporting/ViewDefinition.cs | 16 ++ BTCPayServer/TagHelpers/CheatModeTagHelper.cs | 30 ++ .../Views/UIReports/StoreReports.cshtml | 128 +++++++++ BTCPayServer/Views/UIStores/StoreNavPages.cs | 1 + BTCPayServer/wwwroot/js/datatable.js | 269 ++++++++++++++++++ BTCPayServer/wwwroot/js/store-reports.js | 212 ++++++++++++++ .../wwwroot/vendor/FileSaver/FileSaver.min.js | 3 + .../wwwroot/vendor/papaparse/papaparse.min.js | 7 + 31 files changed, 1830 insertions(+), 8 deletions(-) create mode 100644 BTCPayServer.Client/Models/StoreReportRequest.cs create mode 100644 BTCPayServer.Client/Models/StoreReportsResponse.cs create mode 100644 BTCPayServer/Controllers/GreenField/GreenfieldReportsController.cs create mode 100644 BTCPayServer/Controllers/UIReportsController.CheatMode.cs create mode 100644 BTCPayServer/Controllers/UIReportsController.cs create mode 100644 BTCPayServer/Models/StoreReportsViewModels/StoreReportsViewModel.cs create mode 100644 BTCPayServer/Services/ReportService.cs create mode 100644 BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs create mode 100644 BTCPayServer/Services/Reporting/PaymentsReportProvider.cs create mode 100644 BTCPayServer/Services/Reporting/ProductsReportProvider.cs create mode 100644 BTCPayServer/Services/Reporting/QueryContext.cs create mode 100644 BTCPayServer/Services/Reporting/ReportProvider.cs create mode 100644 BTCPayServer/Services/Reporting/ViewDefinition.cs create mode 100644 BTCPayServer/TagHelpers/CheatModeTagHelper.cs create mode 100644 BTCPayServer/Views/UIReports/StoreReports.cshtml create mode 100644 BTCPayServer/wwwroot/js/datatable.js create mode 100644 BTCPayServer/wwwroot/js/store-reports.js create mode 100644 BTCPayServer/wwwroot/vendor/FileSaver/FileSaver.min.js create mode 100644 BTCPayServer/wwwroot/vendor/papaparse/papaparse.min.js diff --git a/BTCPayServer.Abstractions/TagHelpers/PermissionTagHelper.cs b/BTCPayServer.Abstractions/TagHelpers/PermissionTagHelper.cs index 59d454c1a..436405271 100644 --- a/BTCPayServer.Abstractions/TagHelpers/PermissionTagHelper.cs +++ b/BTCPayServer.Abstractions/TagHelpers/PermissionTagHelper.cs @@ -12,13 +12,11 @@ public class PermissionTagHelper : TagHelper { private readonly IAuthorizationService _authorizationService; private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ILogger _logger; - public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor, ILogger logger) + public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor) { _authorizationService = authorizationService; _httpContextAccessor = httpContextAccessor; - _logger = logger; } public string Permission { get; set; } diff --git a/BTCPayServer.Client/Models/StoreReportRequest.cs b/BTCPayServer.Client/Models/StoreReportRequest.cs new file mode 100644 index 000000000..f9c7b4ca3 --- /dev/null +++ b/BTCPayServer.Client/Models/StoreReportRequest.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace BTCPayServer.Client.Models; + +public class StoreReportRequest +{ + public string ViewName { get; set; } + public TimePeriod TimePeriod { get; set; } +} +public class StoreReportResponse +{ + public class Field + { + public Field() + { + + } + public Field(string name, string type) + { + Name = name; + Type = type; + } + public string Name { get; set; } + public string Type { get; set; } + } + public IList Fields { get; set; } = new List(); + public List Data { get; set; } + public DateTimeOffset From { get; set; } + public DateTimeOffset To { get; set; } + public List Charts { get; set; } + + public int GetIndex(string fieldName) + { + return Fields.ToList().FindIndex(f => f.Name == fieldName); + } +} + +public class ChartDefinition +{ + public string Name { get; set; } + + public List Groups { get; set; } = new List(); + public List Totals { get; set; } = new List(); + public bool HasGrandTotal { get; set; } + public List Aggregates { get; set; } = new List(); + public List Filters { get; set; } = new List(); +} + +public class TimePeriod +{ + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? From { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? To { get; set; } +} diff --git a/BTCPayServer.Client/Models/StoreReportsResponse.cs b/BTCPayServer.Client/Models/StoreReportsResponse.cs new file mode 100644 index 000000000..514dfbb84 --- /dev/null +++ b/BTCPayServer.Client/Models/StoreReportsResponse.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BTCPayServer.Client.Models +{ + public class StoreReportsResponse + { + public string ViewName { get; set; } + public StoreReportResponse.Field[] Fields + { + get; + set; + } + } +} diff --git a/BTCPayServer.Data/ApplicationDbContextFactory.cs b/BTCPayServer.Data/ApplicationDbContextFactory.cs index fe5dbf569..156f18964 100644 --- a/BTCPayServer.Data/ApplicationDbContextFactory.cs +++ b/BTCPayServer.Data/ApplicationDbContextFactory.cs @@ -1,3 +1,6 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Models; using Microsoft.EntityFrameworkCore; diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index 038ea3371..54474e7bd 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -40,6 +40,7 @@ namespace BTCPayServer.Tests public class TestAccount { readonly ServerTester parent; + public string LNAddress; public TestAccount(ServerTester parent) { @@ -242,7 +243,7 @@ namespace BTCPayServer.Tests policies.LockSubscription = false; await account.Register(RegisterDetails); } - + TestLogs.LogInformation($"UserId: {account.RegisteredUserId} Password: {Password}"); UserId = account.RegisteredUserId; Email = RegisterDetails.Email; IsAdmin = account.RegisteredAdmin; @@ -309,8 +310,9 @@ namespace BTCPayServer.Tests Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage); } - public async Task ReceiveUTXO(Money value, BTCPayNetwork network) + public async Task ReceiveUTXO(Money value, BTCPayNetwork network = null) { + network ??= SupportedNetwork; var cashCow = parent.ExplorerNode; var btcPayWallet = parent.PayTester.GetService().GetWallet(network); var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address; @@ -553,5 +555,94 @@ retry: var repo = this.parent.PayTester.GetService(); await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner); } + + public async Task PayOnChain(string invoiceId) + { + var cryptoCode = "BTC"; + var client = await CreateClient(); + var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId); + var method = methods.First(m => m.PaymentMethod == cryptoCode); + var address = method.Destination; + var tx = await client.CreateOnChainTransaction(StoreId, cryptoCode, new CreateOnChainTransactionRequest() + { + Destinations = new List() + { + new () + { + Destination = address, + Amount = method.Due + } + }, + FeeRate = new FeeRate(1.0m) + }); + await WaitInvoicePaid(invoiceId); + return tx.TransactionHash; + } + + public async Task PayOnBOLT11(string invoiceId) + { + var cryptoCode = "BTC"; + var client = await CreateClient(); + var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId); + var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LightningNetwork"); + var bolt11 = method.Destination; + TestLogs.LogInformation("PAYING"); + await parent.CustomerLightningD.Pay(bolt11); + TestLogs.LogInformation("PAID"); + await WaitInvoicePaid(invoiceId); + } + + public async Task PayOnLNUrl(string invoiceId) + { + var cryptoCode = "BTC"; + var network = SupportedNetwork.NBitcoinNetwork; + var client = await CreateClient(); + var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId); + var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LNURLPAY"); + var lnurL = LNURL.LNURL.Parse(method.PaymentLink, out var tag); + var http = new HttpClient(); + var payreq = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurL, tag, http); + var resp = await payreq.SendRequest(payreq.MinSendable, network, http); + var bolt11 = resp.Pr; + await parent.CustomerLightningD.Pay(bolt11); + await WaitInvoicePaid(invoiceId); + } + + public Task WaitInvoicePaid(string invoiceId) + { + return TestUtils.EventuallyAsync(async () => + { + var client = await CreateClient(); + var invoice = await client.GetInvoice(StoreId, invoiceId); + if (invoice.Status == InvoiceStatus.Settled) + return; + Assert.Equal(InvoiceStatus.Processing, invoice.Status); + }); + } + + public async Task PayOnLNAddress(string lnAddrUser = null) + { + lnAddrUser ??= LNAddress; + var network = SupportedNetwork.NBitcoinNetwork; + var payReqStr = await (await parent.PayTester.HttpClient.GetAsync($".well-known/lnurlp/{lnAddrUser}")).Content.ReadAsStringAsync(); + var payreq = JsonConvert.DeserializeObject(payReqStr); + var resp = await payreq.SendRequest(payreq.MinSendable, network, parent.PayTester.HttpClient); + var bolt11 = resp.Pr; + await parent.CustomerLightningD.Pay(bolt11); + } + + public async Task CreateLNAddress() + { + var lnAddrUser = Guid.NewGuid().ToString(); + var ctx = parent.PayTester.GetService().CreateContext(); + ctx.LightningAddresses.Add(new() + { + StoreDataId = StoreId, + Username = lnAddrUser + }); + await ctx.SaveChangesAsync(); + LNAddress = lnAddrUser; + return lnAddrUser; + } } } diff --git a/BTCPayServer.Tests/ThirdPartyTests.cs b/BTCPayServer.Tests/ThirdPartyTests.cs index 2d2d69b39..c004f24b1 100644 --- a/BTCPayServer.Tests/ThirdPartyTests.cs +++ b/BTCPayServer.Tests/ThirdPartyTests.cs @@ -391,6 +391,14 @@ retry: expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/bootstrap-vue/{version}/bootstrap-vue.min.js")).Content.ReadAsStringAsync()).Trim(); EqualJsContent(expected, actual); + actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "FileSaver", "FileSaver.min.js").Trim(); + expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/eligrey/FileSaver.js/43bbd2f0ae6794f8d452cd360e9d33aef6071234/dist/FileSaver.min.js")).Content.ReadAsStringAsync()).Trim(); + EqualJsContent(expected, actual); + + actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "papaparse", "papaparse.min.js").Trim(); + expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/mholt/PapaParse/5.4.1/papaparse.min.js")).Content.ReadAsStringAsync()).Trim(); + EqualJsContent(expected, actual); + actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sanitize-directive", "vue-sanitize-directive.umd.min.js").Trim(); version = Regex.Match(actual, "Original file: /npm/vue-sanitize-directive@([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value; expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/vue-sanitize-directive@{version}/dist/vue-sanitize-directive.umd.min.js")).Content.ReadAsStringAsync()).Trim(); diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 8a511f149..dc78784a7 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -2936,5 +2936,124 @@ namespace BTCPayServer.Tests Assert.IsType(Assert.IsType(await controller.Files(new string[] { fileId })).Model); Assert.Null(viewFilesViewModel.DirectUrlByFiles); } + + [Fact] + [Trait("Selenium", "Selenium")] + public async Task CanCreateReports() + { + using var tester = CreateServerTester(); + tester.ActivateLightning(); + tester.DeleteStore = false; + await tester.StartAsync(); + await tester.EnsureChannelsSetup(); + var acc = tester.NewAccount(); + await acc.GrantAccessAsync(); + await acc.MakeAdmin(); + acc.RegisterDerivationScheme("BTC", importKeysToNBX: true); + acc.RegisterLightningNode("BTC"); + await acc.ReceiveUTXO(Money.Coins(1.0m)); + + var client = await acc.CreateClient(); + var posController = acc.GetController(); + + var app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest() + { + AppName = "Static", + DefaultView = Client.Models.PosViewType.Static, + Template = new PointOfSaleSettings().Template + }); + var resp = await posController.ViewPointOfSale(app.Id, choiceKey: "green-tea"); + var invoiceId = GetInvoiceId(resp); + await acc.PayOnChain(invoiceId); + + app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest() + { + AppName = "Cart", + DefaultView = Client.Models.PosViewType.Cart, + Template = new PointOfSaleSettings().Template + }); + resp = await posController.ViewPointOfSale(app.Id, posData: new JObject() + { + ["cart"] = new JArray() + { + new JObject() + { + ["id"] = "green-tea", + ["count"] = 2 + }, + new JObject() + { + ["id"] = "black-tea", + ["count"] = 1 + }, + } + }.ToString()); + invoiceId = GetInvoiceId(resp); + await acc.PayOnBOLT11(invoiceId); + + resp = await posController.ViewPointOfSale(app.Id, posData: new JObject() + { + ["cart"] = new JArray() + { + new JObject() + { + ["id"] = "green-tea", + ["count"] = 5 + } + } + }.ToString()); + invoiceId = GetInvoiceId(resp); + await acc.PayOnLNUrl(invoiceId); + + await acc.CreateLNAddress(); + await acc.PayOnLNAddress(); + + var report = await GetReport(acc, new() { ViewName = "Payments" }); + // 1 payment on LN Address + // 1 payment on LNURL + // 1 payment on BOLT11 + // 1 payment on chain + Assert.Equal(4, report.Data.Count); + var lnAddressIndex = report.GetIndex("LightningAddress"); + var paymentTypeIndex = report.GetIndex("PaymentType"); + Assert.Contains(report.Data, d => d[lnAddressIndex]?.Value()?.Contains(acc.LNAddress) is true); + var paymentTypes = report.Data + .GroupBy(d => d[paymentTypeIndex].Value()) + .ToDictionary(d => d.Key); + Assert.Equal(3, paymentTypes["Lightning"].Count()); + Assert.Single(paymentTypes["On-Chain"]); + + // 2 on-chain transactions: It received from the cashcow, then paid its own invoice + report = await GetReport(acc, new() { ViewName = "On-Chain Wallets" }); + var txIdIndex = report.GetIndex("TransactionId"); + var balanceIndex = report.GetIndex("BalanceChange"); + Assert.Equal(2, report.Data.Count); + Assert.Equal(64, report.Data[0][txIdIndex].Value().Length); + Assert.Contains(report.Data, d => d[balanceIndex].Value() == 1.0m); + + // Items sold + report = await GetReport(acc, new() { ViewName = "Products sold" }); + var itemIndex = report.GetIndex("Product"); + var countIndex = report.GetIndex("Quantity"); + var itemsCount = report.Data.GroupBy(d => d[itemIndex].Value()) + .ToDictionary(d => d.Key, r => r.Sum(d => d[countIndex].Value())); + Assert.Equal(8, itemsCount["green-tea"]); + Assert.Equal(1, itemsCount["black-tea"]); + } + + private async Task GetReport(TestAccount acc, StoreReportRequest req) + { + var controller = acc.GetController(); + return (await controller.StoreReportsJson(acc.StoreId, req)).AssertType() + .Value + .AssertType(); + } + + private static string GetInvoiceId(IActionResult resp) + { + var redirect = resp.AssertType(); + Assert.Equal("Checkout", redirect.ActionName); + return (string)redirect.RouteValues["invoiceId"]; + } } } diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index cb9cd045c..7766e5156 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -81,6 +81,7 @@ + @@ -119,6 +120,7 @@ + @@ -135,7 +137,9 @@ + + PreserveNewest $(IncludeRazorContentInPack) diff --git a/BTCPayServer/Components/MainNav/Default.cshtml b/BTCPayServer/Components/MainNav/Default.cshtml index fde0c8720..a531533e8 100644 --- a/BTCPayServer/Components/MainNav/Default.cshtml +++ b/BTCPayServer/Components/MainNav/Default.cshtml @@ -131,6 +131,12 @@ Invoices +