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:
Andrew Camilleri 2019-05-07 08:26:40 +00:00 committed by Nicolas Dorier
parent 60a361f963
commit be844978c1
9 changed files with 191 additions and 11 deletions

View file

@ -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));
}
}
}
}

View file

@ -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);

View file

@ -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
{

View file

@ -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");

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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 = "";

View file

@ -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 {