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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long