mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
Allow cancelling a non paid pending invoice in payment requests (#815)
* allow cancel on un paid new invoices in payment requests * start work on cancel pr payment * finish up cancel action * final touch and add tests
This commit is contained in:
parent
60a361f963
commit
be844978c1
9 changed files with 191 additions and 11 deletions
|
@ -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<PaymentRequestController>();
|
||||
|
||||
|
||||
Assert.IsType<NotFoundResult>(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<RedirectToActionResult>(paymentRequestController.EditPaymentRequest(null, request).Result)
|
||||
.RouteValues.First();
|
||||
|
||||
var paymentRequestId = response.Value.ToString();
|
||||
|
||||
var invoiceId = Assert
|
||||
.IsType<OkObjectResult>(await paymentRequestController.PayPaymentRequest(paymentRequestId, false)).Value
|
||||
.ToString();
|
||||
|
||||
var actionResult = Assert
|
||||
.IsType<RedirectToActionResult>(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<OkObjectResult>(await
|
||||
paymentRequestController.CancelUnpaidPendingInvoice(paymentRequestId, false));
|
||||
|
||||
invoice = user.BitPay.GetInvoice(invoiceId, Facade.Merchant);
|
||||
Assert.Equal(InvoiceState.ToString(InvoiceStatus.Invalid), invoice.Status);
|
||||
|
||||
|
||||
Assert.IsType<BadRequestObjectResult>(await
|
||||
paymentRequestController.CancelUnpaidPendingInvoice(paymentRequestId, false));
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<IActionResult> ViewPaymentRequest(string id)
|
||||
public async Task<IActionResult> 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<IActionResult> 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);
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
|
@ -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<object>());
|
||||
break;
|
||||
|
||||
default:
|
||||
await Clients.Caller.SendCoreAsync(CancelInvoiceError, System.Array.Empty<object>());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetHubPath(HttpRequest request)
|
||||
{
|
||||
return request.GetRelativePathOrAbsolute("/payment-requests/hub");
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -136,9 +136,16 @@
|
|||
}
|
||||
else
|
||||
{
|
||||
<a class="btn btn-primary btn-lg d-print-none" asp-action="PayPaymentRequest">
|
||||
<a class="btn btn-primary btn-lg d-print-none mt-1" asp-action="PayPaymentRequest">
|
||||
Pay now
|
||||
</a>
|
||||
if (Model.AnyPendingInvoice && !Model.PendingInvoiceHasPayments)
|
||||
{
|
||||
<form method="get" asp-action="CancelUnpaidPendingInvoice">
|
||||
<button class="btn btn-secondary btn-lg mt-1" type="submit">
|
||||
Cancel current invoice</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -40,6 +40,7 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<partial name="_StatusMessage" for="StatusMessage" />
|
||||
@if (Context.Request.Query.ContainsKey("simple"))
|
||||
{
|
||||
@await Html.PartialAsync("MinimalPaymentRequest", Model)
|
||||
|
@ -62,6 +63,7 @@ else
|
|||
<template v-else>
|
||||
<template v-if="ended">Request Expired</template>
|
||||
<template v-else-if="endDiff">Expires in {{endDiff}}</template>
|
||||
<template v-else>{{srvModel.status}}</template>
|
||||
</template>
|
||||
</span>
|
||||
</h1>
|
||||
|
@ -178,15 +180,26 @@ else
|
|||
</form>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button class="btn btn-primary btn-lg mt-1" v-on:click="pay(null)"
|
||||
:disabled="loading">
|
||||
<div v-if="loading" class="spinner-grow spinner-grow-sm" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
<button v-else class="btn btn-primary btn-lg " v-on:click="pay(null)"
|
||||
:disabled="loading">
|
||||
<div v-if="loading" class="spinner-grow spinner-grow-sm" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
Pay now
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-lg mt-1"
|
||||
v-if="!srvModel.pendingInvoiceHasPayments"
|
||||
v-on:click="cancelPayment()"
|
||||
:disabled="loading">
|
||||
<div v-if="loading" class="spinner-grow spinner-grow-sm" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
|
||||
Pay now
|
||||
</button>
|
||||
Cancel current invoice</button>
|
||||
</template>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
|
@ -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 = "";
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue