diff --git a/BTCPayServer.Client/Models/RefundInvoiceRequest.cs b/BTCPayServer.Client/Models/RefundInvoiceRequest.cs index e5de617c2..c850366eb 100644 --- a/BTCPayServer.Client/Models/RefundInvoiceRequest.cs +++ b/BTCPayServer.Client/Models/RefundInvoiceRequest.cs @@ -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; } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 79a73276e..be97fef24 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -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(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)] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs index 7e2daf94d..600f46332 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs @@ -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); diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 249fe8dd4..124f93e37 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -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 { diff --git a/BTCPayServer/Models/InvoicingModels/RefundModel.cs b/BTCPayServer/Models/InvoicingModels/RefundModel.cs index f852e3c59..48443cdb2 100644 --- a/BTCPayServer/Models/InvoicingModels/RefundModel.cs +++ b/BTCPayServer/Models/InvoicingModels/RefundModel.cs @@ -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; } } } diff --git a/BTCPayServer/Views/UIInvoice/Invoice.cshtml b/BTCPayServer/Views/UIInvoice/Invoice.cshtml index 94f1a0a2a..f5a201063 100644 --- a/BTCPayServer/Views/UIInvoice/Invoice.cshtml +++ b/BTCPayServer/Views/UIInvoice/Invoice.cshtml @@ -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); } @@ -401,7 +485,7 @@

Webhooks

- +
@@ -478,7 +562,7 @@

Refunds

-
Status
+
diff --git a/BTCPayServer/Views/UIInvoice/_RefundModal.cshtml b/BTCPayServer/Views/UIInvoice/_RefundModal.cshtml index 36a2c9bec..a5d326c90 100644 --- a/BTCPayServer/Views/UIInvoice/_RefundModal.cshtml +++ b/BTCPayServer/Views/UIInvoice/_RefundModal.cshtml @@ -31,21 +31,37 @@ -
- -
+ } break; case RefundSteps.SelectRate: - - + + + + + + + + + + @if (Model.OverpaidAmount is not null) + { +
+
+ + +
The crypto currency amount that was overpaid.
+
+
+
+ }
@@ -67,31 +83,39 @@
The invoice currency, at the rate when the refund will be sent.
-
The specified amount with the specified currency, at the rate when the refund will be sent.
-
+
+
- -
- +
+
+ +
+ + % + +
+
-
- -
+ break; } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json index 8085edc68..2e1ea3e3d 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json @@ -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",
Pull Payment