mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
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:
parent
845e2881fa
commit
dc986959fd
31 changed files with 1830 additions and 8 deletions
|
@ -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; }
|
||||
|
|
62
BTCPayServer.Client/Models/StoreReportRequest.cs
Normal file
62
BTCPayServer.Client/Models/StoreReportRequest.cs
Normal 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; }
|
||||
}
|
16
BTCPayServer.Client/Models/StoreReportsResponse.cs
Normal file
16
BTCPayServer.Client/Models/StoreReportsResponse.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
122
BTCPayServer/Controllers/UIReportsController.CheatMode.cs
Normal file
122
BTCPayServer/Controllers/UIReportsController.CheatMode.cs
Normal 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;
|
||||
}
|
||||
}
|
91
BTCPayServer/Controllers/UIReportsController.cs
Normal file
91
BTCPayServer/Controllers/UIReportsController.cs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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; }
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
20
BTCPayServer/Services/ReportService.cs
Normal file
20
BTCPayServer/Services/ReportService.cs
Normal 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>();
|
||||
}
|
||||
}
|
122
BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs
Normal file
122
BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
187
BTCPayServer/Services/Reporting/PaymentsReportProvider.cs
Normal file
187
BTCPayServer/Services/Reporting/PaymentsReportProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
131
BTCPayServer/Services/Reporting/ProductsReportProvider.cs
Normal file
131
BTCPayServer/Services/Reporting/ProductsReportProvider.cs
Normal 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" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
34
BTCPayServer/Services/Reporting/QueryContext.cs
Normal file
34
BTCPayServer/Services/Reporting/QueryContext.cs
Normal 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>>();
|
||||
}
|
||||
}
|
19
BTCPayServer/Services/Reporting/ReportProvider.cs
Normal file
19
BTCPayServer/Services/Reporting/ReportProvider.cs
Normal 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);
|
||||
}
|
||||
}
|
16
BTCPayServer/Services/Reporting/ViewDefinition.cs
Normal file
16
BTCPayServer/Services/Reporting/ViewDefinition.cs
Normal 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>();
|
||||
}
|
||||
}
|
30
BTCPayServer/TagHelpers/CheatModeTagHelper.cs
Normal file
30
BTCPayServer/TagHelpers/CheatModeTagHelper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
128
BTCPayServer/Views/UIReports/StoreReports.cshtml
Normal file
128
BTCPayServer/Views/UIReports/StoreReports.cshtml
Normal 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>
|
||||
}
|
|
@ -19,6 +19,7 @@ namespace BTCPayServer.Views.Stores
|
|||
Plugins,
|
||||
Webhooks,
|
||||
PullPayments,
|
||||
Reporting,
|
||||
Payouts,
|
||||
PayoutProcessors,
|
||||
[Obsolete("Use StoreNavPages.Plugins instead")]
|
||||
|
|
269
BTCPayServer/wwwroot/js/datatable.js
Normal file
269
BTCPayServer/wwwroot/js/datatable.js
Normal 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;
|
||||
})();
|
212
BTCPayServer/wwwroot/js/store-reports.js
Normal file
212
BTCPayServer/wwwroot/js/store-reports.js
Normal 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;
|
3
BTCPayServer/wwwroot/vendor/FileSaver/FileSaver.min.js
vendored
Normal file
3
BTCPayServer/wwwroot/vendor/FileSaver/FileSaver.min.js
vendored
Normal 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
|
7
BTCPayServer/wwwroot/vendor/papaparse/papaparse.min.js
vendored
Normal file
7
BTCPayServer/wwwroot/vendor/papaparse/papaparse.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue