From ddcfa735e05dd8102fda490371d879c577ce64ad Mon Sep 17 00:00:00 2001 From: Nicolas Dorier Date: Mon, 28 Nov 2022 20:58:18 +0900 Subject: [PATCH] Improve documentation of Refund API in Greenfield (#4372) --- .../BTCPayServerClient.Invoices.cs | 3 +- .../Models/RefundInvoiceRequest.cs | 12 ++-- BTCPayServer.Tests/GreenfieldAPITests.cs | 41 ++++++----- .../GreenField/GreenfieldInvoiceController.cs | 39 +++++++---- .../swagger/v1/swagger.template.invoices.json | 68 +++++++++++++++---- 5 files changed, 115 insertions(+), 48 deletions(-) diff --git a/BTCPayServer.Client/BTCPayServerClient.Invoices.cs b/BTCPayServer.Client/BTCPayServerClient.Invoices.cs index c671f2077..aabccfe55 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Invoices.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Invoices.cs @@ -132,13 +132,12 @@ namespace BTCPayServer.Client public virtual async Task 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, + CreateHttpRequest($"api/v1/stores/{storeId}/invoices/{invoiceId}/refund", bodyPayload: request, method: HttpMethod.Post), token); return await HandleResponse(response); } diff --git a/BTCPayServer.Client/Models/RefundInvoiceRequest.cs b/BTCPayServer.Client/Models/RefundInvoiceRequest.cs index b027f51c2..e5de617c2 100644 --- a/BTCPayServer.Client/Models/RefundInvoiceRequest.cs +++ b/BTCPayServer.Client/Models/RefundInvoiceRequest.cs @@ -1,4 +1,5 @@ #nullable enable +using BTCPayServer.JsonConverters; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -9,17 +10,18 @@ namespace BTCPayServer.Client.Models RateThen, CurrentRate, Fiat, - Custom, - NotSet + Custom } public class RefundInvoiceRequest { public string? Name { get; set; } = null; + public string? PaymentMethod { get; set; } 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; + public RefundVariant? RefundVariant { get; set; } + [JsonConverter(typeof(NumericStringJsonConverter))] + public decimal? CustomAmount { get; set; } + public string? CustomCurrency { get; set; } } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 2ca809a7f..574b52863 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -1587,13 +1587,15 @@ namespace BTCPayServer.Tests // test validation that the invoice exists 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 }); }); // 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 })); Assert.Equal("Cannot refund this invoice", apiError.Message); @@ -1610,67 +1612,74 @@ namespace BTCPayServer.Tests }); // 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 }); }); - 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 - 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 }); Assert.Equal("BTC", pp.Currency); - Assert.Equal(true, pp.AutoApproveClaims); + Assert.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() { + pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() { + PaymentMethod = method.PaymentMethod, RefundVariant = RefundVariant.CurrentRate }); Assert.Equal("BTC", pp.Currency); - Assert.Equal(true, pp.AutoApproveClaims); + Assert.True(pp.AutoApproveClaims); Assert.Equal(1, pp.Amount); // 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, Name = "my test name" }); Assert.Equal("USD", pp.Currency); - Assert.Equal(false, pp.AutoApproveClaims); + Assert.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() { + await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() { + PaymentMethod = method.PaymentMethod, 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() { + pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() { + PaymentMethod = method.PaymentMethod, RefundVariant = RefundVariant.Custom, CustomAmount = 69420, CustomCurrency = "JPY" }); Assert.Equal("JPY", pp.Currency); - Assert.Equal(false, pp.AutoApproveClaims); + Assert.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() { + pp = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest() { + PaymentMethod = method.PaymentMethod, RefundVariant = RefundVariant.Custom, CustomAmount = 0.00069420m, CustomCurrency = "BTC" }); - Assert.Equal(true, pp.AutoApproveClaims); + Assert.True(pp.AutoApproveClaims); } [Fact(Timeout = TestTimeout)] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs index 6c8cb0bc6..bb7a886d1 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs @@ -352,11 +352,10 @@ namespace BTCPayServer.Controllers.Greenfield [Authorize(Policy = Policies.CanModifyStoreSettings, 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 RefundInvoice( string storeId, string invoiceId, - string paymentMethod, RefundInvoiceRequest request, CancellationToken cancellationToken = default ) @@ -377,20 +376,24 @@ namespace BTCPayServer.Controllers.Greenfield { 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) + PaymentMethod? invoicePaymentMethod = null; + PaymentMethodId? paymentMethodId = null; + if (request.PaymentMethod is not null && PaymentMethodId.TryParse(request.PaymentMethod, out paymentMethodId)) { - this.ModelState.AddModelError(nameof(paymentMethod), "Please select one of the payment methods which were available for the original invoice"); - - return this.CreateValidationError(ModelState); + invoicePaymentMethod = invoice.GetPaymentMethods().SingleOrDefault(method => method.GetId() == paymentMethodId); } + 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 cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true); @@ -410,6 +413,16 @@ namespace BTCPayServer.Controllers.Greenfield 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) { case RefundVariant.RateThen: @@ -431,7 +444,7 @@ namespace BTCPayServer.Controllers.Greenfield break; 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"); } @@ -449,13 +462,13 @@ namespace BTCPayServer.Controllers.Greenfield $"Impossible to fetch rate: {rateResult.EvaluatedRule}"); } - if (!ModelState.IsValid) + if (!ModelState.IsValid || request.CustomAmount is null) { return this.CreateValidationError(ModelState); } createPullPayment.Currency = request.CustomCurrency; - createPullPayment.Amount = request.CustomAmount; + createPullPayment.Amount = request.CustomAmount.Value; createPullPayment.AutoApproveClaims = paymentMethodId.CryptoCode == request.CustomCurrency; break; diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json index 542b1c763..3911323a1 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json @@ -650,11 +650,10 @@ ] } }, - "/api/v1/stores/{storeId}/invoices/{invoiceId}/payment-methods/{paymentMethod}/refund": { + "/api/v1/stores/{storeId}/invoices/{invoiceId}/refund": { "post": { "tags": [ - "Invoices", - "Refund" + "Invoices" ], "summary": "Refund invoice", "parameters": [ @@ -675,19 +674,64 @@ "schema": { "type": "string" } - }, - { - "name": "paymentMethod", - "in": "path", + } + ], + "description": "Refund invoice", + "operationId": "Invoices_Refund", + "requestBody": { "required": true, - "description": "The payment method to refund with", + "content": { + "application/json": { "schema": { - "type": "string" + "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" + } + } + } } } - ], - "description": "Refund invoice", - "operationId": "Invoices_Refund", + }, "responses": { "200": { "description": "Pull payment for refunding the invoice",