diff --git a/BTCPayServer.Client/Models/PaymentRequestData.cs b/BTCPayServer.Client/Models/PaymentRequestData.cs index 3a6f40aea..86a42f903 100644 --- a/BTCPayServer.Client/Models/PaymentRequestData.cs +++ b/BTCPayServer.Client/Models/PaymentRequestData.cs @@ -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 } } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 4d80a2060..b865a00ca 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -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); diff --git a/BTCPayServer.Tests/PaymentRequestTests.cs b/BTCPayServer.Tests/PaymentRequestTests.cs index 986f0c067..8975d0381 100644 --- a/BTCPayServer.Tests/PaymentRequestTests.cs +++ b/BTCPayServer.Tests/PaymentRequestTests.cs @@ -31,7 +31,6 @@ namespace BTCPayServer.Tests user.RegisterDerivationScheme("BTC"); var user2 = tester.NewAccount(); - await user2.GrantAccessAsync(); var paymentRequestController = user.GetController(); @@ -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(); @@ -170,7 +169,7 @@ namespace BTCPayServer.Tests Assert.IsType(await paymentRequestController.CancelUnpaidPendingInvoice(Guid.NewGuid().ToString(), false)); - var request = new UpdatePaymentRequestViewModel() + var request = new UpdatePaymentRequestViewModel { Title = "original juice", Currency = "BTC", diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 903c2c489..0b72390d2 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -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)] diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs index 79c587f04..9ecf81b65 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -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) { diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs index 405afa842..018359fcc 100644 --- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs +++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs @@ -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; diff --git a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs index c6c5d12e8..e80ed1c5e 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs @@ -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, diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index ed1688f93..4a744dfce 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -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, diff --git a/BTCPayServer/Services/Invoices/InvoiceEntity.cs b/BTCPayServer/Services/Invoices/InvoiceEntity.cs index 6ee7e67d6..0177d502c 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -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)})"); } } diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index eb13f5512..d76cfee16 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -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 States { get; set; } public decimal Value { get; set; } public decimal CurrencyValue { get; set; } } diff --git a/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml b/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml index e49216a95..a26a607df 100644 --- a/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml +++ b/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml @@ -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 {
- + @Model.Status @if (Model.Archived) { @@ -186,7 +182,7 @@