diff --git a/BTCPayServer.Client/Models/PaymentRequestData.cs b/BTCPayServer.Client/Models/PaymentRequestData.cs index 3a6f40aea61..86a42f90307 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 @@ public enum PaymentRequestStatus { Pending = 0, Completed = 1, - Expired = 2 + Expired = 2, + Processing = 3 } } } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 298e8239dc9..6644e096c1f 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -1159,13 +1159,13 @@ public async Task CanCreatePayRequest() 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 @@ public async Task CanCreatePayRequest() // 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 @@ public async Task CanCreatePayRequest() 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 79c587f047d..7bb46e6b674 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -185,7 +185,7 @@ public async Task EditPaymentRequest(string payReqId, UpdatePayme _EventAggregator.Publish(new PaymentRequestUpdated { Data = data, PaymentRequestId = data.Id, }); TempData[WellKnownTempData.SuccessMessage] = $"Payment request \"{viewModel.Title}\" {(isNewPaymentRequest ? "created" : "updated")} successfully"; - return RedirectToAction(nameof(GetPaymentRequests), new { storeId = store.Id, payReqId = data.Id }); + return RedirectToAction(nameof(GetPaymentRequests), new { storeId = store.Id }); } [HttpGet("{payReqId}")] diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs index 405afa842bc..018359fccca 100644 --- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs +++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs @@ -119,6 +119,9 @@ public ViewPaymentRequestViewModel(PaymentRequestData data) 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 c6c5d12e8d2..e80ed1c5e20 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs @@ -23,6 +23,7 @@ public class PaymentRequestHub : Hub { 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 @@ public override async Task StartAsync(CancellationToken cancellationToken) 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 @@ protected override async Task ProcessEvent(object evt, CancellationToken cancell { 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 @@ protected override async Task ProcessEvent(object evt, CancellationToken cancell { 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 @@ protected override async Task ProcessEvent(object evt, CancellationToken cancell 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 ed1688f933a..4a744dfce19 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -10,7 +10,6 @@ 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 @@ public class PaymentRequestService PaymentRequestRepository paymentRequestRepository, BTCPayNetworkProvider btcPayNetworkProvider, InvoiceRepository invoiceRepository, - AppService appService, DisplayFormatter displayFormatter, CurrencyNameTable currencies) { @@ -62,10 +60,19 @@ public async Task UpdatePaymentRequestStateIfNeeded(PaymentRequestData pr) { 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 @@ public async Task GetPaymentRequest(string id, stri 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 6ee7e67d6f8..0177d502c57 100644 --- a/BTCPayServer/Services/Invoices/InvoiceEntity.cs +++ b/BTCPayServer/Services/Invoices/InvoiceEntity.cs @@ -935,6 +935,14 @@ public bool CanRefund() 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 @@ public override bool Equals(object obj) } 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 1c4a8dbd226..e7bed16631e 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -780,9 +780,12 @@ public InvoiceStatistics GetContributionsByPaymentMethodId(string currency, Invo .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 @@ -809,18 +812,22 @@ public InvoiceStatistics GetContributionsByPaymentMethodId(string currency, Invo 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() }); @@ -907,6 +914,7 @@ public InvoiceStatistics(IEnumerable 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 e49216a956d..213fe0b4a7a 100644 --- a/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml +++ b/BTCPayServer/Views/UIPaymentRequest/ViewPaymentRequest.cshtml @@ -13,6 +13,7 @@ switch (state.Status.ToModernStatus()) { case InvoiceStatus.Settled: + return "info"; case InvoiceStatus.Processing: return "success"; case InvoiceStatus.Expired: @@ -131,7 +132,7 @@ else {
- + @Model.Status @if (Model.Archived) { @@ -186,7 +187,7 @@