Improve Refund Flow (#3731)

This commit is contained in:
d11n 2022-06-02 10:08:55 +02:00 committed by GitHub
parent fcd6159b42
commit 5616b7550f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 224 additions and 180 deletions

View file

@ -11,18 +11,10 @@ using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Stores;
using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.Scripting.Parser;
using NBitpayClient;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using Newtonsoft.Json.Linq;
using OpenQA.Selenium;
using Xunit;
@ -358,7 +350,7 @@ namespace BTCPayServer.Tests
{
if (multiCurrency)
user.RegisterDerivationScheme("LTC");
foreach (var rateSelection in new[] { "FiatTextRadio", "CurrentRateTextRadio", "RateThenTextRadio" })
foreach (var rateSelection in new[] { "FiatOption", "CurrentRateOption", "RateThenOption", "CustomOption" })
await CanCreateRefundsCore(s, user, multiCurrency, rateSelection);
}
}
@ -368,7 +360,7 @@ namespace BTCPayServer.Tests
{
s.GoToHome();
s.Server.PayTester.ChangeRate("BTC_USD", new Rating.BidAsk(5000.0m, 5100.0m));
var invoice = await user.BitPay.CreateInvoiceAsync(new NBitpayClient.Invoice()
var invoice = await user.BitPay.CreateInvoiceAsync(new Invoice
{
Currency = "USD",
Price = 5000.0m
@ -390,26 +382,35 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("BOLT11Expiration")).Clear();
s.Driver.FindElement(By.Id("BOLT11Expiration")).SendKeys("5" + Keys.Enter);
s.GoToInvoice(invoice.Id);
s.Driver.FindElement(By.Id("refundlink")).Click();
s.Driver.FindElement(By.Id("IssueRefund")).Click();
if (multiCurrency)
{
s.Driver.WaitUntilAvailable(By.Id("RefundForm"), TimeSpan.FromSeconds(1));
s.Driver.WaitUntilAvailable(By.Id("SelectedPaymentMethod"), TimeSpan.FromSeconds(1));
s.Driver.FindElement(By.Id("SelectedPaymentMethod")).SendKeys("BTC" + Keys.Enter);
s.Driver.FindElement(By.Id("ok")).Click();
}
s.Driver.WaitUntilAvailable(By.Id("RefundForm"), TimeSpan.FromSeconds(1));
Assert.Contains("$5,500.00", s.Driver.PageSource); // Should propose reimburse in fiat
Assert.Contains("1.10000000 ₿", s.Driver.PageSource); // Should propose reimburse in BTC at the rate of before
Assert.Contains("2.20000000 ₿", s.Driver.PageSource); // Should propose reimburse in BTC at the current rate
s.Driver.FindElement(By.Id(rateSelection)).Click();
s.Driver.WaitForAndClick(By.Id(rateSelection));
s.Driver.FindElement(By.Id("ok")).Click();
s.Driver.WaitUntilAvailable(By.Id("Destination"), TimeSpan.FromSeconds(1));
Assert.Contains("pull-payments", s.Driver.Url);
if (rateSelection == "FiatTextRadio")
if (rateSelection == "FiatOption")
Assert.Contains("$5,500.00", s.Driver.PageSource);
if (rateSelection == "CurrentRateTextRadio")
if (rateSelection == "CurrentOption")
Assert.Contains("2.20000000 ₿", s.Driver.PageSource);
if (rateSelection == "RateThenTextRadio")
if (rateSelection == "RateThenOption")
Assert.Contains("1.10000000 ₿", s.Driver.PageSource);
s.GoToInvoice(invoice.Id);
s.Driver.FindElement(By.Id("refundlink")).Click();
s.Driver.FindElement(By.Id("IssueRefund")).Click();
s.Driver.WaitUntilAvailable(By.Id("Destination"), TimeSpan.FromSeconds(1));
Assert.Contains("pull-payments", s.Driver.Url);
var client = await user.CreateClient();
var ppid = s.Driver.Url.Split('/').Last();

View file

@ -136,36 +136,44 @@ retry:
ScrollTo(driver, driver.FindElement(selector));
}
public static void WaitForAndClick(this IWebDriver driver, By selector)
public static void WaitUntilAvailable(this IWebDriver driver, By selector, TimeSpan? waitTime = null)
{
// Try fast path
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
try
{
driver.FindElement(selector).Click();
var el = driver.FindElement(selector);
wait.Until(_ => el.Displayed && el.Enabled);
return;
}
catch { }
// Sometimes, selenium complain, so we enter hack territory
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
wait.UntilJsIsReady();
int retriesLeft = 4;
retry:
retry:
try
{
var el = driver.FindElement(selector);
wait.Until(d => el.Displayed && el.Enabled);
wait.Until(_ => el.Displayed && el.Enabled);
driver.ScrollTo(selector);
driver.FindElement(selector).Click();
driver.FindElement(selector);
}
catch (ElementClickInterceptedException) when (retriesLeft > 0)
catch (NoSuchElementException) when (retriesLeft > 0)
{
retriesLeft--;
if (waitTime != null) Thread.Sleep(waitTime.Value);
goto retry;
}
wait.UntilJsIsReady();
}
public static void WaitForAndClick(this IWebDriver driver, By selector)
{
driver.WaitUntilAvailable(selector);
driver.FindElement(selector).Click();
}
public static void SetCheckbox(this IWebDriver driver, By selector, bool value)
{

View file

@ -198,7 +198,7 @@ namespace BTCPayServer.Controllers
// TODO: What if no option?
var refund = new RefundModel
{
Title = "Select a payment method",
Title = "Payment method",
AvailablePaymentMethods =
new SelectList(options.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString())),
"Value", "Text"),
@ -210,7 +210,7 @@ namespace BTCPayServer.Controllers
{
return await Refund(invoiceId, refund, cancellationToken);
}
return View(refund);
return View("_RefundModal", refund);
}
[HttpPost("invoices/{invoiceId}/refund")]
@ -237,7 +237,7 @@ namespace BTCPayServer.Controllers
{
case RefundSteps.SelectPaymentMethod:
model.RefundStep = RefundSteps.SelectRate;
model.Title = "What to refund?";
model.Title = "How much to refund?";
var pms = invoice.GetPaymentMethods();
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
@ -263,7 +263,7 @@ namespace BTCPayServer.Controllers
{
ModelState.AddModelError(nameof(model.SelectedRefundOption),
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
return View(model);
return View("_RefundModal", model);
}
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
@ -271,9 +271,10 @@ namespace BTCPayServer.Controllers
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
model.FiatAmount = paidCurrency;
}
model.CustomAmount = model.FiatAmount;
model.CustomCurrency = invoice.Currency;
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency);
return View(model);
return View("_RefundModal", model);
case RefundSteps.SelectRate:
createPullPayment = new CreatePullPayment
@ -281,8 +282,7 @@ namespace BTCPayServer.Controllers
Name = $"Refund {invoice.Id}",
PaymentMethodIds = new[] { paymentMethodId },
StoreId = invoice.StoreId,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
//AutoApproveClaims = true
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
};
switch (model.SelectedRefundOption)
{
@ -291,84 +291,82 @@ namespace BTCPayServer.Controllers
createPullPayment.Amount = model.CryptoAmountThen;
createPullPayment.AutoApproveClaims = true;
break;
case "CurrentRate":
createPullPayment.Currency = paymentMethodId.CryptoCode;
createPullPayment.Amount = model.CryptoAmountNow;
createPullPayment.AutoApproveClaims = true;
break;
case "Fiat":
createPullPayment.Currency = invoice.Currency;
createPullPayment.Amount = model.FiatAmount;
createPullPayment.AutoApproveClaims = false;
break;
case "Custom":
model.Title = "How much to refund?";
model.CustomCurrency = invoice.Currency;
model.CustomAmount = model.FiatAmount;
model.RefundStep = RefundSteps.SelectCustomAmount;
return View(model);
model.RefundStep = RefundSteps.SelectRate;
if (model.CustomAmount <= 0)
{
model.AddModelError(refundModel => refundModel.CustomAmount, "Amount must be greater than 0", this);
}
if (string.IsNullOrEmpty(model.CustomCurrency) ||
_CurrencyNameTable.GetCurrencyData(model.CustomCurrency, false) == null)
{
ModelState.AddModelError(nameof(model.CustomCurrency), "Invalid currency");
}
if (!ModelState.IsValid)
{
return View("_RefundModal", model);
}
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodId.CryptoCode, model.CustomCurrency), rules,
cancellationToken);
//TODO: What if fetching rate failed?
if (rateResult.BidAsk is null)
{
ModelState.AddModelError(nameof(model.SelectedRefundOption),
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
return View("_RefundModal", model);
}
createPullPayment.Currency = model.CustomCurrency;
createPullPayment.Amount = model.CustomAmount;
createPullPayment.AutoApproveClaims = paymentMethodId.CryptoCode == model.CustomCurrency;
break;
default:
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Please select an option before proceeding");
return View(model);
return View("_RefundModal", model);
}
break;
case RefundSteps.SelectCustomAmount:
if (model.CustomAmount <= 0)
{
model.AddModelError(refundModel => refundModel.CustomAmount, "Amount must be greater than 0", this);
}
if (string.IsNullOrEmpty(model.CustomCurrency) ||
_CurrencyNameTable.GetCurrencyData(model.CustomCurrency, false) == null)
{
ModelState.AddModelError(nameof(model.CustomCurrency), "Invalid currency");
}
if (!ModelState.IsValid)
{
return View(model);
}
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
rateResult = await _RateProvider.FetchRate(
new CurrencyPair(paymentMethodId.CryptoCode, model.CustomCurrency), rules,
cancellationToken);
//TODO: What if fetching rate failed?
if (rateResult.BidAsk is null)
{
ModelState.AddModelError(nameof(model.SelectedRefundOption),
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
return View(model);
}
createPullPayment = new CreatePullPayment
{
Name = $"Refund {invoice.Id}",
PaymentMethodIds = new[] {paymentMethodId},
StoreId = invoice.StoreId,
Currency = model.CustomCurrency,
Amount = model.CustomAmount,
AutoApproveClaims = paymentMethodId.CryptoCode == model.CustomCurrency
};
break;
default:
throw new ArgumentOutOfRangeException();
}
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment);
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Html = "Refund successfully created!<br />Share the link to this page with a customer.<br />The customer needs to enter their address and claim the refund.<br />Once a customer claims the refund, you will get a notification and would need to approve and initiate it from your Store > Payouts.",
Severity = StatusMessageModel.StatusSeverity.Success
});
(await ctx.Invoices.FindAsync(new[] { invoice.Id }, cancellationToken))!.CurrentRefundId = ppId;
ctx.Refunds.Add(new RefundData()
ctx.Refunds.Add(new RefundData
{
InvoiceDataId = invoice.Id,
PullPaymentDataId = ppId
});
await ctx.SaveChangesAsync(cancellationToken);
// TODO: Having dedicated UI later on
return RedirectToAction(nameof(UIPullPaymentController.ViewPullPayment),
"UIPullPayment",

View file

@ -6,13 +6,14 @@ namespace BTCPayServer.Models.InvoicingModels
public enum RefundSteps
{
SelectPaymentMethod,
SelectRate,
SelectCustomAmount
SelectRate
}
public class RefundModel
{
public string Title { get; set; }
public SelectList AvailablePaymentMethods { get; set; }
[Display(Name = "Select the payment method used for refund")]
public string SelectedPaymentMethod { get; set; }
public RefundSteps RefundStep { get; set; }

View file

@ -37,9 +37,57 @@
const { id, status } = button.dataset
changeInvoiceState(id, status)
})
const handleRefundResponse = async response => {
const modalBody = document.querySelector('#RefundModal .modal-body')
if (response.ok && response.redirected) {
window.location = response.url
} else if (response.ok) {
modalBody.innerHTML = await response.text()
} else {
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">Failed to load refund options.</div>'
}
}
delegate('click', '#IssueRefund', async e => {
e.preventDefault()
const { href: url } = e.target
const response = await fetch(url)
await handleRefundResponse(response)
})
delegate('submit', '#RefundForm', async e => {
e.preventDefault()
const form = e.target
const { action: url, method } = form
const body = new FormData(form)
const response = await fetch(url, { method, body })
await handleRefundResponse(response)
})
</script>
}
@if (Model.CanRefund)
{
<div id="RefundModal" class="modal fade" tabindex="-1" aria-labelledby="RefundTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="RefundTitle">Issue Refund</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close" />
</button>
</div>
<div class="modal-body">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
}
<div class="invoice-details">
<div class="sticky-header-setup"></div>
<div class="sticky-header d-md-flex align-items-center justify-content-between">
@ -51,7 +99,7 @@
}
@if (Model.CanRefund)
{
<a id="refundlink" class="btn btn-success text-nowrap" asp-action="Refund" asp-route-invoiceId="@Context.GetRouteValue("invoiceId")">Issue Refund</a>
<a id="IssueRefund" class="btn btn-success text-nowrap" asp-action="Refund" asp-route-invoiceId="@Model.Id" data-bs-toggle="modal" data-bs-target="#RefundModal">Issue Refund</a>
}
else
{

View file

@ -1,99 +0,0 @@
@model RefundModel
@{
ViewData["Title"] = "Refund";
}
<div class="row">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">@Model.Title</h4>
</div>
<div class="modal-body">
<form method="post">
<input type="hidden" asp-for="RefundStep" value="@Model.RefundStep"/>
<input type="hidden" asp-for="Title" value="@Model.Title"/>
<input type="hidden" asp-for="RateThenText" value="@Model.RateThenText"/>
<input type="hidden" asp-for="CurrentRateText" value="@Model.CurrentRateText"/>
<input type="hidden" asp-for="FiatText" value="@Model.FiatText"/>
@switch (Model.RefundStep)
{
case RefundSteps.SelectPaymentMethod:
<div class="form-group">
<label asp-for="SelectedPaymentMethod" class="form-label"></label>
<select asp-items="Model.AvailablePaymentMethods" asp-for="SelectedPaymentMethod" class="form-select"></select>
<span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span>
</div>
<div class="form-group">
<button id="ok" type="submit" class="btn btn-primary btn-lg w-100">Next</button>
</div>
break;
case RefundSteps.SelectRate:
<input type="hidden" asp-for="SelectedPaymentMethod"/>
<input type="hidden" asp-for="CryptoAmountThen"/>
<input type="hidden" asp-for="FiatAmount"/>
<input type="hidden" asp-for="CryptoAmountNow"/>
<div class="form-group">
<div class="form-check">
<input id="RateThenTextRadio" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input"/>
<label for="RateThenTextRadio" class="form-check-label">@Model.RateThenText</label>
</div>
<small class="form-text text-muted">The crypto currency price, at the rate the invoice got paid.</small>
</div>
<div class="form-group">
<div class="form-check">
<input id="CurrentRateTextRadio" asp-for="SelectedRefundOption" type="radio" value="CurrentRate" class="form-check-input"/>
<label for="CurrentRateTextRadio" class="form-check-label">@Model.CurrentRateText</label>
</div>
<small class="form-text text-muted">The crypto currency price, at the current rate.</small>
</div>
<div class="form-group">
<div class="form-check">
<input id="FiatTextRadio" asp-for="SelectedRefundOption" type="radio" value="Fiat" class="form-check-input"/>
<label for="FiatTextRadio" class="form-check-label">@Model.FiatText</label>
</div>
<small class="form-text text-muted">The invoice currency, at the rate when the refund will be sent.</small>
</div>
<div class="form-group">
<div class="form-check">
<input id="CustomText" asp-for="SelectedRefundOption" type="radio" value="Custom" class="form-check-input"/>
<label for="CustomText" class="form-check-label">Custom</label>
</div>
<small class="form-text text-muted">The specified amount with the specified currency, at the rate when the refund will be sent. </small>
</div>
<div class="form-group">
<span asp-validation-for="SelectedRefundOption" class="text-danger w-100"></span>
</div>
<div class="form-group">
<button id="ok" type="submit" class="btn btn-primary btn-lg w-100">Create refund</button>
</div>
break;
case RefundSteps.SelectCustomAmount:
<input type="hidden" asp-for="SelectedPaymentMethod"/>
<input type="hidden" asp-for="CryptoAmountThen"/>
<input type="hidden" asp-for="FiatAmount"/>
<input type="hidden" asp-for="CryptoAmountNow"/>
<input type="hidden" asp-for="SelectedRefundOption"/>
<div class="form-group">
<label asp-for="CustomAmount" class="form-label"></label>
<div class="input-group">
<input asp-for="CustomAmount" type="number" step="any" asp-format="{0}" class="form-control"/>
<input asp-for="CustomCurrency" type="text" class="form-control"/>
</div>
<span asp-validation-for="CustomAmount" class="text-danger w-100"></span>
<span asp-validation-for="CustomCurrency" class="text-danger w-100"></span>
</div>
<div class="form-group">
<button id="ok" type="submit" class="btn btn-primary btn-lg w-100">Next</button>
</div>
break;
}
</form>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,87 @@
@model RefundModel
@{
Layout = null;
}
<form method="post" asp-action="Refund" asp-route-invoiceId="@Context.GetRouteValue("invoiceId")" id="RefundForm">
<input type="hidden" asp-for="RefundStep" value="@Model.RefundStep"/>
<input type="hidden" asp-for="Title" value="@Model.Title"/>
<input type="hidden" asp-for="RateThenText" value="@Model.RateThenText"/>
<input type="hidden" asp-for="CurrentRateText" value="@Model.CurrentRateText"/>
<input type="hidden" asp-for="FiatText" value="@Model.FiatText"/>
<h5 class="mb-3">@Model.Title</h5>
@switch (Model.RefundStep)
{
case RefundSteps.SelectPaymentMethod:
<div class="form-group">
<label asp-for="SelectedPaymentMethod" class="form-label"></label>
<select asp-items="Model.AvailablePaymentMethods" asp-for="SelectedPaymentMethod" class="form-select"></select>
<span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span>
</div>
<div class="form-group">
<button id="ok" type="submit" class="btn btn-primary w-100">Next</button>
</div>
break;
case RefundSteps.SelectRate:
<input type="hidden" asp-for="SelectedPaymentMethod"/>
<input type="hidden" asp-for="CryptoAmountThen"/>
<input type="hidden" asp-for="FiatAmount"/>
<input type="hidden" asp-for="CryptoAmountNow"/>
<style>
#CustomOption ~ .form-group { display: none; }
#CustomOption:checked ~ .form-group { display: block; }
</style>
<div class="form-group">
<div class="form-check">
<input id="RateThenOption" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input"/>
<label for="RateThenOption" class="form-check-label">@Model.RateThenText</label>
<div class="form-text text-muted">The crypto currency price, at the rate the invoice got paid.</div>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input id="CurrentRateOption" asp-for="SelectedRefundOption" type="radio" value="CurrentRate" class="form-check-input"/>
<label for="CurrentRateOption" class="form-check-label">@Model.CurrentRateText</label>
<div class="form-text text-muted">The crypto currency price, at the current rate.</div>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input id="FiatOption" asp-for="SelectedRefundOption" type="radio" value="Fiat" class="form-check-input"/>
<label for="FiatOption" class="form-check-label">@Model.FiatText</label>
<div class="form-text text-muted">The invoice currency, at the rate when the refund will be sent.</div>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input id="CustomOption" asp-for="SelectedRefundOption" type="radio" value="Custom" class="form-check-input"/>
<label for="CustomOption" class="form-check-label">Custom amount</label>
<div class="form-text text-muted">The specified amount with the specified currency, at the rate when the refund will be sent.</div>
<div class="form-group pt-2">
<label asp-for="CustomAmount" class="form-label"></label>
<div class="input-group">
<input asp-for="CustomAmount" type="number" step="any" asp-format="{0}" class="form-control"/>
<input asp-for="CustomCurrency" type="text" class="form-control" currency-selection style="max-width:10ch;"/>
</div>
<span asp-validation-for="CustomAmount" class="text-danger w-100"></span>
<span asp-validation-for="CustomCurrency" class="text-danger w-100"></span>
</div>
</div>
</div>
<div class="form-group">
<span asp-validation-for="SelectedRefundOption" class="text-danger w-100"></span>
</div>
<div class="form-group">
<button id="ok" type="submit" class="btn btn-primary w-100">Create refund</button>
</div>
break;
}
</form>