Refund updates (#4934)

This commit is contained in:
d11n 2023-05-11 10:33:33 +02:00 committed by GitHub
parent 541b6cf9eb
commit 195dfc2c47
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 342 additions and 45 deletions

View file

@ -9,6 +9,7 @@ namespace BTCPayServer.Client.Models
{ {
RateThen, RateThen,
CurrentRate, CurrentRate,
OverpaidAmount,
Fiat, Fiat,
Custom Custom
} }
@ -18,8 +19,13 @@ namespace BTCPayServer.Client.Models
public string? Name { get; set; } = null; public string? Name { get; set; } = null;
public string? PaymentMethod { get; set; } public string? PaymentMethod { get; set; }
public string? Description { get; set; } = null; public string? Description { get; set; } = null;
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public RefundVariant? RefundVariant { get; set; } public RefundVariant? RefundVariant { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal SubtractPercentage { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))] [JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? CustomAmount { get; set; } public decimal? CustomAmount { get; set; }
public string? CustomCurrency { get; set; } public string? CustomCurrency { get; set; }

View file

@ -1950,6 +1950,82 @@ namespace BTCPayServer.Tests
CustomCurrency = "BTC" CustomCurrency = "BTC"
}); });
Assert.True(pp.AutoApproveClaims); Assert.True(pp.AutoApproveClaims);
// test subtract percentage
validationError = await AssertValidationError(new[] { "SubtractPercentage" }, async () =>
{
await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.RateThen,
SubtractPercentage = 101
});
});
Assert.Contains("SubtractPercentage: Percentage must be a numeric value between 0 and 100", validationError.Message);
// should auto-approve
pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.RateThen,
SubtractPercentage = 6.15m
});
Assert.Equal("BTC", pp.Currency);
Assert.True(pp.AutoApproveClaims);
Assert.Equal(0.9385m, pp.Amount);
// test RefundVariant.OverpaidAmount
validationError = await AssertValidationError(new[] { "RefundVariant" }, async () =>
{
await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.OverpaidAmount
});
});
Assert.Contains("Invoice is not overpaid", validationError.Message);
// should auto-approve
invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = 5000.0m, Currency = "USD" });
methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id);
method = methods.First();
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
await tester.ExplorerNode.SendToAddressAsync(
BitcoinAddress.Create(method.Destination, tester.NetworkProvider.BTC.NBitcoinNetwork),
Money.Coins(method.Due * 2)
);
});
await tester.ExplorerNode.GenerateAsync(5);
await TestUtils.EventuallyAsync(async () =>
{
invoice = await client.GetInvoice(user.StoreId, invoice.Id);
Assert.True(invoice.Status == InvoiceStatus.Settled);
Assert.True(invoice.AdditionalStatus == InvoiceExceptionStatus.PaidOver);
});
pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.OverpaidAmount
});
Assert.Equal("BTC", pp.Currency);
Assert.True(pp.AutoApproveClaims);
Assert.Equal(method.Due, pp.Amount);
// once more with subtract percentage
pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest
{
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.OverpaidAmount,
SubtractPercentage = 21m
});
Assert.Equal("BTC", pp.Currency);
Assert.True(pp.AutoApproveClaims);
Assert.Equal(0.79m, pp.Amount);
} }
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]

View file

@ -383,14 +383,15 @@ namespace BTCPayServer.Controllers.Greenfield
} }
if (invoicePaymentMethod is null) if (invoicePaymentMethod is null)
{ {
this.ModelState.AddModelError(nameof(request.PaymentMethod), "Please select one of the payment methods which were available for the original invoice"); ModelState.AddModelError(nameof(request.PaymentMethod), "Please select one of the payment methods which were available for the original invoice");
} }
if (request.RefundVariant is null) if (request.RefundVariant is null)
this.ModelState.AddModelError(nameof(request.RefundVariant), "`refundVariant` is mandatory"); ModelState.AddModelError(nameof(request.RefundVariant), "`refundVariant` is mandatory");
if (!ModelState.IsValid || invoicePaymentMethod is null || paymentMethodId is null) if (!ModelState.IsValid || invoicePaymentMethod is null || paymentMethodId is null)
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
var cryptoPaid = invoicePaymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC); var accounting = invoicePaymentMethod.Calculate();
var cryptoPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC);
var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true); var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true);
var paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility); var paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility);
var rateResult = await _rateProvider.FetchRate( var rateResult = await _rateProvider.FetchRate(
@ -398,8 +399,10 @@ namespace BTCPayServer.Controllers.Greenfield
store.GetStoreBlob().GetRateRules(_networkProvider), store.GetStoreBlob().GetRateRules(_networkProvider),
cancellationToken cancellationToken
); );
var cryptoCode = invoicePaymentMethod.GetId().CryptoCode;
var paymentMethodDivisibility = _currencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8; var paymentMethodDivisibility = _currencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8;
var createPullPayment = new HostedServices.CreatePullPayment() var paidAmount = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
var createPullPayment = new CreatePullPayment
{ {
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration, BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
Name = request.Name ?? $"Refund {invoice.Id}", Name = request.Name ?? $"Refund {invoice.Id}",
@ -411,37 +414,61 @@ namespace BTCPayServer.Controllers.Greenfield
if (request.RefundVariant != RefundVariant.Custom) if (request.RefundVariant != RefundVariant.Custom)
{ {
if (request.CustomAmount is not null) if (request.CustomAmount is not null)
this.ModelState.AddModelError(nameof(request.CustomAmount), "CustomAmount should only be set if the refundVariant is Custom"); ModelState.AddModelError(nameof(request.CustomAmount), "CustomAmount should only be set if the refundVariant is Custom");
if (request.CustomCurrency is not null) if (request.CustomCurrency is not null)
this.ModelState.AddModelError(nameof(request.CustomCurrency), "CustomCurrency should only be set if the refundVariant is Custom"); ModelState.AddModelError(nameof(request.CustomCurrency), "CustomCurrency should only be set if the refundVariant is Custom");
if (!ModelState.IsValid) }
return this.CreateValidationError(ModelState); if (request.SubtractPercentage is < 0 or > 100)
{
ModelState.AddModelError(nameof(request.SubtractPercentage), "Percentage must be a numeric value between 0 and 100");
}
if (!ModelState.IsValid)
{
return this.CreateValidationError(ModelState);
} }
var appliedDivisibility = paymentMethodDivisibility;
switch (request.RefundVariant) switch (request.RefundVariant)
{ {
case RefundVariant.RateThen: case RefundVariant.RateThen:
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode; createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = cryptoPaid.RoundToSignificant(paymentMethodDivisibility); createPullPayment.Amount = paidAmount;
createPullPayment.AutoApproveClaims = true; createPullPayment.AutoApproveClaims = true;
break; break;
case RefundVariant.CurrentRate: case RefundVariant.CurrentRate:
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode; createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility); createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, appliedDivisibility);
createPullPayment.AutoApproveClaims = true; createPullPayment.AutoApproveClaims = true;
break; break;
case RefundVariant.Fiat: case RefundVariant.Fiat:
appliedDivisibility = cdCurrency.Divisibility;
createPullPayment.Currency = invoice.Currency; createPullPayment.Currency = invoice.Currency;
createPullPayment.Amount = paidCurrency; createPullPayment.Amount = paidCurrency;
createPullPayment.AutoApproveClaims = false; createPullPayment.AutoApproveClaims = false;
break; break;
case RefundVariant.OverpaidAmount:
if (invoice.ExceptionStatus != InvoiceExceptionStatus.PaidOver)
{
ModelState.AddModelError(nameof(request.RefundVariant), "Invoice is not overpaid");
}
if (!ModelState.IsValid)
{
return this.CreateValidationError(ModelState);
}
var dueAmount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC);
createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = Math.Round(paidAmount - dueAmount, appliedDivisibility);
createPullPayment.AutoApproveClaims = true;
break;
case RefundVariant.Custom: case RefundVariant.Custom:
if (request.CustomAmount is null || (request.CustomAmount is decimal v && v <= 0)) if (request.CustomAmount is null || (request.CustomAmount is decimal v && v <= 0))
{ {
this.ModelState.AddModelError(nameof(request.CustomAmount), "Amount must be greater than 0"); ModelState.AddModelError(nameof(request.CustomAmount), "Amount must be greater than 0");
} }
if ( if (
@ -472,6 +499,13 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.RefundVariant), "Please select a valid refund option"); ModelState.AddModelError(nameof(request.RefundVariant), "Please select a valid refund option");
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
// reduce by percentage
if (request.SubtractPercentage is > 0 and <= 100)
{
var reduceByAmount = createPullPayment.Amount * (request.SubtractPercentage / 100);
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, appliedDivisibility);
}
var ppId = await _pullPaymentService.CreatePullPayment(createPullPayment); var ppId = await _pullPaymentService.CreatePullPayment(createPullPayment);

View file

@ -347,23 +347,39 @@ namespace BTCPayServer.Controllers
RateRules rules; RateRules rules;
RateResult rateResult; RateResult rateResult;
CreatePullPayment createPullPayment; CreatePullPayment createPullPayment;
PaymentMethodAccounting accounting;
var pms = invoice.GetPaymentMethods();
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
var appliedDivisibility = paymentMethodDivisibility;
decimal dueAmount = default;
decimal paidAmount = default;
decimal cryptoPaid = default;
//TODO: Make this clean
if (paymentMethod is null && paymentMethodId.PaymentType == LightningPaymentType.Instance)
{
paymentMethod = pms[new PaymentMethodId(paymentMethodId.CryptoCode, PaymentTypes.LNURLPay)];
}
if (paymentMethod != null)
{
accounting = paymentMethod.Calculate();
cryptoPaid = accounting.Paid.ToDecimal(MoneyUnit.BTC);
dueAmount = accounting.TotalDue.ToDecimal(MoneyUnit.BTC);
paidAmount = cryptoPaid.RoundToSignificant(appliedDivisibility);
}
var isPaidOver = invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver;
decimal? overpaidAmount = isPaidOver ? Math.Round(paidAmount - dueAmount, appliedDivisibility) : null;
switch (model.RefundStep) switch (model.RefundStep)
{ {
case RefundSteps.SelectPaymentMethod: case RefundSteps.SelectPaymentMethod:
model.RefundStep = RefundSteps.SelectRate; model.RefundStep = RefundSteps.SelectRate;
model.Title = "How much to refund?"; model.Title = "How much to refund?";
var pms = invoice.GetPaymentMethods();
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
//TODO: Make this clean if (paymentMethod != null && cryptoPaid != default)
if (paymentMethod is null && paymentMethodId.PaymentType == LightningPaymentType.Instance)
{ {
paymentMethod = pms[new PaymentMethodId(paymentMethodId.CryptoCode, PaymentTypes.LNURLPay)];
}
if (paymentMethod != null)
{
var cryptoPaid = paymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC);
var paidCurrency = Math.Round(cryptoPaid * paymentMethod.Rate, cdCurrency.Divisibility); var paidCurrency = Math.Round(cryptoPaid * paymentMethod.Rate, cdCurrency.Divisibility);
model.CryptoAmountThen = cryptoPaid.RoundToSignificant(paymentMethodDivisibility); model.CryptoAmountThen = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodId.CryptoCode); model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodId.CryptoCode);
@ -383,8 +399,15 @@ namespace BTCPayServer.Controllers
model.CurrentRateText = _displayFormatter.Currency(model.CryptoAmountNow, paymentMethodId.CryptoCode); model.CurrentRateText = _displayFormatter.Currency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
model.FiatAmount = paidCurrency; model.FiatAmount = paidCurrency;
} }
model.CryptoCode = paymentMethodId.CryptoCode;
model.CryptoDivisibility = paymentMethodDivisibility;
model.InvoiceDivisibility = cdCurrency.Divisibility;
model.InvoiceCurrency = invoice.Currency;
model.CustomAmount = model.FiatAmount; model.CustomAmount = model.FiatAmount;
model.CustomCurrency = invoice.Currency; model.CustomCurrency = invoice.Currency;
model.SubtractPercentage = 0;
model.OverpaidAmount = overpaidAmount;
model.OverpaidAmountText = overpaidAmount != null ? _displayFormatter.Currency(overpaidAmount.Value, paymentMethodId.CryptoCode) : null;
model.FiatText = _displayFormatter.Currency(model.FiatAmount, invoice.Currency); model.FiatText = _displayFormatter.Currency(model.FiatAmount, invoice.Currency);
return View("_RefundModal", model); return View("_RefundModal", model);
@ -399,6 +422,15 @@ namespace BTCPayServer.Controllers
var authorizedForAutoApprove = (await var authorizedForAutoApprove = (await
_authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments)) _authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments))
.Succeeded; .Succeeded;
if (model.SubtractPercentage is < 0 or > 100)
{
ModelState.AddModelError(nameof(model.SubtractPercentage), "Percentage must be a numeric value between 0 and 100");
}
if (!ModelState.IsValid)
{
return View("_RefundModal", model);
}
switch (model.SelectedRefundOption) switch (model.SelectedRefundOption)
{ {
case "RateThen": case "RateThen":
@ -414,27 +446,47 @@ namespace BTCPayServer.Controllers
break; break;
case "Fiat": case "Fiat":
appliedDivisibility = cdCurrency.Divisibility;
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 "OverpaidAmount":
model.Title = "How much to refund?";
model.RefundStep = RefundSteps.SelectRate;
if (isPaidOver)
{
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Invoice is not overpaid");
}
if (overpaidAmount == null)
{
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Overpaid amount cannot be calculated");
}
if (!ModelState.IsValid)
{
return this.CreateValidationError(ModelState);
}
createPullPayment.Currency = paymentMethodId.CryptoCode;
createPullPayment.Amount = overpaidAmount!.Value;
createPullPayment.AutoApproveClaims = true;
break;
case "Custom": case "Custom":
model.Title = "How much to refund?"; model.Title = "How much to refund?";
model.RefundStep = RefundSteps.SelectRate; model.RefundStep = RefundSteps.SelectRate;
if (model.CustomAmount <= 0) if (model.CustomAmount <= 0)
{ {
model.AddModelError(refundModel => refundModel.CustomAmount, "Amount must be greater than 0", this); model.AddModelError(refundModel => refundModel.CustomAmount, "Amount must be greater than 0", this);
} }
if (string.IsNullOrEmpty(model.CustomCurrency) || if (string.IsNullOrEmpty(model.CustomCurrency) ||
_CurrencyNameTable.GetCurrencyData(model.CustomCurrency, false) == null) _CurrencyNameTable.GetCurrencyData(model.CustomCurrency, false) == null)
{ {
ModelState.AddModelError(nameof(model.CustomCurrency), "Invalid currency"); ModelState.AddModelError(nameof(model.CustomCurrency), "Invalid currency");
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return View("_RefundModal", model); return View("_RefundModal", model);
@ -468,6 +520,13 @@ namespace BTCPayServer.Controllers
throw new ArgumentOutOfRangeException(); throw new ArgumentOutOfRangeException();
} }
// reduce by percentage
if (model.SubtractPercentage is > 0 and <= 100)
{
var reduceByAmount = createPullPayment.Amount * (model.SubtractPercentage / 100);
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, appliedDivisibility);
}
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment); var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment);
TempData.SetStatusMessageModel(new StatusMessageModel TempData.SetStatusMessageModel(new StatusMessageModel
{ {

View file

@ -24,9 +24,16 @@ namespace BTCPayServer.Models.InvoicingModels
public string RateThenText { get; set; } public string RateThenText { get; set; }
public string FiatText { get; set; } public string FiatText { get; set; }
public decimal FiatAmount { get; set; } public decimal FiatAmount { get; set; }
public decimal? OverpaidAmount { get; set; }
public string OverpaidAmountText { get; set; }
public decimal SubtractPercentage { get; set; }
[Display(Name = "Specify the amount and currency for the refund")] [Display(Name = "Specify the amount and currency for the refund")]
public decimal CustomAmount { get; set; } public decimal CustomAmount { get; set; }
public string CustomCurrency { get; set; } public string CustomCurrency { get; set; }
public string InvoiceCurrency { get; set; }
public string CryptoCode { get; set; }
public int CryptoDivisibility { get; set; }
public int InvoiceDivisibility { get; set; }
} }
} }

View file

@ -1,4 +1,3 @@
@using BTCPayServer.Client.Models
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Client @using BTCPayServer.Client
@using BTCPayServer.Abstractions.TagHelpers @using BTCPayServer.Abstractions.TagHelpers
@ -68,6 +67,91 @@
const response = await fetch(url, { method, body }) const response = await fetch(url, { method, body })
await handleRefundResponse(response) await handleRefundResponse(response)
}) })
function checkCustomAmount() {
const $refundForm = document.getElementById('RefundForm');
const currency = $refundForm.querySelector('#CustomCurrency').value;
const cryptoCode = $refundForm.querySelector('#CryptoCode').value;
const invoiceCurrency = $refundForm.querySelector('#InvoiceCurrency').value;
const amount = parseFloat($refundForm.querySelector('#CustomAmount').value);
const fiatAmount = parseFloat($refundForm.querySelector('#FiatAmount').value);
const cryptoAmountNow = parseFloat($refundForm.querySelector('#CryptoAmountNow').value);
const cryptoAmountThen = parseFloat($refundForm.querySelector('#CryptoAmountThen').value);
let isOverpaying = false;
if (currency === cryptoCode) {
isOverpaying = amount > Math.max(cryptoAmountNow, cryptoAmountThen);
} else if (currency === invoiceCurrency) {
isOverpaying = amount > fiatAmount;
}
document.getElementById('CustomAmountWarning').hidden = !isOverpaying;
}
delegate('change', '#CustomAmount', checkCustomAmount);
delegate('change', '#CustomCurrency', checkCustomAmount);
function updateSubtractPercentageResult() {
const $refundForm = document.getElementById('RefundForm');
const $result = document.getElementById('SubtractPercentageResult');
const $selectedRefundOption = $refundForm.querySelector('[name="SelectedRefundOption"]:checked');
if (!$selectedRefundOption) {
$result.hidden = true;
return;
}
const refundOption = $selectedRefundOption.value;
const cryptoCode = $refundForm.querySelector('#CryptoCode').value;
const customCurrency = $refundForm.querySelector('#CustomCurrency').value;
const invoiceCurrency = $refundForm.querySelector('#InvoiceCurrency').value;
const customAmount = parseFloat($refundForm.querySelector('#CustomAmount').value);
const fiatAmount = parseFloat($refundForm.querySelector('#FiatAmount').value);
const overpaidAmount = parseFloat($refundForm.querySelector('#OverpaidAmount').value);
const cryptoAmountNow = parseFloat($refundForm.querySelector('#CryptoAmountNow').value);
const cryptoAmountThen = parseFloat($refundForm.querySelector('#CryptoAmountThen').value);
const cryptoDivisibility = parseInt($refundForm.querySelector('#CryptoDivisibility').value);
const invoiceDivisibility = parseInt($refundForm.querySelector('#InvoiceDivisibility').value);
const percentage = parseFloat($refundForm.querySelector('#SubtractPercentage').value);
const isInvalid = isNaN(percentage);
let amount = null;
let currency = cryptoCode;
let divisibility = cryptoDivisibility;
switch (refundOption) {
case 'RateThen':
amount = cryptoAmountThen;
break;
case 'CurrentRate':
amount = cryptoAmountNow;
break;
case 'OverpaidAmount':
amount = overpaidAmount;
break;
case 'Fiat':
amount = fiatAmount;
currency = invoiceCurrency;
divisibility = invoiceDivisibility;
break;
case 'Custom':
amount = customAmount;
currency = customCurrency;
divisibility = customCurrency === invoiceCurrency ? invoiceDivisibility : cryptoDivisibility;
break;
}
if (amount == null || isInvalid) {
$result.hidden = true;
return;
}
console.log({ refundOption, isInvalid, amount, currency })
const reduceByAmount = (amount * (percentage / 100));
const refundAmount = (amount - reduceByAmount).toFixed(divisibility);
$result.innerText = `= ${refundAmount} ${currency} refund`;
$result.hidden = false;
}
delegate('change', '[name="SelectedRefundOption"]', updateSubtractPercentageResult);
delegate('change', '#SubtractPercentage', updateSubtractPercentageResult);
delegate('change', '#CustomCurrency', updateSubtractPercentageResult);
delegate('change', '#CustomAmount', updateSubtractPercentageResult);
</script> </script>
} }
@ -401,7 +485,7 @@
<section class="mt-4 d-print-none"> <section class="mt-4 d-print-none">
<h3 class="mb-3">Webhooks</h3> <h3 class="mb-3">Webhooks</h3>
<div class="table-responsive-xl"> <div class="table-responsive-xl">
<table class="table table-hover table-responsive-md mb-5"> <table class="table table-hover mb-5">
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
@ -478,7 +562,7 @@
<section class="mt-4"> <section class="mt-4">
<h3 class="mb-3">Refunds</h3> <h3 class="mb-3">Refunds</h3>
<div class="table-responsive-xl"> <div class="table-responsive-xl">
<table class="table table-hover table-responsive-md mb-5"> <table class="table table-hover mb-5">
<thead> <thead>
<tr> <tr>
<th>Pull Payment</th> <th>Pull Payment</th>

View file

@ -31,21 +31,37 @@
<span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span> <span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span>
</div> </div>
<div class="form-group"> <button id="ok" type="submit" class="btn btn-primary w-100">Next</button>
<button id="ok" type="submit" class="btn btn-primary w-100">Next</button>
</div>
} }
break; break;
case RefundSteps.SelectRate: case RefundSteps.SelectRate:
<input type="hidden" asp-for="SelectedPaymentMethod"/> <input type="hidden" asp-for="SelectedPaymentMethod"/>
<input type="hidden" asp-for="CryptoAmountThen"/> <input type="hidden" asp-for="CryptoAmountThen"/>
<input type="hidden" asp-for="FiatAmount"/> <input type="hidden" asp-for="FiatAmount"/>
<input type="hidden" asp-for="CryptoAmountNow"/> <input type="hidden" asp-for="OverpaidAmount"/>
<input type="hidden" asp-for="CryptoAmountNow"/>
<input type="hidden" asp-for="CryptoDivisibility"/>
<input type="hidden" asp-for="CryptoCode"/>
<input type="hidden" asp-for="InvoiceCurrency"/>
<input type="hidden" asp-for="InvoiceDivisibility"/>
<style> <style>
#CustomOption ~ .form-group { display: none; } .additional-options { display: none; }
#CustomOption:checked ~ .form-group { display: block; } [name="SelectedRefundOption"]:checked ~ .additional-options { display: block; }
</style> </style>
<span asp-validation-for="SelectedRefundOption" class="text-danger w-100"></span>
@if (Model.OverpaidAmount is not null)
{
<div class="form-group">
<div class="form-check">
<input id="OverpaidAmountOption" asp-for="SelectedRefundOption" type="radio" value="OverpaidAmount" class="form-check-input"/>
<label for="OverpaidAmountOption" class="form-check-label d-flex align-items-center gap-2">@Model.OverpaidAmountText <span class="badge bg-info">Overpaid amount</span></label>
<div class="form-text">The crypto currency amount that was overpaid.</div>
</div>
</div>
<hr class="border" />
}
<div class="form-group"> <div class="form-group">
<div class="form-check"> <div class="form-check">
<input id="RateThenOption" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input"/> <input id="RateThenOption" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input"/>
@ -67,31 +83,39 @@
<div class="form-text">The invoice currency, at the rate when the refund will be sent.</div> <div class="form-text">The invoice currency, at the rate when the refund will be sent.</div>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="form-check"> <div class="form-check">
<input id="CustomOption" asp-for="SelectedRefundOption" type="radio" value="Custom" class="form-check-input"/> <input id="CustomOption" asp-for="SelectedRefundOption" type="radio" value="Custom" class="form-check-input"/>
<label for="CustomOption" class="form-check-label">Custom amount</label> <label for="CustomOption" class="form-check-label">Custom amount</label>
<div class="form-text">The specified amount with the specified currency, at the rate when the refund will be sent.</div> <div class="form-text">The specified amount with the specified currency, at the rate when the refund will be sent.</div>
<div class="form-group pt-2"> <div class="form-group pt-2 additional-options">
<label asp-for="CustomAmount" class="form-label"></label> <label asp-for="CustomAmount" class="form-label"></label>
<div class="input-group"> <div class="input-group">
<input asp-for="CustomAmount" type="number" step="any" asp-format="{0}" class="form-control"/> <input asp-for="CustomAmount" type="number" step="any" asp-format="{0}" class="form-control"/>
<input asp-for="CustomCurrency" type="text" class="form-control w-auto" currency-selection /> <input asp-for="CustomCurrency" type="text" class="form-control w-auto" currency-selection />
</div> </div>
<div class="alert alert-warning my-2" hidden id="CustomAmountWarning" role="alert">
This is an overpayment of the initial amount.
</div>
<span asp-validation-for="CustomAmount" class="text-danger w-100"></span> <span asp-validation-for="CustomAmount" class="text-danger w-100"></span>
<span asp-validation-for="CustomCurrency" class="text-danger w-100"></span> <span asp-validation-for="CustomCurrency" class="text-danger w-100"></span>
</div> </div>
</div> </div>
</div> </div>
<hr class="border" />
<div class="form-group"> <div class="form-group form-check">
<span asp-validation-for="SelectedRefundOption" class="text-danger w-100"></span> <label asp-for="SubtractPercentage" class="form-label">
Optional: Specify the percentage by which to reduce the refund, e.g. as processing charge or to compensate for the mining fee.
</label>
<div class="input-group">
<input asp-for="SubtractPercentage" type="number" step=".01" min="0" class="form-control" style="flex: 0 0 10ch;" />
<span class="input-group-text">%</span>
<span class="input-group-text" id="SubtractPercentageResult" hidden></span>
</div>
<span asp-validation-for="SubtractPercentage" class="text-danger w-100"></span>
</div> </div>
<div class="form-group"> <button id="ok" type="submit" class="btn btn-primary w-100">Create refund</button>
<button id="ok" type="submit" class="btn btn-primary w-100">Create refund</button>
</div>
break; break;
} }
</form> </form>

View file

@ -702,7 +702,7 @@
}, },
"refundVariant": { "refundVariant": {
"type": "string", "type": "string",
"description": "* `RateThen`: Refund the crypto currency price, at the rate the invoice got paid.\r\n* `CurrentRate`: Refund the crypto currency price, at the current rate.\r\n*`Fiat`: Refund the invoice currency, at the rate when the refund will be sent.\r\n*`Custom`: Specify the amount, currency, and rate of the refund. (see `customAmount` and `customCurrency`)", "description": "* `RateThen`: Refund the crypto currency price, at the rate the invoice got paid.\r\n* `CurrentRate`: Refund the crypto currency price, at the current rate.\r\n*`Fiat`: Refund the invoice currency, at the rate when the refund will be sent.\r\n*`OverpaidAmount`: Refund the crypto currency amount that was overpaid.\r\n*`Custom`: Specify the amount, currency, and rate of the refund. (see `customAmount` and `customCurrency`)",
"x-enumNames": [ "x-enumNames": [
"RateThen", "RateThen",
"CurrentRate", "CurrentRate",
@ -712,10 +712,17 @@
"enum": [ "enum": [
"RateThen", "RateThen",
"CurrentRate", "CurrentRate",
"OverpaidAmount",
"Fiat", "Fiat",
"Custom" "Custom"
] ]
}, },
"subtractPercentage": {
"type": "string",
"format": "decimal",
"description": "Optional percentage by which to reduce the refund, e.g. as processing charge or to compensate for the mining fee.",
"example": "2.1"
},
"customAmount": { "customAmount": {
"type": "string", "type": "string",
"format": "decimal", "format": "decimal",