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 +