diff --git a/BTCPayServer.Data/DBScripts/002.RefactorPayouts.sql b/BTCPayServer.Data/DBScripts/002.RefactorPayouts.sql index 59c8ea384..b27747e7f 100644 --- a/BTCPayServer.Data/DBScripts/002.RefactorPayouts.sql +++ b/BTCPayServer.Data/DBScripts/002.RefactorPayouts.sql @@ -7,6 +7,7 @@ UPDATE "Payouts" SET "Currency" = split_part("PayoutMethodId", '_', 1), "PayoutMethodId"= CASE + WHEN ("Blob"->>'Amount')::NUMERIC < 0 THEN 'TOPUP' WHEN split_part("PayoutMethodId", '_', 2) = 'LightningLike' THEN split_part("PayoutMethodId", '_', 1) || '-LN' ELSE split_part("PayoutMethodId", '_', 1) || '-CHAIN' END; diff --git a/BTCPayServer.Tests/DatabaseTests.cs b/BTCPayServer.Tests/DatabaseTests.cs index 4d75e6acb..af709d4bc 100644 --- a/BTCPayServer.Tests/DatabaseTests.cs +++ b/BTCPayServer.Tests/DatabaseTests.cs @@ -59,6 +59,14 @@ namespace BTCPayServer.Tests PullPaymentDataId = null as string, PaymentMethodId = "BTC_LightningLike", Blob = "{\"Amount\": \"10.0\", \"Revision\": 0, \"Destination\": \"address\", \"CryptoAmount\": null, \"MinimumConfirmation\": 1}" + }, + new + { + Id = "p4", + StoreId = "store1", + PullPaymentDataId = null as string, + PaymentMethodId = "BTC_LightningLike", + Blob = "{\"Amount\": \"-10.0\", \"Revision\": 0, \"Destination\": \"address\", \"CryptoAmount\": null, \"MinimumConfirmation\": 1}" } }; await conn.ExecuteAsync("INSERT INTO \"Payouts\"(\"Id\", \"StoreDataId\", \"PullPaymentDataId\", \"PaymentMethodId\", \"Blob\", \"State\", \"Date\") VALUES (@Id, @StoreId, @PullPaymentDataId, @PaymentMethodId, @Blob::JSONB, 'state', NOW())", parameters); @@ -75,6 +83,9 @@ namespace BTCPayServer.Tests migrated = await conn.ExecuteScalarAsync("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p3' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='BTC'"); Assert.True(migrated); + + migrated = await conn.ExecuteScalarAsync("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p4' AND \"Amount\" IS NULL AND \"OriginalAmount\"=-10.0 AND \"OriginalCurrency\"='BTC' AND \"PayoutMethodId\"='TOPUP'"); + Assert.True(migrated); } } } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 59ada9576..a789aa54a 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1448,6 +1448,41 @@ namespace BTCPayServer.Tests Assert.Contains("DOGE_X", rateVm.Script, StringComparison.OrdinalIgnoreCase); } + + [Fact] + [Trait("Integration", "Integration")] + public async Task CanTopUpPullPayment() + { + using var tester = CreateServerTester(); + await tester.StartAsync(); + var user = tester.NewAccount(); + await user.GrantAccessAsync(true); + await user.RegisterDerivationSchemeAsync("BTC"); + var client = await user.CreateClient(); + var pp = await client.CreatePullPayment(user.StoreId, new() + { + Currency = "BTC", + Amount = 1.0m, + PaymentMethods = [ "BTC-CHAIN" ] + }); + var controller = user.GetController(); + var invoice = await controller.CreateInvoiceCoreRaw(new() + { + Amount = 0.5m, + Currency = "BTC", + }, controller.HttpContext.GetStoreData(), controller.Url.Link(null, null), [PullPaymentHostedService.GetInternalTag(pp.Id)]); + await client.MarkInvoiceStatus(user.StoreId, invoice.Id, new() { Status = InvoiceStatus.Settled }); + + await TestUtils.EventuallyAsync(async () => + { + var payouts = await client.GetPayouts(pp.Id); + var payout = Assert.Single(payouts); + Assert.Equal("TOPUP", payout.PaymentMethod); + Assert.Equal(invoice.Id, payout.Destination); + Assert.Equal(-0.5m, payout.Amount); + }); + } + [Fact] [Trait("Integration", "Integration")] public async Task CanUseDefaultCurrency() diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index e9b040d89..45465f14c 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -424,7 +424,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler, IHasNetwork var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(newTransaction.CryptoCode); await using var ctx = _dbContextFactory.CreateContext(); - var payouts = await ctx.Payouts + var payout = await ctx.Payouts .Include(o => o.StoreData) .Include(o => o.PullPaymentData) .Where(p => p.State == PayoutState.AwaitingPayment) @@ -432,10 +432,9 @@ public class BitcoinLikePayoutHandler : IPayoutHandler, IHasNetwork #pragma warning disable CA1307 // Specify StringComparison .Where(p => destination.Equals(p.Destination)) #pragma warning restore CA1307 // Specify StringComparison - .ToListAsync(); - var payoutByDestination = payouts.ToDictionary(p => p.Destination); + .FirstOrDefaultAsync(); - if (!payoutByDestination.TryGetValue(destination, out var payout)) + if (payout is null) return; var payoutBlob = payout.GetBlob(_jsonSerializerSettings); if (payout.Amount is null || diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index d239ad54e..dd99c7391 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client.Models; using BTCPayServer.Data; +using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; @@ -273,7 +274,7 @@ namespace BTCPayServer.HostedServices return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId); } - + record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity); class PayoutRequest { public PayoutRequest(TaskCompletionSource completionSource, @@ -336,10 +337,21 @@ namespace BTCPayServer.HostedServices { payoutHandler.StartBackgroundCheck(Subscribe); } - + _eventAggregator.Subscribe(TopUpInvoiceCore); return new[] { Loop() }; } + private void TopUpInvoiceCore(InvoiceEvent evt) + { + if (evt.EventCode == InvoiceEventCode.Completed || evt.EventCode == InvoiceEventCode.MarkedCompleted) + { + foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#")) + { + _Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice)); + } + } + } + private void Subscribe(params Type[] events) { foreach (Type @event in events) @@ -352,6 +364,11 @@ namespace BTCPayServer.HostedServices { await foreach (var o in _Channel.Reader.ReadAllAsync()) { + if (o is TopUpRequest topUp) + { + await HandleTopUp(topUp); + } + if (o is PayoutRequest req) { await HandleCreatePayout(req); @@ -386,6 +403,38 @@ namespace BTCPayServer.HostedServices } } + private async Task HandleTopUp(TopUpRequest topUp) + { + var pp = await this.GetPullPayment(topUp.PullPaymentId, false); + using var ctx = _dbContextFactory.CreateContext(); + + var payout = new Data.PayoutData() + { + Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), + PayoutMethodId = PayoutMethodIds.TopUp.ToString(), + Date = DateTimeOffset.UtcNow, + State = PayoutState.Completed, + PullPaymentDataId = pp.Id, + Destination = topUp.InvoiceEntity.Id, + StoreDataId = pp.StoreId + }; + if (topUp.InvoiceEntity.Currency != pp.Currency || + pp.Currency is not ("SATS" or "BTC")) + return; + payout.Currency = pp.Currency; + payout.Amount = -topUp.InvoiceEntity.Price; + payout.OriginalCurrency = payout.Currency; + payout.OriginalAmount = payout.Amount.Value; + var payoutBlob = new PayoutBlob() + { + Destination = topUp.InvoiceEntity.Id, + Metadata = new JObject() + }; + payout.SetBlob(payoutBlob, _jsonSerializerSettings); + await ctx.Payouts.AddAsync(payout); + await ctx.SaveChangesAsync(); + } + public bool SupportsLNURL(PullPaymentData pp, PullPaymentBlob blob = null) { blob ??= pp.GetBlob(); @@ -842,6 +891,10 @@ namespace BTCPayServer.HostedServices return time; } + public static string GetInternalTag(string ppId) + { + return $"PULLPAY#{ppId}"; + } class InternalPayoutPaidRequest { diff --git a/BTCPayServer/Payouts/PayoutTypes.cs b/BTCPayServer/Payouts/PayoutTypes.cs index 5afd0c102..6b4d2d9b1 100644 --- a/BTCPayServer/Payouts/PayoutTypes.cs +++ b/BTCPayServer/Payouts/PayoutTypes.cs @@ -2,6 +2,10 @@ using BTCPayServer.Payments; namespace BTCPayServer.Payouts { + public class PayoutMethodIds + { + public static readonly PayoutMethodId TopUp = PayoutMethodId.Parse("TOPUP"); + } public class PayoutTypes { public static readonly PayoutType LN = new("LN");