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:
d11n 2023-10-11 13:48:40 +02:00 committed by GitHub
parent 9e76b4d28e
commit 41e3828eea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 293 additions and 234 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View 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);
}
}
}

View file

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

View file

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

View file

@ -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" },

View file

@ -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 =
{ {

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long