mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +01:00
Improve Refund Flow (#3731)
This commit is contained in:
@@ -11,18 +11,10 @@ using BTCPayServer.Models.AppViewModels;
|
|||||||
using BTCPayServer.Models.StoreViewModels;
|
using BTCPayServer.Models.StoreViewModels;
|
||||||
using BTCPayServer.Models.WalletViewModels;
|
using BTCPayServer.Models.WalletViewModels;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Payments.Bitcoin;
|
|
||||||
using BTCPayServer.Payments.Lightning;
|
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
using BTCPayServer.Services.Invoices;
|
|
||||||
using BTCPayServer.Tests.Logging;
|
|
||||||
using BTCPayServer.Views.Stores;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using NBitcoin.Scripting.Parser;
|
|
||||||
using NBitpayClient;
|
using NBitpayClient;
|
||||||
using NBXplorer.DerivationStrategy;
|
|
||||||
using NBXplorer.Models;
|
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using OpenQA.Selenium;
|
using OpenQA.Selenium;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -358,7 +350,7 @@ namespace BTCPayServer.Tests
|
|||||||
{
|
{
|
||||||
if (multiCurrency)
|
if (multiCurrency)
|
||||||
user.RegisterDerivationScheme("LTC");
|
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);
|
await CanCreateRefundsCore(s, user, multiCurrency, rateSelection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,7 +360,7 @@ namespace BTCPayServer.Tests
|
|||||||
{
|
{
|
||||||
s.GoToHome();
|
s.GoToHome();
|
||||||
s.Server.PayTester.ChangeRate("BTC_USD", new Rating.BidAsk(5000.0m, 5100.0m));
|
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",
|
Currency = "USD",
|
||||||
Price = 5000.0m
|
Price = 5000.0m
|
||||||
@@ -390,26 +382,35 @@ namespace BTCPayServer.Tests
|
|||||||
s.Driver.FindElement(By.Id("BOLT11Expiration")).Clear();
|
s.Driver.FindElement(By.Id("BOLT11Expiration")).Clear();
|
||||||
s.Driver.FindElement(By.Id("BOLT11Expiration")).SendKeys("5" + Keys.Enter);
|
s.Driver.FindElement(By.Id("BOLT11Expiration")).SendKeys("5" + Keys.Enter);
|
||||||
s.GoToInvoice(invoice.Id);
|
s.GoToInvoice(invoice.Id);
|
||||||
s.Driver.FindElement(By.Id("refundlink")).Click();
|
s.Driver.FindElement(By.Id("IssueRefund")).Click();
|
||||||
|
|
||||||
if (multiCurrency)
|
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("SelectedPaymentMethod")).SendKeys("BTC" + Keys.Enter);
|
||||||
s.Driver.FindElement(By.Id("ok")).Click();
|
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("$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("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
|
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.FindElement(By.Id("ok")).Click();
|
||||||
|
|
||||||
|
s.Driver.WaitUntilAvailable(By.Id("Destination"), TimeSpan.FromSeconds(1));
|
||||||
Assert.Contains("pull-payments", s.Driver.Url);
|
Assert.Contains("pull-payments", s.Driver.Url);
|
||||||
if (rateSelection == "FiatTextRadio")
|
if (rateSelection == "FiatOption")
|
||||||
Assert.Contains("$5,500.00", s.Driver.PageSource);
|
Assert.Contains("$5,500.00", s.Driver.PageSource);
|
||||||
if (rateSelection == "CurrentRateTextRadio")
|
if (rateSelection == "CurrentOption")
|
||||||
Assert.Contains("2.20000000 ₿", s.Driver.PageSource);
|
Assert.Contains("2.20000000 ₿", s.Driver.PageSource);
|
||||||
if (rateSelection == "RateThenTextRadio")
|
if (rateSelection == "RateThenOption")
|
||||||
Assert.Contains("1.10000000 ₿", s.Driver.PageSource);
|
Assert.Contains("1.10000000 ₿", s.Driver.PageSource);
|
||||||
|
|
||||||
s.GoToInvoice(invoice.Id);
|
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);
|
Assert.Contains("pull-payments", s.Driver.Url);
|
||||||
var client = await user.CreateClient();
|
var client = await user.CreateClient();
|
||||||
var ppid = s.Driver.Url.Split('/').Last();
|
var ppid = s.Driver.Url.Split('/').Last();
|
||||||
|
|||||||
@@ -136,36 +136,44 @@ retry:
|
|||||||
ScrollTo(driver, driver.FindElement(selector));
|
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
|
// Try fast path
|
||||||
|
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
driver.FindElement(selector).Click();
|
var el = driver.FindElement(selector);
|
||||||
|
wait.Until(_ => el.Displayed && el.Enabled);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
|
||||||
// Sometimes, selenium complain, so we enter hack territory
|
// Sometimes, selenium complain, so we enter hack territory
|
||||||
var wait = new WebDriverWait(driver, SeleniumTester.ImplicitWait);
|
|
||||||
wait.UntilJsIsReady();
|
wait.UntilJsIsReady();
|
||||||
|
|
||||||
int retriesLeft = 4;
|
int retriesLeft = 4;
|
||||||
retry:
|
retry:
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var el = driver.FindElement(selector);
|
var el = driver.FindElement(selector);
|
||||||
wait.Until(d => el.Displayed && el.Enabled);
|
wait.Until(_ => el.Displayed && el.Enabled);
|
||||||
driver.ScrollTo(selector);
|
driver.ScrollTo(selector);
|
||||||
driver.FindElement(selector).Click();
|
driver.FindElement(selector);
|
||||||
}
|
}
|
||||||
catch (ElementClickInterceptedException) when (retriesLeft > 0)
|
catch (NoSuchElementException) when (retriesLeft > 0)
|
||||||
{
|
{
|
||||||
retriesLeft--;
|
retriesLeft--;
|
||||||
|
if (waitTime != null) Thread.Sleep(waitTime.Value);
|
||||||
goto retry;
|
goto retry;
|
||||||
}
|
}
|
||||||
wait.UntilJsIsReady();
|
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)
|
public static void SetCheckbox(this IWebDriver driver, By selector, bool value)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ namespace BTCPayServer.Controllers
|
|||||||
// TODO: What if no option?
|
// TODO: What if no option?
|
||||||
var refund = new RefundModel
|
var refund = new RefundModel
|
||||||
{
|
{
|
||||||
Title = "Select a payment method",
|
Title = "Payment method",
|
||||||
AvailablePaymentMethods =
|
AvailablePaymentMethods =
|
||||||
new SelectList(options.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString())),
|
new SelectList(options.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString())),
|
||||||
"Value", "Text"),
|
"Value", "Text"),
|
||||||
@@ -210,7 +210,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
return await Refund(invoiceId, refund, cancellationToken);
|
return await Refund(invoiceId, refund, cancellationToken);
|
||||||
}
|
}
|
||||||
return View(refund);
|
return View("_RefundModal", refund);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("invoices/{invoiceId}/refund")]
|
[HttpPost("invoices/{invoiceId}/refund")]
|
||||||
@@ -237,7 +237,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
case RefundSteps.SelectPaymentMethod:
|
case RefundSteps.SelectPaymentMethod:
|
||||||
model.RefundStep = RefundSteps.SelectRate;
|
model.RefundStep = RefundSteps.SelectRate;
|
||||||
model.Title = "What to refund?";
|
model.Title = "How much to refund?";
|
||||||
var pms = invoice.GetPaymentMethods();
|
var pms = invoice.GetPaymentMethods();
|
||||||
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
|
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@ namespace BTCPayServer.Controllers
|
|||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.SelectedRefundOption),
|
ModelState.AddModelError(nameof(model.SelectedRefundOption),
|
||||||
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
|
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
|
||||||
return View(model);
|
return View("_RefundModal", model);
|
||||||
}
|
}
|
||||||
|
|
||||||
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
|
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
|
||||||
@@ -271,9 +271,10 @@ namespace BTCPayServer.Controllers
|
|||||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
|
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
|
||||||
model.FiatAmount = paidCurrency;
|
model.FiatAmount = paidCurrency;
|
||||||
}
|
}
|
||||||
|
model.CustomAmount = model.FiatAmount;
|
||||||
|
model.CustomCurrency = invoice.Currency;
|
||||||
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency);
|
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency);
|
||||||
return View(model);
|
return View("_RefundModal", model);
|
||||||
|
|
||||||
case RefundSteps.SelectRate:
|
case RefundSteps.SelectRate:
|
||||||
createPullPayment = new CreatePullPayment
|
createPullPayment = new CreatePullPayment
|
||||||
@@ -281,8 +282,7 @@ namespace BTCPayServer.Controllers
|
|||||||
Name = $"Refund {invoice.Id}",
|
Name = $"Refund {invoice.Id}",
|
||||||
PaymentMethodIds = new[] { paymentMethodId },
|
PaymentMethodIds = new[] { paymentMethodId },
|
||||||
StoreId = invoice.StoreId,
|
StoreId = invoice.StoreId,
|
||||||
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
|
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
|
||||||
//AutoApproveClaims = true
|
|
||||||
};
|
};
|
||||||
switch (model.SelectedRefundOption)
|
switch (model.SelectedRefundOption)
|
||||||
{
|
{
|
||||||
@@ -291,84 +291,82 @@ namespace BTCPayServer.Controllers
|
|||||||
createPullPayment.Amount = model.CryptoAmountThen;
|
createPullPayment.Amount = model.CryptoAmountThen;
|
||||||
createPullPayment.AutoApproveClaims = true;
|
createPullPayment.AutoApproveClaims = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "CurrentRate":
|
case "CurrentRate":
|
||||||
createPullPayment.Currency = paymentMethodId.CryptoCode;
|
createPullPayment.Currency = paymentMethodId.CryptoCode;
|
||||||
createPullPayment.Amount = model.CryptoAmountNow;
|
createPullPayment.Amount = model.CryptoAmountNow;
|
||||||
createPullPayment.AutoApproveClaims = true;
|
createPullPayment.AutoApproveClaims = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "Fiat":
|
case "Fiat":
|
||||||
createPullPayment.Currency = invoice.Currency;
|
createPullPayment.Currency = invoice.Currency;
|
||||||
createPullPayment.Amount = model.FiatAmount;
|
createPullPayment.Amount = model.FiatAmount;
|
||||||
createPullPayment.AutoApproveClaims = false;
|
createPullPayment.AutoApproveClaims = false;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "Custom":
|
case "Custom":
|
||||||
model.Title = "How much to refund?";
|
model.Title = "How much to refund?";
|
||||||
model.CustomCurrency = invoice.Currency;
|
|
||||||
model.CustomAmount = model.FiatAmount;
|
model.RefundStep = RefundSteps.SelectRate;
|
||||||
model.RefundStep = RefundSteps.SelectCustomAmount;
|
|
||||||
return View(model);
|
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:
|
default:
|
||||||
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Please select an option before proceeding");
|
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;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment);
|
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.",
|
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
|
Severity = StatusMessageModel.StatusSeverity.Success
|
||||||
});
|
});
|
||||||
(await ctx.Invoices.FindAsync(new[] { invoice.Id }, cancellationToken))!.CurrentRefundId = ppId;
|
(await ctx.Invoices.FindAsync(new[] { invoice.Id }, cancellationToken))!.CurrentRefundId = ppId;
|
||||||
ctx.Refunds.Add(new RefundData()
|
ctx.Refunds.Add(new RefundData
|
||||||
{
|
{
|
||||||
InvoiceDataId = invoice.Id,
|
InvoiceDataId = invoice.Id,
|
||||||
PullPaymentDataId = ppId
|
PullPaymentDataId = ppId
|
||||||
});
|
});
|
||||||
await ctx.SaveChangesAsync(cancellationToken);
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
// TODO: Having dedicated UI later on
|
// TODO: Having dedicated UI later on
|
||||||
return RedirectToAction(nameof(UIPullPaymentController.ViewPullPayment),
|
return RedirectToAction(nameof(UIPullPaymentController.ViewPullPayment),
|
||||||
"UIPullPayment",
|
"UIPullPayment",
|
||||||
|
|||||||
@@ -6,13 +6,14 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||||||
public enum RefundSteps
|
public enum RefundSteps
|
||||||
{
|
{
|
||||||
SelectPaymentMethod,
|
SelectPaymentMethod,
|
||||||
SelectRate,
|
SelectRate
|
||||||
SelectCustomAmount
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RefundModel
|
public class RefundModel
|
||||||
{
|
{
|
||||||
public string Title { get; set; }
|
public string Title { get; set; }
|
||||||
public SelectList AvailablePaymentMethods { get; set; }
|
public SelectList AvailablePaymentMethods { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Select the payment method used for refund")]
|
[Display(Name = "Select the payment method used for refund")]
|
||||||
public string SelectedPaymentMethod { get; set; }
|
public string SelectedPaymentMethod { get; set; }
|
||||||
public RefundSteps RefundStep { get; set; }
|
public RefundSteps RefundStep { get; set; }
|
||||||
|
|||||||
@@ -37,9 +37,57 @@
|
|||||||
const { id, status } = button.dataset
|
const { id, status } = button.dataset
|
||||||
changeInvoiceState(id, status)
|
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>
|
</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="invoice-details">
|
||||||
<div class="sticky-header-setup"></div>
|
<div class="sticky-header-setup"></div>
|
||||||
<div class="sticky-header d-md-flex align-items-center justify-content-between">
|
<div class="sticky-header d-md-flex align-items-center justify-content-between">
|
||||||
@@ -51,7 +99,7 @@
|
|||||||
}
|
}
|
||||||
@if (Model.CanRefund)
|
@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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
|
||||||
87
BTCPayServer/Views/UIInvoice/_RefundModal.cshtml
Normal file
87
BTCPayServer/Views/UIInvoice/_RefundModal.cshtml
Normal 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>
|
||||||
Reference in New Issue
Block a user