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 provider = new BTCPayNetworkProvider(ChainName.Mainnet);
var b = new StoreBlob();
string[] temporarilyBroken = { "UGX" };
string[] temporarilyBroken = { "COP", "UGX" };
foreach (var k in StoreBlob.RecommendedExchanges)
{
b.DefaultCurrency = k.Key;
@ -307,14 +307,20 @@ retry:
var result = fetcher.FetchRates(pairs, rules, default);
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))
{
TestLogs.LogInformation($"Skipping {key} because it is marked as temporarily broken");
continue;
if (!hasRate)
{
TestLogs.LogInformation($"Skipping {key} because it is marked as temporarily broken");
continue;
}
TestLogs.LogInformation($"Note: {key} is marked as temporarily broken, but the rate is available");
}
var rateResult = await value;
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.Payments = details.Payments;
model.Overpaid = details.Overpaid;
model.StillDue = details.StillDue;
model.HasRates = details.HasRates;
if (additionalData.ContainsKey("receiptData"))
{
@ -565,37 +567,42 @@ namespace BTCPayServer.Controllers
private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice)
{
var overpaid = false;
var stillDue = false;
var hasRates = false;
var model = new InvoiceDetailsModel
{
Archived = invoice.Archived,
Payments = invoice.GetPayments(false),
Overpaid = true,
CryptoPayments = invoice.GetPaymentMethods().Select(
data =>
{
var accounting = data.Calculate();
var paymentMethodId = data.GetId();
var hasPayment = accounting.CryptoPaid > 0;
var overpaidAmount = accounting.OverpaidHelper;
if (overpaidAmount > 0)
{
overpaid = true;
}
var rate = ExchangeRate(data.GetId().CryptoCode, data);
if (rate is not null) hasRates = true;
if (hasPayment && overpaidAmount > 0) overpaid = true;
if (hasPayment && accounting.Due > 0) stillDue = true;
return new InvoiceDetailsModel.CryptoPayment
{
Rate = rate,
PaymentMethodRaw = data,
PaymentMethodId = paymentMethodId,
PaymentMethod = paymentMethodId.ToPrettyString(),
Due = _displayFormatter.Currency(accounting.Due, paymentMethodId.CryptoCode),
Paid = _displayFormatter.Currency(accounting.CryptoPaid, paymentMethodId.CryptoCode),
Overpaid = _displayFormatter.Currency(overpaidAmount, paymentMethodId.CryptoCode),
Address = data.GetPaymentMethodDetails().GetPaymentDestination(),
Rate = ExchangeRate(data.GetId().CryptoCode, data),
PaymentMethodRaw = data
TotalDue = _displayFormatter.Currency(accounting.TotalDue, paymentMethodId.CryptoCode),
Due = hasPayment ? _displayFormatter.Currency(accounting.Due, paymentMethodId.CryptoCode) : null,
Paid = hasPayment ? _displayFormatter.Currency(accounting.CryptoPaid, paymentMethodId.CryptoCode) : null,
Overpaid = hasPayment ? _displayFormatter.Currency(overpaidAmount, paymentMethodId.CryptoCode) : null,
Address = data.GetPaymentMethodDetails().GetPaymentDestination()
};
}).ToList()
}).ToList(),
Overpaid = overpaid,
StillDue = stillDue,
HasRates = hasRates
};
model.Overpaid = overpaid;
return model;
}

View file

@ -41,6 +41,7 @@ namespace BTCPayServer.Models.InvoicingModels
public class CryptoPayment
{
public string PaymentMethod { get; set; }
public string TotalDue { get; set; }
public string Due { get; set; }
public string Paid { get; set; }
public string Address { get; internal set; }
@ -138,6 +139,8 @@ namespace BTCPayServer.Models.InvoicingModels
public bool CanMarkStatus => CanMarkSettled || CanMarkInvalid;
public List<RefundData> Refunds { 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>
<div class="invoice-payments table-responsive mt-0">
<table class="table table-hover mb-0">
<thead class="thead-inverse">
<thead>
<tr>
<th class="w-75px">Crypto</th>
<th class="w-100px">Index</th>
@ -75,7 +75,7 @@
</th>
}
<th class="text-end">Confirmations</th>
<th class="w-150px text-end">Amount</th>
<th class="w-150px text-end">Paid</th>
</tr>
</thead>
<tbody>
@ -96,7 +96,7 @@
}
<td class="text-end">@payment.Confirmations</td>
<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))
{
<div>(@payment.AdditionalInformation)</div>

View file

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

View file

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

View file

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

View file

@ -327,7 +327,7 @@
<td>@Model.TransactionSpeed</td>
</tr>
<tr>
<th>Total Fiat Due</th>
<th>Total Amount Due</th>
<td><span data-sensitive>@Model.Fiat</span></td>
</tr>
@if (!string.IsNullOrEmpty(Model.RefundEmail))
@ -506,7 +506,8 @@
}
</div>
</div>
<div class="row">
<div class="col-xxl-constrain">
<h3 class="mb-3">Invoice Summary</h3>
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
@ -638,20 +639,22 @@
<h3 class="mb-0">Events</h3>
<table class="table table-hover mt-3 mb-4">
<thead>
<tr>
<th>Date</th>
<th>Message</th>
</tr>
<tr>
<th>Date</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@foreach (var evt in Model.Events)
{
<tr class="text-@evt.GetCssClass()">
<td>@evt.Timestamp.ToBrowserDate()</td>
<td>@evt.Message</td>
</tr>
}
@foreach (var evt in Model.Events)
{
<tr class="text-@evt.GetCssClass()">
<td>@evt.Timestamp.ToBrowserDate()</td>
<td>@evt.Message</td>
</tr>
}
</tbody>
</table>
</section>
</div>
</div>
</div>

View file

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

View file

@ -6,22 +6,41 @@
.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">
<table class="table table-hover mb-0">
<thead class="thead-inverse">
<thead>
<tr>
<th class="text-nowrap w-175px">Payment method</th>
@if (Model.ShowAddress)
{
<th>Destination</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>
@if (invoice.Overpaid)
@if (invoice.HasRates)
{
<th class="w-150px text-end">Rate</th>
}
<th class="w-150px text-end">Total due</th>
@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">Paid</th>
</tr>
</thead>
<tbody>
@ -35,13 +54,39 @@
<vc:truncate-center text="@payment.Address" classes="truncate-center-id" />
</td>
}
<td class="text-nowrap text-end"><span data-sensitive>@payment.Rate</span></td>
<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)
@if (invoice.HasRates)
{
<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>
var details = payment.PaymentMethodRaw.GetPaymentMethodDetails();
var name = details.GetAdditionalDataPartialName();

View file

@ -164,7 +164,7 @@
{
<div class="table-responsive">
<table class="table table-hover">
<thead class="thead-inverse">
<thead>
<tr>
<th permission="@Policies.CanModifyStoreSettings">
<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">
<table class="table table-hover">
<thead class="thead-inverse">
<thead>
<tr>
<th scope="col">
<a asp-action="PullPayments"

View file

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

View file

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