mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-24 01:14:20 +01:00
Add refund reports (#5791)
* Add refund reports * Fix fake data generator in reports
This commit is contained in:
@@ -3006,7 +3006,7 @@ namespace BTCPayServer.Tests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Selenium", "Selenium")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task CanCreateReports()
|
public async Task CanCreateReports()
|
||||||
{
|
{
|
||||||
using var tester = CreateServerTester(newDb: true);
|
using var tester = CreateServerTester(newDb: true);
|
||||||
@@ -3114,6 +3114,48 @@ namespace BTCPayServer.Tests
|
|||||||
var invoiceIdIndex = report.GetIndex("InvoiceId");
|
var invoiceIdIndex = report.GetIndex("InvoiceId");
|
||||||
var oldPaymentsCount = report.Data.Count(d => d[invoiceIdIndex].Value<string>() == "Q7RqoHLngK9svM4MgRyi9y");
|
var oldPaymentsCount = report.Data.Count(d => d[invoiceIdIndex].Value<string>() == "Q7RqoHLngK9svM4MgRyi9y");
|
||||||
Assert.Equal(8, oldPaymentsCount); // 10 payments, but 2 unaccounted
|
Assert.Equal(8, oldPaymentsCount); // 10 payments, but 2 unaccounted
|
||||||
|
|
||||||
|
var addr = await tester.ExplorerNode.GetNewAddressAsync();
|
||||||
|
// Two invoices get refunded
|
||||||
|
for (int i = 0; i < 2; i++)
|
||||||
|
{
|
||||||
|
var inv = await client.CreateInvoice(acc.StoreId, new CreateInvoiceRequest() { Amount = 10m, Currency = "USD" });
|
||||||
|
await acc.PayInvoice(inv.Id);
|
||||||
|
await client.MarkInvoiceStatus(acc.StoreId, inv.Id, new MarkInvoiceStatusRequest() { Status = InvoiceStatus.Settled });
|
||||||
|
var refund = await client.RefundInvoice(acc.StoreId, inv.Id, new RefundInvoiceRequest() { RefundVariant = RefundVariant.Fiat, PaymentMethod = "BTC-CHAIN" });
|
||||||
|
|
||||||
|
async Task AssertData(string currency, decimal awaiting, decimal limit, decimal completed, bool fullyPaid)
|
||||||
|
{
|
||||||
|
report = await GetReport(acc, new() { ViewName = "Refunds" });
|
||||||
|
var currencyIndex = report.GetIndex("Currency");
|
||||||
|
var awaitingIndex = report.GetIndex("Awaiting");
|
||||||
|
var fullyPaidIndex = report.GetIndex("FullyPaid");
|
||||||
|
var completedIndex = report.GetIndex("Completed");
|
||||||
|
var limitIndex = report.GetIndex("Limit");
|
||||||
|
var d = Assert.Single(report.Data.Where(d => d[report.GetIndex("InvoiceId")].Value<string>() == inv.Id));
|
||||||
|
Assert.Equal(fullyPaid, (bool)d[fullyPaidIndex]);
|
||||||
|
Assert.Equal(currency, d[currencyIndex].Value<string>());
|
||||||
|
Assert.Equal(completed, (((JObject)d[completedIndex])["v"]).Value<decimal>());
|
||||||
|
Assert.Equal(awaiting, (((JObject)d[awaitingIndex])["v"]).Value<decimal>());
|
||||||
|
Assert.Equal(limit, (((JObject)d[limitIndex])["v"]).Value<decimal>());
|
||||||
|
}
|
||||||
|
|
||||||
|
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
|
||||||
|
var payout = await client.CreatePayout(refund.Id, new CreatePayoutRequest() { Destination = addr.ToString(), PaymentMethod = "BTC-CHAIN" });
|
||||||
|
await AssertData("USD", awaiting: 10.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
|
||||||
|
await client.ApprovePayout(acc.StoreId, payout.Id, new ApprovePayoutRequest());
|
||||||
|
await AssertData("USD", awaiting: 10.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
|
||||||
|
if (i == 0)
|
||||||
|
{
|
||||||
|
await client.MarkPayoutPaid(acc.StoreId, payout.Id);
|
||||||
|
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 10.0m, fullyPaid: true);
|
||||||
|
}
|
||||||
|
if (i == 1)
|
||||||
|
{
|
||||||
|
await client.CancelPayout(acc.StoreId, payout.Id);
|
||||||
|
await AssertData("USD", awaiting: 0.0m, limit: 10.0m, completed: 0.0m, fullyPaid: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
|
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
|
||||||
|
|||||||
@@ -64,7 +64,27 @@ public partial class UIReportsController
|
|||||||
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
|
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
|
||||||
return decimal.Round(randomValue, precision);
|
return decimal.Round(randomValue, precision);
|
||||||
}
|
}
|
||||||
|
JObject GetFormattedAmount()
|
||||||
|
{
|
||||||
|
string? curr = null;
|
||||||
|
decimal value = 0m;
|
||||||
|
int offset = 0;
|
||||||
|
while (curr is null)
|
||||||
|
{
|
||||||
|
curr = row[fi - 1 - offset]?.ToString();
|
||||||
|
value = curr switch
|
||||||
|
{
|
||||||
|
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
|
||||||
|
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
|
||||||
|
_ => 0.0m
|
||||||
|
};
|
||||||
|
if (value != 0.0m)
|
||||||
|
break;
|
||||||
|
curr = null;
|
||||||
|
offset++;
|
||||||
|
}
|
||||||
|
return DisplayFormatter.ToFormattedAmount(value, curr);
|
||||||
|
}
|
||||||
var fiatCurrency = rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
|
var fiatCurrency = rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
|
||||||
var cryptoCurrency = rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
|
var cryptoCurrency = rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
|
||||||
|
|
||||||
@@ -116,14 +136,11 @@ public partial class UIReportsController
|
|||||||
return Encoders.Hex.EncodeData(GenerateBytes(32));
|
return Encoders.Hex.EncodeData(GenerateBytes(32));
|
||||||
if (f.Name == "Rate")
|
if (f.Name == "Rate")
|
||||||
{
|
{
|
||||||
var curr = row[fi - 1]?.ToString();
|
return GetFormattedAmount();
|
||||||
var value = curr switch
|
}
|
||||||
|
if (f.Type == "amount")
|
||||||
{
|
{
|
||||||
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
|
return GetFormattedAmount();
|
||||||
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
|
|
||||||
_ => GenerateDecimal(30_000m, 60_000, 2)
|
|
||||||
};
|
|
||||||
return DisplayFormatter.ToFormattedAmount(value, curr);
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,7 @@ namespace BTCPayServer.Hosting
|
|||||||
services.AddReportProvider<ProductsReportProvider>();
|
services.AddReportProvider<ProductsReportProvider>();
|
||||||
services.AddReportProvider<PayoutsReportProvider>();
|
services.AddReportProvider<PayoutsReportProvider>();
|
||||||
services.AddReportProvider<LegacyInvoiceExportReportProvider>();
|
services.AddReportProvider<LegacyInvoiceExportReportProvider>();
|
||||||
|
services.AddReportProvider<RefundsReportProvider>();
|
||||||
services.AddWebhooks();
|
services.AddWebhooks();
|
||||||
|
|
||||||
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
|
services.AddSingleton<Dictionary<PaymentMethodId, IPaymentMethodBitpayAPIExtension>>(o =>
|
||||||
|
|||||||
133
BTCPayServer/Services/Reporting/RefundsReportProvider.cs
Normal file
133
BTCPayServer/Services/Reporting/RefundsReportProvider.cs
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Services.Reporting
|
||||||
|
{
|
||||||
|
public class RefundsReportProvider : ReportProvider
|
||||||
|
{
|
||||||
|
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
|
||||||
|
private readonly DisplayFormatter _displayFormatter;
|
||||||
|
|
||||||
|
private ViewDefinition CreateDefinition()
|
||||||
|
{
|
||||||
|
return new ViewDefinition
|
||||||
|
{
|
||||||
|
Fields = new List<StoreReportResponse.Field>
|
||||||
|
{
|
||||||
|
new("Date", "datetime"),
|
||||||
|
new("InvoiceId", "invoice_id"),
|
||||||
|
new("Currency", "string"),
|
||||||
|
new("Completed", "amount"),
|
||||||
|
new("Awaiting", "amount"),
|
||||||
|
new("Limit", "amount"),
|
||||||
|
new("FullyPaid", "boolean")
|
||||||
|
},
|
||||||
|
Charts =
|
||||||
|
{
|
||||||
|
new ()
|
||||||
|
{
|
||||||
|
Name = "Aggregated amount",
|
||||||
|
Groups = { "Currency" },
|
||||||
|
HasGrandTotal = false,
|
||||||
|
Aggregates = { "Awaiting", "Completed", "Limit" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
public override string Name => "Refunds";
|
||||||
|
|
||||||
|
public ApplicationDbContextFactory DbContextFactory { get; }
|
||||||
|
|
||||||
|
public RefundsReportProvider(
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
BTCPayNetworkJsonSerializerSettings serializerSettings,
|
||||||
|
DisplayFormatter displayFormatter)
|
||||||
|
{
|
||||||
|
DbContextFactory = dbContextFactory;
|
||||||
|
_serializerSettings = serializerSettings;
|
||||||
|
_displayFormatter = displayFormatter;
|
||||||
|
}
|
||||||
|
record RefundRow(DateTimeOffset Created, string InvoiceId, string PullPaymentId, string Currency, decimal Limit)
|
||||||
|
{
|
||||||
|
public decimal Completed { get; set; }
|
||||||
|
public decimal Awaiting { get; set; }
|
||||||
|
}
|
||||||
|
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||||
|
{
|
||||||
|
queryContext.ViewDefinition = CreateDefinition();
|
||||||
|
RefundRow? currentRow = null;
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var conn = ctx.Database.GetDbConnection();
|
||||||
|
var rows = await conn.QueryAsync(
|
||||||
|
"""
|
||||||
|
SELECT i."Created", i."Id" AS "InvoiceId", p."State", p."PaymentMethodId", pp."Id" AS "PullPaymentId", pp."Blob" AS "ppBlob", p."Blob" AS "pBlob" FROM "Invoices" i
|
||||||
|
JOIN "Refunds" r ON r."InvoiceDataId"= i."Id"
|
||||||
|
JOIN "PullPayments" pp ON r."PullPaymentDataId"=pp."Id"
|
||||||
|
LEFT JOIN "Payouts" p ON p."PullPaymentDataId"=pp."Id"
|
||||||
|
WHERE i."StoreDataId" = @storeId
|
||||||
|
AND i."Created" >= @start AND i."Created" <= @end
|
||||||
|
AND pp."Archived" IS FALSE
|
||||||
|
ORDER BY i."Created", pp."Id"
|
||||||
|
""", new { start = queryContext.From, end = queryContext.To, storeId = queryContext.StoreId });
|
||||||
|
foreach (var r in rows)
|
||||||
|
{
|
||||||
|
PullPaymentBlob ppBlob = GetPullPaymentBlob(r);
|
||||||
|
PayoutBlob? pBlob = GetPayoutBlob(r);
|
||||||
|
|
||||||
|
if ((string)r.PullPaymentId != currentRow?.PullPaymentId)
|
||||||
|
{
|
||||||
|
AddRow(queryContext, currentRow);
|
||||||
|
currentRow = new(r.Created, r.InvoiceId, r.PullPaymentId, ppBlob.Currency, ppBlob.Limit);
|
||||||
|
}
|
||||||
|
if (pBlob is null)
|
||||||
|
continue;
|
||||||
|
var state = Enum.Parse<PayoutState>((string)r.State);
|
||||||
|
if (state == PayoutState.Cancelled)
|
||||||
|
continue;
|
||||||
|
if (state is PayoutState.Completed)
|
||||||
|
currentRow.Completed += pBlob.Amount;
|
||||||
|
else
|
||||||
|
currentRow.Awaiting += pBlob.Amount;
|
||||||
|
}
|
||||||
|
AddRow(queryContext, currentRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PayoutBlob? GetPayoutBlob(dynamic r)
|
||||||
|
{
|
||||||
|
if (r.pBlob is null)
|
||||||
|
return null;
|
||||||
|
Data.PayoutData p = new Data.PayoutData();
|
||||||
|
p.PaymentMethodId = r.PaymentMethodId;
|
||||||
|
p.Blob = (string)r.pBlob;
|
||||||
|
return p.GetBlob(_serializerSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PullPaymentBlob GetPullPaymentBlob(dynamic r)
|
||||||
|
{
|
||||||
|
Data.PullPaymentData pp = new Data.PullPaymentData();
|
||||||
|
pp.Blob = (string)r.ppBlob;
|
||||||
|
return pp.GetBlob();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddRow(QueryContext queryContext, RefundRow? currentRow)
|
||||||
|
{
|
||||||
|
if (currentRow is null)
|
||||||
|
return;
|
||||||
|
var data = queryContext.AddData();
|
||||||
|
data.Add(currentRow.Created);
|
||||||
|
data.Add(currentRow.InvoiceId);
|
||||||
|
data.Add(currentRow.Currency);
|
||||||
|
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Completed, currentRow.Currency));
|
||||||
|
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Awaiting, currentRow.Currency));
|
||||||
|
data.Add(_displayFormatter.ToFormattedAmount(currentRow.Limit, currentRow.Currency));
|
||||||
|
data.Add(currentRow.Limit <= currentRow.Completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="d-flex flex-wrap gap-3">
|
<div class="d-flex flex-wrap gap-3">
|
||||||
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake date</a>
|
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake data</a>
|
||||||
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">Export</button>
|
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">Export</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user