mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 14:34:23 +01:00
Export all metadatas of invoices in the Legacy Invoice Export
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
using NBitcoin;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
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)
|
||||
{
|
||||
foreach (var item in items)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -17,6 +19,7 @@ using BTCPayServer.Views.Stores;
|
||||
using LNURL;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Playwright;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
@@ -143,6 +146,192 @@ fruit tea:
|
||||
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)]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanUsePoSApp1()
|
||||
|
||||
@@ -9,12 +9,14 @@ using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Blazor.VaultBridge.Elements;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Lightning.CLightning;
|
||||
using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Server;
|
||||
using BTCPayServer.Views.Stores;
|
||||
using BTCPayServer.Views.Wallets;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Playwright;
|
||||
using NBitcoin;
|
||||
@@ -571,7 +573,10 @@ namespace BTCPayServer.Tests
|
||||
{
|
||||
await MineBlockOnInvoiceCheckout();
|
||||
}
|
||||
if (amount is null)
|
||||
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>
|
||||
|
||||
9
BTCPayServer/ActionDisposable.cs
Normal file
9
BTCPayServer/ActionDisposable.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
|
||||
namespace BTCPayServer;
|
||||
|
||||
public class ActionDisposable(Action disposeAction) : IDisposable
|
||||
{
|
||||
public void Dispose() => disposeAction();
|
||||
}
|
||||
@@ -32,13 +32,14 @@ public class GreenfieldReportsController : Controller
|
||||
public ApplicationDbContextFactory DBContextFactory { get; }
|
||||
public ReportService ReportService { get; }
|
||||
|
||||
public const string DefaultReport = "Invoices";
|
||||
[Authorize(Policy = Policies.CanViewReports, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpPost("~/api/v1/stores/{storeId}/reports")]
|
||||
[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)
|
||||
{
|
||||
vm ??= new StoreReportRequest();
|
||||
vm.ViewName ??= "Payments";
|
||||
vm.ViewName ??= DefaultReport;
|
||||
vm.TimePeriod ??= new TimePeriod();
|
||||
vm.TimePeriod.To ??= DateTime.UtcNow;
|
||||
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);
|
||||
await report.Query(ctx, cancellationToken);
|
||||
ResizeRowsIfNeeded(ctx.ViewDefinition?.Fields.Count ?? 0, ctx.Data);
|
||||
var result = new StoreReportResponse
|
||||
{
|
||||
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");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,9 @@ namespace BTCPayServer.Controllers
|
||||
model.StillDue = details.StillDue;
|
||||
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");
|
||||
}
|
||||
|
||||
|
||||
@@ -476,6 +476,10 @@ namespace BTCPayServer
|
||||
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
|
||||
if (store is null)
|
||||
return NotFound(StringLocalizer["Unknown username"]);
|
||||
|
||||
var address = $"{username}@{Request.Host}";
|
||||
var invoiceMetadata = blob?.InvoiceMetadata ?? new();
|
||||
invoiceMetadata.TryAdd("lightningAddress", address);
|
||||
var result = await GetLNURLRequest(
|
||||
cryptoCode,
|
||||
store,
|
||||
@@ -493,7 +497,7 @@ namespace BTCPayServer
|
||||
},
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "text/identifier", $"{username}@{Request.Host}" }
|
||||
{ "text/identifier", address }
|
||||
});
|
||||
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
|
||||
return result;
|
||||
|
||||
@@ -71,7 +71,7 @@ public partial class UIReportsController : Controller
|
||||
{
|
||||
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")),
|
||||
Request = new StoreReportRequest { ViewName = viewName ?? "Payments" },
|
||||
Request = new StoreReportRequest { ViewName = viewName ?? GreenfieldReportsController.DefaultReport },
|
||||
AvailableViews = ReportService.ReportProviders
|
||||
.Values
|
||||
.Where(r => r.IsAvailable())
|
||||
|
||||
@@ -393,7 +393,7 @@ namespace BTCPayServer.Hosting
|
||||
services.AddReportProvider<OnChainWalletReportProvider>();
|
||||
services.AddReportProvider<ProductsReportProvider>();
|
||||
services.AddReportProvider<PayoutsReportProvider>();
|
||||
services.AddReportProvider<LegacyInvoiceExportReportProvider>();
|
||||
services.AddReportProvider<InvoicesReportProvider>();
|
||||
services.AddReportProvider<RefundsReportProvider>();
|
||||
services.AddWebhooks();
|
||||
|
||||
|
||||
283
BTCPayServer/Services/Reporting/InvoicesReportProvider.cs
Normal file
283
BTCPayServer/Services/Reporting/InvoicesReportProvider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -112,16 +112,29 @@
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</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"
|
||||
permission="@Policies.CanCreateInvoice"
|
||||
asp-action="CreateInvoice"
|
||||
asp-route-storeId="@Model.StoreId"
|
||||
asp-route-searchTerm="@Model.SearchTerm"
|
||||
class="btn btn-primary mt-3 mt-sm-0"
|
||||
class="btn btn-primary"
|
||||
text-translate="true">
|
||||
Create Invoice
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="descriptor" class="collapse">
|
||||
<div class="d-flex px-4 py-4 mb-4 bg-tile rounded">
|
||||
|
||||
@@ -129,6 +129,13 @@
|
||||
<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])" />
|
||||
</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)" >
|
||||
<vc:truncate-center text="value" is-vue="true" padding="15" classes="truncate-center-id" />
|
||||
</template>
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
for (let ai = 0; ai < aggregatesIndices.length; ai++) {
|
||||
const v = data[i][aggregatesIndices[ai]];
|
||||
// TODO: support other aggregate functions
|
||||
if (v === null)
|
||||
continue;
|
||||
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 agg = summaryRow[groupIndices.length + ai];
|
||||
|
||||
Reference in New Issue
Block a user