mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-21 14:04:12 +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;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.Filters;
|
using BTCPayServer.Filters;
|
||||||
using BTCPayServer.Models;
|
using BTCPayServer.Models;
|
||||||
using BTCPayServer.Models.PaymentRequestViewModels;
|
using BTCPayServer.Models.PaymentRequestViewModels;
|
||||||
|
@ -41,6 +42,7 @@ namespace BTCPayServer.Controllers
|
||||||
private readonly EventAggregator _EventAggregator;
|
private readonly EventAggregator _EventAggregator;
|
||||||
private readonly CurrencyNameTable _Currencies;
|
private readonly CurrencyNameTable _Currencies;
|
||||||
private readonly HtmlSanitizer _htmlSanitizer;
|
private readonly HtmlSanitizer _htmlSanitizer;
|
||||||
|
private readonly InvoiceRepository _InvoiceRepository;
|
||||||
|
|
||||||
public PaymentRequestController(
|
public PaymentRequestController(
|
||||||
InvoiceController invoiceController,
|
InvoiceController invoiceController,
|
||||||
|
@ -50,7 +52,8 @@ namespace BTCPayServer.Controllers
|
||||||
PaymentRequestService paymentRequestService,
|
PaymentRequestService paymentRequestService,
|
||||||
EventAggregator eventAggregator,
|
EventAggregator eventAggregator,
|
||||||
CurrencyNameTable currencies,
|
CurrencyNameTable currencies,
|
||||||
HtmlSanitizer htmlSanitizer)
|
HtmlSanitizer htmlSanitizer,
|
||||||
|
InvoiceRepository invoiceRepository)
|
||||||
{
|
{
|
||||||
_InvoiceController = invoiceController;
|
_InvoiceController = invoiceController;
|
||||||
_UserManager = userManager;
|
_UserManager = userManager;
|
||||||
|
@ -60,6 +63,7 @@ namespace BTCPayServer.Controllers
|
||||||
_EventAggregator = eventAggregator;
|
_EventAggregator = eventAggregator;
|
||||||
_Currencies = currencies;
|
_Currencies = currencies;
|
||||||
_htmlSanitizer = htmlSanitizer;
|
_htmlSanitizer = htmlSanitizer;
|
||||||
|
_InvoiceRepository = invoiceRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
@ -212,7 +216,7 @@ namespace BTCPayServer.Controllers
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("{id}")]
|
[Route("{id}")]
|
||||||
[AllowAnonymous]
|
[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());
|
var result = await _PaymentRequestService.GetPaymentRequest(id, GetUserId());
|
||||||
if (result == null)
|
if (result == null)
|
||||||
|
@ -220,6 +224,8 @@ namespace BTCPayServer.Controllers
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
result.HubPath = PaymentRequestHub.GetHubPath(this.Request);
|
result.HubPath = PaymentRequestHub.GetHubPath(this.Request);
|
||||||
|
result.StatusMessage = statusMessage;
|
||||||
|
|
||||||
return View(result);
|
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()
|
private string GetUserId()
|
||||||
{
|
{
|
||||||
return _UserManager.GetUserId(User);
|
return _UserManager.GetUserId(User);
|
||||||
|
|
|
@ -134,7 +134,9 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
|
||||||
public string AmountCollectedFormatted { get; set; }
|
public string AmountCollectedFormatted { get; set; }
|
||||||
public string AmountFormatted { get; set; }
|
public string AmountFormatted { get; set; }
|
||||||
public bool AnyPendingInvoice { get; set; }
|
public bool AnyPendingInvoice { get; set; }
|
||||||
|
public bool PendingInvoiceHasPayments { get; set; }
|
||||||
public string HubPath { get; set; }
|
public string HubPath { get; set; }
|
||||||
|
public string StatusMessage { get; set; }
|
||||||
|
|
||||||
public class PaymentRequestInvoice
|
public class PaymentRequestInvoice
|
||||||
{
|
{
|
||||||
|
|
|
@ -23,6 +23,8 @@ namespace BTCPayServer.PaymentRequest
|
||||||
public const string PaymentReceived = "PaymentReceived";
|
public const string PaymentReceived = "PaymentReceived";
|
||||||
public const string InfoUpdated = "InfoUpdated";
|
public const string InfoUpdated = "InfoUpdated";
|
||||||
public const string InvoiceError = "InvoiceError";
|
public const string InvoiceError = "InvoiceError";
|
||||||
|
public const string CancelInvoiceError = "CancelInvoiceError";
|
||||||
|
public const string InvoiceCancelled = "InvoiceCancelled";
|
||||||
|
|
||||||
public PaymentRequestHub(PaymentRequestController paymentRequestController)
|
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)
|
public static string GetHubPath(HttpRequest request)
|
||||||
{
|
{
|
||||||
return request.GetRelativePathOrAbsolute("/payment-requests/hub");
|
return request.GetRelativePathOrAbsolute("/payment-requests/hub");
|
||||||
|
|
|
@ -80,6 +80,7 @@ namespace BTCPayServer.PaymentRequest
|
||||||
|
|
||||||
var paymentStats = _AppService.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
|
var paymentStats = _AppService.GetContributionsByPaymentMethodId(blob.Currency, invoices, true);
|
||||||
var amountDue = blob.Amount - paymentStats.TotalCurrency;
|
var amountDue = blob.Amount - paymentStats.TotalCurrency;
|
||||||
|
var pendingInvoice = invoices.SingleOrDefault(entity => entity.Status == InvoiceStatus.New);
|
||||||
|
|
||||||
return new ViewPaymentRequestViewModel(pr)
|
return new ViewPaymentRequestViewModel(pr)
|
||||||
{
|
{
|
||||||
|
@ -90,7 +91,9 @@ namespace BTCPayServer.PaymentRequest
|
||||||
AmountDueFormatted = _currencies.FormatCurrency(amountDue, blob.Currency),
|
AmountDueFormatted = _currencies.FormatCurrency(amountDue, blob.Currency),
|
||||||
CurrencyData = _currencies.GetCurrencyData(blob.Currency, true),
|
CurrencyData = _currencies.GetCurrencyData(blob.Currency, true),
|
||||||
LastUpdated = DateTime.Now,
|
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()
|
Invoices = invoices.Select(entity => new ViewPaymentRequestViewModel.PaymentRequestInvoice()
|
||||||
{
|
{
|
||||||
Id = entity.Id,
|
Id = entity.Id,
|
||||||
|
|
|
@ -136,9 +136,16 @@
|
||||||
}
|
}
|
||||||
else
|
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
|
Pay now
|
||||||
</a>
|
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -40,6 +40,7 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<partial name="_StatusMessage" for="StatusMessage" />
|
||||||
@if (Context.Request.Query.ContainsKey("simple"))
|
@if (Context.Request.Query.ContainsKey("simple"))
|
||||||
{
|
{
|
||||||
@await Html.PartialAsync("MinimalPaymentRequest", Model)
|
@await Html.PartialAsync("MinimalPaymentRequest", Model)
|
||||||
|
@ -62,6 +63,7 @@ else
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-if="ended">Request Expired</template>
|
<template v-if="ended">Request Expired</template>
|
||||||
<template v-else-if="endDiff">Expires in {{endDiff}}</template>
|
<template v-else-if="endDiff">Expires in {{endDiff}}</template>
|
||||||
|
<template v-else>{{srvModel.status}}</template>
|
||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
@ -178,15 +180,26 @@ else
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</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)"
|
Pay now
|
||||||
:disabled="loading">
|
</button>
|
||||||
<div v-if="loading" class="spinner-grow spinner-grow-sm" role="status">
|
<button class="btn btn-secondary btn-lg mt-1"
|
||||||
<span class="sr-only">Loading...</span>
|
v-if="!srvModel.pendingInvoiceHasPayments"
|
||||||
</div>
|
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
|
Cancel current invoice</button>
|
||||||
</button>
|
</template>
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -83,6 +83,15 @@ addLoadEvent(function (ev) {
|
||||||
|
|
||||||
eventAggregator.$emit("pay", amount);
|
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) {
|
formatPaymentMethod: function (str) {
|
||||||
|
|
||||||
if (str.endsWith("LightningLike")) {
|
if (str.endsWith("LightningLike")) {
|
||||||
|
@ -115,6 +124,26 @@ addLoadEvent(function (ev) {
|
||||||
btcpay.showInvoice(invoiceId);
|
btcpay.showInvoice(invoiceId);
|
||||||
btcpay.showFrame();
|
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) {
|
eventAggregator.$on("invoice-error", function (error) {
|
||||||
self.setLoading(false);
|
self.setLoading(false);
|
||||||
var msg = "";
|
var msg = "";
|
||||||
|
|
|
@ -19,6 +19,13 @@ var hubListener = function () {
|
||||||
connection.on("InfoUpdated", function (model) {
|
connection.on("InfoUpdated", function (model) {
|
||||||
eventAggregator.$emit("info-updated", 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() {
|
function connect() {
|
||||||
|
|
||||||
|
@ -39,6 +46,9 @@ var hubListener = function () {
|
||||||
eventAggregator.$on("pay", function (amount) {
|
eventAggregator.$on("pay", function (amount) {
|
||||||
connection.invoke("Pay", amount);
|
connection.invoke("Pay", amount);
|
||||||
});
|
});
|
||||||
|
eventAggregator.$on("cancel-invoice", function () {
|
||||||
|
connection.invoke("CancelUnpaidPendingInvoice");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
Loading…
Add table
Reference in a new issue