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

View file

@ -1950,6 +1950,82 @@ namespace BTCPayServer.Tests
CustomCurrency = "BTC"
});
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)]

View file

@ -383,14 +383,15 @@ namespace BTCPayServer.Controllers.Greenfield
}
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)
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)
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 paidCurrency = Math.Round(cryptoPaid * invoicePaymentMethod.Rate, cdCurrency.Divisibility);
var rateResult = await _rateProvider.FetchRate(
@ -398,8 +399,10 @@ namespace BTCPayServer.Controllers.Greenfield
store.GetStoreBlob().GetRateRules(_networkProvider),
cancellationToken
);
var cryptoCode = invoicePaymentMethod.GetId().CryptoCode;
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,
Name = request.Name ?? $"Refund {invoice.Id}",
@ -411,37 +414,61 @@ namespace BTCPayServer.Controllers.Greenfield
if (request.RefundVariant != RefundVariant.Custom)
{
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)
this.ModelState.AddModelError(nameof(request.CustomCurrency), "CustomCurrency should only be set if the refundVariant is Custom");
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
ModelState.AddModelError(nameof(request.CustomCurrency), "CustomCurrency should only be set if the refundVariant is Custom");
}
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)
{
case RefundVariant.RateThen:
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode;
createPullPayment.Amount = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = paidAmount;
createPullPayment.AutoApproveClaims = true;
break;
case RefundVariant.CurrentRate:
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode;
createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
createPullPayment.Currency = cryptoCode;
createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, appliedDivisibility);
createPullPayment.AutoApproveClaims = true;
break;
case RefundVariant.Fiat:
appliedDivisibility = cdCurrency.Divisibility;
createPullPayment.Currency = invoice.Currency;
createPullPayment.Amount = paidCurrency;
createPullPayment.AutoApproveClaims = false;
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:
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 (
@ -472,6 +499,13 @@ namespace BTCPayServer.Controllers.Greenfield
ModelState.AddModelError(nameof(request.RefundVariant), "Please select a valid refund option");
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);

View file

@ -347,23 +347,39 @@ namespace BTCPayServer.Controllers
RateRules rules;
RateResult rateResult;
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)
{
case RefundSteps.SelectPaymentMethod:
model.RefundStep = RefundSteps.SelectRate;
model.Title = "How much to refund?";
var pms = invoice.GetPaymentMethods();
var paymentMethod = pms.SingleOrDefault(method => method.GetId() == paymentMethodId);
//TODO: Make this clean
if (paymentMethod is null && paymentMethodId.PaymentType == LightningPaymentType.Instance)
if (paymentMethod != null && cryptoPaid != default)
{
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);
model.CryptoAmountThen = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
model.RateThenText = _displayFormatter.Currency(model.CryptoAmountThen, paymentMethodId.CryptoCode);
@ -383,8 +399,15 @@ namespace BTCPayServer.Controllers
model.CurrentRateText = _displayFormatter.Currency(model.CryptoAmountNow, paymentMethodId.CryptoCode);
model.FiatAmount = paidCurrency;
}
model.CryptoCode = paymentMethodId.CryptoCode;
model.CryptoDivisibility = paymentMethodDivisibility;
model.InvoiceDivisibility = cdCurrency.Divisibility;
model.InvoiceCurrency = invoice.Currency;
model.CustomAmount = model.FiatAmount;
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);
return View("_RefundModal", model);
@ -399,6 +422,15 @@ namespace BTCPayServer.Controllers
var authorizedForAutoApprove = (await
_authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments))
.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)
{
case "RateThen":
@ -414,27 +446,47 @@ namespace BTCPayServer.Controllers
break;
case "Fiat":
appliedDivisibility = cdCurrency.Divisibility;
createPullPayment.Currency = invoice.Currency;
createPullPayment.Amount = model.FiatAmount;
createPullPayment.AutoApproveClaims = false;
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":
model.Title = "How much to refund?";
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);
@ -468,6 +520,13 @@ namespace BTCPayServer.Controllers
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);
TempData.SetStatusMessageModel(new StatusMessageModel
{

View file

@ -24,9 +24,16 @@ namespace BTCPayServer.Models.InvoicingModels
public string RateThenText { get; set; }
public string FiatText { 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")]
public decimal CustomAmount { 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 BTCPayServer.Client
@using BTCPayServer.Abstractions.TagHelpers
@ -68,6 +67,91 @@
const response = await fetch(url, { method, body })
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>
}
@ -401,7 +485,7 @@
<section class="mt-4 d-print-none">
<h3 class="mb-3">Webhooks</h3>
<div class="table-responsive-xl">
<table class="table table-hover table-responsive-md mb-5">
<table class="table table-hover mb-5">
<thead>
<tr>
<th>Status</th>
@ -478,7 +562,7 @@
<section class="mt-4">
<h3 class="mb-3">Refunds</h3>
<div class="table-responsive-xl">
<table class="table table-hover table-responsive-md mb-5">
<table class="table table-hover mb-5">
<thead>
<tr>
<th>Pull Payment</th>

View file

@ -31,21 +31,37 @@
<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>
<button id="ok" type="submit" class="btn btn-primary w-100">Next</button>
}
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"/>
<input type="hidden" asp-for="FiatAmount"/>
<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>
#CustomOption ~ .form-group { display: none; }
#CustomOption:checked ~ .form-group { display: block; }
.additional-options { display: none; }
[name="SelectedRefundOption"]:checked ~ .additional-options { display: block; }
</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-check">
<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>
</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">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>
<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 w-auto" currency-selection />
</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="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>
<hr class="border" />
<div class="form-group form-check">
<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 class="form-group">
<button id="ok" type="submit" class="btn btn-primary w-100">Create refund</button>
</div>
<button id="ok" type="submit" class="btn btn-primary w-100">Create refund</button>
break;
}
</form>

View file

@ -702,7 +702,7 @@
},
"refundVariant": {
"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": [
"RateThen",
"CurrentRate",
@ -712,10 +712,17 @@
"enum": [
"RateThen",
"CurrentRate",
"OverpaidAmount",
"Fiat",
"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": {
"type": "string",
"format": "decimal",