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