Improve documentation of Refund API in Greenfield (#4372)

This commit is contained in:
Nicolas Dorier
2022-11-28 20:58:18 +09:00
committed by GitHub
parent 3370240541
commit ddcfa735e0
5 changed files with 115 additions and 48 deletions

View File

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

View File

@@ -1,4 +1,5 @@
#nullable enable #nullable enable
using BTCPayServer.JsonConverters;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
@@ -9,17 +10,18 @@ namespace BTCPayServer.Client.Models
RateThen, RateThen,
CurrentRate, CurrentRate,
Fiat, Fiat,
Custom, Custom
NotSet
} }
public class RefundInvoiceRequest public class RefundInvoiceRequest
{ {
public string? Name { get; set; } = null; public string? Name { get; set; } = null;
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; } = RefundVariant.NotSet; public RefundVariant? RefundVariant { get; set; }
public decimal CustomAmount { get; set; } = 0; [JsonConverter(typeof(NumericStringJsonConverter))]
public string? CustomCurrency { get; set; } = null; public decimal? CustomAmount { get; set; }
public string? CustomCurrency { get; set; }
} }
} }

View File

@@ -1587,13 +1587,15 @@ namespace BTCPayServer.Tests
// test validation that the invoice exists // test validation that the invoice exists
await AssertHttpError(404, async () => await AssertHttpError(404, async () =>
{ {
await client.RefundInvoice(user.StoreId, "lol fake invoice id", method.PaymentMethod, new RefundInvoiceRequest() { await client.RefundInvoice(user.StoreId, "lol fake invoice id", new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.RateThen RefundVariant = RefundVariant.RateThen
}); });
}); });
// test validation error for when invoice is not yet in the state in which it can be refunded // 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() { var apiError = await AssertAPIError("non-refundable", () => client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.RateThen RefundVariant = RefundVariant.RateThen
})); }));
Assert.Equal("Cannot refund this invoice", apiError.Message); Assert.Equal("Cannot refund this invoice", apiError.Message);
@@ -1610,67 +1612,74 @@ namespace BTCPayServer.Tests
}); });
// test validation for the payment method // test validation for the payment method
var validationError = await AssertValidationError(new[] { "paymentMethod" }, async () => var validationError = await AssertValidationError(new[] { "PaymentMethod" }, async () =>
{ {
await client.RefundInvoice(user.StoreId, invoice.Id, "fake payment method", new RefundInvoiceRequest() { await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = "fake payment method",
RefundVariant = RefundVariant.RateThen RefundVariant = RefundVariant.RateThen
}); });
}); });
Assert.Contains("paymentMethod: Please select one of the payment methods which were available for the original invoice", validationError.Message); Assert.Contains("PaymentMethod: Please select one of the payment methods which were available for the original invoice", validationError.Message);
// test RefundVariant.RateThen // test RefundVariant.RateThen
var pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() { var pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.RateThen RefundVariant = RefundVariant.RateThen
}); });
Assert.Equal("BTC", pp.Currency); Assert.Equal("BTC", pp.Currency);
Assert.Equal(true, pp.AutoApproveClaims); Assert.True(pp.AutoApproveClaims);
Assert.Equal(1, pp.Amount); Assert.Equal(1, pp.Amount);
Assert.Equal(pp.Name, $"Refund {invoice.Id}"); Assert.Equal(pp.Name, $"Refund {invoice.Id}");
// test RefundVariant.CurrentRate // test RefundVariant.CurrentRate
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() { pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.CurrentRate RefundVariant = RefundVariant.CurrentRate
}); });
Assert.Equal("BTC", pp.Currency); Assert.Equal("BTC", pp.Currency);
Assert.Equal(true, pp.AutoApproveClaims); Assert.True(pp.AutoApproveClaims);
Assert.Equal(1, pp.Amount); Assert.Equal(1, pp.Amount);
// test RefundVariant.Fiat // test RefundVariant.Fiat
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() { pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.Fiat, RefundVariant = RefundVariant.Fiat,
Name = "my test name" Name = "my test name"
}); });
Assert.Equal("USD", pp.Currency); Assert.Equal("USD", pp.Currency);
Assert.Equal(false, pp.AutoApproveClaims); Assert.False(pp.AutoApproveClaims);
Assert.Equal(5000, pp.Amount); Assert.Equal(5000, pp.Amount);
Assert.Equal("my test name", pp.Name); Assert.Equal("my test name", pp.Name);
// test RefundVariant.Custom // test RefundVariant.Custom
validationError = await AssertValidationError(new[] { "CustomAmount", "CustomCurrency" }, async () => validationError = await AssertValidationError(new[] { "CustomAmount", "CustomCurrency" }, async () =>
{ {
await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() { await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.Custom, RefundVariant = RefundVariant.Custom,
}); });
}); });
Assert.Contains("CustomAmount: Amount must be greater than 0", validationError.Message); Assert.Contains("CustomAmount: Amount must be greater than 0", validationError.Message);
Assert.Contains("CustomCurrency: Invalid currency", validationError.Message); Assert.Contains("CustomCurrency: Invalid currency", validationError.Message);
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() { pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.Custom, RefundVariant = RefundVariant.Custom,
CustomAmount = 69420, CustomAmount = 69420,
CustomCurrency = "JPY" CustomCurrency = "JPY"
}); });
Assert.Equal("JPY", pp.Currency); Assert.Equal("JPY", pp.Currency);
Assert.Equal(false, pp.AutoApproveClaims); Assert.False(pp.AutoApproveClaims);
Assert.Equal(69420, pp.Amount); Assert.Equal(69420, pp.Amount);
// should auto-approve if currencies match // should auto-approve if currencies match
pp = await client.RefundInvoice(user.StoreId, invoice.Id, method.PaymentMethod, new RefundInvoiceRequest() { pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() {
PaymentMethod = method.PaymentMethod,
RefundVariant = RefundVariant.Custom, RefundVariant = RefundVariant.Custom,
CustomAmount = 0.00069420m, CustomAmount = 0.00069420m,
CustomCurrency = "BTC" CustomCurrency = "BTC"
}); });
Assert.Equal(true, pp.AutoApproveClaims); Assert.True(pp.AutoApproveClaims);
} }
[Fact(Timeout = TestTimeout)] [Fact(Timeout = TestTimeout)]

View File

@@ -352,11 +352,10 @@ namespace BTCPayServer.Controllers.Greenfield
[Authorize(Policy = Policies.CanModifyStoreSettings, [Authorize(Policy = Policies.CanModifyStoreSettings,
AuthenticationSchemes = AuthenticationSchemes.Greenfield)] AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/refund")] [HttpPost("~/api/v1/stores/{storeId}/invoices/{invoiceId}/refund")]
public async Task<IActionResult> RefundInvoice( public async Task<IActionResult> RefundInvoice(
string storeId, string storeId,
string invoiceId, string invoiceId,
string paymentMethod,
RefundInvoiceRequest request, RefundInvoiceRequest request,
CancellationToken cancellationToken = default CancellationToken cancellationToken = default
) )
@@ -377,20 +376,24 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
return InvoiceNotFound(); return InvoiceNotFound();
} }
if (!invoice.GetInvoiceState().CanRefund()) if (!invoice.GetInvoiceState().CanRefund())
{ {
return this.CreateAPIError("non-refundable", "Cannot refund this invoice"); return this.CreateAPIError("non-refundable", "Cannot refund this invoice");
} }
PaymentMethod? invoicePaymentMethod = null;
var paymentMethodId = PaymentMethodId.Parse(paymentMethod); PaymentMethodId? paymentMethodId = null;
var invoicePaymentMethod = invoice.GetPaymentMethods().SingleOrDefault(method => method.GetId() == paymentMethodId); if (request.PaymentMethod is not null && PaymentMethodId.TryParse(request.PaymentMethod, out paymentMethodId))
if (invoicePaymentMethod == null)
{ {
this.ModelState.AddModelError(nameof(paymentMethod), "Please select one of the payment methods which were available for the original invoice"); invoicePaymentMethod = invoice.GetPaymentMethods().SingleOrDefault(method => method.GetId() == paymentMethodId);
return this.CreateValidationError(ModelState);
} }
if (invoicePaymentMethod is null)
{
this.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");
if (!ModelState.IsValid || invoicePaymentMethod is null || paymentMethodId is null)
return this.CreateValidationError(ModelState);
var cryptoPaid = invoicePaymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC); var cryptoPaid = invoicePaymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC);
var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true); var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true);
@@ -410,6 +413,16 @@ namespace BTCPayServer.Controllers.Greenfield
PaymentMethodIds = new[] { paymentMethodId }, PaymentMethodIds = new[] { paymentMethodId },
}; };
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");
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);
}
switch (request.RefundVariant) switch (request.RefundVariant)
{ {
case RefundVariant.RateThen: case RefundVariant.RateThen:
@@ -431,7 +444,7 @@ namespace BTCPayServer.Controllers.Greenfield
break; break;
case RefundVariant.Custom: case RefundVariant.Custom:
if (request.CustomAmount <= 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"); this.ModelState.AddModelError(nameof(request.CustomAmount), "Amount must be greater than 0");
} }
@@ -449,13 +462,13 @@ namespace BTCPayServer.Controllers.Greenfield
$"Impossible to fetch rate: {rateResult.EvaluatedRule}"); $"Impossible to fetch rate: {rateResult.EvaluatedRule}");
} }
if (!ModelState.IsValid) if (!ModelState.IsValid || request.CustomAmount is null)
{ {
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
} }
createPullPayment.Currency = request.CustomCurrency; createPullPayment.Currency = request.CustomCurrency;
createPullPayment.Amount = request.CustomAmount; createPullPayment.Amount = request.CustomAmount.Value;
createPullPayment.AutoApproveClaims = paymentMethodId.CryptoCode == request.CustomCurrency; createPullPayment.AutoApproveClaims = paymentMethodId.CryptoCode == request.CustomCurrency;
break; break;

View File

@@ -650,11 +650,10 @@
] ]
} }
}, },
"/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/refund": { "/api/v1/stores/{storeId}/invoices/{invoiceId}/refund": {
"post": { "post": {
"tags": [ "tags": [
"Invoices", "Invoices"
"Refund"
], ],
"summary": "Refund invoice", "summary": "Refund invoice",
"parameters": [ "parameters": [
@@ -675,19 +674,64 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "paymentMethod",
"in": "path",
"required": true,
"description": "The payment method to refund with",
"schema": {
"type": "string"
}
} }
], ],
"description": "Refund invoice", "description": "Refund invoice",
"operationId": "Invoices_Refund", "operationId": "Invoices_Refund",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Name of the pull payment (Default: 'Refund' followed by the invoice id)",
"nullable": true
},
"description": {
"type": "string",
"description": "Description of the pull payment"
},
"paymentMethod": {
"type": "string",
"description": "The payment method to use for the refund",
"example": "BTC"
},
"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`)",
"x-enumNames": [
"RateThen",
"CurrentRate",
"Fiat",
"Custom"
],
"enum": [
"RateThen",
"CurrentRate",
"Fiat",
"Custom"
]
},
"customAmount": {
"type": "string",
"format": "decimal",
"description": "The amount to refund if the `refundVariant` is `Custom`.",
"example": "5.00"
},
"customCurrency": {
"type": "string",
"description": "The currency to refund if the `refundVariant` is `Custom`",
"example": "USD"
}
}
}
}
}
},
"responses": { "responses": {
"200": { "200": {
"description": "Pull payment for refunding the invoice", "description": "Pull payment for refunding the invoice",