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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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