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 <mail@dennisreimann.de>
This commit is contained in:
Nicolas Dorier 2023-07-24 09:24:32 +09:00 committed by GitHub
parent 845e2881fa
commit dc986959fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 1830 additions and 8 deletions

View file

@ -12,13 +12,11 @@ public class PermissionTagHelper : TagHelper
{
private readonly IAuthorizationService _authorizationService;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<PermissionTagHelper> _logger;
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor, ILogger<PermissionTagHelper> logger)
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor)
{
_authorizationService = authorizationService;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public string Permission { get; set; }

View file

@ -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<Field> Fields { get; set; } = new List<Field>();
public List<JArray> Data { get; set; }
public DateTimeOffset From { get; set; }
public DateTimeOffset To { get; set; }
public List<ChartDefinition> 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<string> Groups { get; set; } = new List<string>();
public List<string> Totals { get; set; } = new List<string>();
public bool HasGrandTotal { get; set; }
public List<string> Aggregates { get; set; } = new List<string>();
public List<string> Filters { get; set; } = new List<string>();
}
public class TimePeriod
{
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? From { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? To { get; set; }
}

View file

@ -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;
}
}
}

View file

@ -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;

View file

@ -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<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network = null)
{
network ??= SupportedNetwork;
var cashCow = parent.ExplorerNode;
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
@ -553,5 +555,94 @@ retry:
var repo = this.parent.PayTester.GetService<StoreRepository>();
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
}
public async Task<uint256> 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<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
{
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<LNURL.LNURLPayRequest>(payReqStr);
var resp = await payreq.SendRequest(payreq.MinSendable, network, parent.PayTester.HttpClient);
var bolt11 = resp.Pr;
await parent.CustomerLightningD.Pay(bolt11);
}
public async Task<string> CreateLNAddress()
{
var lnAddrUser = Guid.NewGuid().ToString();
var ctx = parent.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
ctx.LightningAddresses.Add(new()
{
StoreDataId = StoreId,
Username = lnAddrUser
});
await ctx.SaveChangesAsync();
LNAddress = lnAddrUser;
return lnAddrUser;
}
}
}

View file

@ -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();

View file

@ -2936,5 +2936,124 @@ namespace BTCPayServer.Tests
Assert.IsType<ViewFilesViewModel>(Assert.IsType<ViewResult>(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<UIPointOfSaleController>();
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<string>()?.Contains(acc.LNAddress) is true);
var paymentTypes = report.Data
.GroupBy(d => d[paymentTypeIndex].Value<string>())
.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<string>().Length);
Assert.Contains(report.Data, d => d[balanceIndex].Value<decimal>() == 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<string>())
.ToDictionary(d => d.Key, r => r.Sum(d => d[countIndex].Value<int>()));
Assert.Equal(8, itemsCount["green-tea"]);
Assert.Equal(1, itemsCount["black-tea"]);
}
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
{
var controller = acc.GetController<UIReportsController>();
return (await controller.StoreReportsJson(acc.StoreId, req)).AssertType<JsonResult>()
.Value
.AssertType<StoreReportResponse>();
}
private static string GetInvoiceId(IActionResult resp)
{
var redirect = resp.AssertType<RedirectToActionResult>();
Assert.Equal("Checkout", redirect.ActionName);
return (string)redirect.RouteValues["invoiceId"];
}
}
}

View file

@ -81,6 +81,7 @@
</ItemGroup>
<ItemGroup>
<None Include="Views\UIReports\StoreReports.cshtml" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.svg" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.woff2" />
<None Include="wwwroot\vendor\font-awesome\less\animated.less" />
@ -119,6 +120,7 @@
<Folder Include="wwwroot\vendor\bootstrap" />
<Folder Include="wwwroot\vendor\clipboard.js\" />
<Folder Include="wwwroot\vendor\highlightjs\" />
<Folder Include="wwwroot\vendor\pivottable\" />
<Folder Include="wwwroot\vendor\summernote" />
<Folder Include="wwwroot\vendor\tom-select" />
<Folder Include="wwwroot\vendor\ur-registry" />
@ -135,7 +137,9 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Content Remove="Views\UIReports\StoreReports.cshtml" />
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
<Pack>$(IncludeRazorContentInPack)</Pack>

View file

@ -131,6 +131,12 @@
<span>Invoices</span>
</a>
</li>
<li class="nav-item" permission="@Policies.CanViewInvoices">
<a asp-area="" asp-controller="UIReports" asp-action="StoreReports" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActivePage(StoreNavPages.Reporting)" id="SectionNav-Reporting">
<vc:icon symbol="invoice" />
<span>Reporting</span>
</a>
</li>
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIPaymentRequest" asp-action="GetPaymentRequests" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActiveCategory(typeof(PaymentRequestsNavPages))" id="StoreNav-PaymentRequests">
<vc:icon symbol="payment-requests"/>

View file

@ -0,0 +1,80 @@
#nullable enable
using BTCPayServer.Lightning;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services.Invoices;
using System.Collections.Generic;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore.Internal;
using NBitcoin;
using Newtonsoft.Json.Linq;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Services;
using System.Linq;
using System.Threading;
namespace BTCPayServer.Controllers.GreenField;
[ApiController]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[EnableCors(CorsPolicies.All)]
public class GreenfieldReportsController : Controller
{
public GreenfieldReportsController(
ApplicationDbContextFactory dbContextFactory,
ReportService reportService)
{
DBContextFactory = dbContextFactory;
ReportService = reportService;
}
public ApplicationDbContextFactory DBContextFactory { get; }
public ReportService ReportService { get; }
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/reports")]
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default)
{
vm ??= new StoreReportRequest();
vm.ViewName ??= "Payments";
vm.TimePeriod ??= new TimePeriod();
vm.TimePeriod.To ??= DateTime.UtcNow;
vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1);
var from = vm.TimePeriod.From.Value;
var to = vm.TimePeriod.To.Value;
if (ReportService.ReportProviders.TryGetValue(vm.ViewName, out var report))
{
if (!report.IsAvailable())
return this.CreateAPIError(503, "view-unavailable", $"This view is unavailable at this moment");
var ctx = new Services.Reporting.QueryContext(storeId, from, to);
await report.Query(ctx, cancellationToken);
var result = new StoreReportResponse()
{
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
Charts = ctx.ViewDefinition?.Charts ?? new List<ChartDefinition>(),
Data = ctx.Data.Select(d => new JArray(d)).ToList(),
From = from,
To = to
};
return Json(result);
}
else
{
ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
return this.CreateValidationError(ModelState);
}
}
}

View file

@ -0,0 +1,122 @@
#nullable enable
using System;
using Dapper;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.StoreReportsViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using Org.BouncyCastle.Ocsp;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NBitcoin.DataEncoders;
using System.Net;
namespace BTCPayServer.Controllers;
public partial class UIReportsController
{
private IList<IList<object?>> Generate(IList<StoreReportResponse.Field> fields)
{
var rand = new Random();
int rowCount = 1_000;
List<object?> row = new List<object?>();
List<IList<object?>> result = new List<IList<object?>>();
for (int i = 0; i < rowCount; i++)
{
int fi = 0;
foreach (var f in fields)
{
row.Add(GenerateData(fields, f, fi, row, rand));
fi++;
}
result.Add(row);
row = new List<object?>();
}
return result;
}
private object? GenerateData(IList<StoreReportResponse.Field> fields, StoreReportResponse.Field f, int fi, List<object?> row, Random rand)
{
byte[] GenerateBytes(int count)
{
var bytes = new byte[count];
rand.NextBytes(bytes);
return bytes;
}
T TakeOne<T>(params T[] v)
{
return v[rand.NextInt64(0, v.Length)];
}
decimal GenerateDecimal(decimal from, decimal to, int precision)
{
decimal range = to - from;
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
return decimal.Round(randomValue, precision);
}
if (f.Type == "invoice_id")
return Encoders.Base58.EncodeData(GenerateBytes(20));
if (f.Type == "boolean")
return GenerateBytes(1)[0] % 2 == 0;
if (f.Name == "PaymentType")
return TakeOne("On-Chain", "Lightning");
if (f.Name == "PaymentId")
if (row[fi -1] is "On-Chain")
return Encoders.Hex.EncodeData(GenerateBytes(32)) + "-" + rand.NextInt64(0, 4);
else
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Address")
return Encoders.Bech32("bc1").Encode(0, GenerateBytes(20));
if (f.Name == "Crypto")
return rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
if (f.Name == "CryptoAmount")
return GenerateDecimal(0.1m, 5m, 8);
if (f.Name == "LightningAddress")
return TakeOne("satoshi", "satosan", "satoichi") + "@bitcoin.org";
if (f.Name == "BalanceChange")
return GenerateDecimal(-5.0m, 5.0m, 8);
if (f.Type == "datetime")
return DateTimeOffset.UtcNow - TimeSpan.FromHours(rand.Next(0, 24 * 30 * 6)) - TimeSpan.FromMinutes(rand.Next(0, 60));
if (f.Name == "Product")
return TakeOne("green-tea", "black-tea", "oolong-tea", "coca-cola");
if (f.Name == "State")
return TakeOne("Settled", "Processing");
if (f.Name == "AppId")
return TakeOne("AppA", "AppB");
if (f.Name == "Quantity")
return TakeOne(1, 2, 3, 4, 5);
if (f.Name == "Currency")
return rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
if (f.Name == "CurrencyAmount")
return row[fi - 1] switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(100.0m, 10_000m, 2),
"JPY" => GenerateDecimal(10_000m, 1000_0000, 0),
_ => GenerateDecimal(100.0m, 10_000m, 2)
};
if (f.Type == "tx_id")
return Encoders.Hex.EncodeData(GenerateBytes(32));
if (f.Name == "Rate")
{
return row[fi - 1] switch
{
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
_ => GenerateDecimal(30_000m, 60_000, 2)
};
}
return null;
}
}

View file

@ -0,0 +1,91 @@
#nullable enable
using System;
using Dapper;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.GreenField;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.StoreReportsViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using System.Text.Json.Nodes;
using Org.BouncyCastle.Ocsp;
using System.Threading;
using System.Collections.Generic;
using Newtonsoft.Json.Linq;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Controllers;
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
public partial class UIReportsController : Controller
{
public UIReportsController(
BTCPayNetworkProvider networkProvider,
ApplicationDbContextFactory dbContextFactory,
GreenfieldReportsController api,
ReportService reportService,
BTCPayServerEnvironment env
)
{
Api = api;
ReportService = reportService;
Env = env;
DBContextFactory = dbContextFactory;
NetworkProvider = networkProvider;
}
private BTCPayNetworkProvider NetworkProvider { get; }
public GreenfieldReportsController Api { get; }
public ReportService ReportService { get; }
public BTCPayServerEnvironment Env { get; }
public ApplicationDbContextFactory DBContextFactory { get; }
[HttpPost("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("application/json")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> StoreReportsJson(string storeId, [FromBody] StoreReportRequest? request = null, bool fakeData = false, CancellationToken cancellation = default)
{
var result = await Api.StoreReports(storeId, request, cancellation);
if (fakeData && Env.CheatMode)
{
var r = (StoreReportResponse)((JsonResult)result!).Value!;
r.Data = Generate(r.Fields).Select(r => new JArray(r)).ToList();
}
return result;
}
[HttpGet("stores/{storeId}/reports")]
[AcceptMediaTypeConstraint("text/html")]
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult StoreReports(
string storeId,
string ? viewName = null)
{
var vm = new StoreReportsViewModel()
{
InvoiceTemplateUrl = this.Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
ExplorerTemplateUrls = NetworkProvider.GetAll().ToDictionary(network => network.CryptoCode, network => network.BlockExplorerLink?.Replace("{0}", "TX_ID")),
Request = new StoreReportRequest()
{
ViewName = viewName ?? "Payments"
}
};
vm.AvailableViews = ReportService.ReportProviders
.Values
.Where(r => r.IsAvailable())
.Select(k => k.Name)
.OrderBy(k => k).ToList();
return View(vm);
}
}

View file

@ -22,6 +22,7 @@ using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Security;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Reporting;
using BTCPayServer.Services.Wallets;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@ -134,6 +135,14 @@ namespace BTCPayServer
}
}
public static IServiceCollection AddReportProvider<T>(this IServiceCollection services)
where T : ReportProvider
{
services.AddSingleton<T>();
services.AddSingleton<ReportProvider, T>();
return services;
}
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
where T : class, IPeriodicTask
{

View file

@ -1,4 +1,5 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client;
using BTCPayServer.Data;
@ -43,5 +44,13 @@ namespace BTCPayServer
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode);
return paymentMethod;
}
public static IEnumerable<DerivationSchemeSettings> GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider)
{
var paymentMethod = store
.GetSupportedPaymentMethods(networkProvider)
.OfType<DerivationSchemeSettings>()
.Where(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike);
return paymentMethod;
}
}
}

View file

@ -65,6 +65,7 @@ using NBXplorer.DerivationStrategy;
using Newtonsoft.Json;
using NicolasDorier.RateLimits;
using Serilog;
using BTCPayServer.Services.Reporting;
#if ALTCOINS
using BTCPayServer.Services.Altcoins.Monero;
using BTCPayServer.Services.Altcoins.Zcash;
@ -325,6 +326,7 @@ namespace BTCPayServer.Hosting
services.TryAddSingleton<LightningConfigurationProvider>();
services.TryAddSingleton<LanguageService>();
services.TryAddSingleton<ReportService>();
services.TryAddSingleton<NBXplorerDashboard>();
services.AddSingleton<ISyncSummaryProvider, NBXSyncSummaryProvider>();
services.TryAddSingleton<StoreRepository>();
@ -354,6 +356,10 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
services.AddReportProvider<PaymentsReportProvider>();
services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>();
services.AddHttpClient(WebhookSender.OnionNamedClient)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddHttpClient(WebhookSender.LoopbackNamedClient)

View file

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Models.StoreReportsViewModels;
public class StoreReportsViewModel
{
public string InvoiceTemplateUrl { get; set; }
public Dictionary<string,string> ExplorerTemplateUrls { get; set; }
public StoreReportRequest Request { get; set; }
public List<string> AvailableViews { get; set; }
public StoreReportResponse Result { get; set; }
}

View file

@ -721,8 +721,11 @@ namespace BTCPayServer.Services.Invoices
query = query.Take(queryObject.Take.Value);
return query;
}
public async Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject)
public Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject)
{
return GetInvoices(queryObject, default);
}
public async Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject, CancellationToken cancellationToken)
{
await using var context = _applicationDbContextFactory.CreateContext();
var query = GetInvoiceQuery(context, queryObject);
@ -733,7 +736,7 @@ namespace BTCPayServer.Services.Invoices
query = query.Include(o => o.Events);
if (queryObject.IncludeRefunds)
query = query.Include(o => o.Refunds).ThenInclude(refundData => refundData.PullPaymentData);
var data = await query.ToArrayAsync().ConfigureAwait(false);
var data = await query.ToArrayAsync(cancellationToken).ConfigureAwait(false);
return data.Select(ToEntity).ToArray();
}

View file

@ -0,0 +1,20 @@
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Reporting;
namespace BTCPayServer.Services
{
public class ReportService
{
public ReportService(IEnumerable<ReportProvider> reportProviders)
{
foreach (var r in reportProviders)
{
ReportProviders.Add(r.Name, r);
}
}
public Dictionary<string, ReportProvider> ReportProviders { get; } = new Dictionary<string, ReportProvider>();
}
}

View file

@ -0,0 +1,122 @@
#nullable enable
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Services.Stores;
using BTCPayServer.Services.Wallets;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBXplorer.DerivationStrategy;
using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database;
namespace BTCPayServer.Services.Reporting;
public class OnChainWalletReportProvider : ReportProvider
{
public OnChainWalletReportProvider(
NBXplorerConnectionFactory NbxplorerConnectionFactory,
StoreRepository storeRepository,
BTCPayNetworkProvider networkProvider,
WalletRepository walletRepository)
{
this.NbxplorerConnectionFactory = NbxplorerConnectionFactory;
StoreRepository = storeRepository;
NetworkProvider = networkProvider;
WalletRepository = walletRepository;
}
public NBXplorerConnectionFactory NbxplorerConnectionFactory { get; }
public StoreRepository StoreRepository { get; }
public BTCPayNetworkProvider NetworkProvider { get; }
public WalletRepository WalletRepository { get; }
public override string Name => "On-Chain Wallets";
ViewDefinition CreateViewDefinition()
{
return
new()
{
Fields =
{
new ("Date", "datetime"),
new ("Crypto", "string"),
// For proper rendering of explorer links, Crypto should always be before tx_id
new ("TransactionId", "tx_id"),
new ("InvoiceId", "invoice_id"),
new ("Confirmed", "boolean"),
new ("BalanceChange", "decimal")
},
Charts =
{
new ()
{
Name = "Group by Crypto",
Totals = { "Crypto" },
Groups = { "Crypto", "Confirmed" },
Aggregates = { "BalanceChange" }
}
}
};
}
public override bool IsAvailable()
{
return this.NbxplorerConnectionFactory.Available;
}
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
queryContext.ViewDefinition = CreateViewDefinition();
await using var conn = await NbxplorerConnectionFactory.OpenConnection();
var store = await StoreRepository.FindStore(queryContext.StoreId);
if (store is null)
return;
var interval = DateTimeOffset.UtcNow - queryContext.From;
foreach (var settings in store.GetDerivationSchemeSettings(NetworkProvider))
{
var walletId = new WalletId(store.Id, settings.Network.CryptoCode);
var command = new CommandDefinition(
commandText:
"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change " +
"FROM get_wallets_recent(@wallet_id, @code, @interval, NULL, NULL) r " +
"JOIN txs t USING (code, tx_id) " +
"ORDER BY r.seen_at",
parameters: new
{
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(settings.Network.CryptoCode, settings.AccountDerivation.ToString()),
code = settings.Network.CryptoCode,
interval = interval
},
cancellationToken: cancellation);
var rows = await conn.QueryAsync(command);
foreach (var r in rows)
{
var date = (DateTimeOffset)r.seen_at;
if (date > queryContext.To)
continue;
var values = queryContext.AddData();
values.Add((DateTimeOffset)date);
values.Add(settings.Network.CryptoCode);
values.Add((string)r.tx_id);
values.Add(null);
values.Add((long?)r.blk_height is not null);
values.Add(Money.Satoshis((long)r.balance_change).ToDecimal(MoneyUnit.BTC));
}
var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery()
{
Ids = queryContext.Data.Select(d => (string)d[2]).ToArray(),
WalletId = walletId,
Type = "tx"
});
foreach (var row in queryContext.Data)
{
if (!objects.TryGetValue(new WalletObjectId(walletId, "tx", (string)row[2]), out var txObject))
continue;
var invoiceId = txObject.GetLinks().Where(t => t.type == "invoice").Select(t => t.id).FirstOrDefault();
row[3] = invoiceId;
}
}
}
}

View file

@ -0,0 +1,187 @@
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.LND;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using NBitcoin;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using static BTCPayServer.HostedServices.PullPaymentHostedService.PayoutApproval;
namespace BTCPayServer.Services.Reporting;
public class PaymentsReportProvider : ReportProvider
{
public PaymentsReportProvider(ApplicationDbContextFactory dbContextFactory, CurrencyNameTable currencyNameTable)
{
DbContextFactory = dbContextFactory;
CurrencyNameTable = currencyNameTable;
}
public override string Name => "Payments";
public ApplicationDbContextFactory DbContextFactory { get; }
public CurrencyNameTable CurrencyNameTable { get; }
ViewDefinition CreateViewDefinition()
{
return
new()
{
Fields =
{
new ("Date", "datetime"),
new ("InvoiceId", "invoice_id"),
new ("OrderId", "string"),
new ("PaymentType", "string"),
new ("PaymentId", "string"),
new ("Confirmed", "boolean"),
new ("Address", "string"),
new ("Crypto", "string"),
new ("CryptoAmount", "decimal"),
new ("NetworkFee", "decimal"),
new ("LightningAddress", "string"),
new ("Currency", "string"),
new ("CurrencyAmount", "decimal"),
new ("Rate", "decimal")
},
Charts =
{
new ()
{
Name = "Aggregated crypto amount",
Groups = { "Crypto", "PaymentType" },
Totals = { "Crypto" },
HasGrandTotal = false,
Aggregates = { "CryptoAmount" }
},
new ()
{
Name = "Aggregated currency amount",
Groups = { "Currency" },
Totals = { "Currency" },
HasGrandTotal = false,
Aggregates = { "CurrencyAmount" }
},
new ()
{
Name = "Group by Lightning Address (Currency amount)",
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
Groups = { "LightningAddress", "Currency" },
Aggregates = { "CurrencyAmount" },
HasGrandTotal = true
},
new ()
{
Name = "Group by Lightning Address (Crypto amount)",
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
Groups = { "LightningAddress" },
Aggregates = { "CryptoAmount" },
HasGrandTotal = true
}
}
};
}
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
queryContext.ViewDefinition = CreateViewDefinition();
await using var ctx = DbContextFactory.CreateContext();
var conn = ctx.Database.GetDbConnection();
string[] fields = new[]
{
$"i.\"Created\" created",
"i.\"Id\" invoice_id",
"i.\"OrderId\" order_id",
"p.\"Id\" payment_id",
"p.\"Type\" payment_type",
"i.\"Blob2\" invoice_blob",
"p.\"Blob2\" payment_blob",
};
string select = "SELECT " + String.Join(", ", fields) + " ";
string body =
"FROM \"Payments\" p " +
"JOIN \"Invoices\" i ON i.\"Id\" = p.\"InvoiceDataId\" " +
$"WHERE p.\"Accounted\" IS TRUE AND i.\"Created\" >= @from AND i.\"Created\" < @to AND i.\"StoreDataId\"=@storeId " +
"ORDER BY i.\"Created\"";
var command = new CommandDefinition(
commandText: select + body,
parameters: new
{
storeId = queryContext.StoreId,
from = queryContext.From,
to = queryContext.To
},
cancellationToken: cancellation);
var rows = await conn.QueryAsync(command);
foreach (var r in rows)
{
var values = queryContext.CreateData();
values.Add((DateTime)r.created);
values.Add((string)r.invoice_id);
values.Add((string)r.order_id);
if (PaymentMethodId.TryParse((string)r.payment_type, out var paymentType))
{
if (paymentType.PaymentType == PaymentTypes.LightningLike || paymentType.PaymentType == PaymentTypes.LNURLPay)
values.Add("Lightning");
else if (paymentType.PaymentType == PaymentTypes.BTCLike)
values.Add("On-Chain");
else
values.Add(paymentType.ToStringNormalized());
}
else
continue;
values.Add((string)r.payment_id);
var invoiceBlob = JObject.Parse((string)r.invoice_blob);
var paymentBlob = JObject.Parse((string)r.payment_blob);
var data = JObject.Parse(paymentBlob.SelectToken("$.cryptoPaymentData")?.Value<string>()!);
var conf = data.SelectToken("$.confirmationCount")?.Value<int>();
values.Add(conf is int o ? o > 0 :
paymentType.PaymentType != PaymentTypes.BTCLike ? true : null);
values.Add(data.SelectToken("$.address")?.Value<string>());
values.Add(paymentType.CryptoCode);
decimal cryptoAmount;
if (data.SelectToken("$.amount")?.Value<long>() is long v)
{
cryptoAmount = LightMoney.MilliSatoshis(v).ToDecimal(LightMoneyUnit.BTC);
}
else if (data.SelectToken("$.value")?.Value<long>() is long amount)
{
cryptoAmount = Money.Satoshis(amount).ToDecimal(MoneyUnit.BTC);
}
else
{
continue;
}
values.Add(cryptoAmount);
values.Add(paymentBlob.SelectToken("$.networkFee", false)?.Value<decimal>());
values.Add(invoiceBlob.SelectToken("$.cryptoData.BTC_LNURLPAY.paymentMethod.ConsumedLightningAddress", false)?.Value<string>());
var currency = invoiceBlob.SelectToken("$.currency")?.Value<string>();
values.Add(currency);
values.Add(null); // Currency amount
var rate = invoiceBlob.SelectToken($"$.cryptoData.{paymentType}.rate")?.Value<decimal>();
values.Add(rate);
if (rate is not null)
{
values[^2] = (rate.Value * cryptoAmount).RoundToSignificant(CurrencyNameTable.GetCurrencyData(currency ?? "USD", true).Divisibility);
}
queryContext.Data.Add(values);
}
}
}

View file

@ -0,0 +1,131 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Rating;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Internal;
using Newtonsoft.Json;
using Org.BouncyCastle.Crypto.Signers;
namespace BTCPayServer.Services.Reporting;
public class ProductsReportProvider : ReportProvider
{
public ProductsReportProvider(InvoiceRepository invoiceRepository, CurrencyNameTable currencyNameTable, AppService apps)
{
InvoiceRepository = invoiceRepository;
CurrencyNameTable = currencyNameTable;
Apps = apps;
}
public InvoiceRepository InvoiceRepository { get; }
public CurrencyNameTable CurrencyNameTable { get; }
public AppService Apps { get; }
public override string Name => "Products sold";
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
var appsById = (await Apps.GetApps(queryContext.StoreId)).ToDictionary(o => o.Id);
var tagAllinvoicesApps = appsById.Values.Where(a => a.TagAllInvoices).ToList();
queryContext.ViewDefinition = CreateDefinition();
foreach (var i in (await InvoiceRepository.GetInvoices(new InvoiceQuery()
{
IncludeArchived = true,
IncludeAddresses = false,
IncludeEvents = false,
IncludeRefunds = false,
StartDate = queryContext.From,
EndDate = queryContext.To,
StoreId = new[] { queryContext.StoreId }
}, cancellation)).OrderBy(c => c.InvoiceTime))
{
var values = queryContext.CreateData();
values.Add(i.InvoiceTime);
values.Add(i.Id);
var status = i.Status.ToModernStatus();
if (status == Client.Models.InvoiceStatus.Expired && i.ExceptionStatus == Client.Models.InvoiceExceptionStatus.None)
continue;
values.Add(status.ToString());
// There are two ways an invoice belong to a particular app.
// 1. The invoice is internally tagged with the app id
// 2. The app is a tag all invoices app
// In both cases, we want to include the invoice in the report
var appIds = tagAllinvoicesApps.Select(a => a.Id);
var taggedAppId = AppService.GetAppInternalTags(i)?.FirstOrDefault();
if (taggedAppId is string)
appIds = appIds.Concat(new[] { taggedAppId }).Distinct().ToArray();
foreach (var appId in appIds)
{
values = values.ToList();
values.Add(appId);
if (i.Metadata?.ItemCode is string code)
{
values.Add(code);
values.Add(1);
values.Add(i.Currency);
values.Add(i.Price);
queryContext.Data.Add(values);
}
else
{
var posData = i.Metadata.PosData?.ToObject<PosAppData>();
if (posData.Cart is { } cart)
{
foreach (var item in cart)
{
var copy = values.ToList();
copy.Add(item.Id);
copy.Add(item.Count);
copy.Add(i.Currency);
copy.Add(item.Price * item.Count);
queryContext.Data.Add(copy);
}
}
}
}
}
// Round the currency amount
foreach (var r in queryContext.Data)
{
r[^1] = ((decimal)r[^1]).RoundToSignificant(CurrencyNameTable.GetCurrencyData((string)r[^2] ?? "USD", true).Divisibility);
}
}
private ViewDefinition CreateDefinition()
{
return new ViewDefinition()
{
Fields =
{
new ("Date", "datetime"),
new ("InvoiceId", "invoice_id"),
new ("State", "string"),
new ("AppId", "string"),
new ("Product", "string"),
new ("Quantity", "decimal"),
new ("Currency", "string"),
new ("CurrencyAmount", "decimal")
},
Charts =
{
new ()
{
Name = "Summary by products",
Groups = { "AppId", "Currency", "State", "Product" },
Aggregates = { "Quantity", "CurrencyAmount" },
Totals = { "State" }
}
}
};
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.Collections;
using System.Collections.Generic;
namespace BTCPayServer.Services.Reporting
{
public record QueryContext
{
public QueryContext(string storeId, DateTimeOffset from, DateTimeOffset to)
{
StoreId = storeId;
From = from;
To = to;
}
public string StoreId { get; }
public DateTimeOffset From { get; }
public DateTimeOffset To { get; }
public ViewDefinition? ViewDefinition { get; set; }
public IList<object> AddData()
{
var l = CreateData();
Data.Add(l);
return l;
}
public IList<object> CreateData()
{
return new List<object>(ViewDefinition.Fields.Count);
}
public IList<IList<object>> Data { get; set; } = new List<IList<object>>();
}
}

View file

@ -0,0 +1,19 @@
#nullable enable
using System.Threading.Tasks;
using System.Threading;
using BTCPayServer.Data;
using System;
using System.Linq;
namespace BTCPayServer.Services.Reporting
{
public abstract class ReportProvider
{
public virtual bool IsAvailable()
{
return true;
}
public abstract string Name { get; }
public abstract Task Query(QueryContext queryContext, CancellationToken cancellation);
}
}

View file

@ -0,0 +1,16 @@
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Services.Reporting
{
public class ViewDefinition
{
public IList<StoreReportResponse.Field> Fields
{
get;
set;
} = new List<StoreReportResponse.Field>();
public List<ChartDefinition> Charts { get; set; } = new List<ChartDefinition>();
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.Xml.Linq;
using BTCPayServer.Configuration;
namespace BTCPayServer.TagHelpers;
[HtmlTargetElement(Attributes = "[cheat-mode]")]
public class CheatModeTagHelper
{
public CheatModeTagHelper(BTCPayServerOptions env)
{
Env = env;
}
public BTCPayServerOptions Env { get; }
public bool CheatMode { get; set; }
public void Process(TagHelperContext context, TagHelperOutput output)
{
if (Env.CheatMode != CheatMode)
{
output.SuppressOutput();
}
}
}

View file

@ -0,0 +1,128 @@
@using BTCPayServer.Abstractions.Extensions;
@using BTCPayServer.Client.Models;
@using BTCPayServer.Models.StoreReportsViewModels;
@using BTCPayServer.Views.Invoice;
@using BTCPayServer.Views.Stores;
@using BTCPayServer.Abstractions.Services;
@using Microsoft.AspNetCore.Routing;
@inject Safe Safe
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model StoreReportsViewModel
@{
ViewData.SetActivePage(StoreNavPages.Reporting, "Reporting");
Csp.UnsafeEval();
}
@section PageHeadContent
{
@* Set a height for the responsive table container to make it work with the fixed table headers.
Details described here: thttps://uxdesign.cc/position-stuck-96c9f55d9526 *@
<style>#app .table-responsive { max-height: 80vh; }</style>
}
<div class="sticky-header">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
<h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex flex-wrap gap-3">
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake date</a>
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">
Export CSV
</button>
</div>
</div>
<div class="d-flex flex-column flex-sm-row align-items-sm-0center gap-3">
<div class="dropdown" v-pre>
<button id="ViewNameToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">@Model.Request.ViewName</button>
<div class="dropdown-menu" aria-labelledby="ViewNameToggle">
@foreach (var v in Model.AvailableViews)
{
<a href="#" data-view="@v" class="available-view dropdown-item @(Model.Request.ViewName == v ? "custom-active" : "")">@v</a>
}
</div>
</div>
<div class="input-group">
<input id="fromDate" class="form-control flatdtpicker" type="datetime-local"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="Start Date" />
<button type="button" class="btn btn-primary input-group-clear" title="Clear">
<span class="fa fa-times"></span>
</button>
</div>
<div class="input-group">
<input id="toDate" class="form-control flatdtpicker" type="datetime-local"
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
placeholder="End Date" />
<button type="button" class="btn btn-primary input-group-clear" title="Clear">
<span class="fa fa-times"></span>
</button>
</div>
</div>
</div>
<div id="app" v-cloak class="w-100-fixed">
<article v-for="chart in srv.charts" class="mb-5">
<h3>{{ chart.name }}</h3>
<div class="table-responsive">
<table class="table table-bordered table-hover w-auto">
<thead class="sticky-top bg-body">
<tr>
<th v-for="group in chart.groups">{{ group }}</th>
<th v-for="agg in chart.aggregates">{{ agg }}</th>
</tr>
</thead>
<tbody>
<tr v-for="row in chart.rows">
<td v-for="group in row.groups" :rowspan="group.rowCount">{{ group.name }}</td>
<td v-if="row.isTotal" :colspan="row.rLevel">Total</td>
<td v-for="value in row.values">{{ value }}</td>
</tr>
<tr v-if="chart.hasGrandTotal"><td :colspan="chart.groups.length">Grand total</td><td v-for="value in chart.grandTotalValues">{{ value }}</td></tr>
</tbody>
</table>
</div>
</article>
<article>
<h3 id="raw-data">Raw data</h3>
<div class="table-responsive">
<table class="table table-hover w-auto">
<thead class="sticky-top bg-body">
<tr>
<th v-for="field in srv.result.fields">
<a class="text-nowrap sort-column"
href="#"
:data-field="field.name"
@@click.prevent="srv.sortBy(field.name)"
:title="srv.fieldViews[field.name].sortByTitle">
{{ field.name }}
<span :class="srv.fieldViews[field.name].sortIconClass" />
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in srv.result.data" :key="index">
<td class="text-nowrap" v-for="(value, columnIndex) in row" :key="columnIndex">
<a :href="getInvoiceUrl(value)"
target="_blank"
v-if="srv.result.fields[columnIndex].type === 'invoice_id'">{{ value }}</a>
<a :href="getExplorerUrl(value, row[columnIndex-1])"
target="_blank"
rel="noreferrer noopener"
v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">{{ value }}</a>
<span v-else>{{ value }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
@section PageFootContent {
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
<script src="~/vendor/papaparse/papaparse.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script>const srv = @Safe.Json(Model);</script>
<script src="~/js/datatable.js" asp-append-version="true"></script>
<script src="~/js/store-reports.js" asp-append-version="true"></script>
}

View file

@ -19,6 +19,7 @@ namespace BTCPayServer.Views.Stores
Plugins,
Webhooks,
PullPayments,
Reporting,
Payouts,
PayoutProcessors,
[Obsolete("Use StoreNavPages.Plugins instead")]

View file

@ -0,0 +1,269 @@
(function () {
// Given sorted data, build a tabular data of given groups and aggregates.
function groupBy(groupIndices, aggregatesIndices, data) {
var summaryRows = [];
var summaryRow = null;
for (var i = 0; i < data.length; i++) {
if (summaryRow) {
for (var gi = 0; gi < groupIndices.length; gi++) {
if (summaryRow[gi] !== data[i][groupIndices[gi]]) {
summaryRows.push(summaryRow);
summaryRow = null;
break;
}
}
}
if (!summaryRow) {
summaryRow = new Array(groupIndices.length + aggregatesIndices.length);
for (var gi = 0; gi < groupIndices.length; gi++) {
summaryRow[gi] = data[i][groupIndices[gi]];
}
summaryRow.fill(0, groupIndices.length);
}
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
var v = data[i][aggregatesIndices[ai]];
// TODO: support other aggregate functions
if (v)
summaryRow[groupIndices.length + ai] += v;
}
}
if (summaryRow) {
summaryRows.push(summaryRow);
}
return summaryRows;
}
// Sort tabular data by the column indices
function byColumns(columnIndices) {
return (a, b) => {
for (var i = 0; i < columnIndices.length; i++) {
var fieldIndex = columnIndices[i];
if (!a[fieldIndex]) return 1;
if (!b[fieldIndex]) return -1;
if (a[fieldIndex] < b[fieldIndex])
return -1;
if (a[fieldIndex] > b[fieldIndex])
return 1;
}
return 0;
}
}
// Build a representation of the HTML table's data 'rows' from the tree of nodes.
function buildRows(node, rows) {
if (node.children.length === 0 && node.level !== 0) {
var row =
{
values: node.values,
groups: [],
isTotal: node.isTotal,
rLevel: node.rLevel
};
// Round the nuber to 8 decimal to avoid weird decimal outputs
for (var i = 0; i < row.values.length; i++) {
if (typeof row.values[i] === 'number')
row.values[i] = new Number(row.values[i].toFixed(8));
}
if (!node.isTotal)
row.groups.push({ name: node.groups[node.groups.length - 1], rowCount: node.leafCount })
var parent = node.parent;
var n = node;
while (parent && parent.level != 0 && parent.children[0] === n) {
row.groups.push({ name: parent.groups[parent.groups.length - 1], rowCount: parent.leafCount })
n = parent;
parent = parent.parent;
}
row.groups.reverse();
rows.push(row);
}
for (var i = 0; i < node.children.length; i++) {
buildRows(node.children[i], rows);
}
}
// Add a leafCount property, the number of leaf below each nodes
// Remove total if there is only one child outside of the total
function visitTree(node) {
node.leafCount = 0;
if (node.children.length === 0) {
node.leafCount++;
return;
}
for (var i = 0; i < node.children.length; i++) {
visitTree(node.children[i]);
node.leafCount += node.children[i].leafCount;
}
// Remove total if there is only one child outside of the total
if (node.children.length == 2 && node.children[0].isTotal) {
node.children.shift();
node.leafCount--;
}
}
// Build a tree of nodes from all the group levels.
function makeTree(totalLevels, parent, groupLevels, level) {
if (totalLevels.indexOf(level - 1) !== -1) {
parent.children.push({
parent: parent,
groups: parent.groups,
values: parent.values,
children: [],
level: level,
rLevel: groupLevels.length - level,
isTotal: true
});
}
for (var i = 0; i < groupLevels[level].length; i++) {
var foundFirst = false;
var groupData = groupLevels[level][i];
var gotoNextRow = false;
var stop = false;
for (var gi = 0; gi < parent.groups.length; gi++) {
if (parent.groups[gi] !== groupData[gi]) {
if (foundFirst) {
stop = true;
}
else {
gotoNextRow = true;
foundFirst = true;
break;
}
}
}
if (stop)
break;
if (gotoNextRow)
continue;
var node =
{
parent: parent,
groups: groupData.slice(0, level),
values: groupData.slice(level),
children: [],
level: level,
rLevel: groupLevels.length - level
};
parent.children.push(node);
if (groupLevels.length > level + 1)
makeTree(totalLevels, node, groupLevels, level + 1);
}
}
function applyFilters(rows, fields, filterStrings) {
if (!filterStrings || filterStrings.length === 0)
return rows;
// filterStrings are aggregated into one filter function:
// filter(){ return filter1 && filter2 && filter3; }
var newData = [];
var o = {};
eval('function filter() {return ' + filterStrings.join(' && ') + ';}');
// For each row, build a JSON objects representing it, and evaluate it on the fitler
for (var i = 0; i < rows.length; i++) {
for (var fi = 0; fi < fields.length; fi++) {
o[fields[fi]] = rows[i][fi];
}
if (!filter.bind(o)())
continue;
newData.push(rows[i]);
}
return newData;
}
function clone(a) {
return Array.from(a, subArray => [...subArray]);
}
function createTable(summaryDefinition, fields, rows) {
var groupIndices = summaryDefinition.groups.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
var aggregatesIndices = summaryDefinition.aggregates.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
aggregatesIndices = aggregatesIndices.filter(g => g !== -1);
// Filter rows
rows = applyFilters(rows, fields, summaryDefinition.filters);
// Sort by group columns
rows.sort(byColumns(groupIndices));
// Group data represent tabular data of all the groups and aggregates given the data.
// [Region, Crypto, PaymentType]
var groupRows = groupBy(groupIndices, aggregatesIndices, rows);
// There will be several level of aggregation
// For example, if you have 3 groups: [Region, Crypto, PaymentType] then you have 4 group data.
// [Region, Crypto, PaymentType]
// [Region, Crypto]
// [Region]
// []
var groupLevels = [];
groupLevels.push(groupRows);
// We build the group rows with less columns
// Those builds the level:
// [Region, Crypto], [Region] and []
for (var i = 1; i < groupIndices.length + 1; i++) {
// We are grouping the group data.
// For our example of 3 groups and 2 aggregate2, then:
// First iteration: newGroupIndices = [0, 1], newAggregatesIndices = [3, 4]
// Second iteration: newGroupIndices = [0], newAggregatesIndices = [2, 3]
// Last iteration: newGroupIndices = [], newAggregatesIndices = [1, 2]
var newGroupIndices = [];
for (var gi = 0; gi < groupIndices.length - i; gi++) {
newGroupIndices.push(gi);
}
var newAggregatesIndices = [];
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
newAggregatesIndices.push(newGroupIndices.length + 1 + ai);
}
// Group the group rows
groupRows = groupBy(newGroupIndices, newAggregatesIndices, groupRows);
groupLevels.push(groupRows);
}
// Put the highest level ([]) on top
groupLevels.reverse();
var root =
{
parent: null,
groups: [],
// Note that the top group data always have one row aggregating all
values: groupLevels[0][0],
children: [],
// level=0 means the root, it increments 1 each level
level: 0,
// rlevel is the reverse. It starts from the highest level and goes down to 0
rLevel: groupLevels.length
};
// Which levels will have a total row
var totalLevels = [];
if (summaryDefinition.totals) {
totalLevels = summaryDefinition.totals.map(g => summaryDefinition.groups.findIndex((a) => a === g) + 1).filter(a => a !== 0);
}
// Build the tree of nodes
makeTree(totalLevels, root, groupLevels, 1);
// Add a leafCount property to each node, it is the number of leaf below each nodes.
visitTree(root);
// Create a representation that can easily be binded to VueJS
var rows = [];
buildRows(root, rows);
return {
groups: summaryDefinition.groups,
aggregates: summaryDefinition.aggregates,
hasGrandTotal: root.values && summaryDefinition.hasGrandTotal,
grandTotalValues: root.values,
rows: rows
};
}
window.clone = clone;
window.createTable = createTable;
})();

View file

@ -0,0 +1,212 @@
let app, origData;
srv.sortBy = function (field) {
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
const sortedField = field === key;
const fieldView = this.fieldViews[key];
if (sortedField && (fieldView.sortBy === "" || fieldView.sortBy === "desc")) {
fieldView.sortByTitle = "asc";
fieldView.sortBy = "asc";
fieldView.sortIconClass = "fa fa-sort-alpha-asc";
}
else if (sortedField && (fieldView.sortByTitle === "asc")) {
fieldView.sortByTitle = "desc";
fieldView.sortBy = "desc";
fieldView.sortIconClass = "fa fa-sort-alpha-desc";
}
else {
fieldView.sortByTitle = "";
fieldView.sortBy = "";
fieldView.sortIconClass = "fa fa-sort";
}
}
}
this.applySort();
}
srv.applySort = function () {
let fieldIndex, fieldView;
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
fieldView = this.fieldViews[key];
if (fieldView.sortBy !== "") {
fieldIndex = this.result.fields.findIndex((a) => a.name === key);
break;
}
fieldView = null;
}
}
if (!fieldView)
return;
const sortType = fieldView.sortBy === "desc" ? 1 : -1;
srv.result.data.sort(function (a, b) {
const aVal = a[fieldIndex];
const bVal = b[fieldIndex];
if (aVal === bVal) return 0;
if (aVal === null) return 1 * sortType;
if (bVal === null) return -1 * sortType;
if (aVal > bVal) return 1 * sortType;
return -1 * sortType;
});
};
srv.dataUpdated = function () {
this.updateFieldViews();
origData = clone(this.result.data);
this.applySort();
};
srv.updateFieldViews = function () {
this.fieldViews = this.fieldViews || {};
// First we remove the fieldViews that doesn't apply anymore
for (let key in this.fieldViews) {
if (this.fieldViews.hasOwnProperty(key)) {
if (!this.result.fields.find(i => i.name === key))
delete this.fieldViews[key];
}
}
// Then we add those that are missing
for (let i = 0; i < this.result.fields.length; i++) {
const field = this.result.fields[i];
if (!this.fieldViews.hasOwnProperty(field.name)) {
this.fieldViews[field.name] =
{
sortBy: "",
sortByTitle: "",
sortIconClass: "fa fa-sort"
};
}
}
};
document.addEventListener("DOMContentLoaded", () => {
delegate("input", ".flatdtpicker", function () {
// We don't use vue to bind dates, because VueJS break the flatpickr as soon as binding occurs.
let to = document.getElementById("toDate").value
let from = document.getElementById("fromDate").value
if (!to || !from)
return;
from = moment(from).unix();
to = moment(to).endOf('day').unix();
srv.request.timePeriod.from = from;
srv.request.timePeriod.to = to;
fetchStoreReports();
});
delegate("click", "#exportCSV", downloadCSV);
const $viewNameToggle = document.getElementById("ViewNameToggle")
delegate("click", ".available-view", function (e) {
e.preventDefault();
const { view } = e.target.dataset;
$viewNameToggle.innerText = view;
document.querySelectorAll(".available-view").forEach($el => $el.classList.remove("custom-active"));
e.target.classList.add("custom-active");
srv.request.viewName = view;
fetchStoreReports();
});
let to = new Date();
let from = new Date(to.getTime() - 1000 * 60 * 60 * 24 * 30);
var urlParams = new URLSearchParams(new URL(window.location).search);
if (urlParams.has("from")) {
from = new Date(parseInt(urlParams.get("from")) * 1000);
}
if (urlParams.has("to")) {
to = new Date(parseInt(urlParams.get("to")) * 1000);
}
srv.request = srv.request || {};
srv.request.timePeriod = srv.request.timePeriod || {};
srv.request.timePeriod.to = moment(to).unix();
srv.request.viewName = srv.request.viewName || "Payments";
srv.request.timePeriod.from = moment(from).unix();
srv.request.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
srv.result = { fields: [], values: [] };
updateUIDateRange();
app = new Vue({
el: '#app',
data() { return { srv } }
});
fetchStoreReports();
});
function updateUIDateRange() {
document.getElementById("toDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.to).toDate());
document.getElementById("fromDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.from).toDate());
}
// This function modify all the fields of a given type
function modifyFields(fields, data, type, action) {
var fieldIndices = fields.map((f, i) => ({ i: i, type: f.type })).filter(f => f.type == type).map(f => f.i);
if (fieldIndices.length === 0)
return;
for (var i = 0; i < data.length; i++) {
for (var f = 0; f < fieldIndices.length; f++) {
data[i][fieldIndices[f]] = action(data[i][fieldIndices[f]]);
}
}
}
function downloadCSV() {
if (!origData) return;
const data = clone(origData);
// Convert ISO8601 dates to YYYY-MM-DD HH:mm:ss so the CSV easily integrate with Excel
modifyFields(srv.result.fields, data, 'datetime', v => moment(v).format('YYYY-MM-DD hh:mm:ss'));
const csv = Papa.unparse({ fields: srv.result.fields.map(f => f.name), data });
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
saveAs(blob, "export.csv");
}
async function fetchStoreReports() {
const result = await fetch(window.location, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(srv.request)
});
srv.result = await result.json();
srv.dataUpdated();
// Dates from API are UTC, convert them to local time
modifyFields(srv.result.fields, srv.result.data, 'datetime', a => moment(a).format());
var urlParams = new URLSearchParams(new URL(window.location).search);
urlParams.set("viewName", srv.request.viewName);
urlParams.set("from", srv.request.timePeriod.from);
urlParams.set("to", srv.request.timePeriod.to);
history.replaceState(null, null, "?" + urlParams.toString());
updateUIDateRange();
srv.charts = [];
for (let i = 0; i < srv.result.charts.length; i++) {
const chart = srv.result.charts[i];
const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
table.name = chart.name;
srv.charts.push(table);
}
app.srv = srv;
}
function getInvoiceUrl(value) {
if (!value)
return;
return srv.invoiceTemplateUrl.replace("INVOICE_ID", value);
}
window.getInvoiceUrl = getInvoiceUrl;
function getExplorerUrl(tx_id, cryptoCode) {
if (!tx_id || !cryptoCode)
return null;
var explorer = srv.explorerTemplateUrls[cryptoCode];
if (!explorer)
return null;
return explorer.replace("TX_ID", tx_id);
}
window.getExplorerUrl = getExplorerUrl;

View file

@ -0,0 +1,3 @@
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
//# sourceMappingURL=FileSaver.min.js.map

File diff suppressed because one or more lines are too long