mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 14:34:23 +01:00
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:
@@ -128,5 +128,19 @@ namespace BTCPayServer.Client
|
|||||||
method: HttpMethod.Post), token);
|
method: HttpMethod.Post), token);
|
||||||
await HandleResponse(response);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
25
BTCPayServer.Client/Models/RefundInvoiceRequest.cs
Normal file
25
BTCPayServer.Client/Models/RefundInvoiceRequest.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
[Fact(Timeout = TestTimeout)]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task InvoiceTests()
|
public async Task InvoiceTests()
|
||||||
|
|||||||
@@ -2,14 +2,19 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Abstractions.Constants;
|
using BTCPayServer.Abstractions.Constants;
|
||||||
using BTCPayServer.Abstractions.Extensions;
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Invoices;
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Services.Rates;
|
||||||
|
using BTCPayServer.Rating;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -32,12 +37,19 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
|
||||||
private readonly EventAggregator _eventAggregator;
|
private readonly EventAggregator _eventAggregator;
|
||||||
private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary;
|
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 LanguageService LanguageService { get; }
|
||||||
|
|
||||||
public GreenfieldInvoiceController(UIInvoiceController invoiceController, InvoiceRepository invoiceRepository,
|
public GreenfieldInvoiceController(UIInvoiceController invoiceController, InvoiceRepository invoiceRepository,
|
||||||
LinkGenerator linkGenerator, LanguageService languageService, BTCPayNetworkProvider btcPayNetworkProvider,
|
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;
|
_invoiceController = invoiceController;
|
||||||
_invoiceRepository = invoiceRepository;
|
_invoiceRepository = invoiceRepository;
|
||||||
@@ -45,6 +57,11 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
_btcPayNetworkProvider = btcPayNetworkProvider;
|
_btcPayNetworkProvider = btcPayNetworkProvider;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
|
_paymentMethodHandlerDictionary = paymentMethodHandlerDictionary;
|
||||||
|
_currencyNameTable = currencyNameTable;
|
||||||
|
_networkProvider = networkProvider;
|
||||||
|
_rateProvider = rateProvider;
|
||||||
|
_pullPaymentService = pullPaymentService;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
LanguageService = languageService;
|
LanguageService = languageService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,6 +350,162 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||||||
return this.CreateValidationError(ModelState);
|
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()
|
private IActionResult InvoiceNotFound()
|
||||||
{
|
{
|
||||||
return this.CreateAPIError(404, "invoice-not-found", "The invoice was not found");
|
return this.CreateAPIError(404, "invoice-not-found", "The invoice was not found");
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ namespace BTCPayServer.Controllers
|
|||||||
Events = invoice.Events,
|
Events = invoice.Events,
|
||||||
PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData),
|
PosData = PosDataParser.ParsePosData(invoice.Metadata.PosData),
|
||||||
Archived = invoice.Archived,
|
Archived = invoice.Archived,
|
||||||
CanRefund = CanRefund(invoiceState),
|
CanRefund = invoiceState.CanRefund(),
|
||||||
Refunds = invoice.Refunds,
|
Refunds = invoice.Refunds,
|
||||||
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
|
ShowCheckout = invoice.Status == InvoiceStatusLegacy.New,
|
||||||
ShowReceipt = invoice.Status.ToModernStatus() == InvoiceStatus.Settled && (invoice.ReceiptOptions?.Enabled ?? receipt.Enabled is true),
|
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);
|
var network = _NetworkProvider.GetNetwork(paymentMethodId.CryptoCode);
|
||||||
return network == null ? null : paymentMethodId.PaymentType.GetTransactionLink(network, txId);
|
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")]
|
[HttpGet("invoices/{invoiceId}/refund")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
@@ -262,7 +252,7 @@ namespace BTCPayServer.Controllers
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
if (invoice.CurrentRefund?.PullPaymentDataId is null && GetUserId() is null)
|
if (invoice.CurrentRefund?.PullPaymentDataId is null && GetUserId() is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
if (!CanRefund(invoice.GetInvoiceState()))
|
if (!invoice.GetInvoiceState().CanRefund())
|
||||||
return NotFound();
|
return NotFound();
|
||||||
if (invoice.CurrentRefund?.PullPaymentDataId is string ppId && !invoice.CurrentRefund.PullPaymentData.Archived)
|
if (invoice.CurrentRefund?.PullPaymentDataId is string ppId && !invoice.CurrentRefund.PullPaymentData.Archived)
|
||||||
{
|
{
|
||||||
@@ -318,7 +308,7 @@ namespace BTCPayServer.Controllers
|
|||||||
if (invoice == null)
|
if (invoice == null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
if (!CanRefund(invoice.GetInvoiceState()))
|
if (!invoice.GetInvoiceState().CanRefund())
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
var store = GetCurrentStore();
|
var store = GetCurrentStore();
|
||||||
|
|||||||
@@ -835,6 +835,17 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
(Status != InvoiceStatusLegacy.Invalid && ExceptionStatus == InvoiceExceptionStatus.Marked);
|
(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()
|
public override int GetHashCode()
|
||||||
{
|
{
|
||||||
return HashCode.Combine(Status, ExceptionStatus);
|
return HashCode.Combine(Status, ExceptionStatus);
|
||||||
|
|||||||
@@ -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": {
|
"components": {
|
||||||
|
|||||||
Reference in New Issue
Block a user