Payment Request: Add processing status for on-chain payments (#5309)

Closes #5297.
This commit is contained in:
d11n 2023-09-19 03:10:13 +02:00 committed by GitHub
parent f034e2cd65
commit 17d1832dad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 151 additions and 83 deletions

View File

@ -7,7 +7,7 @@ namespace BTCPayServer.Client.Models
public class PaymentRequestData : PaymentRequestBaseData
{
[JsonConverter(typeof(StringEnumConverter))]
public PaymentRequestData.PaymentRequestStatus Status { get; set; }
public PaymentRequestStatus Status { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset CreatedTime { get; set; }
public string Id { get; set; }
@ -16,7 +16,8 @@ namespace BTCPayServer.Client.Models
{
Pending = 0,
Completed = 1,
Expired = 2
Expired = 2,
Processing = 3
}
}
}

View File

@ -1606,7 +1606,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.GrantAccessAsync();
await user.MakeAdmin();
var client = await user.CreateClient(Policies.Unrestricted);
var viewOnly = await user.CreateClient(Policies.CanViewPaymentRequests);
@ -1688,11 +1688,18 @@ namespace BTCPayServer.Tests
BitcoinAddress.Create(invoice.BitcoinAddress, tester.ExplorerNode.Network), invoice.BtcDue);
});
await TestUtils.EventuallyAsync(async () =>
{
Assert.Equal(Invoice.STATUS_PAID, user.BitPay.GetInvoice(invoiceId).Status);
if (!partialPayment)
Assert.Equal(PaymentRequestData.PaymentRequestStatus.Completed, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status);
});
{
Assert.Equal(Invoice.STATUS_PAID, (await user.BitPay.GetInvoiceAsync(invoiceId)).Status);
if (!partialPayment)
Assert.Equal(PaymentRequestData.PaymentRequestStatus.Processing, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status);
});
await tester.ExplorerNode.GenerateAsync(1);
await TestUtils.EventuallyAsync(async () =>
{
Assert.Equal(Invoice.STATUS_CONFIRMED, (await user.BitPay.GetInvoiceAsync(invoiceId)).Status);
if (!partialPayment)
Assert.Equal(PaymentRequestData.PaymentRequestStatus.Completed, (await client.GetPaymentRequest(user.StoreId, paymentTestPaymentRequest.Id)).Status);
});
}
await Pay(invoiceId);

View File

@ -31,7 +31,6 @@ namespace BTCPayServer.Tests
user.RegisterDerivationScheme("BTC");
var user2 = tester.NewAccount();
await user2.GrantAccessAsync();
var paymentRequestController = user.GetController<UIPaymentRequestController>();
@ -162,7 +161,7 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
user.GrantAccess();
await user.GrantAccessAsync();
user.RegisterDerivationScheme("BTC");
var paymentRequestController = user.GetController<UIPaymentRequestController>();
@ -170,7 +169,7 @@ namespace BTCPayServer.Tests
Assert.IsType<NotFoundResult>(await
paymentRequestController.CancelUnpaidPendingInvoice(Guid.NewGuid().ToString(), false));
var request = new UpdatePaymentRequestViewModel()
var request = new UpdatePaymentRequestViewModel
{
Title = "original juice",
Currency = "BTC",

View File

@ -1159,13 +1159,13 @@ namespace BTCPayServer.Tests
await s.StartAsync();
s.RegisterNewUser();
s.CreateNewStore();
s.EnableCheckout(CheckoutType.V1);
s.AddDerivationScheme();
s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click();
s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click();
s.Driver.FindElement(By.Id("Title")).SendKeys("Pay123");
s.Driver.FindElement(By.Id("Amount")).SendKeys("700");
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys(".01");
var currencyInput = s.Driver.FindElement(By.Id("Currency"));
Assert.Equal("USD", currencyInput.GetAttribute("value"));
@ -1208,9 +1208,7 @@ namespace BTCPayServer.Tests
// test invoice creation, click with JS, because the button is inside a sticky header
s.Driver.ExecuteJavaScript("document.querySelector('[data-test=\"pay-button\"]').click()");
// checkout v1
s.Driver.WaitForElement(By.CssSelector("invoice"));
Assert.Contains("Awaiting Payment", s.Driver.PageSource);
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
// amount and currency should not be editable, because invoice exists
s.GoToUrl(editUrl);
@ -1231,6 +1229,36 @@ namespace BTCPayServer.Tests
s.Driver.WaitForElement(By.Id($"ToggleArchival-{payReqId}")).Click();
Assert.Contains("The payment request has been unarchived", s.FindAlertMessage().Text);
Assert.Contains("Pay123", s.Driver.PageSource);
// payment
s.GoToUrl(viewUrl);
s.Driver.ExecuteJavaScript("document.querySelector('[data-test=\"pay-button\"]').click()");
// Pay full amount
s.PayInvoice();
// Processing
TestUtils.Eventually(() =>
{
var processingSection = s.Driver.WaitForElement(By.Id("processing"));
Assert.True(processingSection.Displayed);
Assert.Contains("Payment Received", processingSection.Text);
Assert.Contains("Your payment has been received and is now processing", processingSection.Text);
});
s.GoToUrl(viewUrl);
Assert.Equal("Processing", s.Driver.WaitForElement(By.CssSelector("[data-test='status']")).Text);
s.Driver.Navigate().Back();
// Mine
s.MineBlockOnInvoiceCheckout();
TestUtils.Eventually(() =>
{
Assert.Contains("Mined 1 block",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
});
s.GoToUrl(viewUrl);
Assert.Equal("Settled", s.Driver.WaitForElement(By.CssSelector("[data-test='status']")).Text);
}
[Fact(Timeout = TestTimeout)]

View File

@ -333,7 +333,7 @@ namespace BTCPayServer.Controllers
try
{
var store = await _storeRepository.FindStore(result.StoreId);
var prData = await _PaymentRequestRepository.FindPaymentRequest(result.Id, null);
var prData = await _PaymentRequestRepository.FindPaymentRequest(result.Id, null, cancellationToken);
var newInvoice = await _InvoiceController.CreatePaymentRequestInvoice(prData, amount, result.AmountDue, store, Request, cancellationToken);
if (redirectToInvoice)
{

View File

@ -119,6 +119,9 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
Status = "Pending";
IsPending = true;
break;
case Client.Models.PaymentRequestData.PaymentRequestStatus.Processing:
Status = "Processing";
break;
case Client.Models.PaymentRequestData.PaymentRequestStatus.Completed:
Status = "Settled";
break;

View File

@ -23,6 +23,7 @@ namespace BTCPayServer.PaymentRequest
{
private readonly UIPaymentRequestController _PaymentRequestController;
public const string InvoiceCreated = "InvoiceCreated";
public const string InvoiceConfirmed = "InvoiceConfirmed";
public const string PaymentReceived = "PaymentReceived";
public const string InfoUpdated = "InfoUpdated";
public const string InvoiceError = "InvoiceError";
@ -128,9 +129,13 @@ namespace BTCPayServer.PaymentRequest
private async Task CheckingPendingPayments(CancellationToken cancellationToken)
{
Logs.PayServer.LogInformation("Starting payment request expiration watcher");
var items = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery()
var items = await _PaymentRequestRepository.FindPaymentRequests(new PaymentRequestQuery
{
Status = new[] { Client.Models.PaymentRequestData.PaymentRequestStatus.Pending }
Status = new[]
{
PaymentRequestData.PaymentRequestStatus.Pending,
PaymentRequestData.PaymentRequestStatus.Processing
}
}, cancellationToken);
Logs.PayServer.LogInformation($"{items.Length} pending payment requests being checked since last run");
await Task.WhenAll(items.Select(i => _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(i))
@ -157,7 +162,7 @@ namespace BTCPayServer.PaymentRequest
{
foreach (var paymentId in PaymentRequestRepository.GetPaymentIdsFromInternalTags(invoiceEvent.Invoice))
{
if (invoiceEvent.Name == InvoiceEvent.ReceivedPayment || invoiceEvent.Name == InvoiceEvent.MarkedCompleted || invoiceEvent.Name == InvoiceEvent.MarkedInvalid)
if (invoiceEvent.Name is InvoiceEvent.ReceivedPayment or InvoiceEvent.MarkedCompleted or InvoiceEvent.MarkedInvalid)
{
await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(paymentId);
var data = invoiceEvent.Payment?.GetCryptoPaymentData();
@ -168,10 +173,19 @@ namespace BTCPayServer.PaymentRequest
{
data.GetValue(),
invoiceEvent.Payment.Currency,
invoiceEvent.Payment.GetPaymentMethodId()?.PaymentType?.ToString()
invoiceEvent.Payment.GetPaymentMethodId()?.PaymentType.ToString()
}, cancellationToken);
}
}
else if (invoiceEvent.Name is InvoiceEvent.Completed or InvoiceEvent.Confirmed)
{
await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(paymentId);
await _HubContext.Clients.Group(paymentId).SendCoreAsync(PaymentRequestHub.InvoiceConfirmed,
new object[]
{
invoiceEvent.InvoiceId
}, cancellationToken);
}
await InfoUpdated(paymentId);
}
@ -181,10 +195,11 @@ namespace BTCPayServer.PaymentRequest
await _PaymentRequestService.UpdatePaymentRequestStateIfNeeded(updated.PaymentRequestId);
await InfoUpdated(updated.PaymentRequestId);
var isPending = updated.Data.Status is
PaymentRequestData.PaymentRequestStatus.Pending or
PaymentRequestData.PaymentRequestStatus.Processing;
var expiry = updated.Data.GetBlob().ExpiryDate;
if (updated.Data.Status ==
PaymentRequestData.PaymentRequestStatus.Pending &&
expiry.HasValue)
if (isPending && expiry.HasValue)
{
QueueExpiryTask(
updated.PaymentRequestId,

View File

@ -10,7 +10,6 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using Microsoft.AspNetCore.SignalR;
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
namespace BTCPayServer.PaymentRequest
@ -27,7 +26,6 @@ namespace BTCPayServer.PaymentRequest
PaymentRequestRepository paymentRequestRepository,
BTCPayNetworkProvider btcPayNetworkProvider,
InvoiceRepository invoiceRepository,
AppService appService,
DisplayFormatter displayFormatter,
CurrencyNameTable currencies)
{
@ -62,10 +60,19 @@ namespace BTCPayServer.PaymentRequest
{
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(pr.Id);
var contributions = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
var allSettled = contributions.All(i => i.Value.States.All(s => s.IsSettled()));
var isPaid = contributions.TotalCurrency >= blob.Amount;
currentStatus = contributions.TotalCurrency >= blob.Amount
? Client.Models.PaymentRequestData.PaymentRequestStatus.Completed
: Client.Models.PaymentRequestData.PaymentRequestStatus.Pending;
if (isPaid)
{
currentStatus = allSettled
? Client.Models.PaymentRequestData.PaymentRequestStatus.Completed
: Client.Models.PaymentRequestData.PaymentRequestStatus.Processing;
}
else
{
currentStatus = Client.Models.PaymentRequestData.PaymentRequestStatus.Pending;
}
}
if (currentStatus != pr.Status)
@ -86,12 +93,11 @@ namespace BTCPayServer.PaymentRequest
var blob = pr.GetBlob();
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id);
var paymentStats = _invoiceRepository.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
var amountDue = blob.Amount - paymentStats.TotalCurrency;
var pendingInvoice = invoices.OrderByDescending(entity => entity.InvoiceTime)
.FirstOrDefault(entity => entity.Status == InvoiceStatusLegacy.New);
return new ViewPaymentRequestViewModel(pr)
{
Archived = pr.Archived,

View File

@ -935,6 +935,14 @@ namespace BTCPayServer.Services.Invoices
Status == InvoiceStatusLegacy.Invalid;
}
public bool IsSettled()
{
return Status == InvoiceStatusLegacy.Confirmed ||
Status == InvoiceStatusLegacy.Complete ||
(Status == InvoiceStatusLegacy.Expired &&
ExceptionStatus is InvoiceExceptionStatus.PaidLate or InvoiceExceptionStatus.PaidOver);
}
public override int GetHashCode()
{
return HashCode.Combine(Status, ExceptionStatus);
@ -970,7 +978,7 @@ namespace BTCPayServer.Services.Invoices
}
public override string ToString()
{
return Status.ToModernStatus().ToString() + (ExceptionStatus == InvoiceExceptionStatus.None ? string.Empty : $" ({ToString(ExceptionStatus)})");
return Status.ToModernStatus() + (ExceptionStatus == InvoiceExceptionStatus.None ? string.Empty : $" ({ToString(ExceptionStatus)})");
}
}

View File

@ -786,9 +786,12 @@ namespace BTCPayServer.Services.Invoices
.Where(p => p.Currency.Equals(currency, StringComparison.OrdinalIgnoreCase))
.SelectMany(p =>
{
var contribution = new InvoiceStatistics.Contribution();
contribution.PaymentMethodId = new PaymentMethodId(p.Currency, PaymentTypes.BTCLike);
contribution.CurrencyValue = p.Price;
var contribution = new InvoiceStatistics.Contribution
{
PaymentMethodId = new PaymentMethodId(p.Currency, PaymentTypes.BTCLike),
CurrencyValue = p.Price,
States = new [] { p.GetInvoiceState() }
};
contribution.Value = contribution.CurrencyValue;
// For hardcap, we count newly created invoices as part of the contributions
@ -815,18 +818,22 @@ namespace BTCPayServer.Services.Invoices
return payments
.Select(pay =>
{
var paymentMethodContribution = new InvoiceStatistics.Contribution();
paymentMethodContribution.PaymentMethodId = pay.GetPaymentMethodId();
paymentMethodContribution.CurrencyValue = pay.InvoicePaidAmount.Net;
paymentMethodContribution.Value = pay.PaidAmount.Net;
var paymentMethodContribution = new InvoiceStatistics.Contribution
{
PaymentMethodId = pay.GetPaymentMethodId(),
CurrencyValue = pay.InvoicePaidAmount.Net,
Value = pay.PaidAmount.Net,
States = new [] { pay.InvoiceEntity.GetInvoiceState() }
};
return paymentMethodContribution;
})
.ToArray();
})
.GroupBy(p => p.PaymentMethodId)
.ToDictionary(p => p.Key, p => new InvoiceStatistics.Contribution()
.ToDictionary(p => p.Key, p => new InvoiceStatistics.Contribution
{
PaymentMethodId = p.Key,
States = p.SelectMany(v => v.States),
Value = p.Select(v => v.Value).Sum(),
CurrencyValue = p.Select(v => v.CurrencyValue).Sum()
});
@ -913,6 +920,7 @@ namespace BTCPayServer.Services.Invoices
public class Contribution
{
public PaymentMethodId PaymentMethodId { get; set; }
public IEnumerable<InvoiceState> States { get; set; }
public decimal Value { get; set; }
public decimal CurrencyValue { get; set; }
}

View File

@ -10,25 +10,21 @@
Layout = null;
string StatusClass(InvoiceState state)
{
switch (state.Status.ToModernStatus())
var status = state.Status.ToModernStatus();
switch (status)
{
case InvoiceStatus.Settled:
case InvoiceStatus.Processing:
return "success";
case InvoiceStatus.Expired:
switch (state.ExceptionStatus)
{
case InvoiceExceptionStatus.PaidLate:
case InvoiceExceptionStatus.PaidPartial:
case InvoiceExceptionStatus.PaidOver:
return "warning";
return "unusual";
default:
return "danger";
return "expired";
}
case InvoiceStatus.Invalid:
return "danger";
default:
return "warning";
return status.ToString().ToLowerInvariant();
}
}
}
@ -131,7 +127,7 @@
else
{
<div class="h2 text-md-end">
<span class="badge @if (Model.Status == "Settled") { @("bg-primary") } else if (Model.Status == "Expired") { @("bg-danger") } else { @("bg-info") }" data-test="status">
<span class="badge badge-@Model.Status.ToLowerInvariant()" data-test="status" style="font-size:.75em">
@Model.Status
@if (Model.Archived)
{
@ -186,7 +182,7 @@
</template>
<template v-else>
<div class="h2 text-md-end">
<span class="badge" :class="{ 'bg-primary': srvModel.status === 'Settled', 'bg-danger': srvModel.status === 'Expired', 'bg-info': (srvModel.status !== 'Settled' && srvModel.status !== 'Expired') }" data-test="status">
<span class="badge" :class="`badge-${srvModel.status.toLowerCase()}`" data-test="status" style="font-size:.75em">
{{srvModel.status}}
<span v-if="srvModel.archived">(archived)</span>
</span>
@ -262,18 +258,18 @@
</tr>
</thead>
<tbody>
<tr class="table-borderless table-light">
<tr class="table-borderless">
<td>@invoice.Id</td>
<td>@invoice.ExpiryDate.ToString("g")</td>
<td class="text-end">@invoice.AmountFormatted</td>
<td class="text-end"></td>
<td class="text-end text-print-default">
<span class="badge bg-@StatusClass(invoice.State)">@invoice.StateFormatted</span>
<span class="badge badge-@StatusClass(invoice.State)">@invoice.StateFormatted</span>
</td>
</tr>
@if (invoice.Payments != null && invoice.Payments.Any())
{
<tr class="table-borderless table-light">
<tr class="table-borderless">
<th class="fw-normal text-secondary">Destination</th>
<th class="fw-normal text-secondary">Received</th>
<th class="fw-normal text-secondary text-end">Paid</th>
@ -282,14 +278,14 @@
</tr>
@foreach (var payment in invoice.Payments)
{
<tr class="table-borderless table-light">
<tr class="table-borderless">
<td class="text-break"><code>@payment.Destination</code></td>
<td>@payment.ReceivedDate.ToString("g")</td>
<td class="text-end">@payment.PaidFormatted</td>
<td class="text-end">@payment.RateFormatted</td>
<td class="text-end text-nowrap">@payment.Amount @payment.PaymentMethod</td>
</tr>
<tr class="table-borderless table-light">
<tr class="table-borderless">
<td class="fw-normal" colspan="5">
<span class="text-secondary">Transaction Id:</span>
@if (!string.IsNullOrEmpty(payment.Link))
@ -326,17 +322,17 @@
</tr>
</thead>
<tbody>
<tr class="table-borderless table-light">
<tr class="table-borderless">
<td>{{invoice.id}}</td>
<td v-text="formatDate(invoice.expiryDate)"></td>
<td class="text-end">{{invoice.amountFormatted}}</td>
<td class="text-end"></td>
<td class="text-end text-print-default">
<span class="badge" :class="`bg-${statusClass(invoice.stateFormatted)}`">{{invoice.stateFormatted}}</span>
<span class="badge" :class="`badge-${statusClass(invoice.stateFormatted)}`">{{invoice.stateFormatted}}</span>
</td>
</tr>
<template v-if="invoice.payments && invoice.payments.length > 0">
<tr class="table-borderless table-light">
<tr class="table-borderless">
<th class="fw-normal text-secondary">Destination</th>
<th class="fw-normal text-secondary">Received</th>
<th class="fw-normal text-secondary text-end">Paid</th>
@ -344,14 +340,14 @@
<th class="fw-normal text-secondary text-end">Payment</th>
</tr>
<template v-for="payment of invoice.payments">
<tr class="table-borderless table-light">
<tr class="table-borderless">
<td class="text-break"><code>{{payment.destination}}</code></td>
<td v-text="formatDate(payment.receivedDate)"></td>
<td class="text-end">{{payment.paidFormatted}}</td>
<td class="text-end">{{payment.rateFormatted}}</td>
<td class="text-end text-nowrap">{{payment.amount.noExponents()}} {{payment.paymentMethod}}</td>
</tr>
<tr class="table-borderless table-light">
<tr class="table-borderless">
<td class="fw-normal" colspan="5">
<span class="text-secondary">Transaction Id:</span>
<a v-if="payment.link" :href="payment.link" class="text-print-default" target="_blank" rel="noreferrer noopener">{{payment.id}}</a>

View File

@ -102,30 +102,30 @@ a.unobtrusive-link {
}
/* Badges */
.badge-pending,
.badge-new {
.badge-new,
.badge-pending {
background: #d4edda;
color: #000;
}
.badge-expired {
background: #eee;
color: #000;
}
.badge-invalid {
background: #c94a47;
color: #fff;
background: var(--btcpay-danger);
color: var(--btcpay-danger-text);
}
.badge-unusual {
background: var(--btcpay-warning);
color: var(--btcpay-warning-text);
}
.badge-processing {
background: #f1c332;
color: #000;
background: var(--btcpay-info);
color: var(--btcpay-info-text);
}
.badge-settled {
background: #329f80;
color: #fff;
background: var(--btcpay-success);
color: var(--btcpay-success-text);
}
/* Info icons in main headline */

View File

@ -95,24 +95,19 @@ document.addEventListener("DOMContentLoaded",function (ev) {
}
},
statusClass: function (state) {
var [, status,, exceptionStatus] = state.match(/(\w*)\s?(\((\w*)\))?/) || [];
const [, status,, exceptionStatus] = state.match(/(\w*)\s?(\((\w*)\))?/) || [];
switch (status) {
case "Settled":
case "Processing":
return "success";
case "Expired":
switch (exceptionStatus) {
case "paidLate":
case "paidPartial":
case "paidOver":
return "warning";
return "unusual";
default:
return "danger";
return "expired";
}
case "Invalid":
return "danger";
default:
return "warning";
return status.toLowerCase();
}
}
},
@ -164,7 +159,6 @@ document.addEventListener("DOMContentLoaded",function (ev) {
Vue.toasted.success(title, Object.assign({}, toastOptions), { icon });
});
eventAggregator.$on("info-updated", function (model) {
console.warn("UPDATED", self.srvModel, arguments);
self.srvModel = model;
});
eventAggregator.$on("connection-pending", function () {

View File

@ -13,6 +13,9 @@ var hubListener = function () {
connection.on("InvoiceCreated", function (invoiceId) {
eventAggregator.$emit("invoice-created", invoiceId);
});
connection.on("InvoiceConfirmed", function (invoiceId) {
eventAggregator.$emit("invoice-confirmed", invoiceId);
});
connection.on("InvoiceError", function (error) {
eventAggregator.$emit("invoice-error", error);
});