Invoice: Improve payment details (#5362)

* Invoice: Improve payment details

Clearer description and display, especially for overpayments. Closes #5207.

* Further refinements

* Test fix
This commit is contained in:
d11n 2023-10-10 05:28:00 +02:00 committed by GitHub
parent f20e6d3768
commit 229a4ea56c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 185 additions and 116 deletions

View file

@ -298,7 +298,7 @@ retry:
var fetcher = new RateFetcher(factory); var fetcher = new RateFetcher(factory);
var provider = new BTCPayNetworkProvider(ChainName.Mainnet); var provider = new BTCPayNetworkProvider(ChainName.Mainnet);
var b = new StoreBlob(); var b = new StoreBlob();
string[] temporarilyBroken = { "UGX" }; string[] temporarilyBroken = { "COP", "UGX" };
foreach (var k in StoreBlob.RecommendedExchanges) foreach (var k in StoreBlob.RecommendedExchanges)
{ {
b.DefaultCurrency = k.Key; b.DefaultCurrency = k.Key;
@ -307,14 +307,20 @@ retry:
var result = fetcher.FetchRates(pairs, rules, default); var result = fetcher.FetchRates(pairs, rules, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result) foreach ((CurrencyPair key, Task<RateResult> value) in result)
{ {
TestLogs.LogInformation($"Testing {key} when default currency is {k.Key}");
var rateResult = await value;
var hasRate = rateResult.BidAsk != null;
if (temporarilyBroken.Contains(k.Key)) if (temporarilyBroken.Contains(k.Key))
{
if (!hasRate)
{ {
TestLogs.LogInformation($"Skipping {key} because it is marked as temporarily broken"); TestLogs.LogInformation($"Skipping {key} because it is marked as temporarily broken");
continue; continue;
} }
var rateResult = await value; TestLogs.LogInformation($"Note: {key} is marked as temporarily broken, but the rate is available");
TestLogs.LogInformation($"Testing {key} when default currency is {k.Key}"); }
Assert.True(rateResult.BidAsk != null, $"Impossible to get the rate {rateResult.EvaluatedRule}"); Assert.True(hasRate, $"Impossible to get the rate {rateResult.EvaluatedRule}");
} }
} }
} }

View file

@ -165,6 +165,8 @@ namespace BTCPayServer.Controllers
model.CryptoPayments = details.CryptoPayments; model.CryptoPayments = details.CryptoPayments;
model.Payments = details.Payments; model.Payments = details.Payments;
model.Overpaid = details.Overpaid; model.Overpaid = details.Overpaid;
model.StillDue = details.StillDue;
model.HasRates = details.HasRates;
if (additionalData.ContainsKey("receiptData")) if (additionalData.ContainsKey("receiptData"))
{ {
@ -565,37 +567,42 @@ namespace BTCPayServer.Controllers
private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice) private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice)
{ {
var overpaid = false; var overpaid = false;
var stillDue = false;
var hasRates = false;
var model = new InvoiceDetailsModel var model = new InvoiceDetailsModel
{ {
Archived = invoice.Archived, Archived = invoice.Archived,
Payments = invoice.GetPayments(false), Payments = invoice.GetPayments(false),
Overpaid = true,
CryptoPayments = invoice.GetPaymentMethods().Select( CryptoPayments = invoice.GetPaymentMethods().Select(
data => data =>
{ {
var accounting = data.Calculate(); var accounting = data.Calculate();
var paymentMethodId = data.GetId(); var paymentMethodId = data.GetId();
var hasPayment = accounting.CryptoPaid > 0;
var overpaidAmount = accounting.OverpaidHelper; var overpaidAmount = accounting.OverpaidHelper;
var rate = ExchangeRate(data.GetId().CryptoCode, data);
if (overpaidAmount > 0) if (rate is not null) hasRates = true;
{ if (hasPayment && overpaidAmount > 0) overpaid = true;
overpaid = true; if (hasPayment && accounting.Due > 0) stillDue = true;
}
return new InvoiceDetailsModel.CryptoPayment return new InvoiceDetailsModel.CryptoPayment
{ {
Rate = rate,
PaymentMethodRaw = data,
PaymentMethodId = paymentMethodId, PaymentMethodId = paymentMethodId,
PaymentMethod = paymentMethodId.ToPrettyString(), PaymentMethod = paymentMethodId.ToPrettyString(),
Due = _displayFormatter.Currency(accounting.Due, paymentMethodId.CryptoCode), TotalDue = _displayFormatter.Currency(accounting.TotalDue, paymentMethodId.CryptoCode),
Paid = _displayFormatter.Currency(accounting.CryptoPaid, paymentMethodId.CryptoCode), Due = hasPayment ? _displayFormatter.Currency(accounting.Due, paymentMethodId.CryptoCode) : null,
Overpaid = _displayFormatter.Currency(overpaidAmount, paymentMethodId.CryptoCode), Paid = hasPayment ? _displayFormatter.Currency(accounting.CryptoPaid, paymentMethodId.CryptoCode) : null,
Address = data.GetPaymentMethodDetails().GetPaymentDestination(), Overpaid = hasPayment ? _displayFormatter.Currency(overpaidAmount, paymentMethodId.CryptoCode) : null,
Rate = ExchangeRate(data.GetId().CryptoCode, data), Address = data.GetPaymentMethodDetails().GetPaymentDestination()
PaymentMethodRaw = data
}; };
}).ToList() }).ToList(),
Overpaid = overpaid,
StillDue = stillDue,
HasRates = hasRates
}; };
model.Overpaid = overpaid;
return model; return model;
} }

View file

@ -41,6 +41,7 @@ namespace BTCPayServer.Models.InvoicingModels
public class CryptoPayment public class CryptoPayment
{ {
public string PaymentMethod { get; set; } public string PaymentMethod { get; set; }
public string TotalDue { get; set; }
public string Due { get; set; } public string Due { get; set; }
public string Paid { get; set; } public string Paid { get; set; }
public string Address { get; internal set; } public string Address { get; internal set; }
@ -138,6 +139,8 @@ namespace BTCPayServer.Models.InvoicingModels
public bool CanMarkStatus => CanMarkSettled || CanMarkInvalid; public bool CanMarkStatus => CanMarkSettled || CanMarkInvalid;
public List<RefundData> Refunds { get; set; } public List<RefundData> Refunds { get; set; }
public bool ShowReceipt { get; set; } public bool ShowReceipt { get; set; }
public bool Overpaid { get; set; } = false; public bool Overpaid { get; set; }
public bool StillDue { get; set; }
public bool HasRates { get; set; }
} }
} }

View file

@ -59,7 +59,7 @@
<h5>On-Chain Payments</h5> <h5>On-Chain Payments</h5>
<div class="invoice-payments table-responsive mt-0"> <div class="invoice-payments table-responsive mt-0">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th class="w-75px">Crypto</th> <th class="w-75px">Crypto</th>
<th class="w-100px">Index</th> <th class="w-100px">Index</th>
@ -75,7 +75,7 @@
</th> </th>
} }
<th class="text-end">Confirmations</th> <th class="text-end">Confirmations</th>
<th class="w-150px text-end">Amount</th> <th class="w-150px text-end">Paid</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -96,7 +96,7 @@
} }
<td class="text-end">@payment.Confirmations</td> <td class="text-end">@payment.Confirmations</td>
<td class="payment-value text-end text-nowrap"> <td class="payment-value text-end text-nowrap">
<span data-sensitive>@DisplayFormatter.Currency(payment.CryptoPaymentData.GetValue(), payment.Crypto)</span> <span data-sensitive class="text-success">@DisplayFormatter.Currency(payment.CryptoPaymentData.GetValue(), payment.Crypto)</span>
@if (!string.IsNullOrEmpty(payment.AdditionalInformation)) @if (!string.IsNullOrEmpty(payment.AdditionalInformation))
{ {
<div>(@payment.AdditionalInformation)</div> <div>(@payment.AdditionalInformation)</div>

View file

@ -30,13 +30,13 @@
<h5>Off-Chain Payments</h5> <h5>Off-Chain Payments</h5>
<div class="invoice-payments table-responsive mt-0"> <div class="invoice-payments table-responsive mt-0">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th class="w-75px">Crypto</th> <th class="w-75px">Crypto</th>
<th class="w-100px">Type</th> <th class="w-100px">Type</th>
<th class="w-175px">Destination</th> <th class="w-175px">Destination</th>
<th class="text-nowrap">Payment Proof</th> <th class="text-nowrap">Payment Proof</th>
<th class="w-150px text-end">Amount</th> <th class="w-150px text-end">Paid</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -52,7 +52,7 @@
<vc:truncate-center text="@payment.PaymentProof" classes="truncate-center-id" /> <vc:truncate-center text="@payment.PaymentProof" classes="truncate-center-id" />
</td> </td>
<td class="payment-value text-end text-nowrap"> <td class="payment-value text-end text-nowrap">
<span data-sensitive>@payment.Amount</span> <span data-sensitive class="text-success">@payment.Amount</span>
</td> </td>
</tr> </tr>
} }

View file

@ -1,10 +1,12 @@
@using System.Globalization @using System.Globalization
@using BTCPayServer.Services
@using BTCPayServer.Services.Altcoins.Monero.Payments @using BTCPayServer.Services.Altcoins.Monero.Payments
@using BTCPayServer.Services.Altcoins.Monero.UI @using BTCPayServer.Services.Altcoins.Monero.UI
@inject DisplayFormatter DisplayFormatter
@model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity> @model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity>
@{ @{
var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == MoneroPaymentType.Instance).Select(payment => var payments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == MoneroPaymentType.Instance).Select(payment =>
{ {
var m = new MoneroPaymentViewModel(); var m = new MoneroPaymentViewModel();
var onChainPaymentData = payment.GetCryptoPaymentData() as MoneroLikePaymentData; var onChainPaymentData = payment.GetCryptoPaymentData() as MoneroLikePaymentData;
@ -26,37 +28,37 @@
m.ReceivedTime = payment.ReceivedTime; m.ReceivedTime = payment.ReceivedTime;
m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId); m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId);
return m; return m;
}); }).ToList();
} }
@if (onchainPayments.Any()) @if (payments.Any())
{ {
<section>
<h5>Monero Payments</h5> <h5>Monero Payments</h5>
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th>Crypto</th> <th class="w-75px">Crypto</th>
<th>Deposit address</th> <th class="w-175px">Destination</th>
<th>Amount</th> <th class="text-nowrap">Payment Proof</th>
<th>Transaction Id</th> <th class="text-end">Confirmations</th>
<th class="text-right">Confirmations</th> <th class="w-150px text-end">Paid</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var payment in onchainPayments) @foreach (var payment in payments)
{ {
<tr > <tr >
<td>@payment.Crypto</td> <td>@payment.Crypto</td>
<td>@payment.DepositAddress</td> <td><vc:truncate-center text="@payment.DepositAddress" classes="truncate-center-id" /></td>
<td>@payment.Amount</td> <td><vc:truncate-center text="@payment.TransactionId" link="@payment.TransactionLink" classes="truncate-center-id" /></td>
<td> <td class="text-end">@payment.Confirmations</td>
<a href="@payment.TransactionLink" class="text-break" target="_blank" rel="noreferrer noopener"> <td class="payment-value text-end text-nowrap">
@payment.TransactionId <span data-sensitive class="text-success">@DisplayFormatter.Currency(payment.Amount, payment.Crypto)</span>
</a>
</td> </td>
<td class="text-right">@payment.Confirmations</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</section>
} }

View file

@ -1,10 +1,13 @@
@using System.Globalization @using System.Globalization
@using BTCPayServer.Components.TruncateCenter
@using BTCPayServer.Services
@using BTCPayServer.Services.Altcoins.Zcash.Payments @using BTCPayServer.Services.Altcoins.Zcash.Payments
@using BTCPayServer.Services.Altcoins.Zcash.UI @using BTCPayServer.Services.Altcoins.Zcash.UI
@inject DisplayFormatter DisplayFormatter
@model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity> @model IEnumerable<BTCPayServer.Services.Invoices.PaymentEntity>
@{ @{
var onchainPayments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == ZcashPaymentType.Instance).Select(payment => var payments = Model.Where(entity => entity.GetPaymentMethodId().PaymentType == ZcashPaymentType.Instance).Select(payment =>
{ {
var m = new ZcashPaymentViewModel(); var m = new ZcashPaymentViewModel();
var onChainPaymentData = payment.GetCryptoPaymentData() as ZcashLikePaymentData; var onChainPaymentData = payment.GetCryptoPaymentData() as ZcashLikePaymentData;
@ -26,37 +29,37 @@
m.ReceivedTime = payment.ReceivedTime; m.ReceivedTime = payment.ReceivedTime;
m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId); m.TransactionLink = string.Format(CultureInfo.InvariantCulture, payment.Network.BlockExplorerLink, m.TransactionId);
return m; return m;
}); }).ToList();
} }
@if (onchainPayments.Any()) @if (payments.Any())
{ {
<section>
<h5>Zcash Payments</h5> <h5>Zcash Payments</h5>
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th>Crypto</th> <th class="w-75px">Crypto</th>
<th>Deposit address</th> <th class="w-175px">Destination</th>
<th>Amount</th> <th class="text-nowrap">Payment Proof</th>
<th>Transaction Id</th> <th class="text-end">Confirmations</th>
<th class="text-right">Confirmations</th> <th class="w-150px text-end">Paid</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var payment in onchainPayments) @foreach (var payment in payments)
{ {
<tr > <tr >
<td>@payment.Crypto</td> <td>@payment.Crypto</td>
<td>@payment.DepositAddress</td> <td><vc:truncate-center text="@payment.DepositAddress" classes="truncate-center-id" /></td>
<td>@payment.Amount</td> <td><vc:truncate-center text="@payment.TransactionId" link="@payment.TransactionLink" classes="truncate-center-id" /></td>
<td> <td class="text-end">@payment.Confirmations</td>
<a href="@payment.TransactionLink" class="text-break" target="_blank" rel="noreferrer noopener"> <td class="payment-value text-end text-nowrap">
@payment.TransactionId <span data-sensitive class="text-success">@DisplayFormatter.Currency(payment.Amount, payment.Crypto)</span>
</a>
</td> </td>
<td class="text-right">@payment.Confirmations</td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
</section>
} }

View file

@ -327,7 +327,7 @@
<td>@Model.TransactionSpeed</td> <td>@Model.TransactionSpeed</td>
</tr> </tr>
<tr> <tr>
<th>Total Fiat Due</th> <th>Total Amount Due</th>
<td><span data-sensitive>@Model.Fiat</span></td> <td><span data-sensitive>@Model.Fiat</span></td>
</tr> </tr>
@if (!string.IsNullOrEmpty(Model.RefundEmail)) @if (!string.IsNullOrEmpty(Model.RefundEmail))
@ -506,7 +506,8 @@
} }
</div> </div>
</div> </div>
<div class="row">
<div class="col-xxl-constrain">
<h3 class="mb-3">Invoice Summary</h3> <h3 class="mb-3">Invoice Summary</h3>
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)" /> <partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
@ -655,3 +656,5 @@
</table> </table>
</section> </section>
</div> </div>
</div>
</div>

View file

@ -32,8 +32,8 @@
@section PageHeadContent @section PageHeadContent
{ {
<style> <style>
.invoice-payments { .invoice-details-row > td {
padding-left: var(--btcpay-space-l); padding: 1.5rem .5rem 0 2.65rem;
} }
.dropdown > .btn { .dropdown > .btn {
min-width: 7rem; min-width: 7rem;

View file

@ -6,22 +6,41 @@
.Where(entities => entities.Key != null); .Where(entities => entities.Key != null);
} }
@if (invoice.Overpaid)
{
var usedPaymentMethods = invoice.CryptoPayments.Count(p => p.Paid != null);
<p class="d-flex align-items-center gap-2 mb-3 text-warning">
<vc:icon symbol="warning"/>
This invoice got overpaid.
@if (usedPaymentMethods > 1)
{
@("Each payment method shows the total excess amount.")
}
</p>
}
<div class="invoice-payments table-responsive mt-0"> <div class="invoice-payments table-responsive mt-0">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th class="text-nowrap w-175px">Payment method</th> <th class="text-nowrap w-175px">Payment method</th>
@if (Model.ShowAddress) @if (Model.ShowAddress)
{ {
<th>Destination</th> <th>Destination</th>
} }
@if (invoice.HasRates)
{
<th class="w-150px text-end">Rate</th> <th class="w-150px text-end">Rate</th>
<th class="w-150px text-end">Paid</th> }
<th class="w-150px text-end">Due</th> <th class="w-150px text-end">Total due</th>
@if (invoice.Overpaid) @if (invoice.StillDue)
{
<th class="w-150px text-end">Still due</th>
}
else if (invoice.Overpaid)
{ {
<th class="w-150px text-end">Overpaid</th> <th class="w-150px text-end">Overpaid</th>
} }
<th class="w-150px text-end">Paid</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -35,13 +54,39 @@
<vc:truncate-center text="@payment.Address" classes="truncate-center-id" /> <vc:truncate-center text="@payment.Address" classes="truncate-center-id" />
</td> </td>
} }
<td class="text-nowrap text-end"><span data-sensitive>@payment.Rate</span></td> @if (invoice.HasRates)
<td class="text-nowrap text-end"><span data-sensitive>@payment.Paid</span></td>
<td class="text-nowrap text-end"><span data-sensitive>@payment.Due</span></td>
@if (invoice.Overpaid)
{ {
<td class="text-nowrap text-end"><span data-sensitive>@payment.Overpaid</span></td> <td class="text-nowrap text-end">
<span data-sensitive>@payment.Rate</span>
</td>
} }
<td class="text-nowrap text-end">
<span data-sensitive>@payment.TotalDue</span>
</td>
@if (invoice.StillDue)
{
<td class="text-nowrap text-end">
@if (payment.Due != null)
{
<span data-sensitive>@payment.Due</span>
}
</td>
}
else if (invoice.Overpaid)
{
<td class="text-nowrap text-end">
@if (payment.Overpaid != null)
{
<span data-sensitive class="text-warning">@payment.Overpaid</span>
}
</td>
}
<td class="text-nowrap text-end">
@if (payment.Paid != null)
{
<span data-sensitive class="text-success">@payment.Paid</span>
}
</td>
</tr> </tr>
var details = payment.PaymentMethodRaw.GetPaymentMethodDetails(); var details = payment.PaymentMethodRaw.GetPaymentMethodDetails();
var name = details.GetAdditionalDataPartialName(); var name = details.GetAdditionalDataPartialName();

View file

@ -164,7 +164,7 @@
{ {
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th permission="@Policies.CanModifyStoreSettings"> <th permission="@Policies.CanModifyStoreSettings">
<input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input selectAll" data-payout-state="@Model.PayoutState.ToString()" /> <input id="@Model.PayoutState-selectAllCheckbox" type="checkbox" class="form-check-input selectAll" data-payout-state="@Model.PayoutState.ToString()" />

View file

@ -102,7 +102,7 @@
} }
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th scope="col"> <th scope="col">
<a asp-action="PullPayments" <a asp-action="PullPayments"

View file

@ -169,7 +169,7 @@
<div id="WalletTransactions" class="table-responsive-md"> <div id="WalletTransactions" class="table-responsive-md">
<table class="table table-hover"> <table class="table table-hover">
<thead class="thead-inverse"> <thead>
<tr> <tr>
<th style="width:2rem;" class="only-for-js"> <th style="width:2rem;" class="only-for-js">
<input id="selectAllCheckbox" type="checkbox" class="form-check-input" /> <input id="selectAllCheckbox" type="checkbox" class="form-check-input" />

View file

@ -76,7 +76,7 @@ hr.primary {
@media (min-width: 1400px) { @media (min-width: 1400px) {
.col-xxl-constrain { .col-xxl-constrain {
max-width: 800px; max-width: 984px;
} }
} }