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:
Kukks 2020-09-02 11:24:18 +02:00
parent 3c3e2f80da
commit 735995954f
3 changed files with 180 additions and 80 deletions

View file

@ -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)

View file

@ -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; }
}
}

View file

@ -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>