Add Greenfield invoice refund endpoint (#4238)

* Add Greenfield invoice refund endpoint

See discussion here: https://github.com/btcpayserver/btcpayserver/discussions/4181

* add test

* add docs
This commit is contained in:
Umar Bolatov 2022-11-28 00:53:08 -08:00 committed by GitHub
parent 420954ed00
commit 425d70f261
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 412 additions and 14 deletions

View File

@ -128,5 +128,19 @@ namespace BTCPayServer.Client
method: HttpMethod.Post), token);
await HandleResponse(response);
}
public virtual async Task<PullPaymentData> RefundInvoice(
string storeId,
string invoiceId,
string paymentMethod,
RefundInvoiceRequest request,
CancellationToken token = default
)
{
var response = await _httpClient.SendAsync(
CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/refund", bodyPayload: request,
method: HttpMethod.Post), token);
return await HandleResponse<PullPaymentData>(response);
}
}
}

View File

@ -0,0 +1,25 @@
#nullable enable
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models
{
public enum RefundVariant
{
RateThen,
CurrentRate,
Fiat,
Custom,
NotSet
}
public class RefundInvoiceRequest
{
public string? Name { get; set; } = null;
public string? Description { get; set; } = null;
[JsonConverter(typeof(StringEnumConverter))]
public RefundVariant RefundVariant { get; set; } = RefundVariant.NotSet;
public decimal CustomAmount { get; set; } = 0;
public string? CustomCurrency { get; set; } = null;
}
}

View File

@ -1561,6 +1561,118 @@ namespace BTCPayServer.Tests
});
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanRefundInvoice()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var user = tester.NewAccount();
await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient();
var invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = 5000.0m, Currency = "USD" });
var methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id);
var method = methods.First();
var amount = method.Amount;
Assert.Equal(amount, method.Due);
await tester.WaitForEvent<NewOnChainTransactionEvent>(async () =>
{
await tester.ExplorerNode.SendToAddressAsync(
BitcoinAddress.Create(method.Destination, tester.NetworkProvider.BTC.NBitcoinNetwork),
Money.Coins(method.Due)
);
});
// test validation that the invoice exists
await AssertHttpError(404, async () =>
{
await client.RefundInvoice(user.StoreId, "lol fake invoice id", method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.RateThen
});
});
// test validation error for when invoice is not yet in the state in which it can be refunded
var apiError = await AssertAPIError("non-refundable", () => client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.RateThen
}));
Assert.Equal("Cannot refund this invoice", apiError.Message);
await TestUtils.EventuallyAsync(async () =>
{
invoice = await client.GetInvoice(user.StoreId, invoice.Id);
Assert.True(invoice.Status == InvoiceStatus.Processing);
});
// need to set the status to the one in which we can actually refund the invoice
await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest() {
Status = InvoiceStatus.Settled
});
// test validation for the payment method
var validationError = await AssertValidationError(new[] { "paymentMethod" }, async () =>
{
await client.RefundInvoice(user.StoreId, invoice.Id, "fake payment method", new RefundInvoiceRequest() {
RefundVariant = RefundVariant.RateThen
});
});
Assert.Contains("paymentMethod: Please select one of the payment methods which were available for the original invoice", validationError.Message);
// test RefundVariant.RateThen
var pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.RateThen
});
Assert.Equal("BTC", pp.Currency);
Assert.Equal(true, pp.AutoApproveClaims);
Assert.Equal(1, pp.Amount);
Assert.Equal(pp.Name, $"Refund {invoice.Id}");
// test RefundVariant.CurrentRate
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.CurrentRate
});
Assert.Equal("BTC", pp.Currency);
Assert.Equal(true, pp.AutoApproveClaims);
Assert.Equal(1, pp.Amount);
// test RefundVariant.Fiat
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.Fiat,
Name = "my test name"
});
Assert.Equal("USD", pp.Currency);
Assert.Equal(false, pp.AutoApproveClaims);
Assert.Equal(5000, pp.Amount);
Assert.Equal("my test name", pp.Name);
// test RefundVariant.Custom
validationError = await AssertValidationError(new[] { "CustomAmount", "CustomCurrency" }, async () =>
{
await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.Custom,
});
});
Assert.Contains("CustomAmount: Amount must be greater than 0", validationError.Message);
Assert.Contains("CustomCurrency: Invalid currency", validationError.Message);
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.Custom,
CustomAmount = 69420,
CustomCurrency = "JPY"
});
Assert.Equal("JPY", pp.Currency);
Assert.Equal(false, pp.AutoApproveClaims);
Assert.Equal(69420, pp.Amount);
// should auto-approve if currencies match
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() {
RefundVariant = RefundVariant.Custom,
CustomAmount = 0.00069420m,
CustomCurrency = "BTC"
});
Assert.Equal(true, pp.AutoApproveClaims);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task InvoiceTests()

View File

@ -2,14 +2,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Rating;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
@ -32,12 +37,19 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly EventAggregator _eventAggregator;
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
private readonly CurrencyNameTable _currencyNameTable;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly PullPaymentHostedService _pullPaymentService;
private readonly RateFetcher _rateProvider;
private readonly ApplicationDbContextFactory _dbContextFactory;
public LanguageService LanguageService { get; }
public GreenfieldInvoiceController(UIInvoiceController invoiceController, InvoiceRepository invoiceRepository,
LinkGenerator linkGenerator, LanguageService languageService, BTCPayNetworkProvider btcPayNetworkProvider,
EventAggregator eventAggregator, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary)
EventAggregator eventAggregator, PaymentMethodHandlerDictionary paymentMethodHandlerDictionary,
CurrencyNameTable currencyNameTable, BTCPayNetworkProvider networkProvider, RateFetcher rateProvider,
PullPaymentHostedService pullPaymentService, ApplicationDbContextFactory dbContextFactory)
{
_invoiceController = invoiceController;
_invoiceRepository = invoiceRepository;
@ -45,6 +57,11 @@ namespace BTCPayServer.Controllers.Greenfield
_btcPayNetworkProvider = btcPayNetworkProvider;
_eventAggregator = eventAggregator;
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
_currencyNameTable = currencyNameTable;
_networkProvider = networkProvider;
_rateProvider = rateProvider;
_pullPaymentService = pullPaymentService;
_dbContextFactory = dbContextFactory;
LanguageService = languageService;
}
@ -333,6 +350,162 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateValidationError(ModelState);
}
[Authorize(Policy = Policies.CanModifyStoreSettings,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/refund")]
public async Task<IActionResult> RefundInvoice(
string storeId,
string invoiceId,
string paymentMethod,
RefundInvoiceRequest request,
CancellationToken cancellationToken = default
)
{
var store = HttpContext.GetStoreData();
if (store == null)
{
return StoreNotFound();
}
var invoice = await _invoiceRepository.GetInvoice(invoiceId, true);
if (invoice == null)
{
return InvoiceNotFound();
}
if (invoice.StoreId != store.Id)
{
return InvoiceNotFound();
}
if (!invoice.GetInvoiceState().CanRefund())
{
return this.CreateAPIError("non-refundable", "Cannot refund this invoice");
}
var paymentMethodId = PaymentMethodId.Parse(paymentMethod);
var invoicePaymentMethod = invoice.GetPaymentMethods().SingleOrDefault(method => method.GetId() == paymentMethodId);
if (invoicePaymentMethod == null)
{
this.ModelState.AddModelError(nameof(paymentMethod), "Please select one of the payment methods which were available for the original invoice");
return this.CreateValidationError(ModelState);
}
var cryptoPaid = invoicePaymentMethod.Calculate().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(
new CurrencyPair(paymentMethodId.CryptoCode, invoice.Currency),
store.GetStoreBlob().GetRateRules(_networkProvider),
cancellationToken
);
var paymentMethodDivisibility = _currencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8;
var createPullPayment = new HostedServices.CreatePullPayment()
{
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
Name = request.Name ?? $"Refund {invoice.Id}",
Description = request.Description,
StoreId = storeId,
PaymentMethodIds = new[] { paymentMethodId },
};
switch (request.RefundVariant)
{
case RefundVariant.RateThen:
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode;
createPullPayment.Amount = cryptoPaid.RoundToSignificant(paymentMethodDivisibility);
createPullPayment.AutoApproveClaims = true;
break;
case RefundVariant.CurrentRate:
createPullPayment.Currency = invoicePaymentMethod.GetId().CryptoCode;
createPullPayment.Amount = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility);
createPullPayment.AutoApproveClaims = true;
break;
case RefundVariant.Fiat:
createPullPayment.Currency = invoice.Currency;
createPullPayment.Amount = paidCurrency;
createPullPayment.AutoApproveClaims = false;
break;
case RefundVariant.Custom:
if (request.CustomAmount <= 0) {
this.ModelState.AddModelError(nameof(request.CustomAmount), "Amount must be greater than 0");
}
if (
string.IsNullOrEmpty(request.CustomCurrency) ||
_currencyNameTable.GetCurrencyData(request.CustomCurrency, false) == null
)
{
ModelState.AddModelError(nameof(request.CustomCurrency), "Invalid currency");
}
if (rateResult.BidAsk is null)
{
ModelState.AddModelError(nameof(request.RefundVariant),
$"Impossible to fetch rate: {rateResult.EvaluatedRule}");
}
if (!ModelState.IsValid)
{
return this.CreateValidationError(ModelState);
}
createPullPayment.Currency = request.CustomCurrency;
createPullPayment.Amount = request.CustomAmount;
createPullPayment.AutoApproveClaims = paymentMethodId.CryptoCode == request.CustomCurrency;
break;
default:
ModelState.AddModelError(nameof(request.RefundVariant), "Please select a valid refund option");
return this.CreateValidationError(ModelState);
}
var ppId = await _pullPaymentService.CreatePullPayment(createPullPayment);
await using var ctx = _dbContextFactory.CreateContext();
(await ctx.Invoices.FindAsync(new[] { invoice.Id }, cancellationToken))!.CurrentRefundId = ppId;
ctx.Refunds.Add(new RefundData
{
InvoiceDataId = invoice.Id,
PullPaymentDataId = ppId
});
await ctx.SaveChangesAsync(cancellationToken);
var pp = await _pullPaymentService.GetPullPayment(ppId, false);
return this.Ok(CreatePullPaymentData(pp));
}
private Client.Models.PullPaymentData CreatePullPaymentData(Data.PullPaymentData pp)
{
var ppBlob = pp.GetBlob();
return new BTCPayServer.Client.Models.PullPaymentData()
{
Id = pp.Id,
StartsAt = pp.StartDate,
ExpiresAt = pp.EndDate,
Amount = ppBlob.Limit,
Name = ppBlob.Name,
Description = ppBlob.Description,
Currency = ppBlob.Currency,
Period = ppBlob.Period,
Archived = pp.Archived,
AutoApproveClaims = ppBlob.AutoApproveClaims,
BOLT11Expiration = ppBlob.BOLT11Expiration,
ViewLink = _linkGenerator.GetUriByAction(
nameof(UIPullPaymentController.ViewPullPayment),
"UIPullPayment",
new { pullPaymentId = pp.Id },
Request.Scheme,
Request.Host,
Request.PathBase)
};
}
private IActionResult InvoiceNotFound()
{
return this.CreateAPIError(404, "invoice-not-found", "The invoice was not found");

View File

@ -133,7 +133,7 @@ namespace BTCPayServer.Controllers
Events = invoice.Events,
PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData),
Archived = invoice.Archived,
CanRefund = CanRefund(invoiceState),
CanRefund = invoiceState.CanRefund(),
Refunds = invoice.Refunds,
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
ShowReceipt = invoice.Status.ToModernStatus() == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
@ -234,16 +234,6 @@ namespace BTCPayServer.Controllers
var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode);
return network == null ? null : paymentMethodId.PaymentType.GetTransactionLink(network, txId);
}
bool CanRefund(InvoiceState invoiceState)
{
return invoiceState.Status == InvoiceStatusLegacy.Confirmed ||
invoiceState.Status == InvoiceStatusLegacy.Complete ||
(invoiceState.Status == InvoiceStatusLegacy.Expired &&
(invoiceState.ExceptionStatus == InvoiceExceptionStatus.PaidLate ||
invoiceState.ExceptionStatus == InvoiceExceptionStatus.PaidOver ||
invoiceState.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)) ||
invoiceState.Status == InvoiceStatusLegacy.Invalid;
}
[HttpGet("invoices/{invoiceId}/refund")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
@ -262,7 +252,7 @@ namespace BTCPayServer.Controllers
return NotFound();
if (invoice.CurrentRefund?.PullPaymentDataId is null && GetUserId() is null)
return NotFound();
if (!CanRefund(invoice.GetInvoiceState()))
if (!invoice.GetInvoiceState().CanRefund())
return NotFound();
if (invoice.CurrentRefund?.PullPaymentDataId is string ppId && !invoice.CurrentRefund.PullPaymentData.Archived)
{
@ -318,7 +308,7 @@ namespace BTCPayServer.Controllers
if (invoice == null)
return NotFound();
if (!CanRefund(invoice.GetInvoiceState()))
if (!invoice.GetInvoiceState().CanRefund())
return NotFound();
var store = GetCurrentStore();

View File

@ -835,6 +835,17 @@ namespace BTCPayServer.Services.Invoices
(Status != InvoiceStatusLegacy.Invalid && ExceptionStatus == InvoiceExceptionStatus.Marked);
}
public bool CanRefund()
{
return Status == InvoiceStatusLegacy.Confirmed ||
Status == InvoiceStatusLegacy.Complete ||
(Status == InvoiceStatusLegacy.Expired &&
(ExceptionStatus == InvoiceExceptionStatus.PaidLate ||
ExceptionStatus == InvoiceExceptionStatus.PaidOver ||
ExceptionStatus == InvoiceExceptionStatus.PaidPartial)) ||
Status == InvoiceStatusLegacy.Invalid;
}
public override int GetHashCode()
{
return HashCode.Combine(Status, ExceptionStatus);

View File

@ -649,6 +649,79 @@
}
]
}
},
"/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/refund": {
"post": {
"tags": [
"Invoices",
"Refund"
],
"summary": "Refund invoice",
"parameters": [
{
"name": "storeId",
"in": "path",
"required": true,
"description": "The store to query",
"schema": {
"type": "string"
}
},
{
"name": "invoiceId",
"in": "path",
"required": true,
"description": "The invoice to refund",
"schema": {
"type": "string"
}
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "The payment method to refund with",
"schema": {
"type": "string"
}
}
],
"description": "Refund invoice",
"operationId": "Invoices_Refund",
"responses": {
"200": {
"description": "Pull payment for refunding the invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PullPaymentData"
}
}
}
},
"400": {
"description": "A list of errors that occurred when refunding the invoice",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationProblemDetails"
}
}
}
},
"403": {
"description": "If you are authenticated but forbidden to refund the invoice"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
}
},
"components": {