diff --git a/BTCPayServer.Tests/PaymentRequestTests.cs b/BTCPayServer.Tests/PaymentRequestTests.cs index 36329cc10..651a9e4ac 100644 --- a/BTCPayServer.Tests/PaymentRequestTests.cs +++ b/BTCPayServer.Tests/PaymentRequestTests.cs @@ -153,5 +153,64 @@ namespace BTCPayServer.Tests } } + + [Fact(Timeout = 60 * 2 * 1000)] + [Trait("Integration", "Integration")] + public async Task CanCancelPaymentWhenPossible() + { + using (var tester = ServerTester.Create()) + { + tester.Start(); + var user = tester.NewAccount(); + user.GrantAccess(); + user.RegisterDerivationScheme("BTC"); + + var paymentRequestController = user.GetController(); + + + Assert.IsType(await + paymentRequestController.CancelUnpaidPendingInvoice(Guid.NewGuid().ToString(), false)); + + + var request = new UpdatePaymentRequestViewModel() + { + Title = "original juice", + Currency = "BTC", + Amount = 1, + StoreId = user.StoreId, + Description = "description" + }; + var response = Assert + .IsType(paymentRequestController.EditPaymentRequest(null, request).Result) + .RouteValues.First(); + + var paymentRequestId = response.Value.ToString(); + + var invoiceId = Assert + .IsType(await paymentRequestController.PayPaymentRequest(paymentRequestId, false)).Value + .ToString(); + + var actionResult = Assert + .IsType(await paymentRequestController.PayPaymentRequest(response.Value.ToString())); + + Assert.Equal("Checkout", actionResult.ActionName); + Assert.Equal("Invoice", actionResult.ControllerName); + Assert.Contains(actionResult.RouteValues, pair => pair.Key == "Id" && pair.Value.ToString() == invoiceId); + + var invoice = user.BitPay.GetInvoice(invoiceId, Facade.Merchant); +Assert.Equal(InvoiceState.ToString(InvoiceStatus.New), invoice.Status); + Assert.IsType(await + paymentRequestController.CancelUnpaidPendingInvoice(paymentRequestId, false)); + + invoice = user.BitPay.GetInvoice(invoiceId, Facade.Merchant); + Assert.Equal(InvoiceState.ToString(InvoiceStatus.Invalid), invoice.Status); + + + Assert.IsType(await + paymentRequestController.CancelUnpaidPendingInvoice(paymentRequestId, false)); + + + } + } } } diff --git a/BTCPayServer/Controllers/PaymentRequestController.cs b/BTCPayServer/Controllers/PaymentRequestController.cs index 587359638..0b09bddef 100644 --- a/BTCPayServer/Controllers/PaymentRequestController.cs +++ b/BTCPayServer/Controllers/PaymentRequestController.cs @@ -6,6 +6,7 @@ using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Filters; using BTCPayServer.Models; using BTCPayServer.Models.PaymentRequestViewModels; @@ -41,6 +42,7 @@ namespace BTCPayServer.Controllers private readonly EventAggregator _EventAggregator; private readonly CurrencyNameTable _Currencies; private readonly HtmlSanitizer _htmlSanitizer; + private readonly InvoiceRepository _InvoiceRepository; public PaymentRequestController( InvoiceController invoiceController, @@ -50,7 +52,8 @@ namespace BTCPayServer.Controllers PaymentRequestService paymentRequestService, EventAggregator eventAggregator, CurrencyNameTable currencies, - HtmlSanitizer htmlSanitizer) + HtmlSanitizer htmlSanitizer, + InvoiceRepository invoiceRepository) { _InvoiceController = invoiceController; _UserManager = userManager; @@ -60,6 +63,7 @@ namespace BTCPayServer.Controllers _EventAggregator = eventAggregator; _Currencies = currencies; _htmlSanitizer = htmlSanitizer; + _InvoiceRepository = invoiceRepository; } [HttpGet] @@ -212,7 +216,7 @@ namespace BTCPayServer.Controllers [HttpGet] [Route("{id}")] [AllowAnonymous] - public async Task ViewPaymentRequest(string id) + public async Task ViewPaymentRequest(string id, string statusMessage = null) { var result = await _PaymentRequestService.GetPaymentRequest(id, GetUserId()); if (result == null) @@ -220,6 +224,8 @@ namespace BTCPayServer.Controllers return NotFound(); } result.HubPath = PaymentRequestHub.GetHubPath(this.Request); + result.StatusMessage = statusMessage; + return View(result); } @@ -313,7 +319,39 @@ namespace BTCPayServer.Controllers } } + [HttpGet] + [Route("{id}/cancel")] + public async Task CancelUnpaidPendingInvoice(string id, bool redirect = true) + { + var result = await _PaymentRequestService.GetPaymentRequest(id, GetUserId()); + if (result == null ) + { + return NotFound(); + } + var invoice = result.Invoices.SingleOrDefault(requestInvoice => + requestInvoice.Status.Equals(InvoiceState.ToString(InvoiceStatus.New),StringComparison.InvariantCulture) && !requestInvoice.Payments.Any()); + + if (invoice == null ) + { + return BadRequest("No unpaid pending invoice to cancel"); + } + + await _InvoiceRepository.UpdatePaidInvoiceToInvalid(invoice.Id); + _EventAggregator.Publish(new InvoiceEvent(await _InvoiceRepository.GetInvoice(invoice.Id), 1008, InvoiceEvent.MarkedInvalid)); + + if (redirect) + { + return RedirectToAction(nameof(ViewPaymentRequest), new + { + Id = id, + StatusMessage = "Payment cancelled" + }); + } + + return Ok("Payment cancelled"); + } + private string GetUserId() { return _UserManager.GetUserId(User); diff --git a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs index abd199035..1921e70a6 100644 --- a/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs +++ b/BTCPayServer/Models/PaymentRequestViewModels/ListPaymentRequestsViewModel.cs @@ -134,7 +134,9 @@ namespace BTCPayServer.Models.PaymentRequestViewModels public string AmountCollectedFormatted { get; set; } public string AmountFormatted { get; set; } public bool AnyPendingInvoice { get; set; } + public bool PendingInvoiceHasPayments { get; set; } public string HubPath { get; set; } + public string StatusMessage { get; set; } public class PaymentRequestInvoice { diff --git a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs index 28080d541..8633318cb 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestHub.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestHub.cs @@ -23,6 +23,8 @@ namespace BTCPayServer.PaymentRequest public const string PaymentReceived = "PaymentReceived"; public const string InfoUpdated = "InfoUpdated"; public const string InvoiceError = "InvoiceError"; + public const string CancelInvoiceError = "CancelInvoiceError"; + public const string InvoiceCancelled = "InvoiceCancelled"; public PaymentRequestHub(PaymentRequestController paymentRequestController) { @@ -61,6 +63,23 @@ namespace BTCPayServer.PaymentRequest } } + public async Task CancelUnpaidPendingInvoice() + { + _PaymentRequestController.ControllerContext.HttpContext = Context.GetHttpContext(); + var result = + await _PaymentRequestController.CancelUnpaidPendingInvoice(Context.Items["pr-id"].ToString(), false); + switch (result) + { + case OkObjectResult okObjectResult: + await Clients.Group(Context.Items["pr-id"].ToString()).SendCoreAsync(InvoiceCancelled, System.Array.Empty()); + break; + + default: + await Clients.Caller.SendCoreAsync(CancelInvoiceError, System.Array.Empty()); + break; + } + } + public static string GetHubPath(HttpRequest request) { return request.GetRelativePathOrAbsolute("/payment-requests/hub"); diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index 8f59fd973..44918ca85 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -80,6 +80,7 @@ namespace BTCPayServer.PaymentRequest var paymentStats = _AppService.GetContributionsByPaymentMethodId(blob.Currency, invoices, true); var amountDue = blob.Amount - paymentStats.TotalCurrency; + var pendingInvoice = invoices.SingleOrDefault(entity => entity.Status == InvoiceStatus.New); return new ViewPaymentRequestViewModel(pr) { @@ -90,7 +91,9 @@ namespace BTCPayServer.PaymentRequest AmountDueFormatted = _currencies.FormatCurrency(amountDue, blob.Currency), CurrencyData = _currencies.GetCurrencyData(blob.Currency, true), LastUpdated = DateTime.Now, - AnyPendingInvoice = invoices.Any(entity => entity.Status == InvoiceStatus.New), + AnyPendingInvoice = pendingInvoice != null, + PendingInvoiceHasPayments = pendingInvoice != null && + pendingInvoice.ExceptionStatus != InvoiceExceptionStatus.None, Invoices = invoices.Select(entity => new ViewPaymentRequestViewModel.PaymentRequestInvoice() { Id = entity.Id, diff --git a/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml b/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml index 4ec3df117..d1d411529 100644 --- a/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml +++ b/BTCPayServer/Views/PaymentRequest/MinimalPaymentRequest.cshtml @@ -136,9 +136,16 @@ } else { - + Pay now + if (Model.AnyPendingInvoice && !Model.PendingInvoiceHasPayments) + { +
+ +
+ } } diff --git a/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml b/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml index 53bd07b0d..aaaf9fb01 100644 --- a/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml +++ b/BTCPayServer/Views/PaymentRequest/ViewPaymentRequest.cshtml @@ -40,6 +40,7 @@ + @if (Context.Request.Query.ContainsKey("simple")) { @await Html.PartialAsync("MinimalPaymentRequest", Model) @@ -62,6 +63,7 @@ else @@ -178,15 +180,26 @@ else + + diff --git a/BTCPayServer/wwwroot/payment-request/app.js b/BTCPayServer/wwwroot/payment-request/app.js index e16416c36..a803b0cf1 100644 --- a/BTCPayServer/wwwroot/payment-request/app.js +++ b/BTCPayServer/wwwroot/payment-request/app.js @@ -83,6 +83,15 @@ addLoadEvent(function (ev) { eventAggregator.$emit("pay", amount); }, + cancelPayment: function (amount) { + this.setLoading(true); + var self = this; + self.timeoutState = setTimeout(function () { + self.setLoading(false); + }, 5000); + + eventAggregator.$emit("cancel-invoice", amount); + }, formatPaymentMethod: function (str) { if (str.endsWith("LightningLike")) { @@ -115,6 +124,26 @@ addLoadEvent(function (ev) { btcpay.showInvoice(invoiceId); btcpay.showFrame(); }); + eventAggregator.$on("invoice-cancelled", function(){ + self.setLoading(false); + Vue.toasted.show('Payment cancelled', { + iconPack: "fontawesome", + icon: "check", + duration: 10000 + }); + }); + eventAggregator.$on("cancel-invoice-error", function (error) { + self.setLoading(false); + Vue.toasted.show("Error cancelling payment", { + iconPack: "fontawesome", + icon: "exclamation-triangle", + fullWidth: false, + theme: "bubble", + type: "error", + position: "top-center", + duration: 10000 + }); + }); eventAggregator.$on("invoice-error", function (error) { self.setLoading(false); var msg = ""; diff --git a/BTCPayServer/wwwroot/payment-request/services/listener.js b/BTCPayServer/wwwroot/payment-request/services/listener.js index b6f2e077e..a0bee720d 100644 --- a/BTCPayServer/wwwroot/payment-request/services/listener.js +++ b/BTCPayServer/wwwroot/payment-request/services/listener.js @@ -19,6 +19,13 @@ var hubListener = function () { connection.on("InfoUpdated", function (model) { eventAggregator.$emit("info-updated", model); }); + connection.on("InvoiceCancelled", function (model) { + eventAggregator.$emit("invoice-cancelled", model); + }); + connection.on("CancelInvoiceError", function (model) { + eventAggregator.$emit("cancel-invoice-error", model); + }); + function connect() { @@ -39,6 +46,9 @@ var hubListener = function () { eventAggregator.$on("pay", function (amount) { connection.invoke("Pay", amount); }); + eventAggregator.$on("cancel-invoice", function () { + connection.invoke("CancelUnpaidPendingInvoice"); + }); return {