mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2024-11-19 09:54:30 +01:00
Add refund reports (#5791)
* Add refund reports * Fix fake data generator in reports
This commit is contained in:
parent
96b90d2444
commit
e10937c253
@ -2985,7 +2985,7 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanCreateReports()
|
||||
{
|
||||
using var tester = CreateServerTester(newDb: true);
|
||||
@ -3093,6 +3093,48 @@ namespace BTCPayServer.Tests
|
||||
var invoiceIdIndex = report.GetIndex("InvoiceId");
|
||||
var oldPaymentsCount = report.Data.Count(d => d[invoiceIdIndex].Value<string>() == "Q7RqoHLngK9svM4MgRyi9y");
|
||||
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)
|
||||
|
@ -64,7 +64,27 @@ public partial class UIReportsController
|
||||
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
|
||||
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 cryptoCurrency = rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
|
||||
|
||||
@ -116,14 +136,11 @@ public partial class UIReportsController
|
||||
return Encoders.Hex.EncodeData(GenerateBytes(32));
|
||||
if (f.Name == "Rate")
|
||||
{
|
||||
var curr = row[fi - 1]?.ToString();
|
||||
var value = curr switch
|
||||
return GetFormattedAmount();
|
||||
}
|
||||
if (f.Type == "amount")
|
||||
{
|
||||
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
|
||||
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
|
||||
_ => GenerateDecimal(30_000m, 60_000, 2)
|
||||
};
|
||||
return DisplayFormatter.ToFormattedAmount(value, curr);
|
||||
return GetFormattedAmount();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -385,6 +385,7 @@ namespace BTCPayServer.Hosting
|
||||
services.AddReportProvider<ProductsReportProvider>();
|
||||
services.AddReportProvider<PayoutsReportProvider>();
|
||||
services.AddReportProvider<LegacyInvoiceExportReportProvider>();
|
||||
services.AddReportProvider<RefundsReportProvider>();
|
||||
services.AddWebhooks();
|
||||
services.AddSingleton<BitcoinLikePayoutHandler>();
|
||||
services.AddSingleton<IPayoutHandler>(provider => provider.GetRequiredService<BitcoinLikePayoutHandler>());
|
||||
|
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>
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
Loading…
Reference in New Issue
Block a user