Fix: Refunds through API were ignoring BOLT11 expiration at store level (#6644)

This commit is contained in:
Nicolas Dorier
2025-03-31 09:32:12 +09:00
committed by GitHub
parent c5270fa441
commit 658ddd1f27
11 changed files with 78 additions and 60 deletions

View File

@@ -30,6 +30,12 @@ public partial class BTCPayServerClient
return await SendHttpRequest<StoreData>("api/v1/stores", request, HttpMethod.Post, token); return await SendHttpRequest<StoreData>("api/v1/stores", request, HttpMethod.Post, token);
} }
public async Task<StoreData> UpdateStore(string storeId, StoreData request, CancellationToken token = default)
{
if (request is null)
throw new ArgumentNullException(nameof(request));
return await UpdateStore(storeId, Newtonsoft.Json.JsonConvert.DeserializeObject<UpdateStoreRequest>(Newtonsoft.Json.JsonConvert.SerializeObject(request)), token);
}
public virtual async Task<StoreData> UpdateStore(string storeId, UpdateStoreRequest request, CancellationToken token = default) public virtual async Task<StoreData> UpdateStore(string storeId, UpdateStoreRequest request, CancellationToken token = default)
{ {
if (request == null) throw new ArgumentNullException(nameof(request)); if (request == null) throw new ArgumentNullException(nameof(request));

View File

@@ -32,6 +32,10 @@ namespace BTCPayServer.Client.Models
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan DisplayExpirationTimer { get; set; } = TimeSpan.FromMinutes(5); public TimeSpan DisplayExpirationTimer { get; set; } = TimeSpan.FromMinutes(5);
[JsonConverter(typeof(TimeSpanJsonConverter.Days))]
[JsonProperty("refundBOLT11Expiration", NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan RefundBOLT11Expiration { get; set; } = TimeSpan.FromDays(30);
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60); public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60);

View File

@@ -2293,6 +2293,13 @@ namespace BTCPayServer.Tests
var user = tester.NewAccount(); var user = tester.NewAccount();
await user.RegisterDerivationSchemeAsync("BTC"); await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient(); var client = await user.CreateClient();
var store = await client.GetStore(user.StoreId);
Assert.Equal(TimeSpan.FromDays(30.0), store.RefundBOLT11Expiration);
store.RefundBOLT11Expiration = TimeSpan.FromDays(1);
await client.UpdateStore(store.Id, store);
store = await client.GetStore(user.StoreId);
Assert.Equal(TimeSpan.FromDays(1.0), store.RefundBOLT11Expiration);
var invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = 5000.0m, Currency = "USD" }); var invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest() { Amount = 5000.0m, Currency = "USD" });
var methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id); var methods = await client.GetInvoicePaymentMethods(user.StoreId, invoice.Id);
var method = methods.First(); var method = methods.First();
@@ -2354,6 +2361,7 @@ namespace BTCPayServer.Tests
PayoutMethodId = method.PaymentMethodId, PayoutMethodId = method.PaymentMethodId,
RefundVariant = RefundVariant.RateThen RefundVariant = RefundVariant.RateThen
}); });
Assert.Equal(pp.BOLT11Expiration, TimeSpan.FromDays(1));
Assert.Equal("BTC", pp.Currency); Assert.Equal("BTC", pp.Currency);
Assert.True(pp.AutoApproveClaims); Assert.True(pp.AutoApproveClaims);
Assert.Equal(1, pp.Amount); Assert.Equal(1, pp.Amount);

View File

@@ -376,13 +376,11 @@ namespace BTCPayServer.Controllers.Greenfield
cancellationToken cancellationToken
); );
var paidAmount = cryptoPaid.RoundToSignificant(paymentPrompt.Divisibility); var paidAmount = cryptoPaid.RoundToSignificant(paymentPrompt.Divisibility);
var createPullPayment = new CreatePullPayment var createPullPayment = new CreatePullPaymentRequest
{ {
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration,
Name = request.Name ?? $"Refund {invoice.Id}", Name = request.Name ?? $"Refund {invoice.Id}",
Description = request.Description, Description = request.Description,
StoreId = storeId, PayoutMethods = new[] { payoutMethodId.ToString() },
PayoutMethods = new[] { payoutMethodId },
}; };
if (request.RefundVariant != RefundVariant.Custom) if (request.RefundVariant != RefundVariant.Custom)
@@ -479,8 +477,8 @@ namespace BTCPayServer.Controllers.Greenfield
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, appliedDivisibility); createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, appliedDivisibility);
} }
createPullPayment.AutoApproveClaims = createPullPayment.AutoApproveClaims && (await _authorizationService.AuthorizeAsync(User, createPullPayment.StoreId ,Policies.CanCreatePullPayments)).Succeeded; createPullPayment.AutoApproveClaims = createPullPayment.AutoApproveClaims && (await _authorizationService.AuthorizeAsync(User, storeId ,Policies.CanCreatePullPayments)).Succeeded;
var ppId = await _pullPaymentService.CreatePullPayment(createPullPayment); var ppId = await _pullPaymentService.CreatePullPayment(store, createPullPayment);
await using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();

View File

@@ -126,22 +126,25 @@ namespace BTCPayServer.Controllers.Greenfield
} }
var supported = _payoutHandlers.GetSupportedPayoutMethods(HttpContext.GetStoreData()); var supported = _payoutHandlers.GetSupportedPayoutMethods(HttpContext.GetStoreData());
request.PayoutMethods ??= supported.Select(s => s.ToString()).ToArray(); if (request.PayoutMethods is not null)
for (int i = 0; i < request.PayoutMethods.Length; i++)
{ {
var pmi = request.PayoutMethods[i] is string pm ? PayoutMethodId.TryParse(pm) : null; for (int i = 0; i < request.PayoutMethods.Length; i++)
if (pmi is null || !supported.Contains(pmi))
{ {
request.AddModelError(paymentRequest => paymentRequest.PayoutMethods[i], "Invalid or unsupported payment method", this); var pmi = request.PayoutMethods[i] is string pm ? PayoutMethodId.TryParse(pm) : null;
if (pmi is null || !supported.Contains(pmi))
{
request.AddModelError(paymentRequest => paymentRequest.PayoutMethods[i], "Invalid or unsupported payment method", this);
}
}
if (request.PayoutMethods.Length is 0)
{
ModelState.AddModelError(nameof(request.PayoutMethods), "At least one payout method is required");
} }
}
if (request.PayoutMethods.Length is 0)
{
ModelState.AddModelError(nameof(request.PayoutMethods), "At least one payout method is required");
} }
if (!ModelState.IsValid) if (!ModelState.IsValid)
return this.CreateValidationError(ModelState); return this.CreateValidationError(ModelState);
var ppId = await _pullPaymentService.CreatePullPayment(storeId, request);
var ppId = await _pullPaymentService.CreatePullPayment(HttpContext.GetStoreData(), request);
var pp = await _pullPaymentService.GetPullPayment(ppId, false); var pp = await _pullPaymentService.GetPullPayment(ppId, false);
return this.Ok(CreatePullPaymentData(pp)); return this.Ok(CreatePullPaymentData(pp));
} }

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon.Runtime.Internal;
using BTCPayServer.Abstractions.Constants; using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
@@ -18,6 +19,7 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using static System.Runtime.InteropServices.JavaScript.JSType;
using StoreData = BTCPayServer.Data.StoreData; using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Controllers.Greenfield namespace BTCPayServer.Controllers.Greenfield
@@ -174,6 +176,7 @@ namespace BTCPayServer.Controllers.Greenfield
Website = data.StoreWebsite, Website = data.StoreWebsite,
Archived = data.Archived, Archived = data.Archived,
BrandColor = storeBlob.BrandColor, BrandColor = storeBlob.BrandColor,
RefundBOLT11Expiration = storeBlob.RefundBOLT11Expiration,
ApplyBrandColorToBackend = storeBlob.ApplyBrandColorToBackend, ApplyBrandColorToBackend = storeBlob.ApplyBrandColorToBackend,
CssUrl = storeBlob.CssUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.CssUrl), CssUrl = storeBlob.CssUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.CssUrl),
LogoUrl = storeBlob.LogoUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.LogoUrl), LogoUrl = storeBlob.LogoUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.LogoUrl),
@@ -258,6 +261,7 @@ namespace BTCPayServer.Controllers.Greenfield
blob.ApplyBrandColorToBackend = restModel.ApplyBrandColorToBackend; blob.ApplyBrandColorToBackend = restModel.ApplyBrandColorToBackend;
blob.LogoUrl = restModel.LogoUrl is null ? null : UnresolvedUri.Create(restModel.LogoUrl); blob.LogoUrl = restModel.LogoUrl is null ? null : UnresolvedUri.Create(restModel.LogoUrl);
blob.CssUrl = restModel.CssUrl is null ? null : UnresolvedUri.Create(restModel.CssUrl); blob.CssUrl = restModel.CssUrl is null ? null : UnresolvedUri.Create(restModel.CssUrl);
blob.RefundBOLT11Expiration = restModel.RefundBOLT11Expiration;
blob.PaymentSoundUrl = restModel.PaymentSoundUrl is null ? null : UnresolvedUri.Create(restModel.PaymentSoundUrl); blob.PaymentSoundUrl = restModel.PaymentSoundUrl is null ? null : UnresolvedUri.Create(restModel.PaymentSoundUrl);
if (restModel.AutoDetectLanguage.HasValue) if (restModel.AutoDetectLanguage.HasValue)
blob.AutoDetectLanguage = restModel.AutoDetectLanguage.Value; blob.AutoDetectLanguage = restModel.AutoDetectLanguage.Value;
@@ -295,6 +299,9 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
ModelState.AddModelError(nameof(request.Name), "DefaultPaymentMethod is invalid"); ModelState.AddModelError(nameof(request.Name), "DefaultPaymentMethod is invalid");
} }
if (request.RefundBOLT11Expiration < TimeSpan.FromDays(0) ||
request.RefundBOLT11Expiration > TimeSpan.FromDays(365 * 10))
ModelState.AddModelError(nameof(request.RefundBOLT11Expiration), "refundBOLT11Expiration should be between 0 and 36500");
if (string.IsNullOrEmpty(request.Name)) if (string.IsNullOrEmpty(request.Name))
ModelState.AddModelError(nameof(request.Name), "Name is missing"); ModelState.AddModelError(nameof(request.Name), "Name is missing");

View File

@@ -342,7 +342,7 @@ namespace BTCPayServer.Controllers
var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true); var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.Currency, true);
RateRules rules; RateRules rules;
RateResult rateResult; RateResult rateResult;
CreatePullPayment createPullPayment; CreatePullPaymentRequest createPullPayment;
var pmis = _payoutHandlers.GetSupportedPayoutMethods(store); var pmis = _payoutHandlers.GetSupportedPayoutMethods(store);
if (!pmis.Contains(pmi)) if (!pmis.Contains(pmi))
@@ -414,12 +414,10 @@ namespace BTCPayServer.Controllers
return View("_RefundModal", model); return View("_RefundModal", model);
case RefundSteps.SelectRate: case RefundSteps.SelectRate:
createPullPayment = new CreatePullPayment createPullPayment = new CreatePullPaymentRequest
{ {
Name = StringLocalizer["Refund {0}", invoice.Id], Name = StringLocalizer["Refund {0}", invoice.Id],
PayoutMethods = new[] { pmi }, PayoutMethods = new[] { pmi.ToString() }
StoreId = invoice.StoreId,
BOLT11Expiration = store.GetStoreBlob().RefundBOLT11Expiration
}; };
var authorizedForAutoApprove = (await var authorizedForAutoApprove = (await
_authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments)) _authorizationService.AuthorizeAsync(User, invoice.StoreId, Policies.CanCreatePullPayments))
@@ -529,7 +527,7 @@ namespace BTCPayServer.Controllers
createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, ppDivisibility); createPullPayment.Amount = Math.Round(createPullPayment.Amount - reduceByAmount, ppDivisibility);
} }
var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment); var ppId = await _paymentHostedService.CreatePullPayment(store, createPullPayment);
TempData.SetStatusMessageModel(new StatusMessageModel TempData.SetStatusMessageModel(new StatusMessageModel
{ {
Html = "Refund successfully created!<br />Share the link to this page with a customer.<br />The customer needs to enter their address and claim the refund.<br />Once a customer claims the refund, you will get a notification and would need to approve and initiate it from your Store > Payouts.", Html = "Refund successfully created!<br />Share the link to this page with a customer.<br />The customer needs to enter their address and claim the refund.<br />Once a customer claims the refund, you will get a notification and would need to approve and initiate it from your Store > Payouts.",

View File

@@ -147,14 +147,13 @@ namespace BTCPayServer.Controllers
return View(model); return View(model);
model.AutoApproveClaims = model.AutoApproveClaims && (await model.AutoApproveClaims = model.AutoApproveClaims && (await
_authorizationService.AuthorizeAsync(User, storeId, Policies.CanCreatePullPayments)).Succeeded; _authorizationService.AuthorizeAsync(User, storeId, Policies.CanCreatePullPayments)).Succeeded;
await _pullPaymentService.CreatePullPayment(new CreatePullPayment await _pullPaymentService.CreatePullPayment(CurrentStore, new CreatePullPaymentRequest
{ {
Name = model.Name, Name = model.Name,
Description = model.Description, Description = model.Description,
Amount = model.Amount, Amount = model.Amount,
Currency = model.Currency, Currency = model.Currency,
StoreId = storeId, PayoutMethods = selectedPaymentMethodIds.Select(p => p.ToString()).ToArray(),
PayoutMethods = selectedPaymentMethodIds,
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration), BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration),
AutoApproveClaims = model.AutoApproveClaims AutoApproveClaims = model.AutoApproveClaims
}); });

View File

@@ -20,6 +20,7 @@ using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using Dapper; using Dapper;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NBitcoin; using NBitcoin;
@@ -33,20 +34,6 @@ using PullPaymentData = BTCPayServer.Data.PullPaymentData;
namespace BTCPayServer.HostedServices namespace BTCPayServer.HostedServices
{ {
public class CreatePullPayment
{
public DateTimeOffset? ExpiresAt { get; set; }
public DateTimeOffset? StartsAt { get; set; }
public string StoreId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
public PayoutMethodId[] PayoutMethods { get; set; }
public bool AutoApproveClaims { get; set; }
public TimeSpan? BOLT11Expiration { get; set; }
}
public class PullPaymentHostedService : BaseAsyncService public class PullPaymentHostedService : BaseAsyncService
{ {
private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" }; private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" };
@@ -110,26 +97,14 @@ namespace BTCPayServer.HostedServices
} }
} }
} }
public Task<string> CreatePullPayment(string storeId, CreatePullPaymentRequest request) public async Task<string> CreatePullPayment(Data.StoreData store, CreatePullPaymentRequest create)
{ {
if (request.PayoutMethods.Length == 0) var supported = this._handlers.GetSupportedPayoutMethods(store);
create.PayoutMethods ??= supported.Select(s => s.ToString()).ToArray();
create.PayoutMethods = create.PayoutMethods.Where(pm => _handlers.Support(PayoutMethodId.Parse(pm))).ToArray();
if (create.PayoutMethods.Length == 0)
throw new InvalidOperationException("request.PayoutMethods should have at least one payout method"); throw new InvalidOperationException("request.PayoutMethods should have at least one payout method");
return CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Description = request.Description,
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PayoutMethods = request.PayoutMethods.Select(p => PayoutMethodId.Parse(p)).ToArray(),
AutoApproveClaims = request.AutoApproveClaims
});
}
public async Task<string> CreatePullPayment(CreatePullPayment create)
{
ArgumentNullException.ThrowIfNull(create); ArgumentNullException.ThrowIfNull(create);
if (create.Amount <= 0.0m) if (create.Amount <= 0.0m)
throw new ArgumentException("Amount out of bound", nameof(create)); throw new ArgumentException("Amount out of bound", nameof(create));
@@ -140,7 +115,7 @@ namespace BTCPayServer.HostedServices
: DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0); : DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0);
o.EndDate = create.ExpiresAt is DateTimeOffset date2 ? new DateTimeOffset?(date2) : null; o.EndDate = create.ExpiresAt is DateTimeOffset date2 ? new DateTimeOffset?(date2) : null;
o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20));
o.StoreId = create.StoreId; o.StoreId = store.Id;
o.Currency = create.Currency; o.Currency = create.Currency;
o.Limit = create.Amount; o.Limit = create.Amount;
@@ -148,7 +123,7 @@ namespace BTCPayServer.HostedServices
{ {
Name = create.Name ?? string.Empty, Name = create.Name ?? string.Empty,
Description = create.Description ?? string.Empty, Description = create.Description ?? string.Empty,
SupportedPayoutMethods = create.PayoutMethods, SupportedPayoutMethods = create.PayoutMethods.Select(p => PayoutMethodId.Parse(p)).ToArray(),
AutoApproveClaims = create.AutoApproveClaims, AutoApproveClaims = create.AutoApproveClaims,
View = new PullPaymentBlob.PullPaymentView View = new PullPaymentBlob.PullPaymentView
{ {
@@ -156,7 +131,7 @@ namespace BTCPayServer.HostedServices
Description = create.Description ?? string.Empty, Description = create.Description ?? string.Empty,
Email = null Email = null
}, },
BOLT11Expiration = create.BOLT11Expiration ?? TimeSpan.FromDays(30.0) BOLT11Expiration = create.BOLT11Expiration ?? store.GetStoreBlob().RefundBOLT11Expiration
}); });
ctx.PullPayments.Add(o); ctx.PullPayments.Add(o);
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();

View File

@@ -126,6 +126,15 @@
"format": "seconds", "format": "seconds",
"description": "A span of times in seconds" "description": "A span of times in seconds"
}, },
"TimeSpanDays": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "days",
"description": "A span of times in days"
},
"TimeSpanMinutes": { "TimeSpanMinutes": {
"allOf": [ "allOf": [
{ {

View File

@@ -518,6 +518,17 @@
} }
] ]
}, },
"refundBOLT11Expiration": {
"default": 30,
"minimum": 0,
"maximum": 3650,
"description": "The minimum expiry of BOLT11 invoices accepted for refunds by default. (in days)",
"allOf": [
{
"$ref": "#/components/schemas/TimeSpanDays"
}
]
},
"displayExpirationTimer": { "displayExpirationTimer": {
"default": 300, "default": 300,
"minimum": 60, "minimum": 60,