Add topups to payouts (#6187)

This commit is contained in:
Nicolas Dorier 2024-09-02 18:37:39 +09:00 committed by GitHub
parent b49f6c3f86
commit 4a94074595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 109 additions and 6 deletions

View File

@ -7,6 +7,7 @@ UPDATE "Payouts" SET
"Currency" = split_part("PayoutMethodId", '_', 1), "Currency" = split_part("PayoutMethodId", '_', 1),
"PayoutMethodId"= "PayoutMethodId"=
CASE CASE
WHEN ("Blob"->>'Amount')::NUMERIC < 0 THEN 'TOPUP'
WHEN split_part("PayoutMethodId", '_', 2) = 'LightningLike' THEN split_part("PayoutMethodId", '_', 1) || '-LN' WHEN split_part("PayoutMethodId", '_', 2) = 'LightningLike' THEN split_part("PayoutMethodId", '_', 1) || '-LN'
ELSE split_part("PayoutMethodId", '_', 1) || '-CHAIN' ELSE split_part("PayoutMethodId", '_', 1) || '-CHAIN'
END; END;

View File

@ -59,6 +59,14 @@ namespace BTCPayServer.Tests
PullPaymentDataId = null as string, PullPaymentDataId = null as string,
PaymentMethodId = "BTC_LightningLike", PaymentMethodId = "BTC_LightningLike",
Blob = "{\"Amount\": \"10.0\", \"Revision\": 0, \"Destination\": \"address\", \"CryptoAmount\": null, \"MinimumConfirmation\": 1}" 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); 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<bool>("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p3' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='BTC'"); migrated = await conn.ExecuteScalarAsync<bool>("SELECT 't'::BOOLEAN FROM \"Payouts\" WHERE \"Id\"='p3' AND \"Amount\" IS NULL AND \"OriginalAmount\"=10.0 AND \"OriginalCurrency\"='BTC'");
Assert.True(migrated); Assert.True(migrated);
migrated = await conn.ExecuteScalarAsync<bool>("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);
} }
} }
} }

View File

@ -1448,6 +1448,41 @@ namespace BTCPayServer.Tests
Assert.Contains("DOGE_X", rateVm.Script, StringComparison.OrdinalIgnoreCase); 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<UIInvoiceController>();
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] [Fact]
[Trait("Integration", "Integration")] [Trait("Integration", "Integration")]
public async Task CanUseDefaultCurrency() public async Task CanUseDefaultCurrency()

View File

@ -424,7 +424,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler, IHasNetwork
var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(newTransaction.CryptoCode); var paymentMethodId = PaymentTypes.CHAIN.GetPaymentMethodId(newTransaction.CryptoCode);
await using var ctx = _dbContextFactory.CreateContext(); await using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts var payout = await ctx.Payouts
.Include(o => o.StoreData) .Include(o => o.StoreData)
.Include(o => o.PullPaymentData) .Include(o => o.PullPaymentData)
.Where(p => p.State == PayoutState.AwaitingPayment) .Where(p => p.State == PayoutState.AwaitingPayment)
@ -432,10 +432,9 @@ public class BitcoinLikePayoutHandler : IPayoutHandler, IHasNetwork
#pragma warning disable CA1307 // Specify StringComparison #pragma warning disable CA1307 // Specify StringComparison
.Where(p => destination.Equals(p.Destination)) .Where(p => destination.Equals(p.Destination))
#pragma warning restore CA1307 // Specify StringComparison #pragma warning restore CA1307 // Specify StringComparison
.ToListAsync(); .FirstOrDefaultAsync();
var payoutByDestination = payouts.ToDictionary(p => p.Destination);
if (!payoutByDestination.TryGetValue(destination, out var payout)) if (payout is null)
return; return;
var payoutBlob = payout.GetBlob(_jsonSerializerSettings); var payoutBlob = payout.GetBlob(_jsonSerializerSettings);
if (payout.Amount is null || if (payout.Amount is null ||

View File

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging; using BTCPayServer.Logging;
using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments; using BTCPayServer.Payments;
@ -273,7 +274,7 @@ namespace BTCPayServer.HostedServices
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId); return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
} }
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
class PayoutRequest class PayoutRequest
{ {
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource, public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
@ -336,10 +337,21 @@ namespace BTCPayServer.HostedServices
{ {
payoutHandler.StartBackgroundCheck(Subscribe); payoutHandler.StartBackgroundCheck(Subscribe);
} }
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoiceCore);
return new[] { Loop() }; 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) private void Subscribe(params Type[] events)
{ {
foreach (Type @event in events) foreach (Type @event in events)
@ -352,6 +364,11 @@ namespace BTCPayServer.HostedServices
{ {
await foreach (var o in _Channel.Reader.ReadAllAsync()) await foreach (var o in _Channel.Reader.ReadAllAsync())
{ {
if (o is TopUpRequest topUp)
{
await HandleTopUp(topUp);
}
if (o is PayoutRequest req) if (o is PayoutRequest req)
{ {
await HandleCreatePayout(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) public bool SupportsLNURL(PullPaymentData pp, PullPaymentBlob blob = null)
{ {
blob ??= pp.GetBlob(); blob ??= pp.GetBlob();
@ -842,6 +891,10 @@ namespace BTCPayServer.HostedServices
return time; return time;
} }
public static string GetInternalTag(string ppId)
{
return $"PULLPAY#{ppId}";
}
class InternalPayoutPaidRequest class InternalPayoutPaidRequest
{ {

View File

@ -2,6 +2,10 @@ using BTCPayServer.Payments;
namespace BTCPayServer.Payouts namespace BTCPayServer.Payouts
{ {
public class PayoutMethodIds
{
public static readonly PayoutMethodId TopUp = PayoutMethodId.Parse("TOPUP");
}
public class PayoutTypes public class PayoutTypes
{ {
public static readonly PayoutType LN = new("LN"); public static readonly PayoutType LN = new("LN");