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 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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
|
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>
|
/// <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 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>(),
|
||||||
@@ -62,9 +64,26 @@ public class GreenfieldReportsController : Controller
|
|||||||
};
|
};
|
||||||
return Json(result);
|
return Json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,13 +15,13 @@
|
|||||||
{
|
{
|
||||||
private int CountArrayFilter(string type) =>
|
private int CountArrayFilter(string type) =>
|
||||||
Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
|
Model.Search.ContainsFilter(type) ? Model.Search.GetFilterArray(type).Length : 0;
|
||||||
|
|
||||||
private bool HasArrayFilter(string type, string key = null) =>
|
private bool HasArrayFilter(string type, string key = null) =>
|
||||||
Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
|
Model.Search.ContainsFilter(type) && (key is null || Model.Search.GetFilterArray(type).Contains(key));
|
||||||
|
|
||||||
private bool HasBooleanFilter(string key) =>
|
private bool HasBooleanFilter(string key) =>
|
||||||
Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
|
Model.Search.ContainsFilter(key) && Model.Search.GetFilterBool(key) is true;
|
||||||
|
|
||||||
private bool HasCustomDateFilter() =>
|
private bool HasCustomDateFilter() =>
|
||||||
Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
|
Model.Search.ContainsFilter("startdate") && Model.Search.ContainsFilter("enddate");
|
||||||
}
|
}
|
||||||
@@ -112,15 +112,28 @@
|
|||||||
<vc:icon symbol="info" />
|
<vc:icon symbol="info" />
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
<a id="page-primary"
|
<div>
|
||||||
permission="@Policies.CanCreateInvoice"
|
<a
|
||||||
asp-action="CreateInvoice"
|
id="view-report"
|
||||||
asp-route-storeId="@Model.StoreId"
|
permission="@Policies.CanViewReports"
|
||||||
asp-route-searchTerm="@Model.SearchTerm"
|
asp-controller="UIReports"
|
||||||
class="btn btn-primary mt-3 mt-sm-0"
|
asp-action="StoreReports"
|
||||||
text-translate="true">
|
asp-route-storeId="@Model.StoreId"
|
||||||
Create Invoice
|
asp-route-viewName="Invoices"
|
||||||
</a>
|
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"
|
||||||
|
text-translate="true">
|
||||||
|
Create Invoice
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="descriptor" class="collapse">
|
<div id="descriptor" class="collapse">
|
||||||
@@ -237,7 +250,7 @@
|
|||||||
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("appid", app.Id)" class="dropdown-item @(HasArrayFilter("appid", app.Id) ? "custom-active" : "")">@app.AppName</a>
|
<a asp-action="ListInvoices" asp-route-storeId="@Model.StoreId" asp-route-count="@Model.Count" asp-route-searchTerm="@Model.Search.Toggle("appid", app.Id)" class="dropdown-item @(HasArrayFilter("appid", app.Id) ? "custom-active" : "")">@app.AppName</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="dropdown">
|
<div class="dropdown">
|
||||||
<button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<button id="DateOptionsToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user