mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
Add custom refund option
Allows you to specify an alternative refund amount and currency. This allows partial refunds and other negotiated terms closes #1874
This commit is contained in:
parent
3c3e2f80da
commit
735995954f
3 changed files with 180 additions and 80 deletions
|
@ -11,15 +11,18 @@ using BTCPayServer.Client.Models;
|
|||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Changelly;
|
||||
using BTCPayServer.Payments.CoinSwitch;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Invoices.Export;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using DBriize.Utils;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -164,7 +167,6 @@ namespace BTCPayServer.Controllers
|
|||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public async Task<IActionResult> Refund(string invoiceId, RefundModel model, CancellationToken cancellationToken)
|
||||
{
|
||||
model.RefundStep = RefundSteps.SelectRate;
|
||||
using var ctx = _dbContextFactory.CreateContext();
|
||||
var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
|
||||
if (invoice is null)
|
||||
|
@ -177,30 +179,43 @@ namespace BTCPayServer.Controllers
|
|||
var paymentMethodId = new PaymentMethodId(model.SelectedPaymentMethod, PaymentTypes.BTCLike);
|
||||
var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true);
|
||||
var paymentMethodDivisibility = _CurrencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8;
|
||||
if (model.SelectedRefundOption is null)
|
||||
RateRules rules;
|
||||
RateResult rateResult;
|
||||
CreatePullPayment createPullPayment;
|
||||
switch (model.RefundStep)
|
||||
{
|
||||
model.Title = "What to refund?";
|
||||
var paymentMethod = invoice.GetPaymentMethods()[paymentMethodId];
|
||||
var paidCurrency = Math.Round(paymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC) * paymentMethod.Rate, cdCurrency.Divisibility);
|
||||
model.CryptoAmountThen = Math.Round(paidCurrency / paymentMethod.Rate, paymentMethodDivisibility);
|
||||
model.RateThenText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode, true);
|
||||
var rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
|
||||
var rateResult = await _RateProvider.FetchRate(new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency), rules, cancellationToken);
|
||||
//TODO: What if fetching rate failed?
|
||||
if (rateResult.BidAsk is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SelectedRefundOption), $"Impossible to fetch rate: {rateResult.EvaluatedRule}");
|
||||
case RefundSteps.SelectPaymentMethod:
|
||||
model.RefundStep = RefundSteps.SelectRate;
|
||||
model.Title = "What to refund?";
|
||||
var paymentMethod = invoice.GetPaymentMethods()[paymentMethodId];
|
||||
var paidCurrency =
|
||||
Math.Round(paymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC) * paymentMethod.Rate,
|
||||
cdCurrency.Divisibility);
|
||||
model.CryptoAmountThen = Math.Round(paidCurrency / paymentMethod.Rate, paymentMethodDivisibility);
|
||||
model.RateThenText =
|
||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode,
|
||||
true);
|
||||
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
|
||||
rateResult = await _RateProvider.FetchRate(
|
||||
new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency), rules,
|
||||
cancellationToken);
|
||||
//TODO: What if fetching rate failed?
|
||||
if (rateResult.BidAsk is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SelectedRefundOption),
|
||||
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
|
||||
model.CurrentRateText =
|
||||
_CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode,
|
||||
true);
|
||||
model.FiatAmount = paidCurrency;
|
||||
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency, true);
|
||||
return View(model);
|
||||
}
|
||||
model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
|
||||
model.CurrentRateText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode, true);
|
||||
model.FiatAmount = paidCurrency;
|
||||
model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.Currency, true);
|
||||
return View(model);
|
||||
}
|
||||
else
|
||||
{
|
||||
var createPullPayment = new HostedServices.CreatePullPayment();
|
||||
case RefundSteps.SelectRate:
|
||||
createPullPayment = new HostedServices.CreatePullPayment();
|
||||
createPullPayment.Name = $"Refund {invoice.Id}";
|
||||
createPullPayment.PaymentMethodIds = new[] { paymentMethodId };
|
||||
createPullPayment.StoreId = invoice.StoreId;
|
||||
|
@ -218,28 +233,76 @@ namespace BTCPayServer.Controllers
|
|||
createPullPayment.Currency = invoice.Currency;
|
||||
createPullPayment.Amount = model.FiatAmount;
|
||||
break;
|
||||
case "Custom":
|
||||
model.Title = "How much to refund?";
|
||||
model.CustomCurrency = invoice.Currency;
|
||||
model.CustomAmount = model.FiatAmount;
|
||||
model.RefundStep = RefundSteps.SelectCustomAmount;
|
||||
return View(model);
|
||||
default:
|
||||
ModelState.AddModelError(nameof(model.SelectedRefundOption), "Invalid choice");
|
||||
return View(model);
|
||||
}
|
||||
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Html = "Share this page with a customer so they can claim a refund <br />Once claimed you need to initiate a refund from Wallet > Payouts",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
(await ctx.Invoices.FindAsync(invoice.Id)).CurrentRefundId = ppId;
|
||||
ctx.Refunds.Add(new RefundData()
|
||||
{
|
||||
InvoiceDataId = invoice.Id,
|
||||
PullPaymentDataId = ppId
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
// TODO: Having dedicated UI later on
|
||||
return RedirectToAction(nameof(PullPaymentController.ViewPullPayment),
|
||||
"PullPayment",
|
||||
new { pullPaymentId = ppId });
|
||||
|
||||
break;
|
||||
case RefundSteps.SelectCustomAmount:
|
||||
if (model.CustomAmount <= 0)
|
||||
{
|
||||
model.AddModelError(refundModel => refundModel.CustomAmount, "Amount must be greater than 0", this);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(model.CustomCurrency) ||
|
||||
_CurrencyNameTable.GetCurrencyData(model.CustomCurrency, false) == null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.CustomCurrency), "Invalid currency");
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
return View(model);
|
||||
}
|
||||
rules = store.GetStoreBlob().GetRateRules(_NetworkProvider);
|
||||
rateResult = await _RateProvider.FetchRate(
|
||||
new Rating.CurrencyPair(paymentMethodId.CryptoCode, model.CustomCurrency), rules,
|
||||
cancellationToken);
|
||||
//TODO: What if fetching rate failed?
|
||||
if (rateResult.BidAsk is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SelectedRefundOption),
|
||||
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
createPullPayment = new HostedServices.CreatePullPayment();
|
||||
createPullPayment.Name = $"Refund {invoice.Id}";
|
||||
createPullPayment.PaymentMethodIds = new[] { paymentMethodId };
|
||||
createPullPayment.StoreId = invoice.StoreId;
|
||||
createPullPayment.Currency = model.CustomCurrency;
|
||||
createPullPayment.Amount = model.CustomAmount;
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
|
||||
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment);
|
||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Html = "Share this page with a customer so they can claim a refund <br />Once claimed you need to initiate a refund from Wallet > Payouts",
|
||||
Severity = StatusMessageModel.StatusSeverity.Success
|
||||
});
|
||||
(await ctx.Invoices.FindAsync(invoice.Id)).CurrentRefundId = ppId;
|
||||
ctx.Refunds.Add(new RefundData()
|
||||
{
|
||||
InvoiceDataId = invoice.Id,
|
||||
PullPaymentDataId = ppId
|
||||
});
|
||||
await ctx.SaveChangesAsync(cancellationToken);
|
||||
// TODO: Having dedicated UI later on
|
||||
return RedirectToAction(nameof(PullPaymentController.ViewPullPayment),
|
||||
"PullPayment",
|
||||
new { pullPaymentId = ppId });
|
||||
|
||||
|
||||
}
|
||||
|
||||
private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice)
|
||||
|
|
|
@ -6,7 +6,8 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||
public enum RefundSteps
|
||||
{
|
||||
SelectPaymentMethod,
|
||||
SelectRate
|
||||
SelectRate,
|
||||
SelectCustomAmount
|
||||
}
|
||||
public class RefundModel
|
||||
{
|
||||
|
@ -22,5 +23,9 @@ namespace BTCPayServer.Models.InvoicingModels
|
|||
public string RateThenText { get; set; }
|
||||
public string FiatText { get; set; }
|
||||
public decimal FiatAmount { get; set; }
|
||||
|
||||
[Display(Name = "Specify the amount and currency for the refund")]
|
||||
public decimal CustomAmount { get; set; }
|
||||
public string CustomCurrency { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,48 +12,81 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
@if (Model.RefundStep == RefundSteps.SelectPaymentMethod)
|
||||
<input type="hidden" asp-for="RefundStep" value="@Model.RefundStep"/>
|
||||
<input type="hidden" asp-for="Title" value="@Model.Title"/>
|
||||
@switch (Model.RefundStep)
|
||||
{
|
||||
<div class="form-group">
|
||||
<label asp-for="SelectedPaymentMethod"></label>
|
||||
<select asp-items="Model.AvailablePaymentMethods" asp-for="SelectedPaymentMethod" class="form-control"></select>
|
||||
<span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Next</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="hidden" asp-for="SelectedPaymentMethod" />
|
||||
<input type="hidden" asp-for="CryptoAmountThen" />
|
||||
<input type="hidden" asp-for="FiatAmount" />
|
||||
<input type="hidden" asp-for="CryptoAmountNow" />
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="RateThenText" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input" />
|
||||
<label for="RateThenText" class="form-check-label">@Model.RateThenText</label>
|
||||
case RefundSteps.SelectPaymentMethod:
|
||||
<div class="form-group">
|
||||
<label asp-for="SelectedPaymentMethod"></label>
|
||||
<select asp-items="Model.AvailablePaymentMethods" asp-for="SelectedPaymentMethod" class="form-control"></select>
|
||||
<span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span>
|
||||
</div>
|
||||
<small class="form-text text-muted">The crypto currency price, at the rate the invoice got paid.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="CurrentRateText" asp-for="SelectedRefundOption" type="radio" value="CurrentRate" class="form-check-input" />
|
||||
<label for="CurrentRateText" class="form-check-label">@Model.CurrentRateText</label>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Next</button>
|
||||
</div>
|
||||
<small class="form-text text-muted">The crypto currency price, at the current rate.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="FiatText" asp-for="SelectedRefundOption" type="radio" value="Fiat" class="form-check-input" />
|
||||
<label for="FiatText" class="form-check-label">@Model.FiatText</label>
|
||||
break;
|
||||
case RefundSteps.SelectRate:
|
||||
<input type="hidden" asp-for="SelectedPaymentMethod"/>
|
||||
<input type="hidden" asp-for="CryptoAmountThen"/>
|
||||
<input type="hidden" asp-for="FiatAmount"/>
|
||||
<input type="hidden" asp-for="CryptoAmountNow"/>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="RateThenText" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input"/>
|
||||
<label for="RateThenText" class="form-check-label">@Model.RateThenText</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">The crypto currency price, at the rate the invoice got paid.</small>
|
||||
</div>
|
||||
<small class="form-text text-muted">the invoice currency, at the rate when the refund will be sent.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Create refund</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="CurrentRateText" asp-for="SelectedRefundOption" type="radio" value="CurrentRate" class="form-check-input"/>
|
||||
<label for="CurrentRateText" class="form-check-label">@Model.CurrentRateText</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">The crypto currency price, at the current rate.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="FiatText" asp-for="SelectedRefundOption" type="radio" value="Fiat" class="form-check-input"/>
|
||||
<label for="FiatText" class="form-check-label">@Model.FiatText</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">The invoice currency, at the rate when the refund will be sent.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="CustomText" asp-for="SelectedRefundOption" type="radio" value="Custom" class="form-check-input"/>
|
||||
<label for="CustomText" class="form-check-label">Custom</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">The specified amount with the specified currency, at the rate when the refund will be sent. </small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Create refund</button>
|
||||
</div>
|
||||
break;
|
||||
case RefundSteps.SelectCustomAmount:
|
||||
|
||||
<input type="hidden" asp-for="SelectedPaymentMethod"/>
|
||||
<input type="hidden" asp-for="CryptoAmountThen"/>
|
||||
<input type="hidden" asp-for="FiatAmount"/>
|
||||
<input type="hidden" asp-for="CryptoAmountNow"/>
|
||||
<input type="hidden" asp-for="SelectedRefundOption"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label asp-for="CustomAmount"></label>
|
||||
<div class="input-group">
|
||||
<input asp-for="CustomAmount" type="number" step="any" asp-format="{0}" class="form-control"/>
|
||||
<div class="input-group-append">
|
||||
<input asp-for="CustomCurrency" type="text"class="form-control"/>
|
||||
</div>
|
||||
</div>
|
||||
<span asp-validation-for="CustomAmount" class="text-danger w-100"></span>
|
||||
<span asp-validation-for="CustomCurrency" class="text-danger w-100"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Next</button>
|
||||
</div>
|
||||
break;
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
|
@ -61,4 +94,3 @@
|
|||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue