Export all metadatas of invoices in the Legacy Invoice Export

This commit is contained in:
nicolas.dorier
2025-07-08 16:21:19 +09:00
parent 290c507b43
commit a130630042
15 changed files with 600 additions and 182 deletions

View File

@@ -1,11 +1,22 @@
using System.Collections.Generic; using System.Collections.Generic;
using NBitcoin; using NBitcoin;
using NBXplorer.DerivationStrategy; using NBXplorer.DerivationStrategy;
using Newtonsoft.Json.Linq;
namespace BTCPayServer namespace BTCPayServer
{ {
public static class UtilitiesExtensions public static class UtilitiesExtensions
{ {
public static bool TryAdd(this JObject obj, string key, JToken value)
{
if (obj.TryGetValue(key, out var existing))
{
return false;
}
obj.Add(key, value);
return true;
}
public static void AddRange<T>(this HashSet<T> hashSet, IEnumerable<T> items) public static void AddRange<T>(this HashSet<T> hashSet, IEnumerable<T> items)
{ {
foreach (var item in items) foreach (var item in items)

View File

@@ -0,0 +1,38 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20250709000000_lightningaddressinmetadata")]
public partial class lightningaddressinmetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
"""
UPDATE "Invoices"
SET "Blob2" = jsonb_set(
"Blob2",
'{metadata}',
"Blob2"->'metadata' || jsonb_build_object(
'lightningAddress',
"Blob2"->'prompts'->'BTC-LNURL'->'details'->>'consumedLightningAddress'
)
)
WHERE
"Status" != 'Expired'
AND "Blob2"->'prompts'->'BTC-LNURL'->'details'->'consumedLightningAddress' IS NOT NULL;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -1,4 +1,6 @@
using System; using System;
using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -17,6 +19,7 @@ using BTCPayServer.Views.Stores;
using LNURL; using LNURL;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Playwright; using Microsoft.Playwright;
using NBitcoin;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Xunit; using Xunit;
@@ -143,6 +146,192 @@ fruit tea:
Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true)); Assert.Throws<ArgumentException>(() => AppService.Parse(duplicateId, true, true));
} }
[Fact]
[Trait("Integration", "Integration")]
public async Task CanExportInvoicesWithMetadata()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser();
await s.CreateNewStore();
var client = await s.AsTestAccount().CreateClient();
await s.AddDerivationScheme();
await s.GoToStore(StoreNavPages.General);
await s.Page.SelectOptionAsync("#NetworkFeeMode", "Never");
await s.ClickPagePrimary();
var invoiceRepo = s.Server.PayTester.InvoiceRepository;
// One expired invoice
var expiredInvoiceId = await s.CreateInvoice(amount: 1000m);
await invoiceRepo.UpdateInvoiceExpiry(expiredInvoiceId, TimeSpan.Zero);
TestLogs.LogInformation($"Expired invoice ID: {expiredInvoiceId}");
var newInvoiceId = await s.CreateInvoice(amount: 1000m);
TestLogs.LogInformation($"New invoice ID: {newInvoiceId}");
// One expired invoice with a late payment
var expiredLatePaidInvoiceId = await s.CreateInvoice(amount: 1000m);
await invoiceRepo.UpdateInvoiceExpiry(expiredLatePaidInvoiceId, TimeSpan.Zero);
TestLogs.LogInformation($"Expired late paid invoice ID: {expiredLatePaidInvoiceId}");
var address = (await client.GetInvoicePaymentMethods(s.StoreId, expiredLatePaidInvoiceId))[0].Destination;
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest), Money.Coins(1.0m));
// One 0 amount invoice
var freeInvoiceId = await s.CreateInvoice(amount: 0m);
TestLogs.LogInformation($"Free invoice ID: {freeInvoiceId}");
// One invoice with two payments
var twoPaymentsInvoiceId = await s.CreateInvoice(amount: 5000m);
TestLogs.LogInformation($"Invoice with two payments: {twoPaymentsInvoiceId}");
await s.GoToInvoiceCheckout(twoPaymentsInvoiceId);
await s.PayInvoice(amount: 0.4m, mine: false);
await s.PayInvoice(mine: true);
var expiredLatePaidInvoice = await client.GetInvoice(s.StoreId, expiredLatePaidInvoiceId);
Assert.Equal(InvoiceStatus.Expired, expiredLatePaidInvoice.Status);
Assert.Equal(InvoiceExceptionStatus.PaidLate, expiredLatePaidInvoice.AdditionalStatus);
var expiredInvoice = await client.GetInvoice(s.StoreId, expiredInvoiceId);
Assert.Equal(InvoiceStatus.Expired, expiredInvoice.Status);
Assert.Equal(InvoiceExceptionStatus.None, expiredInvoice.AdditionalStatus);
await s.GoToStore(s.StoreId);
await s.CreateApp("PointOfSale");
await s.Page.ClickAsync("label[for='DefaultView_Cart']");
await s.Page.FillAsync("#DefaultTaxRate", "10");
await s.ClickPagePrimary();
var o = s.Page.Context.WaitForPageAsync();
await s.Page.ClickAsync("#ViewApp");
string posInvoiceId;
await using (_ = await s.SwitchPage(o))
{
await s.Page.ClickAsync("#card_rooibos button");
await s.Page.ClickAsync("#card_rooibos button");
await s.Page.ClickAsync("#card_black-tea button");
await s.Page.ClickAsync("#CartSubmit");
await s.PayInvoice(amount: 0.00012m, mine: false);
await s.PayInvoice(mine: true);
posInvoiceId = s.Page.Url.Split('/').Last();
}
await s.GoToInvoices(s.StoreId);
await s.Page.ClickAsync("#view-report");
await s.Page.WaitForSelectorAsync($"xpath=//*[text()=\"{freeInvoiceId}\"]");
var download = await s.Page.RunAndWaitForDownloadAsync(async () =>
{
await s.ClickPagePrimary();
});
var csvTxt = await new StreamReader(await download.CreateReadStreamAsync()).ReadToEndAsync();
var csvTester = CSVTester.ParseCSV(csvTxt);
csvTester
.ForInvoice(posInvoiceId)
.AssertCount(2)
.AssertValues(
("InvoiceExceptionStatus", ""),
("rooibos-count", "2"),
("black-tea-count", "1"),
("total", "3.74"),
("subTotal", "3.4"),
("taxIncluded", "0.34"),
("InvoiceStatus", "Settled"))
.SelectPayment(1)
.AssertValues(
("rooibos-count", ""),
("taxIncluded", ""),
("InvoiceStatus", ""));
Assert.DoesNotContain(expiredInvoiceId, csvTxt);
Assert.DoesNotContain(newInvoiceId, csvTxt);
csvTester
.ForInvoice(expiredLatePaidInvoiceId)
.AssertCount(1)
.AssertValues(
("InvoiceDue", "-4000.00"),
("InvoiceExceptionStatus", "PaidLate"),
("rooibos-count", ""), ("taxIncluded", ""),
("InvoiceStatus", "Expired"));
csvTester
.ForInvoice(freeInvoiceId)
.AssertCount(1)
.AssertValues(
("InvoiceStatus", "Settled"),
("PaymentAddress", ""),
("PaymentReceivedDate", ""));
csvTester
.ForInvoice(twoPaymentsInvoiceId)
.AssertCount(2)
.AssertValues(
("InvoiceStatus", "Settled"),
("PaymentMethodId", "BTC-CHAIN"),
("PaymentCurrency", "BTC"),
("PaymentAmount", "0.40000000"),
("PaymentInvoiceAmount", "2000.00"),
("Rate", "5000"))
.SelectPayment(1)
.AssertValues(
("InvoiceStatus", ""),
("PaymentCurrency", "BTC"),
("PaymentAmount", "0.60000000"),
("Rate", "5000"));
}
class CSVTester
{
public static CSVTester ParseCSV(string csvText) => new(csvText);
private readonly Dictionary<string, int> _indexes;
private string invoice = "";
private int payment = 0;
private readonly List<string[]> _lines;
public CSVTester(string text)
{
var lines = text.Split("\r\n").ToList();
var headers = lines[0].Split(',');
_indexes = headers.Select((h,i) => (h,i)).ToDictionary(h => h.h, h => h.i);
_lines = lines.Skip(1).ToList().Select(l => l.Split(',')).ToList();
}
public CSVTester ForInvoice(string invoice)
{
this.payment = 0;
this.invoice = invoice;
return this;
}
public CSVTester SelectPayment(int payment)
{
this.payment = payment;
return this;
}
public CSVTester AssertCount(int count)
{
Assert.Equal(count, _lines
.Count(l => l[_indexes["InvoiceId"]] == invoice));
return this;
}
public CSVTester AssertValues(params (string, string)[] values)
{
var payments = _lines
.Where(l => l[_indexes["InvoiceId"]] == invoice)
.ToArray();
var line = payments[payment];
foreach (var (key, value) in values)
{
Assert.Equal(value, line[_indexes[key]]);
}
return this;
}
}
[Fact(Timeout = LongRunningTestTimeout)] [Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task CanUsePoSApp1() public async Task CanUsePoSApp1()

View File

@@ -9,12 +9,14 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Blazor.VaultBridge.Elements; using BTCPayServer.Blazor.VaultBridge.Elements;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning; using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Views.Manage; using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server; using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
using BTCPayServer.Views.Wallets; using BTCPayServer.Views.Wallets;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Playwright; using Microsoft.Playwright;
using NBitcoin; using NBitcoin;
@@ -571,7 +573,10 @@ namespace BTCPayServer.Tests
{ {
await MineBlockOnInvoiceCheckout(); await MineBlockOnInvoiceCheckout();
} }
if (amount is null)
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync(); await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
else
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]").WaitForAsync();
} }
/// <summary> /// <summary>

View File

@@ -0,0 +1,9 @@
#nullable enable
using System;
namespace BTCPayServer;
public class ActionDisposable(Action disposeAction) : IDisposable
{
public void Dispose() => disposeAction();
}

View File

@@ -32,13 +32,14 @@ public class GreenfieldReportsController : Controller
public ApplicationDbContextFactory DBContextFactory { get; } public ApplicationDbContextFactory DBContextFactory { get; }
public ReportService ReportService { get; } public ReportService ReportService { get; }
public const string DefaultReport = "Invoices";
[Authorize(Policy = Policies.CanViewReports, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [Authorize(Policy = Policies.CanViewReports, 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
public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default) public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default)
{ {
vm ??= new StoreReportRequest(); vm ??= new StoreReportRequest();
vm.ViewName ??= "Payments"; vm.ViewName ??= DefaultReport;
vm.TimePeriod ??= new TimePeriod(); vm.TimePeriod ??= new TimePeriod();
vm.TimePeriod.To ??= DateTime.UtcNow; vm.TimePeriod.To ??= DateTime.UtcNow;
vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1); vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1);
@@ -52,6 +53,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);
ResizeRowsIfNeeded(ctx.ViewDefinition?.Fields.Count ?? 0, ctx.Data);
var result = new StoreReportResponse var result = new StoreReportResponse
{ {
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(), Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
@@ -66,5 +68,22 @@ public class GreenfieldReportsController : Controller
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);
} }
// Make sure all the rows are dimensioned to the number of fields
private void ResizeRowsIfNeeded(int fieldsCount, IList<IList<object?>> ctxData)
{
foreach (var row in ctxData)
{
var dummyCount = fieldsCount - row.Count;
if (dummyCount > 0)
{
for (var i = 0; i < dummyCount; i++)
row.Add(null);
}
if (dummyCount < 0)
for (var i = 0; i < -dummyCount; i++)
row.RemoveAt(row.Count - 1);
}
}
} }

View File

@@ -165,9 +165,9 @@ namespace BTCPayServer.Controllers
model.StillDue = details.StillDue; model.StillDue = details.StillDue;
model.HasRates = details.HasRates; model.HasRates = details.HasRates;
if (additionalData.TryGetValue("receiptData", out object? receiptData)) if (additionalData.TryGetValue("receiptData", out var receiptData) && receiptData is Dictionary<string, object> data)
{ {
model.ReceiptData = (Dictionary<string, object>)receiptData; model.ReceiptData = data;
additionalData.Remove("receiptData"); additionalData.Remove("receiptData");
} }

View File

@@ -476,6 +476,10 @@ namespace BTCPayServer
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId); var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
if (store is null) if (store is null)
return NotFound(StringLocalizer["Unknown username"]); return NotFound(StringLocalizer["Unknown username"]);
var address = $"{username}@{Request.Host}";
var invoiceMetadata = blob?.InvoiceMetadata ?? new();
invoiceMetadata.TryAdd("lightningAddress", address);
var result = await GetLNURLRequest( var result = await GetLNURLRequest(
cryptoCode, cryptoCode,
store, store,
@@ -493,7 +497,7 @@ namespace BTCPayServer
}, },
new Dictionary<string, string> new Dictionary<string, string>
{ {
{ "text/identifier", $"{username}@{Request.Host}" } { "text/identifier", address }
}); });
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest) if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
return result; return result;

View File

@@ -71,7 +71,7 @@ public partial class UIReportsController : Controller
{ {
InvoiceTemplateUrl = Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }), InvoiceTemplateUrl = Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
ExplorerTemplateUrls = TransactionLinkProviders.ToDictionary(p => p.Key, p => p.Value.BlockExplorerLink?.Replace("{0}", "TX_ID")), ExplorerTemplateUrls = TransactionLinkProviders.ToDictionary(p => p.Key, p => p.Value.BlockExplorerLink?.Replace("{0}", "TX_ID")),
Request = new StoreReportRequest { ViewName = viewName ?? "Payments" }, Request = new StoreReportRequest { ViewName = viewName ?? GreenfieldReportsController.DefaultReport },
AvailableViews = ReportService.ReportProviders AvailableViews = ReportService.ReportProviders
.Values .Values
.Where(r => r.IsAvailable()) .Where(r => r.IsAvailable())

View File

@@ -393,7 +393,7 @@ namespace BTCPayServer.Hosting
services.AddReportProvider<OnChainWalletReportProvider>(); services.AddReportProvider<OnChainWalletReportProvider>();
services.AddReportProvider<ProductsReportProvider>(); services.AddReportProvider<ProductsReportProvider>();
services.AddReportProvider<PayoutsReportProvider>(); services.AddReportProvider<PayoutsReportProvider>();
services.AddReportProvider<LegacyInvoiceExportReportProvider>(); services.AddReportProvider<InvoicesReportProvider>();
services.AddReportProvider<RefundsReportProvider>(); services.AddReportProvider<RefundsReportProvider>();
services.AddWebhooks(); services.AddWebhooks();

View File

@@ -0,0 +1,283 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Reporting;
public class InvoicesReportProvider : ReportProvider
{
public DisplayFormatter DisplayFormatter { get; }
class MetadataFields
{
private readonly Dictionary<string, StoreReportResponse.Field> _dict = new();
private readonly HashSet<string?> _baseFields = new();
public MetadataFields(IList<StoreReportResponse.Field> viewDefinitionFields)
{
foreach (var field in viewDefinitionFields)
_baseFields.Add(field.Name);
}
public bool HasField(string fieldName) => _dict.ContainsKey(fieldName);
public List<StoreReportResponse.Field> Fields { get; } = new();
public Dictionary<string, object?> Values { get; } = new();
public void TryAdd(string fieldName, object? value)
{
var type = GeColumnType(value);
if (type is null || _baseFields.Contains(fieldName))
return;
var field = new StoreReportResponse.Field(fieldName, type);
if (_dict.TryAdd(fieldName, field))
{
Fields.Add(field);
}
else if (_dict[fieldName].Type != type)
return;
Values.TryAdd(fieldName, value);
}
private string? GeColumnType(object? value)
=> value switch
{
null => "text",
bool _ => "boolean",
string _ => "text",
decimal or double or float or long or int _ => "number",
DateTimeOffset _ => "datetime",
// FormattedAmount
JObject => "amount",
_ => null
};
public void WriteValues(IList<object?> data)
{
foreach (var field in Fields)
{
data.Add(Values.TryGetValue(field.Name, out var value) ? value : null);
}
}
public HashSet<string> CartItems = new();
public void HasCartItem(string itemId)
{
CartItems.Add(itemId);
}
}
private readonly InvoiceRepository _invoiceRepository;
public override string Name { get; } = "Invoices";
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
{
EndDate = queryContext.To,
StartDate = queryContext.From,
StoreId = new[] { queryContext.StoreId },
}, cancellation);
queryContext.ViewDefinition = new ViewDefinition()
{
Fields = new List<StoreReportResponse.Field>()
{
new("InvoiceCreatedDate", "datetime"),
new("InvoiceId", "invoice_id"),
new("InvoiceCurrency", "text"),
new("InvoiceDue", "amount"),
new("InvoicePrice", "amount"),
new("InvoiceFullStatus", "text"),
new("InvoiceStatus", "text"),
new("InvoiceExceptionStatus", "text"),
new("PaymentReceivedDate", "datetime"),
new("PaymentId", "text"),
new("Rate", "amount"),
new("PaymentAddress", "text"),
new("PaymentMethodId", "text"),
new("PaymentCurrency", "text"),
new("PaymentAmount", "amount"),
new("PaymentMethodFee", "amount"),
new("PaymentInvoiceAmount", "amount"),
}
};
var metadataFields = new MetadataFields(queryContext.ViewDefinition.Fields);
foreach (var invoiceEntity in invoices)
{
var payments = invoiceEntity.GetPayments(true);
metadataFields.Values.Clear();
var firstPayment = payments.FirstOrDefault();
if (firstPayment is not null)
{
FlattenFields(invoiceEntity.Metadata.ToJObject(), metadataFields, new(invoiceEntity, DisplayFormatter));
Write(queryContext, invoiceEntity, firstPayment, true, metadataFields);
foreach (var payment in payments.Skip(1))
{
Write(queryContext, invoiceEntity, payment, false, metadataFields);
}
}
else if (invoiceEntity is
not { Status: InvoiceStatus.Expired, ExceptionStatus: InvoiceExceptionStatus.None } and
not { Status: InvoiceStatus.New, ExceptionStatus: InvoiceExceptionStatus.None })
{
FlattenFields(invoiceEntity.Metadata.ToJObject(), metadataFields, new(invoiceEntity, DisplayFormatter));
Write(queryContext, invoiceEntity, firstPayment, true, metadataFields);
}
}
foreach (var mf in metadataFields.Fields)
{
queryContext.ViewDefinition.Fields.Add(mf);
}
}
private void Write(
QueryContext queryContext,
InvoiceEntity parentEntity,
PaymentEntity? payment, bool isFirst,
MetadataFields metadataFields)
{
var data = queryContext.AddData();
data.Add(parentEntity.InvoiceTime);
data.Add(parentEntity.Id);
var invoiceEntity = parentEntity;
if (!isFirst)
invoiceEntity = null;
data.Add(invoiceEntity?.Currency);
data.Add(invoiceEntity is null ? null : DisplayFormatter.ToFormattedAmount(invoiceEntity.NetDue, parentEntity.Currency));
data.Add(invoiceEntity is null ? null : DisplayFormatter.ToFormattedAmount(invoiceEntity.Price, parentEntity.Currency));
data.Add(invoiceEntity?.GetInvoiceState().ToString());
data.Add(invoiceEntity?.Status.ToString());
data.Add(invoiceEntity?.ExceptionStatus is null or InvoiceExceptionStatus.None ? "" : invoiceEntity.ExceptionStatus.ToString());
data.Add(payment?.ReceivedTime);
data.Add(payment?.Id);
data.Add(payment?.Rate);
data.Add(payment?.Destination);
data.Add(payment?.PaymentMethodId.ToString());
data.Add(payment?.Currency);
data.Add(payment is null ? null : new FormattedAmount(payment.PaidAmount.Gross, payment.Divisibility).ToJObject());
data.Add(payment is null ? null : new FormattedAmount(payment.PaymentMethodFee, payment.Divisibility).ToJObject());
data.Add(payment is null
? null
: DisplayFormatter.ToFormattedAmount(payment.InvoicePaidAmount.Gross, parentEntity.Currency));
metadataFields.WriteValues(data);
metadataFields.Values.Clear(); // We don't want to duplicate the data on all payments
}
class Context
{
public Context(InvoiceEntity invoiceEntity, DisplayFormatter displayFormatter)
{
Invoice = invoiceEntity;
DisplayFormatter = displayFormatter;
}
public InvoiceEntity Invoice { get; }
public DisplayFormatter DisplayFormatter { get; }
public string? ItemName;
public List<String> Path = new();
public IDisposable Enter(string name)
{
var prev = ItemName;
ItemName = name;
Path.Add(name);
return new ActionDisposable(() =>
{
ItemName = prev;
Path.RemoveAt(Path.Count - 1);
});
}
}
private void FlattenFields(JToken obj, MetadataFields result, Context context)
{
if (context.Path
// When we have this field to non-zero, then the invoice has a taxIncluded metadata
is ["posData", "tax"]
// Verbose data
or ["itemDesc"])
return;
switch (obj)
{
case JObject o:
foreach (var prop in o.Properties())
{
if (context.ItemName is null)
{
// Those fields are already exported by default, or doesn't contain useful data.
if (prop.Name is "receiptData")
continue;
}
using var _ = context.Enter(prop.Name);
FlattenFields(prop.Value, result, context);
}
break;
case JArray a:
var isCart = context.Path is ["posData", "cart"];
foreach (var item in a)
{
if (isCart &&
item is JObject cartItem &&
cartItem.SelectToken("id")?.ToString() is string itemId)
{
using var _ = context.Enter(itemId);
result.HasCartItem(itemId);
FlattenFields(item, result, context);
}
else
{
FlattenFields(item, result, context);
}
}
break;
case JValue { Value: { } v } jv when context.ItemName is not null:
var fieldName = context.ItemName;
if (context.Path is ["posData", "cart", { } itemId2, ..])
{
if (fieldName is "id" or "image" or "title" or "inventory")
break;
try
{
if (fieldName == "price")
v = context.DisplayFormatter.ToFormattedAmount(Convert.ToDecimal(v, CultureInfo.InvariantCulture), context.Invoice.Currency);
}
catch (InvalidCastException) { }
fieldName = $"{itemId2}-{fieldName}";
}
result.TryAdd(fieldName, v);
break;
}
}
public InvoicesReportProvider(DisplayFormatter displayFormatter, InvoiceRepository invoiceRepository)
{
DisplayFormatter = displayFormatter;
_invoiceRepository = invoiceRepository;
}
}

View File

@@ -1,162 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
namespace BTCPayServer.Services.Reporting;
public class LegacyInvoiceExportReportProvider : ReportProvider
{
private readonly CurrencyNameTable _currencyNameTable;
private readonly InvoiceRepository _invoiceRepository;
public override string Name { get; } = "Legacy Invoice Export";
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
{
var invoices = await _invoiceRepository.GetInvoices(new InvoiceQuery()
{
EndDate = queryContext.To,
StartDate = queryContext.From,
StoreId = new[] {queryContext.StoreId},
}, cancellation);
queryContext.ViewDefinition = new ViewDefinition()
{
Fields = new List<StoreReportResponse.Field>()
{
new("ReceivedDate", "datetime"),
new("StoreId", "text"),
new("OrderId", "text"),
new("InvoiceId", "invoice_id"),
new("InvoiceCreatedDate", "datetime"),
new("InvoiceExpirationDate", "datetime"),
new("InvoiceMonitoringDate", "datetime"),
new("PaymentId", "text"),
new("Destination", "text"),
new("PaymentType", "text"),
new("CryptoCode", "text"),
new("Paid", "text"),
new("NetworkFee", "text"),
new("ConversionRate", "number"),
new("PaidCurrency", "text"),
new("InvoiceCurrency", "text"),
new("InvoiceDue", "number"),
new("InvoicePrice", "number"),
new("InvoiceTaxIncluded", "number"),
new("InvoiceTip", "number"),
new("InvoiceSubtotal", "number"),
new("InvoiceItemCode", "text"),
new("InvoiceItemDesc", "text"),
new("InvoiceFullStatus", "text"),
new("InvoiceStatus", "text"),
new("InvoiceExceptionStatus", "text"),
new("BuyerEmail", "text"),
new("Accounted", "boolean")
}
};
foreach (var invoiceEntity in invoices)
{
var currency = _currencyNameTable.GetNumberFormatInfo(invoiceEntity.Currency, true);
var invoiceDue = invoiceEntity.Price;
var payments = invoiceEntity.GetPayments(false);
if (payments.Count > 0)
{
foreach (var payment in payments)
{
invoiceDue -= payment.InvoicePaidAmount.Net;
var data = queryContext.AddData();
// Add each field in the order defined in ViewDefinition
data.Add(payment.ReceivedTime);
data.Add(invoiceEntity.StoreId);
data.Add(invoiceEntity.Metadata.OrderId ?? string.Empty);
data.Add(invoiceEntity.Id);
data.Add(invoiceEntity.InvoiceTime);
data.Add(invoiceEntity.ExpirationTime);
data.Add(invoiceEntity.MonitoringExpiration);
data.Add(payment.Id);
data.Add(payment.Destination);
data.Add(payment.PaymentMethodId.ToString());
data.Add(payment.Currency);
data.Add(payment.PaidAmount.Gross.ToString(CultureInfo.InvariantCulture));
data.Add(payment.PaymentMethodFee.ToString(CultureInfo.InvariantCulture));
data.Add(payment.Rate);
data.Add(Math.Round(payment.InvoicePaidAmount.Gross, currency.NumberDecimalDigits)
.ToString(CultureInfo.InvariantCulture));
data.Add(invoiceEntity.Currency);
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits));
data.Add(invoiceEntity.Price);
data.Add(invoiceEntity.Metadata.TaxIncluded ?? 0.0m);
if (invoiceEntity.Metadata.PosData != null &&
PosAppData.TryParse(invoiceEntity.Metadata.PosData) is { } posData)
{
data.Add(posData.Tip);
data.Add(posData.Subtotal);
}
else
{
data.Add(0m);
data.Add(0m);
}
data.Add(invoiceEntity.Metadata.ItemCode);
data.Add(invoiceEntity.Metadata.ItemDesc);
data.Add(invoiceEntity.GetInvoiceState().ToString());
#pragma warning disable CS0618 // Type or member is obsolete
data.Add(invoiceEntity.Status.ToLegacyStatusString());
data.Add(invoiceEntity.ExceptionStatus.ToLegacyExceptionStatusString());
#pragma warning restore CS0618 // Type or member is obsolete
data.Add(invoiceEntity.Metadata.BuyerEmail);
data.Add(payment.Accounted);
}
}
else
{
var data = queryContext.AddData();
// Add fields for invoices without payments
data.Add(null); // ReceivedDate
data.Add(invoiceEntity.StoreId);
data.Add(invoiceEntity.Metadata.OrderId ?? string.Empty);
data.Add(invoiceEntity.Id);
data.Add(invoiceEntity.InvoiceTime);
data.Add(invoiceEntity.ExpirationTime);
data.Add(invoiceEntity.MonitoringExpiration);
data.Add(null); // PaymentId
data.Add(null); // Destination
data.Add(null); // PaymentType
data.Add(null); // CryptoCode
data.Add(null); // Paid
data.Add(null); // NetworkFee
data.Add(null); // ConversionRate
data.Add(null); // PaidCurrency
data.Add(invoiceEntity.Currency);
data.Add(Math.Round(invoiceDue, currency.NumberDecimalDigits)); // InvoiceDue
data.Add(invoiceEntity.Price);
data.Add(invoiceEntity.Metadata.TaxIncluded ?? 0.0m);
data.Add(0m); // Tip
data.Add(0m); // Subtotal
data.Add(invoiceEntity.Metadata.ItemCode);
data.Add(invoiceEntity.Metadata.ItemDesc);
data.Add(invoiceEntity.GetInvoiceState().ToString());
data.Add(invoiceEntity.Status.ToLegacyStatusString());
data.Add(invoiceEntity.ExceptionStatus.ToLegacyExceptionStatusString());
data.Add(invoiceEntity.Metadata.BuyerEmail);
data.Add(null); // Accounted
}
}
}
public LegacyInvoiceExportReportProvider(CurrencyNameTable currencyNameTable, InvoiceRepository invoiceRepository)
{
_currencyNameTable = currencyNameTable;
_invoiceRepository = invoiceRepository;
}
}

View File

@@ -112,16 +112,29 @@
<vc:icon symbol="info" /> <vc:icon symbol="info" />
</a> </a>
</h2> </h2>
<div>
<a
id="view-report"
permission="@Policies.CanViewReports"
asp-controller="UIReports"
asp-action="StoreReports"
asp-route-storeId="@Model.StoreId"
asp-route-viewName="Invoices"
class="btn btn-secondary">
<vc:icon symbol="nav-reporting" />
<span text-translate="true">Reporting</span>
</a>
<a id="page-primary" <a id="page-primary"
permission="@Policies.CanCreateInvoice" permission="@Policies.CanCreateInvoice"
asp-action="CreateInvoice" asp-action="CreateInvoice"
asp-route-storeId="@Model.StoreId" asp-route-storeId="@Model.StoreId"
asp-route-searchTerm="@Model.SearchTerm" asp-route-searchTerm="@Model.SearchTerm"
class="btn btn-primary mt-3 mt-sm-0" class="btn btn-primary"
text-translate="true"> text-translate="true">
Create Invoice Create Invoice
</a> </a>
</div> </div>
</div>
<div id="descriptor" class="collapse"> <div id="descriptor" class="collapse">
<div class="d-flex px-4 py-4 mb-4 bg-tile rounded"> <div class="d-flex px-4 py-4 mb-4 bg-tile rounded">

View File

@@ -129,6 +129,13 @@
<template v-else-if="srv.result.fields[columnIndex].type === 'tx_id'"> <template v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" link="getExplorerUrl(value, row[columnIndex-1])" /> <vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" link="getExplorerUrl(value, row[columnIndex-1])" />
</template> </template>
<template
v-else-if="value && srv.result.fields[columnIndex].name.toLowerCase().endsWith('url')">
<template v-if="value.startsWith('http')">
<a :href="value" target="_blank">Link</a>
</template>
<template v-else>{{ displayValue(value) }}</template>
</template>
<template v-else-if="value && ['Address'].includes(srv.result.fields[columnIndex].name)" > <template v-else-if="value && ['Address'].includes(srv.result.fields[columnIndex].name)" >
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" /> <vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" />
</template> </template>

View File

@@ -23,6 +23,8 @@
for (let ai = 0; ai < aggregatesIndices.length; ai++) { for (let ai = 0; ai < aggregatesIndices.length; ai++) {
const v = data[i][aggregatesIndices[ai]]; const v = data[i][aggregatesIndices[ai]];
// TODO: support other aggregate functions // TODO: support other aggregate functions
if (v === null)
continue;
if (typeof (v) === 'object' && v.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 // Amount in the format of `{ v: "1.0000001", d: 8 }`, where v is decimal string and `d` is divisibility
const agg = summaryRow[groupIndices.length + ai]; const agg = summaryRow[groupIndices.length + ai];