diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 124acf21a..2da533cbe 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2247,6 +2247,17 @@ namespace BTCPayServer.Tests Assert.Equal("BTC", pp.Currency); Assert.True(pp.AutoApproveClaims); Assert.Equal(0.79m, pp.Amount); + + // If an invoice doesn't have payment because it has been marked as paid, we should still be able to refund it. + invoice = await client.CreateInvoice(user.StoreId, new CreateInvoiceRequest { Amount = 5000.0m, Currency = "USD" }); + await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new MarkInvoiceStatusRequest { Status = InvoiceStatus.Settled }); + var refund = await client.RefundInvoice(user.StoreId, invoice.Id, new RefundInvoiceRequest + { + PaymentMethod = method.PaymentMethodId, + RefundVariant = RefundVariant.CurrentRate + }); + Assert.Equal(1.0m, refund.Amount); + Assert.Equal("BTC", refund.Currency); } [Fact(Timeout = TestTimeout)] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs index ca19fbca1..b66631592 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs @@ -408,10 +408,7 @@ namespace BTCPayServer.Controllers.Greenfield var supported = _payoutHandlers.GetSupportedPayoutMethods(store); if (supported.Contains(payoutMethodId)) { - var paymentMethodId = PaymentMethodId.GetSimilarities([payoutMethodId], invoice.GetPayments(false).Select(p => p.PaymentMethodId)) - .OrderByDescending(o => o.similarity) - .Select(o => o.b) - .FirstOrDefault(); + var paymentMethodId = invoice.GetClosestPaymentMethodId([payoutMethodId]); paymentPrompt = paymentMethodId is null ? null : invoice.GetPaymentPrompt(paymentMethodId); } } @@ -426,6 +423,14 @@ namespace BTCPayServer.Controllers.Greenfield var accounting = paymentPrompt.Calculate(); var cryptoPaid = accounting.Paid; + var dueAmount = accounting.TotalDue; + + // If no payment, but settled and marked, assume it has been fully paid + if (cryptoPaid is 0 && invoice is { Status: InvoiceStatus.Settled, ExceptionStatus: InvoiceExceptionStatus.Marked }) + { + cryptoPaid = accounting.TotalDue; + dueAmount = 0; + } var cdCurrency = _currencyNameTable.GetCurrencyData(invoice.Currency, true); var paidCurrency = Math.Round(cryptoPaid * paymentPrompt.Rate, cdCurrency.Divisibility); var rateResult = await _rateProvider.FetchRate( @@ -491,8 +496,6 @@ namespace BTCPayServer.Controllers.Greenfield { return this.CreateValidationError(ModelState); } - - var dueAmount = accounting.TotalDue; createPullPayment.Currency = paymentPrompt.Currency; createPullPayment.Amount = Math.Round(paidAmount - dueAmount, appliedDivisibility); createPullPayment.AutoApproveClaims = true; diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index 34b7c1884..08bfb3946 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -302,12 +302,7 @@ namespace BTCPayServer.Controllers // Find the most similar payment method to the one used for the invoice var defaultRefund = - PaymentMethodId.GetSimilarities( - invoice.Payments.Select(o => o.GetPaymentMethodId()), - payoutMethodIds) - .OrderByDescending(o => o.similarity) - .Select(o => o.b) - .FirstOrDefault(); + invoice.GetClosestPayoutMethodId(payoutMethodIds); var refund = new RefundModel { @@ -353,11 +348,7 @@ namespace BTCPayServer.Controllers return View("_RefundModal", model); } - var availablePaymentMethodIds = invoice.GetPaymentPrompts().Select(p => p.PaymentMethodId).Where(p => _handlers.Support(p)).ToArray(); - var paymentMethodId = PaymentMethodId.GetSimilarities([pmi], availablePaymentMethodIds) - .OrderByDescending(o => o.similarity) - .Select(o => o.b) - .FirstOrDefault(); + var paymentMethodId = invoice.GetClosestPaymentMethodId([pmi]); var paymentMethod = paymentMethodId is null ? null : invoice.GetPaymentPrompt(paymentMethodId); if (paymentMethod?.Currency is null) @@ -367,8 +358,16 @@ namespace BTCPayServer.Controllers } var accounting = paymentMethod.Calculate(); - decimal cryptoPaid = accounting.Paid; - decimal dueAmount = accounting.TotalDue; + var cryptoPaid = accounting.Paid; + var dueAmount = accounting.TotalDue; + + // If no payment, but settled and marked, assume it has been fully paid + if (cryptoPaid is 0 && invoice is { Status: InvoiceStatus.Settled, ExceptionStatus: InvoiceExceptionStatus.Marked }) + { + cryptoPaid = accounting.TotalDue; + dueAmount = 0; + } + var paymentMethodCurrency = paymentMethod.Currency; var isPaidOver = invoice.ExceptionStatus == InvoiceExceptionStatus.PaidOver; diff --git a/BTCPayServer/Data/InvoiceDataExtensions.cs b/BTCPayServer/Data/InvoiceDataExtensions.cs index 50d028ca3..f9c0efe41 100644 --- a/BTCPayServer/Data/InvoiceDataExtensions.cs +++ b/BTCPayServer/Data/InvoiceDataExtensions.cs @@ -1,6 +1,9 @@ +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Reflection.Metadata; +using BTCPayServer.Payments; +using BTCPayServer.Payouts; using BTCPayServer.Services.Invoices; using NBitpayClient; using Newtonsoft.Json; @@ -26,6 +29,28 @@ namespace BTCPayServer.Data invoiceData.Amount = blob.Price; invoiceData.HasTypedBlob().SetBlob(blob, DefaultSerializer); } +#nullable enable + public static PayoutMethodId? GetClosestPayoutMethodId(this InvoiceData invoice, IEnumerable pmids) + { + var paymentMethodIds = invoice.Payments.Select(o => o.GetPaymentMethodId()).ToArray(); + if (paymentMethodIds.Length == 0) + paymentMethodIds = invoice.GetBlob().GetPaymentPrompts().Select(p => p.PaymentMethodId).ToArray(); + return PaymentMethodId.GetSimilarities(pmids, paymentMethodIds) + .OrderByDescending(o => o.similarity) + .Select(o => o.a) + .FirstOrDefault(); + } + public static PaymentMethodId? GetClosestPaymentMethodId(this InvoiceEntity invoice, IEnumerable pmids) + { + var paymentMethodIds = invoice.GetPayments(false).Select(o => o.PaymentMethodId).ToArray(); + if (paymentMethodIds.Length == 0) + paymentMethodIds = invoice.GetPaymentPrompts().Select(p => p.PaymentMethodId).ToArray(); + return PaymentMethodId.GetSimilarities(pmids, paymentMethodIds) + .OrderByDescending(o => o.similarity) + .Select(o => o.b) + .FirstOrDefault(); + } +#nullable restore public static InvoiceEntity GetBlob(this InvoiceData invoiceData) { #pragma warning disable CS0618 // Type or member is obsolete