mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-21 22:11:48 +01:00
Reporting: Improve rounding and display (#5363)
* Reporting: Improve rounding and display * Fix test * Refactor --------- Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
parent
9e76b4d28e
commit
41e3828eea
14 changed files with 293 additions and 234 deletions
|
@ -428,6 +428,11 @@ retry:
|
||||||
version = Regex.Match(actual, "Original file: /npm/vue-sanitize-directive@([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
|
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();
|
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/vue-sanitize-directive@{version}/dist/vue-sanitize-directive.umd.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||||
EqualJsContent(expected, actual);
|
EqualJsContent(expected, actual);
|
||||||
|
|
||||||
|
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "decimal.js", "decimal.min.js").Trim();
|
||||||
|
version = Regex.Match(actual, "Original file: /npm/decimal\\.js@([0-9]+.[0-9]+.[0-9]+)/decimal\\.js").Groups[1].Value;
|
||||||
|
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/decimal.js@{version}/decimal.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||||
|
EqualJsContent(expected, actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EqualJsContent(string expected, string actual)
|
private void EqualJsContent(string expected, string actual)
|
||||||
|
|
|
@ -2886,7 +2886,7 @@ namespace BTCPayServer.Tests
|
||||||
var balanceIndex = report.GetIndex("BalanceChange");
|
var balanceIndex = report.GetIndex("BalanceChange");
|
||||||
Assert.Equal(2, report.Data.Count);
|
Assert.Equal(2, report.Data.Count);
|
||||||
Assert.Equal(64, report.Data[0][txIdIndex].Value<string>().Length);
|
Assert.Equal(64, report.Data[0][txIdIndex].Value<string>().Length);
|
||||||
Assert.Contains(report.Data, d => d[balanceIndex].Value<decimal>() == 1.0m);
|
Assert.Contains(report.Data, d => d[balanceIndex]["v"].Value<decimal>() == 1.0m);
|
||||||
|
|
||||||
// Items sold
|
// Items sold
|
||||||
report = await GetReport(acc, new() { ViewName = "Products sold" });
|
report = await GetReport(acc, new() { ViewName = "Products sold" });
|
||||||
|
|
|
@ -1,18 +1,11 @@
|
||||||
#nullable enable
|
#nullable enable
|
||||||
using BTCPayServer.Lightning;
|
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
|
||||||
using BTCPayServer.Services.Invoices;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System;
|
using System;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore.Internal;
|
|
||||||
using NBitcoin;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Dapper;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
|
@ -39,7 +32,6 @@ public class GreenfieldReportsController : Controller
|
||||||
public ApplicationDbContextFactory DBContextFactory { get; }
|
public ApplicationDbContextFactory DBContextFactory { get; }
|
||||||
public ReportService ReportService { get; }
|
public ReportService ReportService { get; }
|
||||||
|
|
||||||
|
|
||||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
[HttpPost("~/api/v1/stores/{storeId}/reports")]
|
[HttpPost("~/api/v1/stores/{storeId}/reports")]
|
||||||
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
|
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
|
||||||
|
@ -60,7 +52,7 @@ public class GreenfieldReportsController : Controller
|
||||||
|
|
||||||
var ctx = new Services.Reporting.QueryContext(storeId, from, to);
|
var ctx = new Services.Reporting.QueryContext(storeId, from, to);
|
||||||
await report.Query(ctx, cancellationToken);
|
await report.Query(ctx, cancellationToken);
|
||||||
var result = new StoreReportResponse()
|
var result = new StoreReportResponse
|
||||||
{
|
{
|
||||||
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
|
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
|
||||||
Charts = ctx.ViewDefinition?.Charts ?? new List<ChartDefinition>(),
|
Charts = ctx.ViewDefinition?.Charts ?? new List<ChartDefinition>(),
|
||||||
|
@ -70,11 +62,9 @@ public class GreenfieldReportsController : Controller
|
||||||
};
|
};
|
||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
|
||||||
ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
|
return this.CreateValidationError(ModelState);
|
||||||
return this.CreateValidationError(ModelState);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
#nullable enable
|
#nullable enable
|
||||||
using System;
|
|
||||||
using Dapper;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
@ -10,19 +8,11 @@ using BTCPayServer.Controllers.GreenField;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Filters;
|
using BTCPayServer.Filters;
|
||||||
using BTCPayServer.Models.StoreReportsViewModels;
|
using BTCPayServer.Models.StoreReportsViewModels;
|
||||||
using BTCPayServer.Models.StoreViewModels;
|
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
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.Threading;
|
||||||
using System.Collections.Generic;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using NBitcoin.DataEncoders;
|
|
||||||
|
|
||||||
|
|
||||||
namespace BTCPayServer.Controllers;
|
namespace BTCPayServer.Controllers;
|
||||||
|
|
||||||
|
@ -35,8 +25,7 @@ public partial class UIReportsController : Controller
|
||||||
ApplicationDbContextFactory dbContextFactory,
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
GreenfieldReportsController api,
|
GreenfieldReportsController api,
|
||||||
ReportService reportService,
|
ReportService reportService,
|
||||||
BTCPayServerEnvironment env
|
BTCPayServerEnvironment env)
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Api = api;
|
Api = api;
|
||||||
ReportService = reportService;
|
ReportService = reportService;
|
||||||
|
@ -72,20 +61,17 @@ public partial class UIReportsController : Controller
|
||||||
string storeId,
|
string storeId,
|
||||||
string ? viewName = null)
|
string ? viewName = null)
|
||||||
{
|
{
|
||||||
var vm = new StoreReportsViewModel()
|
var vm = new StoreReportsViewModel
|
||||||
{
|
{
|
||||||
InvoiceTemplateUrl = this.Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
|
InvoiceTemplateUrl = Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
|
||||||
ExplorerTemplateUrls = NetworkProvider.GetAll().ToDictionary(network => network.CryptoCode, network => network.BlockExplorerLink?.Replace("{0}", "TX_ID")),
|
ExplorerTemplateUrls = NetworkProvider.GetAll().ToDictionary(network => network.CryptoCode, network => network.BlockExplorerLink?.Replace("{0}", "TX_ID")),
|
||||||
Request = new StoreReportRequest()
|
Request = new StoreReportRequest { ViewName = viewName ?? "Payments" },
|
||||||
{
|
AvailableViews = ReportService.ReportProviders
|
||||||
ViewName = viewName ?? "Payments"
|
.Values
|
||||||
}
|
.Where(r => r.IsAvailable())
|
||||||
|
.Select(k => k.Name)
|
||||||
|
.OrderBy(k => k).ToList()
|
||||||
};
|
};
|
||||||
vm.AvailableViews = ReportService.ReportProviders
|
|
||||||
.Values
|
|
||||||
.Where(r => r.IsAvailable())
|
|
||||||
.Select(k => k.Name)
|
|
||||||
.OrderBy(k => k).ToList();
|
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,8 @@ using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using BTCPayServer.Rating;
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services.Rates;
|
using BTCPayServer.Services.Rates;
|
||||||
|
using BTCPayServer.Services.Reporting;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
namespace BTCPayServer.Services;
|
namespace BTCPayServer.Services;
|
||||||
|
|
||||||
|
@ -18,7 +20,8 @@ public class DisplayFormatter
|
||||||
{
|
{
|
||||||
Code,
|
Code,
|
||||||
Symbol,
|
Symbol,
|
||||||
CodeAndSymbol
|
CodeAndSymbol,
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -43,6 +46,7 @@ public class DisplayFormatter
|
||||||
|
|
||||||
return format switch
|
return format switch
|
||||||
{
|
{
|
||||||
|
CurrencyFormat.None => formatted.Replace(provider.CurrencySymbol, "").Trim(),
|
||||||
CurrencyFormat.Code => $"{formatted.Replace(provider.CurrencySymbol, "").Trim()} {currency}",
|
CurrencyFormat.Code => $"{formatted.Replace(provider.CurrencySymbol, "").Trim()} {currency}",
|
||||||
CurrencyFormat.Symbol => formatted,
|
CurrencyFormat.Symbol => formatted,
|
||||||
CurrencyFormat.CodeAndSymbol => $"{formatted} ({currency})",
|
CurrencyFormat.CodeAndSymbol => $"{formatted} ({currency})",
|
||||||
|
@ -54,4 +58,11 @@ public class DisplayFormatter
|
||||||
{
|
{
|
||||||
return Currency(decimal.Parse(value, CultureInfo.InvariantCulture), currency, format);
|
return Currency(decimal.Parse(value, CultureInfo.InvariantCulture), currency, format);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public JObject ToFormattedAmount(decimal value, string currency)
|
||||||
|
{
|
||||||
|
var currencyData = _currencyNameTable.GetCurrencyData(currency, true);
|
||||||
|
var divisibility = currencyData.Divisibility;
|
||||||
|
return new FormattedAmount(value, divisibility).ToJObject();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
25
BTCPayServer/Services/Reporting/FormattedAmount.cs
Normal file
25
BTCPayServer/Services/Reporting/FormattedAmount.cs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
using BTCPayServer.JsonConverters;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Services.Reporting
|
||||||
|
{
|
||||||
|
public class FormattedAmount
|
||||||
|
{
|
||||||
|
public FormattedAmount(decimal value, int divisibility)
|
||||||
|
{
|
||||||
|
Value = value;
|
||||||
|
Divisibility = divisibility;
|
||||||
|
}
|
||||||
|
[JsonProperty("v")]
|
||||||
|
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||||
|
public decimal Value { get; set; }
|
||||||
|
[JsonProperty("d")]
|
||||||
|
public int Divisibility { get; set; }
|
||||||
|
|
||||||
|
public JObject ToJObject()
|
||||||
|
{
|
||||||
|
return JObject.FromObject(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ public class OnChainWalletReportProvider : ReportProvider
|
||||||
public OnChainWalletReportProvider(
|
public OnChainWalletReportProvider(
|
||||||
NBXplorerConnectionFactory NbxplorerConnectionFactory,
|
NBXplorerConnectionFactory NbxplorerConnectionFactory,
|
||||||
StoreRepository storeRepository,
|
StoreRepository storeRepository,
|
||||||
|
DisplayFormatter displayFormatter,
|
||||||
BTCPayNetworkProvider networkProvider,
|
BTCPayNetworkProvider networkProvider,
|
||||||
WalletRepository walletRepository)
|
WalletRepository walletRepository)
|
||||||
{
|
{
|
||||||
|
@ -26,43 +27,45 @@ public class OnChainWalletReportProvider : ReportProvider
|
||||||
StoreRepository = storeRepository;
|
StoreRepository = storeRepository;
|
||||||
NetworkProvider = networkProvider;
|
NetworkProvider = networkProvider;
|
||||||
WalletRepository = walletRepository;
|
WalletRepository = walletRepository;
|
||||||
|
_displayFormatter = displayFormatter;
|
||||||
}
|
}
|
||||||
public NBXplorerConnectionFactory NbxplorerConnectionFactory { get; }
|
|
||||||
public StoreRepository StoreRepository { get; }
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
public BTCPayNetworkProvider NetworkProvider { get; }
|
private NBXplorerConnectionFactory NbxplorerConnectionFactory { get; }
|
||||||
public WalletRepository WalletRepository { get; }
|
private StoreRepository StoreRepository { get; }
|
||||||
|
private BTCPayNetworkProvider NetworkProvider { get; }
|
||||||
|
private WalletRepository WalletRepository { get; }
|
||||||
public override string Name => "On-Chain Wallets";
|
public override string Name => "On-Chain Wallets";
|
||||||
ViewDefinition CreateViewDefinition()
|
ViewDefinition CreateViewDefinition()
|
||||||
{
|
{
|
||||||
return
|
return new()
|
||||||
new()
|
{
|
||||||
|
Fields =
|
||||||
{
|
{
|
||||||
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", "amount")
|
||||||
|
},
|
||||||
|
Charts =
|
||||||
|
{
|
||||||
|
new ()
|
||||||
{
|
{
|
||||||
new ("Date", "datetime"),
|
Name = "Group by Crypto",
|
||||||
new ("Crypto", "string"),
|
Totals = { "Crypto" },
|
||||||
// For proper rendering of explorer links, Crypto should always be before tx_id
|
Groups = { "Crypto", "Confirmed" },
|
||||||
new ("TransactionId", "tx_id"),
|
Aggregates = { "BalanceChange" }
|
||||||
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()
|
public override bool IsAvailable()
|
||||||
{
|
{
|
||||||
return this.NbxplorerConnectionFactory.Available;
|
return NbxplorerConnectionFactory.Available;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||||
|
@ -86,7 +89,7 @@ public class OnChainWalletReportProvider : ReportProvider
|
||||||
{
|
{
|
||||||
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(settings.Network.CryptoCode, settings.AccountDerivation.ToString()),
|
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(settings.Network.CryptoCode, settings.AccountDerivation.ToString()),
|
||||||
code = settings.Network.CryptoCode,
|
code = settings.Network.CryptoCode,
|
||||||
interval = interval
|
interval
|
||||||
},
|
},
|
||||||
cancellationToken: cancellation);
|
cancellationToken: cancellation);
|
||||||
|
|
||||||
|
@ -97,14 +100,15 @@ public class OnChainWalletReportProvider : ReportProvider
|
||||||
if (date > queryContext.To)
|
if (date > queryContext.To)
|
||||||
continue;
|
continue;
|
||||||
var values = queryContext.AddData();
|
var values = queryContext.AddData();
|
||||||
values.Add((DateTimeOffset)date);
|
var balanceChange = Money.Satoshis((long)r.balance_change).ToDecimal(MoneyUnit.BTC);
|
||||||
|
values.Add(date);
|
||||||
values.Add(settings.Network.CryptoCode);
|
values.Add(settings.Network.CryptoCode);
|
||||||
values.Add((string)r.tx_id);
|
values.Add((string)r.tx_id);
|
||||||
values.Add(null);
|
values.Add(null);
|
||||||
values.Add((long?)r.blk_height is not null);
|
values.Add((long?)r.blk_height is not null);
|
||||||
values.Add(Money.Satoshis((long)r.balance_change).ToDecimal(MoneyUnit.BTC));
|
values.Add(new FormattedAmount(balanceChange, settings.Network.Divisibility).ToJObject());
|
||||||
}
|
}
|
||||||
var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery()
|
var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery
|
||||||
{
|
{
|
||||||
Ids = queryContext.Data.Select(d => (string)d[2]!).ToArray(),
|
Ids = queryContext.Data.Select(d => (string)d[2]!).ToArray(),
|
||||||
WalletId = walletId,
|
WalletId = walletId,
|
||||||
|
|
|
@ -23,85 +23,84 @@ namespace BTCPayServer.Services.Reporting;
|
||||||
|
|
||||||
public class PaymentsReportProvider : ReportProvider
|
public class PaymentsReportProvider : ReportProvider
|
||||||
{
|
{
|
||||||
|
public PaymentsReportProvider(
|
||||||
public PaymentsReportProvider(ApplicationDbContextFactory dbContextFactory, CurrencyNameTable currencyNameTable)
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
DisplayFormatter displayFormatter)
|
||||||
{
|
{
|
||||||
DbContextFactory = dbContextFactory;
|
DbContextFactory = dbContextFactory;
|
||||||
CurrencyNameTable = currencyNameTable;
|
DisplayFormatter = displayFormatter;
|
||||||
}
|
}
|
||||||
public override string Name => "Payments";
|
public override string Name => "Payments";
|
||||||
public ApplicationDbContextFactory DbContextFactory { get; }
|
private ApplicationDbContextFactory DbContextFactory { get; }
|
||||||
public CurrencyNameTable CurrencyNameTable { get; }
|
private DisplayFormatter DisplayFormatter { get; }
|
||||||
|
|
||||||
ViewDefinition CreateViewDefinition()
|
ViewDefinition CreateViewDefinition()
|
||||||
{
|
{
|
||||||
return
|
return new()
|
||||||
new()
|
{
|
||||||
|
Fields =
|
||||||
{
|
{
|
||||||
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", "amount"),
|
||||||
|
new ("NetworkFee", "amount"),
|
||||||
|
new ("LightningAddress", "string"),
|
||||||
|
new ("Currency", "string"),
|
||||||
|
new ("CurrencyAmount", "amount"),
|
||||||
|
new ("Rate", "amount")
|
||||||
|
},
|
||||||
|
Charts =
|
||||||
|
{
|
||||||
|
new ()
|
||||||
{
|
{
|
||||||
new ("Date", "datetime"),
|
Name = "Aggregated crypto amount",
|
||||||
new ("InvoiceId", "invoice_id"),
|
Groups = { "Crypto", "PaymentType" },
|
||||||
new ("OrderId", "string"),
|
Totals = { "Crypto" },
|
||||||
new ("PaymentType", "string"),
|
HasGrandTotal = false,
|
||||||
new ("PaymentId", "string"),
|
Aggregates = { "CryptoAmount" }
|
||||||
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 ()
|
||||||
{
|
{
|
||||||
new ()
|
Name = "Aggregated currency amount",
|
||||||
{
|
Groups = { "Currency" },
|
||||||
Name = "Aggregated crypto amount",
|
Totals = { "Currency" },
|
||||||
Groups = { "Crypto", "PaymentType" },
|
HasGrandTotal = false,
|
||||||
Totals = { "Crypto" },
|
Aggregates = { "CurrencyAmount" }
|
||||||
HasGrandTotal = false,
|
},
|
||||||
Aggregates = { "CryptoAmount" }
|
new ()
|
||||||
},
|
{
|
||||||
new ()
|
Name = "Group by Lightning Address (Currency amount)",
|
||||||
{
|
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
|
||||||
Name = "Aggregated currency amount",
|
Groups = { "LightningAddress", "Currency" },
|
||||||
Groups = { "Currency" },
|
Aggregates = { "CurrencyAmount" },
|
||||||
Totals = { "Currency" },
|
HasGrandTotal = true
|
||||||
HasGrandTotal = false,
|
},
|
||||||
Aggregates = { "CurrencyAmount" }
|
new ()
|
||||||
},
|
{
|
||||||
new ()
|
Name = "Group by Lightning Address (Crypto amount)",
|
||||||
{
|
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
|
||||||
Name = "Group by Lightning Address (Currency amount)",
|
Groups = { "LightningAddress", "Crypto" },
|
||||||
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
|
Aggregates = { "CryptoAmount" },
|
||||||
Groups = { "LightningAddress", "Currency" },
|
HasGrandTotal = true
|
||||||
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)
|
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||||
{
|
{
|
||||||
queryContext.ViewDefinition = CreateViewDefinition();
|
queryContext.ViewDefinition = CreateViewDefinition();
|
||||||
await using var ctx = DbContextFactory.CreateContext();
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
var conn = ctx.Database.GetDbConnection();
|
var conn = ctx.Database.GetDbConnection();
|
||||||
string[] fields = new[]
|
string[] fields =
|
||||||
{
|
{
|
||||||
$"i.\"Created\" created",
|
"i.\"Created\" created",
|
||||||
"i.\"Id\" invoice_id",
|
"i.\"Id\" invoice_id",
|
||||||
"i.\"OrderId\" order_id",
|
"i.\"OrderId\" order_id",
|
||||||
"p.\"Id\" payment_id",
|
"p.\"Id\" payment_id",
|
||||||
|
@ -113,7 +112,7 @@ public class PaymentsReportProvider : ReportProvider
|
||||||
string body =
|
string body =
|
||||||
"FROM \"Payments\" p " +
|
"FROM \"Payments\" p " +
|
||||||
"JOIN \"Invoices\" i ON i.\"Id\" = p.\"InvoiceDataId\" " +
|
"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 " +
|
"WHERE p.\"Accounted\" IS TRUE AND i.\"Created\" >= @from AND i.\"Created\" < @to AND i.\"StoreDataId\"=@storeId " +
|
||||||
"ORDER BY i.\"Created\"";
|
"ORDER BY i.\"Created\"";
|
||||||
var command = new CommandDefinition(
|
var command = new CommandDefinition(
|
||||||
commandText: select + body,
|
commandText: select + body,
|
||||||
|
@ -145,12 +144,12 @@ public class PaymentsReportProvider : ReportProvider
|
||||||
values.Add((string)r.payment_id);
|
values.Add((string)r.payment_id);
|
||||||
var invoiceBlob = JObject.Parse((string)r.invoice_blob);
|
var invoiceBlob = JObject.Parse((string)r.invoice_blob);
|
||||||
var paymentBlob = JObject.Parse((string)r.payment_blob);
|
var paymentBlob = JObject.Parse((string)r.payment_blob);
|
||||||
|
|
||||||
|
|
||||||
var data = JObject.Parse(paymentBlob.SelectToken("$.cryptoPaymentData")?.Value<string>()!);
|
var data = JObject.Parse(paymentBlob.SelectToken("$.cryptoPaymentData")?.Value<string>()!);
|
||||||
var conf = data.SelectToken("$.confirmationCount")?.Value<int>();
|
var conf = data.SelectToken("$.confirmationCount")?.Value<int>();
|
||||||
values.Add(conf is int o ? o > 0 :
|
var currency = invoiceBlob.SelectToken("$.currency")?.Value<string>();
|
||||||
paymentType.PaymentType != PaymentTypes.BTCLike ? true : null);
|
var networkFee = paymentBlob.SelectToken("$.networkFee", false)?.Value<decimal>();
|
||||||
|
var rate = invoiceBlob.SelectToken($"$.cryptoData.{paymentType}.rate")?.Value<decimal>();
|
||||||
|
values.Add(conf is int o ? o > 0 : paymentType.PaymentType != PaymentTypes.BTCLike ? true : null);
|
||||||
values.Add(data.SelectToken("$.address")?.Value<string>());
|
values.Add(data.SelectToken("$.address")?.Value<string>());
|
||||||
values.Add(paymentType.CryptoCode);
|
values.Add(paymentType.CryptoCode);
|
||||||
|
|
||||||
|
@ -167,19 +166,12 @@ public class PaymentsReportProvider : ReportProvider
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
values.Add(cryptoAmount);
|
values.Add(DisplayFormatter.ToFormattedAmount(cryptoAmount, paymentType.CryptoCode));
|
||||||
values.Add(paymentBlob.SelectToken("$.networkFee", false)?.Value<decimal>());
|
values.Add(networkFee is > 0 ? DisplayFormatter.ToFormattedAmount(networkFee.Value, paymentType.CryptoCode) : null);
|
||||||
values.Add(invoiceBlob.SelectToken("$.cryptoData.BTC_LNURLPAY.paymentMethod.ConsumedLightningAddress", false)?.Value<string>());
|
values.Add(invoiceBlob.SelectToken("$.cryptoData.BTC_LNURLPAY.paymentMethod.ConsumedLightningAddress", false)?.Value<string>());
|
||||||
var currency = invoiceBlob.SelectToken("$.currency")?.Value<string>();
|
|
||||||
values.Add(currency);
|
values.Add(currency);
|
||||||
|
values.Add(rate is null ? null : DisplayFormatter.ToFormattedAmount(rate.Value * cryptoAmount, currency ?? "USD")); // Currency amount
|
||||||
values.Add(null); // Currency amount
|
values.Add(rate is null ? null : DisplayFormatter.ToFormattedAmount(rate.Value, currency ?? "USD"));
|
||||||
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);
|
queryContext.Data.Add(values);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
@ -9,13 +9,18 @@ using BTCPayServer.Payments;
|
||||||
|
|
||||||
namespace BTCPayServer.Services.Reporting;
|
namespace BTCPayServer.Services.Reporting;
|
||||||
|
|
||||||
public class PayoutsReportProvider:ReportProvider
|
public class PayoutsReportProvider : ReportProvider
|
||||||
{
|
{
|
||||||
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
private readonly PullPaymentHostedService _pullPaymentHostedService;
|
||||||
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
||||||
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
|
|
||||||
public PayoutsReportProvider(PullPaymentHostedService pullPaymentHostedService, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
|
public PayoutsReportProvider(
|
||||||
|
PullPaymentHostedService pullPaymentHostedService,
|
||||||
|
DisplayFormatter displayFormatter,
|
||||||
|
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
|
||||||
{
|
{
|
||||||
|
_displayFormatter = displayFormatter;
|
||||||
_pullPaymentHostedService = pullPaymentHostedService;
|
_pullPaymentHostedService = pullPaymentHostedService;
|
||||||
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
||||||
}
|
}
|
||||||
|
@ -51,32 +56,32 @@ public class PayoutsReportProvider:ReportProvider
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
continue;
|
continue;
|
||||||
data.Add(paymentType.CryptoCode);
|
|
||||||
data.Add(blob.CryptoAmount);
|
|
||||||
var ppBlob = payout.PullPaymentData?.GetBlob();
|
var ppBlob = payout.PullPaymentData?.GetBlob();
|
||||||
data.Add(ppBlob?.Currency??paymentType.CryptoCode);
|
var currency = ppBlob?.Currency ?? paymentType.CryptoCode;
|
||||||
data.Add(blob.Amount);
|
data.Add(paymentType.CryptoCode);
|
||||||
|
data.Add(blob.CryptoAmount.HasValue ? _displayFormatter.ToFormattedAmount(blob.CryptoAmount.Value, paymentType.CryptoCode) : null);
|
||||||
|
data.Add(currency);
|
||||||
|
data.Add(_displayFormatter.ToFormattedAmount(blob.Amount, currency));
|
||||||
data.Add(blob.Destination);
|
data.Add(blob.Destination);
|
||||||
queryContext.Data.Add(data);
|
queryContext.Data.Add(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ViewDefinition CreateDefinition()
|
private ViewDefinition CreateDefinition()
|
||||||
{
|
{
|
||||||
return new ViewDefinition()
|
return new ViewDefinition
|
||||||
{
|
{
|
||||||
Fields = new List<StoreReportResponse.Field>()
|
Fields = new List<StoreReportResponse.Field>
|
||||||
{
|
{
|
||||||
new("Date", "datetime"),
|
new("Date", "datetime"),
|
||||||
new("Source", "string"),
|
new("Source", "string"),
|
||||||
new("State", "string"),
|
new("State", "string"),
|
||||||
new("PaymentType", "string"),
|
new("PaymentType", "string"),
|
||||||
new("Crypto", "string"),
|
new("Crypto", "string"),
|
||||||
new("CryptoAmount", "decimal"),
|
new("CryptoAmount", "amount"),
|
||||||
new("Currency", "string"),
|
new("Currency", "string"),
|
||||||
new("CurrencyAmount", "decimal"),
|
new("CurrencyAmount", "amount"),
|
||||||
new("Destination", "string")
|
new("Destination", "string")
|
||||||
},
|
},
|
||||||
Charts =
|
Charts =
|
||||||
|
@ -88,14 +93,16 @@ public class PayoutsReportProvider:ReportProvider
|
||||||
Totals = { "Crypto" },
|
Totals = { "Crypto" },
|
||||||
HasGrandTotal = false,
|
HasGrandTotal = false,
|
||||||
Aggregates = { "CryptoAmount" }
|
Aggregates = { "CryptoAmount" }
|
||||||
},new ()
|
},
|
||||||
|
new ()
|
||||||
{
|
{
|
||||||
Name = "Aggregated amount",
|
Name = "Aggregated amount",
|
||||||
Groups = { "Currency", "State" },
|
Groups = { "Currency", "State" },
|
||||||
Totals = { "CurrencyAmount" },
|
Totals = { "CurrencyAmount" },
|
||||||
HasGrandTotal = false,
|
HasGrandTotal = false,
|
||||||
Aggregates = { "CurrencyAmount" }
|
Aggregates = { "CurrencyAmount" }
|
||||||
},new ()
|
},
|
||||||
|
new ()
|
||||||
{
|
{
|
||||||
Name = "Aggregated amount by Source",
|
Name = "Aggregated amount by Source",
|
||||||
Groups = { "Currency", "State", "Source" },
|
Groups = { "Currency", "State", "Source" },
|
||||||
|
|
|
@ -1,24 +1,26 @@
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Rating;
|
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
using BTCPayServer.Services.Rates;
|
|
||||||
namespace BTCPayServer.Services.Reporting;
|
namespace BTCPayServer.Services.Reporting;
|
||||||
|
|
||||||
public class ProductsReportProvider : ReportProvider
|
public class ProductsReportProvider : ReportProvider
|
||||||
{
|
{
|
||||||
public ProductsReportProvider(InvoiceRepository invoiceRepository, CurrencyNameTable currencyNameTable, AppService apps)
|
public ProductsReportProvider(
|
||||||
|
InvoiceRepository invoiceRepository,
|
||||||
|
DisplayFormatter displayFormatter,
|
||||||
|
AppService apps)
|
||||||
{
|
{
|
||||||
InvoiceRepository = invoiceRepository;
|
InvoiceRepository = invoiceRepository;
|
||||||
CurrencyNameTable = currencyNameTable;
|
_displayFormatter = displayFormatter;
|
||||||
Apps = apps;
|
Apps = apps;
|
||||||
}
|
}
|
||||||
|
|
||||||
public InvoiceRepository InvoiceRepository { get; }
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
public CurrencyNameTable CurrencyNameTable { get; }
|
private InvoiceRepository InvoiceRepository { get; }
|
||||||
public AppService Apps { get; }
|
private AppService Apps { get; }
|
||||||
|
|
||||||
public override string Name => "Products sold";
|
public override string Name => "Products sold";
|
||||||
|
|
||||||
|
@ -27,7 +29,7 @@ public class ProductsReportProvider : ReportProvider
|
||||||
var appsById = (await Apps.GetApps(queryContext.StoreId)).ToDictionary(o => o.Id);
|
var appsById = (await Apps.GetApps(queryContext.StoreId)).ToDictionary(o => o.Id);
|
||||||
var tagAllinvoicesApps = appsById.Values.Where(a => a.TagAllInvoices).ToList();
|
var tagAllinvoicesApps = appsById.Values.Where(a => a.TagAllInvoices).ToList();
|
||||||
queryContext.ViewDefinition = CreateDefinition();
|
queryContext.ViewDefinition = CreateDefinition();
|
||||||
foreach (var i in (await InvoiceRepository.GetInvoices(new InvoiceQuery()
|
foreach (var i in (await InvoiceRepository.GetInvoices(new InvoiceQuery
|
||||||
{
|
{
|
||||||
IncludeArchived = true,
|
IncludeArchived = true,
|
||||||
IncludeAddresses = false,
|
IncludeAddresses = false,
|
||||||
|
@ -63,8 +65,8 @@ public class ProductsReportProvider : ReportProvider
|
||||||
{
|
{
|
||||||
values.Add(code);
|
values.Add(code);
|
||||||
values.Add(1);
|
values.Add(1);
|
||||||
values.Add(i.Currency);
|
|
||||||
values.Add(i.Price);
|
values.Add(i.Price);
|
||||||
|
values.Add(i.Currency);
|
||||||
queryContext.Data.Add(values);
|
queryContext.Data.Add(values);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
@ -76,8 +78,8 @@ public class ProductsReportProvider : ReportProvider
|
||||||
var copy = values.ToList();
|
var copy = values.ToList();
|
||||||
copy.Add(item.Id);
|
copy.Add(item.Id);
|
||||||
copy.Add(item.Count);
|
copy.Add(item.Count);
|
||||||
copy.Add(i.Currency);
|
|
||||||
copy.Add(item.Price * item.Count);
|
copy.Add(item.Price * item.Count);
|
||||||
|
copy.Add(i.Currency);
|
||||||
queryContext.Data.Add(copy);
|
queryContext.Data.Add(copy);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,13 +89,15 @@ public class ProductsReportProvider : ReportProvider
|
||||||
// Round the currency amount
|
// Round the currency amount
|
||||||
foreach (var r in queryContext.Data)
|
foreach (var r in queryContext.Data)
|
||||||
{
|
{
|
||||||
r[^1] = ((decimal)r[^1]).RoundToSignificant(CurrencyNameTable.GetCurrencyData((string)r[^2] ?? "USD", true).Divisibility);
|
var amount = (decimal)r[^2];
|
||||||
|
var currency = (string)r[^1] ?? "USD";
|
||||||
|
r[^2] = _displayFormatter.ToFormattedAmount(amount, currency);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private ViewDefinition CreateDefinition()
|
private ViewDefinition CreateDefinition()
|
||||||
{
|
{
|
||||||
return new ViewDefinition()
|
return new ViewDefinition
|
||||||
{
|
{
|
||||||
Fields =
|
Fields =
|
||||||
{
|
{
|
||||||
|
@ -102,9 +106,9 @@ public class ProductsReportProvider : ReportProvider
|
||||||
new ("State", "string"),
|
new ("State", "string"),
|
||||||
new ("AppId", "string"),
|
new ("AppId", "string"),
|
||||||
new ("Product", "string"),
|
new ("Product", "string"),
|
||||||
new ("Quantity", "decimal"),
|
new ("Quantity", "integer"),
|
||||||
new ("Currency", "string"),
|
new ("CurrencyAmount", "amount"),
|
||||||
new ("CurrencyAmount", "decimal")
|
new ("Currency", "string")
|
||||||
},
|
},
|
||||||
Charts =
|
Charts =
|
||||||
{
|
{
|
||||||
|
|
|
@ -61,28 +61,32 @@
|
||||||
<div id="app" v-cloak class="w-100-fixed">
|
<div id="app" v-cloak class="w-100-fixed">
|
||||||
<article v-for="chart in srv.charts" class="mb-5">
|
<article v-for="chart in srv.charts" class="mb-5">
|
||||||
<h3>{{ chart.name }}</h3>
|
<h3>{{ chart.name }}</h3>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive" v-if="chart.rows.length || chart.hasGrandTotal">
|
||||||
<table class="table table-bordered table-hover w-auto">
|
<table class="table table-hover w-auto">
|
||||||
<thead class="sticky-top bg-body">
|
<thead class="sticky-top bg-body">
|
||||||
<tr>
|
<tr>
|
||||||
<th v-for="group in chart.groups">{{ group }}</th>
|
<th v-for="group in chart.groups">{{ titleCase(group) }}</th>
|
||||||
<th v-for="agg in chart.aggregates">{{ agg }}</th>
|
<th v-for="agg in chart.aggregates" class="text-end">{{ titleCase(agg) }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in chart.rows">
|
<tr v-for="row in chart.rows">
|
||||||
<td v-for="group in row.groups" :rowspan="group.rowCount">{{ group.name }}</td>
|
<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-if="row.isTotal" :colspan="row.rLevel">Total</td>
|
||||||
<td v-for="value in row.values">{{ value }}</td>
|
<td v-for="value in row.values" class="text-end">{{ displayValue(value) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="chart.hasGrandTotal"><td :colspan="chart.groups.length">Grand total</td><td v-for="value in chart.grandTotalValues">{{ value }}</td></tr>
|
<tr v-if="chart.hasGrandTotal">
|
||||||
|
<td :colspan="chart.groups.length">Grand Total</td>
|
||||||
|
<td v-for="value in chart.grandTotalValues" class="text-end">{{ displayValue(value) }}</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else class="mt-3 mb-5 text-secondary">No data</p>
|
||||||
</article>
|
</article>
|
||||||
<article>
|
<article v-if="srv.result.data">
|
||||||
<h3 id="raw-data">Raw data</h3>
|
<h3 id="raw-data">Raw data</h3>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive" v-if="srv.result.data.length">
|
||||||
<table class="table table-hover w-auto">
|
<table class="table table-hover w-auto">
|
||||||
<thead class="sticky-top bg-body">
|
<thead class="sticky-top bg-body">
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -92,7 +96,7 @@
|
||||||
:data-field="field.name"
|
:data-field="field.name"
|
||||||
@@click.prevent="srv.sortBy(field.name)"
|
@@click.prevent="srv.sortBy(field.name)"
|
||||||
:title="srv.fieldViews[field.name].sortByTitle">
|
:title="srv.fieldViews[field.name].sortByTitle">
|
||||||
{{ field.name }}
|
{{ titleCase(field.name) }}
|
||||||
<span :class="srv.fieldViews[field.name].sortIconClass" />
|
<span :class="srv.fieldViews[field.name].sortIconClass" />
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</th>
|
||||||
|
@ -100,25 +104,26 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, index) in srv.result.data" :key="index">
|
<tr v-for="(row, index) in srv.result.data" :key="index">
|
||||||
<td class="text-nowrap" v-for="(value, columnIndex) in row" :key="columnIndex">
|
<td class="text-nowrap" v-for="(value, columnIndex) in row" :key="columnIndex" :class="{ 'text-end': ['integer', 'decimal', 'amount'].includes(srv.result.fields[columnIndex].type) }">
|
||||||
<a :href="getInvoiceUrl(value)"
|
<a :href="getInvoiceUrl(value)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
v-if="srv.result.fields[columnIndex].type === 'invoice_id'">{{ value }}</a>
|
v-if="srv.result.fields[columnIndex].type === 'invoice_id'">{{ displayValue(value) }}</a>
|
||||||
|
|
||||||
<a :href="getExplorerUrl(value, row[columnIndex-1])"
|
<a :href="getExplorerUrl(value, row[columnIndex-1])"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">{{ value }}</a>
|
v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">{{ displayValue(value) }}</a>
|
||||||
<span v-else>{{ value }}</span>
|
<template v-else>{{ displayValue(value) }}</template>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-else class="mt-3 mb-5 text-secondary">No data</p>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section PageFootContent {
|
@section PageFootContent {
|
||||||
|
<script src="~/vendor/decimal.js/decimal.min.js" asp-append-version="true"></script>
|
||||||
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
|
<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/papaparse/papaparse.min.js" asp-append-version="true"></script>
|
||||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
(function () {
|
(function () {
|
||||||
// Given sorted data, build a tabular data of given groups and aggregates.
|
// Given sorted data, build a tabular data of given groups and aggregates.
|
||||||
function groupBy(groupIndices, aggregatesIndices, data) {
|
function groupBy(groupIndices, aggregatesIndices, data) {
|
||||||
var summaryRows = [];
|
const summaryRows = [];
|
||||||
var summaryRow = null;
|
let summaryRow = null;
|
||||||
for (var i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
if (summaryRow) {
|
if (summaryRow) {
|
||||||
for (var gi = 0; gi < groupIndices.length; gi++) {
|
for (let gi = 0; gi < groupIndices.length; gi++) {
|
||||||
if (summaryRow[gi] !== data[i][groupIndices[gi]]) {
|
if (summaryRow[gi] !== data[i][groupIndices[gi]]) {
|
||||||
summaryRows.push(summaryRow);
|
summaryRows.push(summaryRow);
|
||||||
summaryRow = null;
|
summaryRow = null;
|
||||||
|
@ -15,16 +15,28 @@
|
||||||
}
|
}
|
||||||
if (!summaryRow) {
|
if (!summaryRow) {
|
||||||
summaryRow = new Array(groupIndices.length + aggregatesIndices.length);
|
summaryRow = new Array(groupIndices.length + aggregatesIndices.length);
|
||||||
for (var gi = 0; gi < groupIndices.length; gi++) {
|
for (let gi = 0; gi < groupIndices.length; gi++) {
|
||||||
summaryRow[gi] = data[i][groupIndices[gi]];
|
summaryRow[gi] = data[i][groupIndices[gi]];
|
||||||
}
|
}
|
||||||
summaryRow.fill(0, groupIndices.length);
|
summaryRow.fill(new Decimal(0), groupIndices.length);
|
||||||
}
|
}
|
||||||
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
|
for (let ai = 0; ai < aggregatesIndices.length; ai++) {
|
||||||
var v = data[i][aggregatesIndices[ai]];
|
const v = data[i][aggregatesIndices[ai]];
|
||||||
// TODO: support other aggregate functions
|
// TODO: support other aggregate functions
|
||||||
if (v)
|
if (typeof (v) === 'object' && v.v) {
|
||||||
summaryRow[groupIndices.length + ai] += v;
|
// Amount in the format of `{ v: "1.0000001", d: 8 }`, where v is decimal string and `d` is divisibility
|
||||||
|
const val = new Decimal(v.v)
|
||||||
|
const agg = summaryRow[groupIndices.length + ai];
|
||||||
|
const aggVal = typeof(agg) === 'object' && agg.v ? new Decimal(agg.v) : agg;
|
||||||
|
const res = aggVal.plus(val);
|
||||||
|
summaryRow[groupIndices.length + ai] = Object.assign({}, v, {
|
||||||
|
v: res,
|
||||||
|
d: v.d
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const val = new Decimal(v);
|
||||||
|
summaryRow[groupIndices.length + ai] = summaryRow[groupIndices.length + ai].plus(val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (summaryRow) {
|
if (summaryRow) {
|
||||||
|
@ -36,15 +48,12 @@
|
||||||
// Sort tabular data by the column indices
|
// Sort tabular data by the column indices
|
||||||
function byColumns(columnIndices) {
|
function byColumns(columnIndices) {
|
||||||
return (a, b) => {
|
return (a, b) => {
|
||||||
for (var i = 0; i < columnIndices.length; i++) {
|
for (let i = 0; i < columnIndices.length; i++) {
|
||||||
var fieldIndex = columnIndices[i];
|
const fieldIndex = columnIndices[i];
|
||||||
if (!a[fieldIndex]) return 1;
|
if (!a[fieldIndex]) return 1;
|
||||||
if (!b[fieldIndex]) return -1;
|
if (!b[fieldIndex]) return -1;
|
||||||
|
if (a[fieldIndex] < b[fieldIndex]) return -1;
|
||||||
if (a[fieldIndex] < b[fieldIndex])
|
if (a[fieldIndex] > b[fieldIndex]) return 1;
|
||||||
return -1;
|
|
||||||
if (a[fieldIndex] > b[fieldIndex])
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
@ -53,23 +62,22 @@
|
||||||
// Build a representation of the HTML table's data 'rows' from the tree of nodes.
|
// Build a representation of the HTML table's data 'rows' from the tree of nodes.
|
||||||
function buildRows(node, rows) {
|
function buildRows(node, rows) {
|
||||||
if (node.children.length === 0 && node.level !== 0) {
|
if (node.children.length === 0 && node.level !== 0) {
|
||||||
var row =
|
const row =
|
||||||
{
|
{
|
||||||
values: node.values,
|
values: node.values,
|
||||||
groups: [],
|
groups: [],
|
||||||
isTotal: node.isTotal,
|
isTotal: node.isTotal,
|
||||||
rLevel: node.rLevel
|
rLevel: node.rLevel
|
||||||
};
|
};
|
||||||
// Round the nuber to 8 decimal to avoid weird decimal outputs
|
for (let i = 0; i < row.values.length; i++) {
|
||||||
for (var i = 0; i < row.values.length; i++) {
|
if (typeof row.values[i] === 'number') {
|
||||||
if (typeof row.values[i] === 'number')
|
row.values[i] = new Decimal(row.values[i]);
|
||||||
row.values[i] = new Number(row.values[i].toFixed(8));
|
}
|
||||||
}
|
}
|
||||||
if (!node.isTotal)
|
if (!node.isTotal)
|
||||||
row.groups.push({ name: node.groups[node.groups.length - 1], rowCount: node.leafCount })
|
row.groups.push({ name: node.groups[node.groups.length - 1], rowCount: node.leafCount })
|
||||||
var parent = node.parent;
|
let parent = node.parent, n = node;
|
||||||
var n = node;
|
while (parent && parent.level !== 0 && parent.children[0] === n) {
|
||||||
while (parent && parent.level != 0 && parent.children[0] === n) {
|
|
||||||
row.groups.push({ name: parent.groups[parent.groups.length - 1], rowCount: parent.leafCount })
|
row.groups.push({ name: parent.groups[parent.groups.length - 1], rowCount: parent.leafCount })
|
||||||
n = parent;
|
n = parent;
|
||||||
parent = parent.parent;
|
parent = parent.parent;
|
||||||
|
@ -77,7 +85,7 @@
|
||||||
row.groups.reverse();
|
row.groups.reverse();
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
}
|
}
|
||||||
for (var i = 0; i < node.children.length; i++) {
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
buildRows(node.children[i], rows);
|
buildRows(node.children[i], rows);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -90,12 +98,12 @@
|
||||||
node.leafCount++;
|
node.leafCount++;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (var i = 0; i < node.children.length; i++) {
|
for (let i = 0; i < node.children.length; i++) {
|
||||||
visitTree(node.children[i]);
|
visitTree(node.children[i]);
|
||||||
node.leafCount += node.children[i].leafCount;
|
node.leafCount += node.children[i].leafCount;
|
||||||
}
|
}
|
||||||
// Remove total if there is only one child outside of the total
|
// Remove total if there is only one child outside of the total
|
||||||
if (node.children.length == 2 && node.children[0].isTotal) {
|
if (node.children.length === 2 && node.children[0].isTotal) {
|
||||||
node.children.shift();
|
node.children.shift();
|
||||||
node.leafCount--;
|
node.leafCount--;
|
||||||
}
|
}
|
||||||
|
@ -114,12 +122,12 @@
|
||||||
isTotal: true
|
isTotal: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (var i = 0; i < groupLevels[level].length; i++) {
|
for (let i = 0; i < groupLevels[level].length; i++) {
|
||||||
var foundFirst = false;
|
let foundFirst = false;
|
||||||
var groupData = groupLevels[level][i];
|
let groupData = groupLevels[level][i];
|
||||||
var gotoNextRow = false;
|
let gotoNextRow = false;
|
||||||
var stop = false;
|
let stop = false;
|
||||||
for (var gi = 0; gi < parent.groups.length; gi++) {
|
for (let gi = 0; gi < parent.groups.length; gi++) {
|
||||||
if (parent.groups[gi] !== groupData[gi]) {
|
if (parent.groups[gi] !== groupData[gi]) {
|
||||||
if (foundFirst) {
|
if (foundFirst) {
|
||||||
stop = true;
|
stop = true;
|
||||||
|
@ -135,7 +143,7 @@
|
||||||
break;
|
break;
|
||||||
if (gotoNextRow)
|
if (gotoNextRow)
|
||||||
continue;
|
continue;
|
||||||
var node =
|
const node =
|
||||||
{
|
{
|
||||||
parent: parent,
|
parent: parent,
|
||||||
groups: groupData.slice(0, level),
|
groups: groupData.slice(0, level),
|
||||||
|
@ -179,7 +187,6 @@
|
||||||
var groupIndices = summaryDefinition.groups.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
|
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);
|
var aggregatesIndices = summaryDefinition.aggregates.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
|
||||||
aggregatesIndices = aggregatesIndices.filter(g => g !== -1);
|
aggregatesIndices = aggregatesIndices.filter(g => g !== -1);
|
||||||
|
|
||||||
// Filter rows
|
// Filter rows
|
||||||
rows = applyFilters(rows, fields, summaryDefinition.filters);
|
rows = applyFilters(rows, fields, summaryDefinition.filters);
|
||||||
|
|
||||||
|
@ -190,7 +197,6 @@
|
||||||
// [Region, Crypto, PaymentType]
|
// [Region, Crypto, PaymentType]
|
||||||
var groupRows = groupBy(groupIndices, aggregatesIndices, rows);
|
var groupRows = groupBy(groupIndices, aggregatesIndices, rows);
|
||||||
|
|
||||||
|
|
||||||
// There will be several level of aggregation
|
// There will be several level of aggregation
|
||||||
// For example, if you have 3 groups: [Region, Crypto, PaymentType] then you have 4 group data.
|
// For example, if you have 3 groups: [Region, Crypto, PaymentType] then you have 4 group data.
|
||||||
// [Region, Crypto, PaymentType]
|
// [Region, Crypto, PaymentType]
|
||||||
|
@ -238,10 +244,8 @@
|
||||||
// rlevel is the reverse. It starts from the highest level and goes down to 0
|
// rlevel is the reverse. It starts from the highest level and goes down to 0
|
||||||
rLevel: groupLevels.length
|
rLevel: groupLevels.length
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Which levels will have a total row
|
// Which levels will have a total row
|
||||||
var totalLevels = [];
|
let totalLevels = [];
|
||||||
if (summaryDefinition.totals) {
|
if (summaryDefinition.totals) {
|
||||||
totalLevels = summaryDefinition.totals.map(g => summaryDefinition.groups.findIndex((a) => a === g) + 1).filter(a => a !== 0);
|
totalLevels = summaryDefinition.totals.map(g => summaryDefinition.groups.findIndex((a) => a === g) + 1).filter(a => a !== 0);
|
||||||
}
|
}
|
||||||
|
@ -251,10 +255,9 @@
|
||||||
// Add a leafCount property to each node, it is the number of leaf below each nodes.
|
// Add a leafCount property to each node, it is the number of leaf below each nodes.
|
||||||
visitTree(root);
|
visitTree(root);
|
||||||
|
|
||||||
// Create a representation that can easily be binded to VueJS
|
// Create a representation that can easily be bound to VueJS
|
||||||
var rows = [];
|
var rows = [];
|
||||||
buildRows(root, rows);
|
buildRows(root, rows);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groups: summaryDefinition.groups,
|
groups: summaryDefinition.groups,
|
||||||
aggregates: summaryDefinition.aggregates,
|
aggregates: summaryDefinition.aggregates,
|
||||||
|
|
|
@ -129,7 +129,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
updateUIDateRange();
|
updateUIDateRange();
|
||||||
app = new Vue({
|
app = new Vue({
|
||||||
el: '#app',
|
el: '#app',
|
||||||
data() { return { srv } }
|
data() { return { srv } },
|
||||||
|
methods: {
|
||||||
|
titleCase(str) {
|
||||||
|
const result = str.replace(/([A-Z])/g, " $1");
|
||||||
|
return result.charAt(0).toUpperCase() + result.slice(1);
|
||||||
|
},
|
||||||
|
displayValue(val) {
|
||||||
|
return val && typeof (val) === "object" && val.d ? new Decimal(val.v).toFixed(val.d) : val;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
fetchStoreReports();
|
fetchStoreReports();
|
||||||
});
|
});
|
||||||
|
@ -141,11 +150,14 @@ function updateUIDateRange() {
|
||||||
|
|
||||||
// This function modify all the fields of a given type
|
// This function modify all the fields of a given type
|
||||||
function modifyFields(fields, data, type, action) {
|
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);
|
const fieldIndices = fields
|
||||||
|
.map((f, i) => ({ i: i, type: f.type }))
|
||||||
|
.filter(f => f.type === type)
|
||||||
|
.map(f => f.i);
|
||||||
if (fieldIndices.length === 0)
|
if (fieldIndices.length === 0)
|
||||||
return;
|
return;
|
||||||
for (var i = 0; i < data.length; i++) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
for (var f = 0; f < fieldIndices.length; f++) {
|
for (let f = 0; f < fieldIndices.length; f++) {
|
||||||
data[i][fieldIndices[f]] = action(data[i][fieldIndices[f]]);
|
data[i][fieldIndices[f]] = action(data[i][fieldIndices[f]]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
15
BTCPayServer/wwwroot/vendor/decimal.js/decimal.min.js
vendored
Normal file
15
BTCPayServer/wwwroot/vendor/decimal.js/decimal.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue