mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-21 22:11:48 +01:00
Refund updates (#4934)
This commit is contained in:
parent
541b6cf9eb
commit
195dfc2c47
8 changed files with 342 additions and 45 deletions
|
@ -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; }
|
||||||
|
|
|
@ -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)]
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
{
|
{
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
Loading…
Add table
Reference in a new issue