diff --git a/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj b/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj index 93ac01ea2..1f2fe04d6 100644 --- a/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj +++ b/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj @@ -33,7 +33,7 @@ - + diff --git a/BTCPayServer.Client/BTCPayServerClient.PayoutProcessors.cs b/BTCPayServer.Client/BTCPayServerClient.PayoutProcessors.cs new file mode 100644 index 000000000..128ce8cee --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.PayoutProcessors.cs @@ -0,0 +1,18 @@ +#nullable enable +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public virtual async Task> GetPayoutProcessors( + CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest("api/v1/payout-processors"), token); + return await HandleResponse>(response); + } + } +} diff --git a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs index 77db3f1cc..38a965934 100644 --- a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs +++ b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs @@ -41,12 +41,23 @@ namespace BTCPayServer.Client var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", queryPayload: query, method: HttpMethod.Get), cancellationToken); return await HandleResponse(response); } + public virtual async Task GetStorePayouts(string storeId, bool includeCancelled = false, CancellationToken cancellationToken = default) + { + Dictionary query = new Dictionary(); + query.Add("includeCancelled", includeCancelled); + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payouts", queryPayload: query, method: HttpMethod.Get), cancellationToken); + return await HandleResponse(response); + } public virtual async Task CreatePayout(string pullPaymentId, CreatePayoutRequest payoutRequest, CancellationToken cancellationToken = default) { var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken); return await HandleResponse(response); + } + public virtual async Task CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest, CancellationToken cancellationToken = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken); + return await HandleResponse(response); } - public virtual async Task CancelPayout(string storeId, string payoutId, CancellationToken cancellationToken = default) { var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", method: HttpMethod.Delete), cancellationToken); diff --git a/BTCPayServer.Client/BTCPayServerClient.StorePayoutProcessors.cs b/BTCPayServer.Client/BTCPayServerClient.StorePayoutProcessors.cs new file mode 100644 index 000000000..b19790647 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.StorePayoutProcessors.cs @@ -0,0 +1,48 @@ +#nullable enable +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public virtual async Task> GetPayoutProcessors(string storeId, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors"), token); + return await HandleResponse>(response); + } + public virtual async Task RemovePayoutProcessor(string storeId, string processor, string paymentMethod, CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}", null, HttpMethod.Delete), token); + await HandleResponse(response); + } + + public virtual async Task> GetStoreLightningAutomatedPayoutProcessors(string storeId, string? paymentMethod = null, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory{(paymentMethod is null? string.Empty: $"/{paymentMethod}")}"), token); + return await HandleResponse>(response); + } + public virtual async Task UpdateStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod,LightningAutomatedPayoutSettings request, CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/LightningAutomatedPayoutSenderFactory/{paymentMethod}",null, request, HttpMethod.Put ), token); + return await HandleResponse(response); + } + public virtual async Task UpdateStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod,OnChainAutomatedPayoutSettings request, CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory/{paymentMethod}",null, request, HttpMethod.Put ), token); + return await HandleResponse(response); + } + + public virtual async Task> GetStoreOnChainAutomatedPayoutProcessors(string storeId, string? paymentMethod = null, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payout-processors/OnChainAutomatedPayoutSenderFactory{(paymentMethod is null? string.Empty: $"/{paymentMethod}")}"), token); + return await HandleResponse>(response); + } + } +} diff --git a/BTCPayServer.Client/Models/AddCustomerEmailRequest.cs b/BTCPayServer.Client/Models/AddCustomerEmailRequest.cs deleted file mode 100644 index c4ada191e..000000000 --- a/BTCPayServer.Client/Models/AddCustomerEmailRequest.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BTCPayServer.Client.Models -{ - public class AddCustomerEmailRequest - { - public string Email { get; set; } - } -} diff --git a/BTCPayServer.Client/Models/CreatePayoutThroughStoreRequest.cs b/BTCPayServer.Client/Models/CreatePayoutThroughStoreRequest.cs new file mode 100644 index 000000000..b237d6532 --- /dev/null +++ b/BTCPayServer.Client/Models/CreatePayoutThroughStoreRequest.cs @@ -0,0 +1,8 @@ +#nullable enable +namespace BTCPayServer.Client.Models; + +public class CreatePayoutThroughStoreRequest : CreatePayoutRequest +{ + public string? PullPaymentId { get; set; } + public bool Approved { get; set; } +} diff --git a/BTCPayServer.Client/Models/LightningAutomatedPayoutSettings.cs b/BTCPayServer.Client/Models/LightningAutomatedPayoutSettings.cs new file mode 100644 index 000000000..64ea5c99b --- /dev/null +++ b/BTCPayServer.Client/Models/LightningAutomatedPayoutSettings.cs @@ -0,0 +1,13 @@ +using System; +using BTCPayServer.Client.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models; + +public class LightningAutomatedPayoutSettings +{ + public string PaymentMethod { get; set; } + + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] + public TimeSpan IntervalSeconds { get; set; } +} diff --git a/BTCPayServer.Client/Models/OnChainAutomatedPayoutSettings.cs b/BTCPayServer.Client/Models/OnChainAutomatedPayoutSettings.cs new file mode 100644 index 000000000..03b12eac9 --- /dev/null +++ b/BTCPayServer.Client/Models/OnChainAutomatedPayoutSettings.cs @@ -0,0 +1,13 @@ +using System; +using BTCPayServer.Client.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models; + +public class OnChainAutomatedPayoutSettings +{ + public string PaymentMethod { get; set; } + + [JsonConverter(typeof(TimeSpanJsonConverter.Seconds))] + public TimeSpan IntervalSeconds { get; set; } +} diff --git a/BTCPayServer.Client/Models/PayLightningInvoiceRequest.cs b/BTCPayServer.Client/Models/PayLightningInvoiceRequest.cs index a6d5877ad..a02837914 100644 --- a/BTCPayServer.Client/Models/PayLightningInvoiceRequest.cs +++ b/BTCPayServer.Client/Models/PayLightningInvoiceRequest.cs @@ -1,4 +1,3 @@ -#nullable enable using BTCPayServer.Client.JsonConverters; using BTCPayServer.JsonConverters; using NBitcoin; @@ -15,6 +14,6 @@ namespace BTCPayServer.Client.Models public float? MaxFeePercent { get; set; } [JsonConverter(typeof(MoneyJsonConverter))] - public Money? MaxFeeFlat { get; set; } + public Money MaxFeeFlat { get; set; } } } diff --git a/BTCPayServer.Client/Models/PayoutProcessorData.cs b/BTCPayServer.Client/Models/PayoutProcessorData.cs new file mode 100644 index 000000000..b8eeb3a48 --- /dev/null +++ b/BTCPayServer.Client/Models/PayoutProcessorData.cs @@ -0,0 +1,9 @@ +namespace BTCPayServer.Client.Models +{ + public class PayoutProcessorData + { + public string Name { get; set; } + public string FriendlyName { get; set; } + public string[] PaymentMethods { get; set; } + } +} diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs index 71eb9a2a6..5b21d98d0 100644 --- a/BTCPayServer.Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/ApplicationDbContext.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using BTCPayServer.Data.Data; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; @@ -62,6 +63,7 @@ namespace BTCPayServer.Data public DbSet WebhookDeliveries { get; set; } public DbSet Webhooks { get; set; } public DbSet LightningAddresses{ get; set; } + public DbSet PayoutProcessors { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -88,7 +90,7 @@ namespace BTCPayServer.Data InvoiceData.OnModelCreating(builder); NotificationData.OnModelCreating(builder); //OffchainTransactionData.OnModelCreating(builder); - Data.PairedSINData.OnModelCreating(builder); + BTCPayServer.Data.PairedSINData.OnModelCreating(builder); PairingCodeData.OnModelCreating(builder); //PayjoinLock.OnModelCreating(builder); PaymentRequestData.OnModelCreating(builder); @@ -103,11 +105,12 @@ namespace BTCPayServer.Data //StoreData.OnModelCreating(builder); U2FDevice.OnModelCreating(builder); Fido2Credential.OnModelCreating(builder); - Data.UserStore.OnModelCreating(builder); + BTCPayServer.Data.UserStore.OnModelCreating(builder); //WalletData.OnModelCreating(builder); WalletTransactionData.OnModelCreating(builder); WebhookDeliveryData.OnModelCreating(builder); LightningAddressData.OnModelCreating(builder); + PayoutProcessorData.OnModelCreating(builder); //WebhookData.OnModelCreating(builder); diff --git a/BTCPayServer.Data/Data/PayoutData.cs b/BTCPayServer.Data/Data/PayoutData.cs index 2a6b7d3e7..d8a6a6bf8 100644 --- a/BTCPayServer.Data/Data/PayoutData.cs +++ b/BTCPayServer.Data/Data/PayoutData.cs @@ -14,6 +14,7 @@ namespace BTCPayServer.Data public string Id { get; set; } public DateTimeOffset Date { get; set; } public string PullPaymentDataId { get; set; } + public string StoreDataId { get; set; } public PullPaymentData PullPaymentData { get; set; } [MaxLength(20)] public PayoutState State { get; set; } @@ -25,12 +26,16 @@ namespace BTCPayServer.Data #nullable enable public string? Destination { get; set; } #nullable restore + public StoreData StoreData { get; set; } internal static void OnModelCreating(ModelBuilder builder) { builder.Entity() .HasOne(o => o.PullPaymentData) .WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade); + builder.Entity() + .HasOne(o => o.StoreData) + .WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade); builder.Entity() .Property(o => o.State) .HasConversion(); diff --git a/BTCPayServer.Data/Data/PayoutProcessorData.cs b/BTCPayServer.Data/Data/PayoutProcessorData.cs new file mode 100644 index 000000000..09f936ed9 --- /dev/null +++ b/BTCPayServer.Data/Data/PayoutProcessorData.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data.Data; + +public class PayoutProcessorData +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public string Id { get; set; } + public string StoreId { get; set; } + public StoreData Store { get; set; } + public string PaymentMethod { get; set; } + public string Processor { get; set; } + + public byte[] Blob { get; set; } + + internal static void OnModelCreating(ModelBuilder builder) + { + + builder.Entity() + .HasOne(o => o.Store) + .WithMany(data => data.PayoutProcessors).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/BTCPayServer.Data/Data/StoreData.cs b/BTCPayServer.Data/Data/StoreData.cs index eb0af5bd6..722f89735 100644 --- a/BTCPayServer.Data/Data/StoreData.cs +++ b/BTCPayServer.Data/Data/StoreData.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using BTCPayServer.Client.Models; +using BTCPayServer.Data.Data; +using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData; namespace BTCPayServer.Data { @@ -42,5 +44,7 @@ namespace BTCPayServer.Data public List PairedSINs { get; set; } public IEnumerable APIKeys { get; set; } public IEnumerable LightningAddresses { get; set; } + public IEnumerable PayoutProcessors { get; set; } + public IEnumerable Payouts { get; set; } } } diff --git a/BTCPayServer.Data/Migrations/20220311135252_AddPayoutProcessors.cs b/BTCPayServer.Data/Migrations/20220311135252_AddPayoutProcessors.cs new file mode 100644 index 000000000..c6b393be0 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20220311135252_AddPayoutProcessors.cs @@ -0,0 +1,92 @@ +// +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20220311135252_AddPayoutProcessors")] + public partial class AddPayoutProcessors : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StoreDataId", + table: "Payouts", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "PayoutProcessors", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + StoreId = table.Column(type: "TEXT", nullable: true), + PaymentMethod = table.Column(type: "TEXT", nullable: true), + Processor = table.Column(type: "TEXT", nullable: true), + Blob = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PayoutProcessors", x => x.Id); + table.ForeignKey( + name: "FK_PayoutProcessors_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Payouts_StoreDataId", + table: "Payouts", + column: "StoreDataId"); + + migrationBuilder.CreateIndex( + name: "IX_PayoutProcessors_StoreId", + table: "PayoutProcessors", + column: "StoreId"); + if (this.SupportAddForeignKey(ActiveProvider)) + { + migrationBuilder.AddForeignKey( + name: "FK_Payouts_Stores_StoreDataId", + table: "Payouts", + column: "StoreDataId", + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + if (this.SupportDropForeignKey(ActiveProvider)) + { + + migrationBuilder.DropForeignKey( + name: "FK_Payouts_Stores_StoreDataId", + table: "Payouts"); + + migrationBuilder.DropTable( + name: "PayoutProcessors"); + + migrationBuilder.DropIndex( + name: "IX_Payouts_StoreDataId", + table: "Payouts"); + } + if(this.SupportDropColumn(ActiveProvider)) + { + migrationBuilder.DropColumn( + name: "StoreDataId", + table: "Payouts"); + } + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index b2dcd0db8..0abec6edc 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using BTCPayServer.Data; using Microsoft.EntityFrameworkCore; @@ -548,12 +548,17 @@ namespace BTCPayServer.Migrations .HasMaxLength(20) .HasColumnType("TEXT"); + b.Property("StoreDataId") + .HasColumnType("TEXT"); + b.HasKey("Id"); b.HasIndex("PullPaymentDataId"); b.HasIndex("State"); + b.HasIndex("StoreDataId"); + b.HasIndex("Destination", "State"); b.ToTable("Payouts"); @@ -848,6 +853,31 @@ namespace BTCPayServer.Migrations b.ToTable("WebhookDeliveries"); }); + modelBuilder.Entity("BTCPayServer.PayoutProcessors.PayoutProcessorData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("PaymentMethod") + .HasColumnType("TEXT"); + + b.Property("Processor") + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("PayoutProcessors"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") @@ -1149,7 +1179,14 @@ namespace BTCPayServer.Migrations .HasForeignKey("PullPaymentDataId") .OnDelete(DeleteBehavior.Cascade); + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("Payouts") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + b.Navigation("PullPaymentData"); + + b.Navigation("StoreData"); }); modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => @@ -1271,6 +1308,16 @@ namespace BTCPayServer.Migrations b.Navigation("Webhook"); }); + modelBuilder.Entity("BTCPayServer.PayoutProcessors.PayoutProcessorData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "Store") + .WithMany("PayoutProcessors") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Store"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -1375,8 +1422,12 @@ namespace BTCPayServer.Migrations b.Navigation("PaymentRequests"); + b.Navigation("Payouts"); + b.Navigation("PullPayments"); + b.Navigation("PayoutProcessors"); + b.Navigation("UserStores"); }); diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index a153cec41..c7c315213 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -449,10 +449,10 @@ namespace BTCPayServer.Tests await tester.StartAsync(); var acc = tester.NewAccount(); acc.Register(); - acc.CreateStore(); + await acc.CreateStoreAsync(); var storeId = (await acc.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true)).StoreId; var client = await acc.CreateClient(); - var result = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + var result = await client.CreatePullPayment(storeId, new CreatePullPaymentRequest() { Name = "Test", Description = "Test description", @@ -2340,5 +2340,126 @@ namespace BTCPayServer.Tests await adminClient.SendEmail(admin.StoreId, new SendEmailRequest() { Body = "lol", Subject = "subj", Email = "sdasdas" }); } + + + [Fact(Timeout = 60 * 2 * 1000)] + [Trait("Integration", "Integration")] + public async Task CanUsePayoutProcessorsThroughAPI() + { + + using var tester = CreateServerTester(); + await tester.StartAsync(); + + var admin = tester.NewAccount(); + await admin.GrantAccessAsync(true); + + var adminClient = await admin.CreateClient(Policies.Unrestricted); + + var registeredProcessors = await adminClient.GetPayoutProcessors(); + Assert.Equal(2,registeredProcessors.Count()); + await adminClient.GenerateOnChainWallet(admin.StoreId, "BTC", new GenerateOnChainWalletRequest() + { + SavePrivateKeys = true + }); + + var preApprovedPayoutWithoutPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest() + { + Amount = 0.0001m, + Approved = true, + PaymentMethod = "BTC", + Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address, + }); + + var notApprovedPayoutWithoutPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest() + { + Amount = 0.00001m, + Approved = false, + PaymentMethod = "BTC", + Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address, + }); + + var pullPayment = await adminClient.CreatePullPayment(admin.StoreId, new CreatePullPaymentRequest() + { + Amount = 100, + Currency = "USD", + Name = "pull payment", + PaymentMethods = new []{ "BTC"} + }); + + var notapprovedPayoutWithPullPayment = await adminClient.CreatePayout(admin.StoreId, new CreatePayoutThroughStoreRequest() + { + PullPaymentId = pullPayment.Id, + Amount = 10, + Approved = false, + PaymentMethod = "BTC", + Destination = (await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address, + }); + await adminClient.ApprovePayout(admin.StoreId, notapprovedPayoutWithPullPayment.Id, + new ApprovePayoutRequest() { }); + + var payouts = await adminClient.GetStorePayouts(admin.StoreId); + + Assert.Equal(3, payouts.Length); + Assert.Single(payouts, data => data.State == PayoutState.AwaitingApproval); + await adminClient.ApprovePayout(admin.StoreId, notApprovedPayoutWithoutPullPayment.Id, + new ApprovePayoutRequest() { }); + + + payouts = await adminClient.GetStorePayouts(admin.StoreId); + + Assert.Equal(3, payouts.Length); + Assert.Empty(payouts.Where(data => data.State == PayoutState.AwaitingApproval)); + Assert.Empty(payouts.Where(data => data.PaymentMethodAmount is null)); + + Assert.Empty( await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")); + + + Assert.Empty( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")); + Assert.Empty(await adminClient.GetPayoutProcessors(admin.StoreId)); + + await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC", + new OnChainAutomatedPayoutSettings() {IntervalSeconds = TimeSpan.FromSeconds(100000)}); + Assert.Equal(100000, Assert.Single( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")).IntervalSeconds.TotalSeconds); + + var tpGen = Assert.Single(await adminClient.GetPayoutProcessors(admin.StoreId)); + Assert.Equal("BTC", Assert.Single(tpGen.PaymentMethods)); + //still too poor to process any payouts + Assert.Empty( await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")); + + + await adminClient.RemovePayoutProcessor(admin.StoreId, tpGen.Name, tpGen.PaymentMethods.First()); + + Assert.Empty( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")); + Assert.Empty(await adminClient.GetPayoutProcessors(admin.StoreId)); + + await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address, + tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.000012m)); + await tester.ExplorerNode.GenerateAsync(1); + await TestUtils.EventuallyAsync(async () => + { + Assert.Single(await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")); + + payouts = await adminClient.GetStorePayouts(admin.StoreId); + Assert.Equal(3, payouts.Length); + }); + await adminClient.UpdateStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC", + new OnChainAutomatedPayoutSettings() {IntervalSeconds = TimeSpan.FromSeconds(5)}); + Assert.Equal(5, Assert.Single( await adminClient.GetStoreOnChainAutomatedPayoutProcessors(admin.StoreId, "BTC")).IntervalSeconds.TotalSeconds); + await TestUtils.EventuallyAsync(async () => + { + Assert.Equal(2, (await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")).Count()); + payouts = await adminClient.GetStorePayouts(admin.StoreId); + Assert.Single(payouts.Where(data => data.State == PayoutState.InProgress)); + }); + + await tester.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create((await adminClient.GetOnChainWalletReceiveAddress(admin.StoreId, "BTC", true)).Address, + tester.ExplorerClient.Network.NBitcoinNetwork), Money.Coins(0.01m)); + await TestUtils.EventuallyAsync(async () => + { + Assert.Equal(4, (await adminClient.ShowOnChainWalletTransactions(admin.StoreId, "BTC")).Count()); + payouts = await adminClient.GetStorePayouts(admin.StoreId); + Assert.Empty(payouts.Where(data => data.State != PayoutState.InProgress)); + }); + } } } diff --git a/BTCPayServer.Tests/docker-compose.altcoins.yml b/BTCPayServer.Tests/docker-compose.altcoins.yml index ed7521024..adbc25611 100644 --- a/BTCPayServer.Tests/docker-compose.altcoins.yml +++ b/BTCPayServer.Tests/docker-compose.altcoins.yml @@ -32,7 +32,7 @@ services: TESTS_SOCKSENDPOINT: "tor:9050" expose: - "80" - links: + depends_on: - dev - selenium extra_hosts: @@ -46,7 +46,7 @@ services: dev: image: alpine:3.7 command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ] - links: + depends_on: - nbxplorer - postgres - customer_lightningd @@ -79,7 +79,7 @@ services: connect=bitcoind:39388 fallbackfee=0.0002 rpcallowip=0.0.0.0/0 - links: + depends_on: - nbxplorer - postgres - customer_lnd @@ -117,7 +117,7 @@ services: NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer NBXPLORER_NOAUTH: 1 NBXPLORER_EXPOSERPC: 1 - links: + depends_on: - bitcoind - litecoind - elementsd-liquid @@ -176,7 +176,7 @@ services: volumes: - "bitcoin_datadir:/etc/bitcoin" - "customer_lightningd_datadir:/root/.lightning" - links: + depends_on: - bitcoind lightning-charged: @@ -197,7 +197,7 @@ services: - "9735" # Lightning ports: - "54938:9112" # Charge - links: + depends_on: - bitcoind - merchant_lightningd @@ -224,7 +224,7 @@ services: volumes: - "bitcoin_datadir:/etc/bitcoin" - "merchant_lightningd_datadir:/root/.lightning" - links: + depends_on: - bitcoind postgres: image: postgres:13.4 @@ -266,7 +266,7 @@ services: volumes: - "merchant_lnd_datadir:/data" - "bitcoin_datadir:/deps/.bitcoin" - links: + depends_on: - bitcoind customer_lnd: @@ -301,7 +301,7 @@ services: volumes: - "customer_lnd_datadir:/root/.lnd" - "bitcoin_datadir:/deps/.bitcoin" - links: + depends_on: - bitcoind tor: diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index 5abb3d390..52a839732 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -30,7 +30,7 @@ services: TESTS_SOCKSENDPOINT: "tor:9050" expose: - "80" - links: + depends_on: - dev - selenium extra_hosts: @@ -44,7 +44,7 @@ services: dev: image: alpine:3.7 command: [ "/bin/sh", "-c", "trap : TERM INT; while :; do echo Ready to code and debug like a rockstar!!!; sleep 2073600; done & wait" ] - links: + depends_on: - nbxplorer - postgres - customer_lightningd @@ -76,7 +76,7 @@ services: connect=bitcoind:39388 rpcallowip=0.0.0.0/0 fallbackfee=0.0002 - links: + depends_on: - nbxplorer - postgres - customer_lnd @@ -106,7 +106,7 @@ services: NBXPLORER_POSTGRES: User ID=postgres;Include Error Detail=true;Host=postgres;Port=5432;Database=nbxplorer NBXPLORER_EXPOSERPC: 1 NBXPLORER_NOAUTH: 1 - links: + depends_on: - bitcoind @@ -163,7 +163,7 @@ services: volumes: - "bitcoin_datadir:/etc/bitcoin" - "customer_lightningd_datadir:/root/.lightning" - links: + depends_on: - bitcoind lightning-charged: @@ -184,7 +184,7 @@ services: - "9735" # Lightning ports: - "54938:9112" # Charge - links: + depends_on: - bitcoind - merchant_lightningd @@ -211,7 +211,7 @@ services: volumes: - "bitcoin_datadir:/etc/bitcoin" - "merchant_lightningd_datadir:/root/.lightning" - links: + depends_on: - bitcoind postgres: @@ -256,7 +256,7 @@ services: volumes: - "merchant_lnd_datadir:/data" - "bitcoin_datadir:/deps/.bitcoin" - links: + depends_on: - bitcoind customer_lnd: @@ -292,7 +292,7 @@ services: volumes: - "customer_lnd_datadir:/root/.lnd" - "bitcoin_datadir:/deps/.bitcoin" - links: + depends_on: - bitcoind tor: diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPayoutProcessorsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPayoutProcessorsController.cs new file mode 100644 index 000000000..2df326f54 --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPayoutProcessorsController.cs @@ -0,0 +1,43 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.PayoutProcessors; +using BTCPayServer.Security; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using StoreData = BTCPayServer.Data.StoreData; + +namespace BTCPayServer.Controllers.Greenfield +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldPayoutProcessorsController : ControllerBase + { + private readonly IEnumerable _factories; + + public GreenfieldPayoutProcessorsController(IEnumerablefactories) + { + _factories = factories; + } + + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/payout-processors")] + public IActionResult GetPayoutProcessors() + { + return Ok(_factories.Select(factory => new PayoutProcessorData() + { + Name = factory.Processor, + FriendlyName = factory.FriendlyName, + PaymentMethods = factory.GetSupportedPaymentMethods().Select(id => id.ToStringNormalized()) + .ToArray() + })); + } + } + + +} diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs index 3c9c9beaf..69888b74c 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldPullPaymentController.cs @@ -31,7 +31,6 @@ namespace BTCPayServer.Controllers.Greenfield private readonly ApplicationDbContextFactory _dbContextFactory; private readonly CurrencyNameTable _currencyNameTable; private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings; - private readonly BTCPayNetworkProvider _networkProvider; private readonly IEnumerable _payoutHandlers; public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService, @@ -39,7 +38,6 @@ namespace BTCPayServer.Controllers.Greenfield ApplicationDbContextFactory dbContextFactory, CurrencyNameTable currencyNameTable, Services.BTCPayNetworkJsonSerializerSettings serializerSettings, - BTCPayNetworkProvider networkProvider, IEnumerable payoutHandlers) { _pullPaymentService = pullPaymentService; @@ -47,7 +45,6 @@ namespace BTCPayServer.Controllers.Greenfield _dbContextFactory = dbContextFactory; _currencyNameTable = currencyNameTable; _serializerSettings = serializerSettings; - _networkProvider = networkProvider; _payoutHandlers = payoutHandlers; } @@ -191,9 +188,8 @@ namespace BTCPayServer.Controllers.Greenfield if (pp is null) return PullPaymentNotFound(); var payouts = pp.Payouts.Where(p => p.State != PayoutState.Cancelled || includeCancelled).ToList(); - var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); return base.Ok(payouts - .Select(p => ToModel(p, cd)).ToList()); + .Select(ToModel).ToList()); } [HttpGet("~/api/v1/pull-payments/{pullPaymentId}/payouts/{payoutId}")] @@ -209,11 +205,10 @@ namespace BTCPayServer.Controllers.Greenfield var payout = pp.Payouts.FirstOrDefault(p => p.Id == payoutId); if (payout is null) return PayoutNotFound(); - var cd = _currencyNameTable.GetCurrencyData(payout.PullPaymentData.GetBlob().Currency, false); - return base.Ok(ToModel(payout, cd)); + return base.Ok(ToModel(payout)); } - private Client.Models.PayoutData ToModel(Data.PayoutData p, CurrencyData cd) + private Client.Models.PayoutData ToModel(Data.PayoutData p) { var blob = p.GetBlob(_serializerSettings); var model = new Client.Models.PayoutData() @@ -275,14 +270,83 @@ namespace BTCPayServer.Controllers.Greenfield ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})"); return this.CreateValidationError(ModelState); } - var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); - var result = await _pullPaymentService.Claim(new ClaimRequest() + var result = await _pullPaymentService.Claim(new ClaimRequest() { Destination = destination.destination, PullPaymentId = pullPaymentId, Value = request.Amount, - PaymentMethodId = paymentMethodId + PaymentMethodId = paymentMethodId, }); + + return HandleClaimResult(result); + } + + [HttpPost("~/api/v1/stores/{storeId}/payouts")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task CreatePayoutThroughStore(string storeId, CreatePayoutThroughStoreRequest request) + { + if (request is null || !PaymentMethodId.TryParse(request?.PaymentMethod, out var paymentMethodId)) + { + ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method"); + return this.CreateValidationError(ModelState); + } + + var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethodId); + if (payoutHandler is null) + { + ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method"); + return this.CreateValidationError(ModelState); + } + + await using var ctx = _dbContextFactory.CreateContext(); + + + PullPaymentBlob? ppBlob = null; + if (request?.PullPaymentId is not null) + { + + var pp = await ctx.PullPayments.FirstOrDefaultAsync(data => + data.Id == request.PullPaymentId && data.StoreId == storeId); + if (pp is null) + return PullPaymentNotFound(); + ppBlob = pp.GetBlob(); + } + var destination = await payoutHandler.ParseAndValidateClaimDestination(paymentMethodId, request!.Destination, ppBlob); + if (destination.destination is null) + { + ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified"); + return this.CreateValidationError(ModelState); + } + + if (request.Amount is null && destination.destination.Amount != null) + { + request.Amount = destination.destination.Amount; + } + else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount) + { + ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})"); + return this.CreateValidationError(ModelState); + } + if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m)) + { + var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m; + ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {minimumClaim})"); + return this.CreateValidationError(ModelState); + } + var result = await _pullPaymentService.Claim(new ClaimRequest() + { + Destination = destination.destination, + PullPaymentId = request.PullPaymentId, + PreApprove = request.Approved, + Value = request.Amount, + PaymentMethodId = paymentMethodId, + StoreId = storeId + }); + return HandleClaimResult(result); + } + + private IActionResult HandleClaimResult(ClaimRequest.ClaimResponse result) + { switch (result.Result) { case ClaimRequest.ClaimResult.Ok: @@ -304,7 +368,8 @@ namespace BTCPayServer.Controllers.Greenfield default: throw new NotSupportedException("Unsupported ClaimResult"); } - return Ok(ToModel(result.PayoutData, cd)); + + return Ok(ToModel(result.PayoutData)); } [HttpDelete("~/api/v1/stores/{storeId}/pull-payments/{pullPaymentId}")] @@ -319,6 +384,20 @@ namespace BTCPayServer.Controllers.Greenfield return Ok(); } + + + [HttpGet("~/api/v1/stores/{storeId}/payouts")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task GetStorePayouts(string storeId, bool includeCancelled = false) + { + await using var ctx = _dbContextFactory.CreateContext(); + var payouts = await ctx.Payouts + .Where(p => p.StoreDataId == storeId && (p.State != PayoutState.Cancelled || includeCancelled)) + .ToListAsync(); + return base.Ok(payouts + .Select(ToModel).ToList()); + } + [HttpDelete("~/api/v1/stores/{storeId}/payouts/{payoutId}")] [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task CancelPayout(string storeId, string payoutId) @@ -361,8 +440,6 @@ namespace BTCPayServer.Controllers.Greenfield ModelState.AddModelError(nameof(approvePayoutRequest.RateRule), "Invalid RateRule"); return this.CreateValidationError(ModelState); } - var ppBlob = payout.PullPaymentData.GetBlob(); - var cd = _currencyNameTable.GetCurrencyData(ppBlob.Currency, false); var result = await _pullPaymentService.Approve(new PullPaymentHostedService.PayoutApproval() { PayoutId = payoutId, @@ -373,7 +450,7 @@ namespace BTCPayServer.Controllers.Greenfield switch (result) { case PullPaymentHostedService.PayoutApproval.Result.Ok: - return Ok(ToModel(await ctx.Payouts.GetPayout(payoutId, storeId, true), cd)); + return Ok(ToModel(await ctx.Payouts.GetPayout(payoutId, storeId, true))); case PullPaymentHostedService.PayoutApproval.Result.InvalidState: return this.CreateAPIError("invalid-state", errorMessage); case PullPaymentHostedService.PayoutApproval.Result.TooLowAmount: diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs new file mode 100644 index 000000000..00e3d799a --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedLightningPayoutProcessorsController.cs @@ -0,0 +1,94 @@ +#nullable enable +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data.Data; +using BTCPayServer.PayoutProcessors; +using BTCPayServer.PayoutProcessors.Lightning; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData; + +namespace BTCPayServer.Controllers.Greenfield +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldStoreAutomatedLightningPayoutProcessorsController : ControllerBase + { + private readonly PayoutProcessorService _payoutProcessorService; + private readonly EventAggregator _eventAggregator; + + public GreenfieldStoreAutomatedLightningPayoutProcessorsController(PayoutProcessorService payoutProcessorService, + EventAggregator eventAggregator) + { + _payoutProcessorService = payoutProcessorService; + _eventAggregator = eventAggregator; + } + + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory))] + [HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory) + + "/{paymentMethod}")] + public async Task GetStoreLightningAutomatedPayoutProcessors( + string storeId, string? paymentMethod) + { + var configured = + await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] {storeId}, + Processors = new[] {LightningAutomatedPayoutSenderFactory.ProcessorName}, + PaymentMethods = paymentMethod is null ? null : new[] {paymentMethod} + }); + + return Ok(configured.Select(ToModel).ToArray()); + } + + private static LightningAutomatedPayoutSettings ToModel(PayoutProcessorData data) + { + return new LightningAutomatedPayoutSettings() + { + PaymentMethod = data.PaymentMethod, + IntervalSeconds = InvoiceRepository.FromBytes(data.Blob).Interval + }; + } + + private static AutomatedPayoutBlob FromModel(LightningAutomatedPayoutSettings data) + { + return new AutomatedPayoutBlob() {Interval = data.IntervalSeconds}; + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPut("~/api/v1/stores/{storeId}/payout-processors/" + nameof(LightningAutomatedPayoutSenderFactory) + + "/{paymentMethod}")] + public async Task UpdateStoreLightningAutomatedPayoutProcessor( + string storeId, string paymentMethod, LightningAutomatedPayoutSettings request) + { + var activeProcessor = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] {storeId}, + Processors = new[] {LightningAutomatedPayoutSenderFactory.ProcessorName}, + PaymentMethods = new[] {paymentMethod} + })) + .FirstOrDefault(); + activeProcessor ??= new PayoutProcessorData(); + activeProcessor.Blob = InvoiceRepository.ToBytes(FromModel(request)); + activeProcessor.StoreId = storeId; + activeProcessor.PaymentMethod = paymentMethod; + activeProcessor.Processor = LightningAutomatedPayoutSenderFactory.ProcessorName; + var tcs = new TaskCompletionSource(); + _eventAggregator.Publish(new PayoutProcessorUpdated() + { + Data = activeProcessor, Id = activeProcessor.Id, Processed = tcs + }); + await tcs.Task; + return Ok(ToModel(activeProcessor)); + } + } +} diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs new file mode 100644 index 000000000..ee70913e4 --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreAutomatedOnChainPayoutProcessorsController.cs @@ -0,0 +1,95 @@ +#nullable enable +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data.Data; +using BTCPayServer.PayoutProcessors; +using BTCPayServer.PayoutProcessors.OnChain; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData; + +namespace BTCPayServer.Controllers.Greenfield +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldStoreAutomatedOnChainPayoutProcessorsController : ControllerBase + { + private readonly PayoutProcessorService _payoutProcessorService; + private readonly EventAggregator _eventAggregator; + + public GreenfieldStoreAutomatedOnChainPayoutProcessorsController(PayoutProcessorService payoutProcessorService, + EventAggregator eventAggregator) + { + _payoutProcessorService = payoutProcessorService; + _eventAggregator = eventAggregator; + } + + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory))] + [HttpGet("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory) + + "/{paymentMethod}")] + public async Task GetStoreOnChainAutomatedPayoutProcessors( + string storeId, string? paymentMethod) + { + var configured = + await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] {storeId}, + Processors = new[] {OnChainAutomatedPayoutSenderFactory.ProcessorName}, + PaymentMethods = paymentMethod is null ? null : new[] {paymentMethod} + }); + + return Ok(configured.Select(ToModel).ToArray()); + } + + private static OnChainAutomatedPayoutSettings ToModel(PayoutProcessorData data) + { + return new OnChainAutomatedPayoutSettings() + { + PaymentMethod = data.PaymentMethod, + IntervalSeconds = InvoiceRepository.FromBytes(data.Blob).Interval + }; + } + + private static AutomatedPayoutBlob FromModel(OnChainAutomatedPayoutSettings data) + { + return new AutomatedPayoutBlob() {Interval = data.IntervalSeconds}; + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPut("~/api/v1/stores/{storeId}/payout-processors/" + nameof(OnChainAutomatedPayoutSenderFactory) + + "/{paymentMethod}")] + public async Task UpdateStoreOnchainAutomatedPayoutProcessor( + string storeId, string paymentMethod, OnChainAutomatedPayoutSettings request) + { + var activeProcessor = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] {storeId}, + Processors = new[] {OnChainAutomatedPayoutSenderFactory.ProcessorName}, + PaymentMethods = new[] {paymentMethod} + })) + .FirstOrDefault(); + activeProcessor ??= new PayoutProcessorData(); + activeProcessor.Blob = InvoiceRepository.ToBytes(FromModel(request)); + activeProcessor.StoreId = storeId; + activeProcessor.PaymentMethod = paymentMethod; + activeProcessor.Processor = OnChainAutomatedPayoutSenderFactory.ProcessorName; + var tcs = new TaskCompletionSource(); + _eventAggregator.Publish(new PayoutProcessorUpdated() + { + Data = activeProcessor, Id = activeProcessor.Id, Processed = tcs + }); + await tcs.Task; + return Ok(ToModel(activeProcessor)); + } + } + +} diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStorePayoutProcessorsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStorePayoutProcessorsController.cs new file mode 100644 index 000000000..2bf84d76c --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStorePayoutProcessorsController.cs @@ -0,0 +1,72 @@ +#nullable enable +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.PayoutProcessors; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Controllers.Greenfield +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldStorePayoutProcessorsController : ControllerBase + { + private readonly PayoutProcessorService _payoutProcessorService; + private readonly IEnumerable _factories; + public GreenfieldStorePayoutProcessorsController(PayoutProcessorService payoutProcessorService, IEnumerable factories) + { + _payoutProcessorService = payoutProcessorService; + _factories = factories; + } + + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/payout-processors")] + public async Task GetStorePayoutProcessors( + string storeId) + { + var configured = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() { Stores = new[] { storeId } })) + .GroupBy(data => data.Processor).Select(datas => new PayoutProcessorData() + { + Name = datas.Key, + FriendlyName = _factories.FirstOrDefault(factory => factory.Processor == datas.Key)?.FriendlyName, + PaymentMethods = datas.Select(data => data.PaymentMethod).ToArray() + }); + return Ok(configured); + + } + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}")] + public async Task RemoveStorePayoutProcessor( + string storeId,string processor,string paymentMethod) + { + var matched = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] { storeId }, + Processors = new []{ processor}, + PaymentMethods = new []{paymentMethod} + })).FirstOrDefault(); + if (matched is null) + { + return NotFound(); + } + + var tcs = new TaskCompletionSource(); + _payoutProcessorService.EventAggregator.Publish(new PayoutProcessorUpdated() + { + Id = matched.Id, + Processed = tcs + }); + await tcs.Task; + return Ok(); + + } + } +} diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index aa1b979b6..d9369c27b 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -65,6 +65,10 @@ namespace BTCPayServer.Controllers.Greenfield private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController; private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController; private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController; + private readonly GreenfieldStorePayoutProcessorsController _greenfieldStorePayoutProcessorsController; + private readonly GreenfieldPayoutProcessorsController _greenfieldPayoutProcessorsController; + private readonly GreenfieldStoreAutomatedOnChainPayoutProcessorsController _greenfieldStoreAutomatedOnChainPayoutProcessorsController; + private readonly GreenfieldStoreAutomatedLightningPayoutProcessorsController _greenfieldStoreAutomatedLightningPayoutProcessorsController; private readonly IServiceProvider _serviceProvider; public BTCPayServerClientFactory(StoreRepository storeRepository, @@ -90,6 +94,10 @@ namespace BTCPayServer.Controllers.Greenfield GreenfieldStorePaymentMethodsController storePaymentMethodsController, GreenfieldStoreEmailController greenfieldStoreEmailController, GreenfieldStoreUsersController greenfieldStoreUsersController, + GreenfieldStorePayoutProcessorsController greenfieldStorePayoutProcessorsController, + GreenfieldPayoutProcessorsController greenfieldPayoutProcessorsController, + GreenfieldStoreAutomatedOnChainPayoutProcessorsController greenfieldStoreAutomatedOnChainPayoutProcessorsController, + GreenfieldStoreAutomatedLightningPayoutProcessorsController greenfieldStoreAutomatedLightningPayoutProcessorsController, IServiceProvider serviceProvider) { _storeRepository = storeRepository; @@ -115,6 +123,10 @@ namespace BTCPayServer.Controllers.Greenfield _storePaymentMethodsController = storePaymentMethodsController; _greenfieldStoreEmailController = greenfieldStoreEmailController; _greenfieldStoreUsersController = greenfieldStoreUsersController; + _greenfieldStorePayoutProcessorsController = greenfieldStorePayoutProcessorsController; + _greenfieldPayoutProcessorsController = greenfieldPayoutProcessorsController; + _greenfieldStoreAutomatedOnChainPayoutProcessorsController = greenfieldStoreAutomatedOnChainPayoutProcessorsController; + _greenfieldStoreAutomatedLightningPayoutProcessorsController = greenfieldStoreAutomatedLightningPayoutProcessorsController; _serviceProvider = serviceProvider; } @@ -189,6 +201,10 @@ namespace BTCPayServer.Controllers.Greenfield _storePaymentMethodsController, _greenfieldStoreEmailController, _greenfieldStoreUsersController, + _greenfieldStorePayoutProcessorsController, + _greenfieldPayoutProcessorsController, + _greenfieldStoreAutomatedOnChainPayoutProcessorsController, + _greenfieldStoreAutomatedLightningPayoutProcessorsController, new LocalHttpContextAccessor() { HttpContext = context } ); } @@ -196,7 +212,7 @@ namespace BTCPayServer.Controllers.Greenfield public class LocalHttpContextAccessor : IHttpContextAccessor { - public HttpContext? HttpContext { get; set; } + public HttpContext HttpContext { get; set; } } public class LocalBTCPayServerClient : BTCPayServerClient @@ -223,6 +239,10 @@ namespace BTCPayServer.Controllers.Greenfield private readonly UIHomeController _homeController; private readonly GreenfieldStorePaymentMethodsController _storePaymentMethodsController; private readonly GreenfieldStoreEmailController _greenfieldStoreEmailController; + private readonly GreenfieldStorePayoutProcessorsController _greenfieldStorePayoutProcessorsController; + private readonly GreenfieldPayoutProcessorsController _greenfieldPayoutProcessorsController; + private readonly GreenfieldStoreAutomatedOnChainPayoutProcessorsController _greenfieldStoreAutomatedOnChainPayoutProcessorsController; + private readonly GreenfieldStoreAutomatedLightningPayoutProcessorsController _greenfieldStoreAutomatedLightningPayoutProcessorsController; private readonly GreenfieldStoreUsersController _greenfieldStoreUsersController; public LocalBTCPayServerClient( @@ -247,6 +267,10 @@ namespace BTCPayServer.Controllers.Greenfield GreenfieldStorePaymentMethodsController storePaymentMethodsController, GreenfieldStoreEmailController greenfieldStoreEmailController, GreenfieldStoreUsersController greenfieldStoreUsersController, + GreenfieldStorePayoutProcessorsController greenfieldStorePayoutProcessorsController, + GreenfieldPayoutProcessorsController greenfieldPayoutProcessorsController, + GreenfieldStoreAutomatedOnChainPayoutProcessorsController greenfieldStoreAutomatedOnChainPayoutProcessorsController, + GreenfieldStoreAutomatedLightningPayoutProcessorsController greenfieldStoreAutomatedLightningPayoutProcessorsController, IHttpContextAccessor httpContextAccessor) : base(new Uri("https://dummy.local"), "", "") { _chainPaymentMethodsController = chainPaymentMethodsController; @@ -269,6 +293,10 @@ namespace BTCPayServer.Controllers.Greenfield _storePaymentMethodsController = storePaymentMethodsController; _greenfieldStoreEmailController = greenfieldStoreEmailController; _greenfieldStoreUsersController = greenfieldStoreUsersController; + _greenfieldStorePayoutProcessorsController = greenfieldStorePayoutProcessorsController; + _greenfieldPayoutProcessorsController = greenfieldPayoutProcessorsController; + _greenfieldStoreAutomatedOnChainPayoutProcessorsController = greenfieldStoreAutomatedOnChainPayoutProcessorsController; + _greenfieldStoreAutomatedLightningPayoutProcessorsController = greenfieldStoreAutomatedLightningPayoutProcessorsController; var controllers = new[] { @@ -277,7 +305,11 @@ namespace BTCPayServer.Controllers.Greenfield storeLightningNetworkPaymentMethodsController, greenFieldInvoiceController, storeWebhooksController, greenFieldServerInfoController, greenfieldPullPaymentController, storesController, homeController, lightningNodeApiController, storeLightningNodeApiController as ControllerBase, - storePaymentMethodsController, greenfieldStoreEmailController, greenfieldStoreUsersController + storePaymentMethodsController, greenfieldStoreEmailController, greenfieldStoreUsersController, + lightningNodeApiController, storeLightningNodeApiController as ControllerBase, storePaymentMethodsController, + greenfieldStoreEmailController, greenfieldStorePayoutProcessorsController, greenfieldPayoutProcessorsController, + greenfieldStoreAutomatedOnChainPayoutProcessorsController, + greenfieldStoreAutomatedLightningPayoutProcessorsController, }; var authoverride = new DefaultAuthorizationService( @@ -1115,5 +1147,67 @@ namespace BTCPayServer.Controllers.Greenfield { return GetFromActionResult(await _usersController.GetUser(idOrEmail)); } + + public override async Task CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest, + CancellationToken cancellationToken = default) + { + return GetFromActionResult( + await _greenfieldPullPaymentController.CreatePayoutThroughStore(storeId, payoutRequest)); + } + + public override async Task> GetPayoutProcessors(string storeId, CancellationToken token = default) + { + return GetFromActionResult>(await _greenfieldStorePayoutProcessorsController.GetStorePayoutProcessors(storeId)); + } + + public override Task> GetPayoutProcessors(CancellationToken token = default) + { + return Task.FromResult(GetFromActionResult>(_greenfieldPayoutProcessorsController.GetPayoutProcessors())); + } + + public override async Task RemovePayoutProcessor(string storeId, string processor, string paymentMethod, CancellationToken token = default) + { + HandleActionResult(await _greenfieldStorePayoutProcessorsController.RemoveStorePayoutProcessor(storeId, processor, paymentMethod)); + } + + public override async Task> + GetStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod = null, + CancellationToken token = default) + { + return GetFromActionResult>( + await _greenfieldStoreAutomatedOnChainPayoutProcessorsController + .GetStoreOnChainAutomatedPayoutProcessors(storeId, paymentMethod)); + } + + public override async Task> GetStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod = null, + CancellationToken token = default) + { + return GetFromActionResult>( + await _greenfieldStoreAutomatedLightningPayoutProcessorsController + .GetStoreLightningAutomatedPayoutProcessors(storeId, paymentMethod)); + } + + public override async Task UpdateStoreOnChainAutomatedPayoutProcessors(string storeId, string paymentMethod, + OnChainAutomatedPayoutSettings request, CancellationToken token = default) + { + return GetFromActionResult( + await _greenfieldStoreAutomatedOnChainPayoutProcessorsController + .UpdateStoreOnchainAutomatedPayoutProcessor(storeId, paymentMethod, request)); + } + + public override async Task UpdateStoreLightningAutomatedPayoutProcessors(string storeId, string paymentMethod, + LightningAutomatedPayoutSettings request, CancellationToken token = default) + { + return GetFromActionResult( + await _greenfieldStoreAutomatedLightningPayoutProcessorsController + .UpdateStoreLightningAutomatedPayoutProcessor(storeId, paymentMethod, request)); + } + + public override async Task GetStorePayouts(string storeId, bool includeCancelled = false, CancellationToken cancellationToken = default) + { + return GetFromActionResult( + await _greenfieldPullPaymentController + .GetStorePayouts(storeId,includeCancelled)); + } } } diff --git a/BTCPayServer/Controllers/LightningAddressService.cs b/BTCPayServer/Controllers/LightningAddressService.cs index 441f1e2e7..099c7f8aa 100644 --- a/BTCPayServer/Controllers/LightningAddressService.cs +++ b/BTCPayServer/Controllers/LightningAddressService.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +#nullable enable +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Data; @@ -30,15 +31,17 @@ public class LightningAddressService query.Usernames = query.Usernames?.Select(NormalizeUsername)?.ToArray(); if (query.Usernames is not null) { - queryable = queryable.Where(data => query.Usernames.Contains(data.Username)); + queryable = queryable.Where(data => query.Usernames.Contains(data!.Username)); } if (query.StoreIds is not null) { - queryable = queryable.Where(data => query.StoreIds.Contains(data.StoreDataId)); + queryable = queryable.Where(data => query.StoreIds.Contains(data!.StoreDataId)); } +#pragma warning disable CS8619 // Nullability of reference types in value doesn't match target type. return await queryable.ToListAsync(); +#pragma warning restore CS8619 // Nullability of reference types in value doesn't match target type. } public async Task ResolveByAddress(string username) @@ -77,7 +80,7 @@ public class LightningAddressService return true; } - public async Task Remove(string username, string storeId = null) + public async Task Remove(string username, string? storeId = null) { await using var context = _applicationDbContextFactory.CreateContext(); var x = (await GetCore(context, new LightningAddressQuery() {Usernames = new[] {username}})).FirstOrDefault(); diff --git a/BTCPayServer/Controllers/LnurlAuthService.cs b/BTCPayServer/Controllers/LnurlAuthService.cs index 2dae99958..43df8d0b0 100644 --- a/BTCPayServer/Controllers/LnurlAuthService.cs +++ b/BTCPayServer/Controllers/LnurlAuthService.cs @@ -1,4 +1,3 @@ -#nullable enable using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -148,8 +147,8 @@ namespace BTCPayServer public class LightningAddressQuery { - public string[]? StoreIds { get; set; } - public string[]? Usernames { get; set; } + public string[] StoreIds { get; set; } + public string[] Usernames { get; set; } } } diff --git a/BTCPayServer/Controllers/UIPullPaymentController.cs b/BTCPayServer/Controllers/UIPullPaymentController.cs index 4444cd83c..0c5680d00 100644 --- a/BTCPayServer/Controllers/UIPullPaymentController.cs +++ b/BTCPayServer/Controllers/UIPullPaymentController.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; diff --git a/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs b/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs index fcd18be48..816d4bfb2 100644 --- a/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs +++ b/BTCPayServer/Controllers/UIStorePullPaymentsController.PullPayments.cs @@ -25,7 +25,6 @@ using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers { - [Route("stores/{storeId}/pull-payments")] [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [AutoValidateAntiforgeryToken] public class UIStorePullPaymentsController : Controller @@ -44,6 +43,7 @@ namespace BTCPayServer.Controllers return HttpContext.GetStoreData(); } } + public UIStorePullPaymentsController(BTCPayNetworkProvider btcPayNetworkProvider, IEnumerable payoutHandlers, CurrencyNameTable currencyNameTable, @@ -59,7 +59,7 @@ namespace BTCPayServer.Controllers _jsonSerializerSettings = jsonSerializerSettings; } - [HttpGet("new")] + [HttpGet("stores/{storeId}/pull-payments/new")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task NewPullPayment(string storeId) { @@ -76,18 +76,19 @@ namespace BTCPayServer.Controllers }); return RedirectToAction(nameof(UIStoresController.GeneralSettings), "UIStores", new { storeId }); } - + return View(new NewPullPaymentModel { Name = "", Currency = CurrentStore.GetStoreBlob().DefaultCurrency, CustomCSSLink = "", EmbeddedCSS = "", - PaymentMethodItems = paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true)) + PaymentMethodItems = + paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true)) }); } - [HttpPost("new")] + [HttpPost("stores/{storeId}/pull-payments/new")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task NewPullPayment(string storeId, NewPullPaymentModel model) { @@ -104,7 +105,8 @@ namespace BTCPayServer.Controllers { // Since we assign all payment methods to be selected by default above we need to update // them here to reflect user's selection so that they can correct their mistake - model.PaymentMethodItems = paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false)); + model.PaymentMethodItems = + paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false)); ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method"); } if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null) @@ -141,13 +143,12 @@ namespace BTCPayServer.Controllers }); this.TempData.SetStatusMessageModel(new StatusMessageModel() { - Message = "Pull payment request created", - Severity = StatusMessageModel.StatusSeverity.Success + Message = "Pull payment request created", Severity = StatusMessageModel.StatusSeverity.Success }); return RedirectToAction(nameof(PullPayments), new { storeId = storeId }); } - [HttpGet("")] + [HttpGet("stores/{storeId}/pull-payments")] public async Task PullPayments( string storeId, PullPaymentState pullPaymentState, @@ -190,20 +191,18 @@ namespace BTCPayServer.Controllers var vm = this.ParseListQuery(new PullPaymentsModel { - Skip = skip, - Count = count, - Total = await ppsQuery.CountAsync(), - ActiveState = pullPaymentState + Skip = skip, Count = count, Total = await ppsQuery.CountAsync(), ActiveState = pullPaymentState }); - switch (pullPaymentState) { + switch (pullPaymentState) + { case PullPaymentState.Active: ppsQuery = ppsQuery .Where( p => !p.Archived && - (p.EndDate != null ? p.EndDate > DateTimeOffset.UtcNow : true) && - p.StartDate <= DateTimeOffset.UtcNow - ); + (p.EndDate != null ? p.EndDate > DateTimeOffset.UtcNow : true) && + p.StartDate <= DateTimeOffset.UtcNow + ); break; case PullPaymentState.Archived: ppsQuery = ppsQuery.Where(p => p.Archived); @@ -225,10 +224,11 @@ namespace BTCPayServer.Controllers { var totalCompleted = pp.Payouts.Where(p => (p.State == PayoutState.Completed || p.State == PayoutState.InProgress) && p.IsInPeriod(pp, now)) - .Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); + .Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); var totalAwaiting = pp.Payouts.Where(p => (p.State == PayoutState.AwaitingPayment || p.State == PayoutState.AwaitingApproval) && - p.IsInPeriod(pp, now)).Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); + p.IsInPeriod(pp, now)).Select(o => + o.GetBlob(_jsonSerializerSettings).Amount).Sum(); ; var ppBlob = pp.GetBlob(); var ni = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true); @@ -262,15 +262,16 @@ namespace BTCPayServer.Controllers return time; } - [HttpGet("{pullPaymentId}/archive")] + [HttpGet("stores/{storeId}/pull-payments/{pullPaymentId}/archive")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public IActionResult ArchivePullPayment(string storeId, string pullPaymentId) { - return View("Confirm", new ConfirmModel("Archive pull payment", "Do you really want to archive the pull payment?", "Archive")); + return View("Confirm", + new ConfirmModel("Archive pull payment", "Do you really want to archive the pull payment?", "Archive")); } - [HttpPost("{pullPaymentId}/archive")] + [HttpPost("stores/{storeId}/pull-payments/{pullPaymentId}/archive")] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public async Task ArchivePullPaymentPost(string storeId, string pullPaymentId) @@ -278,14 +279,15 @@ namespace BTCPayServer.Controllers await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(pullPaymentId)); TempData.SetStatusMessageModel(new StatusMessageModel() { - Message = "Pull payment archived", - Severity = StatusMessageModel.StatusSeverity.Success + Message = "Pull payment archived", Severity = StatusMessageModel.StatusSeverity.Success }); return RedirectToAction(nameof(PullPayments), new { storeId }); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] - [HttpPost("payouts")] + [HttpPost("stores/{storeId}/pull-payments/payouts")] + [HttpPost("stores/{storeId}/pull-payments/{pullPaymentId}/payouts")] + [HttpPost("stores/{storeId}/payouts")] public async Task PayoutsPost( string storeId, PayoutsModel vm, CancellationToken cancellationToken) { @@ -302,16 +304,17 @@ namespace BTCPayServer.Controllers { TempData.SetStatusMessageModel(new StatusMessageModel() { - Message = "No payout selected", - Severity = StatusMessageModel.StatusSeverity.Error - }); - return RedirectToAction(nameof(Payouts), new - { - storeId = storeId, - pullPaymentId = vm.PullPaymentId, - paymentMethodId = paymentMethodId.ToString() + Message = "No payout selected", Severity = StatusMessageModel.StatusSeverity.Error }); + return RedirectToAction(nameof(Payouts), + new + { + storeId = storeId, + pullPaymentId = vm.PullPaymentId, + paymentMethodId = paymentMethodId.ToString() + }); } + var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1); if (handler != null) { @@ -321,124 +324,127 @@ namespace BTCPayServer.Controllers TempData.SetStatusMessageModel(result); } } + switch (command) { case "approve-pay": case "approve": - { - await using var ctx = this._dbContextFactory.CreateContext(); - ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); + { + await using var ctx = this._dbContextFactory.CreateContext(); + ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var payouts = + await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); - var failed = false; - for (int i = 0; i < payouts.Count; i++) + var failed = false; + for (int i = 0; i < payouts.Count; i++) + { + var payout = payouts[i]; + if (payout.State != PayoutState.AwaitingApproval) + continue; + var rateResult = await _pullPaymentService.GetRate(payout, null, cancellationToken); + if (rateResult.BidAsk == null) { - var payout = payouts[i]; - if (payout.State != PayoutState.AwaitingApproval) - continue; - var rateResult = await _pullPaymentService.GetRate(payout, null, cancellationToken); - if (rateResult.BidAsk == null) + this.TempData.SetStatusMessageModel(new StatusMessageModel() { - this.TempData.SetStatusMessageModel(new StatusMessageModel() - { - Message = $"Rate unavailable: {rateResult.EvaluatedRule}", - Severity = StatusMessageModel.StatusSeverity.Error - }); - failed = true; - break; - } - var approveResult = await _pullPaymentService.Approve(new HostedServices.PullPaymentHostedService.PayoutApproval() + Message = $"Rate unavailable: {rateResult.EvaluatedRule}", + Severity = StatusMessageModel.StatusSeverity.Error + }); + failed = true; + break; + } + + var approveResult = await _pullPaymentService.Approve( + new HostedServices.PullPaymentHostedService.PayoutApproval() { PayoutId = payout.Id, Revision = payout.GetBlob(_jsonSerializerSettings).Revision, Rate = rateResult.BidAsk.Ask }); - if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok) - { - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult), - Severity = StatusMessageModel.StatusSeverity.Error - }); - failed = true; - break; - } - } - - if (failed) + if (approveResult != PullPaymentHostedService.PayoutApproval.Result.Ok) { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult), + Severity = StatusMessageModel.StatusSeverity.Error + }); + failed = true; break; } - if (command == "approve-pay") - { - goto case "pay"; - } - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Message = "Payouts approved", - Severity = StatusMessageModel.StatusSeverity.Success - }); + } + + if (failed) + { break; } + if (command == "approve-pay") + { + goto case "pay"; + } + + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Payouts approved", Severity = StatusMessageModel.StatusSeverity.Success + }); + break; + } + case "pay": + { + if (handler is { }) + return await handler?.InitiatePayment(paymentMethodId, payoutIds); + TempData.SetStatusMessageModel(new StatusMessageModel() { - if (handler is { }) - return await handler?.InitiatePayment(paymentMethodId, payoutIds); - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Message = "Paying via this payment method is not supported", - Severity = StatusMessageModel.StatusSeverity.Error - }); - break; - } + Message = "Paying via this payment method is not supported", + Severity = StatusMessageModel.StatusSeverity.Error + }); + break; + } case "mark-paid": + { + await using var ctx = this._dbContextFactory.CreateContext(); + ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var payouts = + await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); + for (int i = 0; i < payouts.Count; i++) { - await using var ctx = this._dbContextFactory.CreateContext(); - ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; - var payouts = await GetPayoutsForPaymentMethod(paymentMethodId, ctx, payoutIds, storeId, cancellationToken); - for (int i = 0; i < payouts.Count; i++) - { - var payout = payouts[i]; - if (payout.State != PayoutState.AwaitingPayment) - continue; + var payout = payouts[i]; + if (payout.State != PayoutState.AwaitingPayment) + continue; - var result = await _pullPaymentService.MarkPaid(new PayoutPaidRequest() + var result = + await _pullPaymentService.MarkPaid(new PayoutPaidRequest() { PayoutId = payout.Id }); + if (result != PayoutPaidRequest.PayoutPaidResult.Ok) + { + TempData.SetStatusMessageModel(new StatusMessageModel() { - PayoutId = payout.Id + Message = PayoutPaidRequest.GetErrorMessage(result), + Severity = StatusMessageModel.StatusSeverity.Error }); - if (result != PayoutPaidRequest.PayoutPaidResult.Ok) - { - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Message = PayoutPaidRequest.GetErrorMessage(result), - Severity = StatusMessageModel.StatusSeverity.Error - }); - return RedirectToAction(nameof(Payouts), new + return RedirectToAction(nameof(Payouts), + new { storeId = storeId, pullPaymentId = vm.PullPaymentId, paymentMethodId = paymentMethodId.ToString() }); - } } - - TempData.SetStatusMessageModel(new StatusMessageModel() - { - Message = "Payouts marked as paid", - Severity = StatusMessageModel.StatusSeverity.Success - }); - break; } + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Payouts marked as paid", Severity = StatusMessageModel.StatusSeverity.Success + }); + break; + } + case "cancel": await _pullPaymentService.Cancel( new PullPaymentHostedService.CancelRequest(payoutIds)); TempData.SetStatusMessageModel(new StatusMessageModel() { - Message = "Payouts archived", - Severity = StatusMessageModel.StatusSeverity.Success + Message = "Payouts archived", Severity = StatusMessageModel.StatusSeverity.Success }); break; } @@ -458,16 +464,17 @@ namespace BTCPayServer.Controllers { var payouts = (await ctx.Payouts .Include(p => p.PullPaymentData) - .Include(p => p.PullPaymentData.StoreData) + .Include(p => p.StoreData) .Where(p => payoutIds.Contains(p.Id)) - .Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived) + .Where(p => p.StoreDataId == storeId && (p.PullPaymentDataId == null || !p.PullPaymentData.Archived)) .ToListAsync(cancellationToken)) .Where(p => p.GetPaymentMethodId() == paymentMethodId) .ToList(); return payouts; } - [HttpGet("payouts")] + [HttpGet("stores/{storeId}/pull-payments/{pullPaymentId}/payouts")] + [HttpGet("stores/{storeId}/payouts")] public async Task Payouts( string storeId, string pullPaymentId, string paymentMethodId, PayoutState payoutState, int skip = 0, int count = 50) @@ -494,7 +501,8 @@ namespace BTCPayServer.Controllers }); vm.Payouts = new List(); await using var ctx = _dbContextFactory.CreateContext(); - var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived); + var payoutRequest = + ctx.Payouts.Where(p => p.StoreDataId == storeId && (p.PullPaymentDataId == null || !p.PullPaymentData.Archived)); if (pullPaymentId != null) { payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId); @@ -507,6 +515,9 @@ namespace BTCPayServer.Controllers payoutRequest = payoutRequest.Where(p => p.PaymentMethodId == pmiStr); } + vm.PaymentMethodCount = (await payoutRequest.GroupBy(data => data.PaymentMethodId) + .Select(datas => new {datas.Key, Count = datas.Count()}).ToListAsync()) + .ToDictionary(datas => datas.Key, arg => arg.Count); vm.PayoutStateCount = payoutRequest.GroupBy(data => data.State) .Select(e => new { e.Key, Count = e.Count() }) .ToDictionary(arg => arg.Key, arg => arg.Count); @@ -525,22 +536,18 @@ namespace BTCPayServer.Controllers payoutRequest = payoutRequest.Skip(vm.Skip).Take(vm.Count); var payouts = await payoutRequest.OrderByDescending(p => p.Date) - .Select(o => new - { - Payout = o, - PullPayment = o.PullPaymentData - }).ToListAsync(); + .Select(o => new { Payout = o, PullPayment = o.PullPaymentData }).ToListAsync(); foreach (var item in payouts) { - var ppBlob = item.PullPayment.GetBlob(); + var ppBlob = item.PullPayment?.GetBlob(); var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings); var m = new PayoutsModel.PayoutModel { - PullPaymentId = item.PullPayment.Id, - PullPaymentName = ppBlob.Name ?? item.PullPayment.Id, + PullPaymentId = item.PullPayment?.Id, + PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id, Date = item.Payout.Date, PayoutId = item.Payout.Id, - Amount = _currencyNameTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency), + Amount = _currencyNameTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode), Destination = payoutBlob.Destination }; var handler = _payoutHandlers diff --git a/BTCPayServer/Controllers/UIWalletsController.cs b/BTCPayServer/Controllers/UIWalletsController.cs index 64a5ded49..aacfe4c2c 100644 --- a/BTCPayServer/Controllers/UIWalletsController.cs +++ b/BTCPayServer/Controllers/UIWalletsController.cs @@ -13,7 +13,6 @@ using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.ModelBinders; using BTCPayServer.Models; -using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; using BTCPayServer.Payments.PayJoin; @@ -28,6 +27,8 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using NBitcoin; +using BTCPayServer.Client.Models; +using BTCPayServer.Logging; using NBXplorer; using NBXplorer.Client; using NBXplorer.DerivationStrategy; @@ -50,7 +51,6 @@ namespace BTCPayServer.Controllers public RateFetcher RateFetcher { get; } private readonly UserManager _userManager; - private readonly JsonSerializerSettings _serializerSettings; private readonly NBXplorerDashboard _dashboard; private readonly IAuthorizationService _authorizationService; private readonly IFeeProviderFactory _feeRateProvider; @@ -61,6 +61,7 @@ namespace BTCPayServer.Controllers private readonly DelayedTransactionBroadcaster _broadcaster; private readonly PayjoinClient _payjoinClient; private readonly LabelFactory _labelFactory; + private readonly PullPaymentHostedService _pullPaymentHostedService; private readonly ApplicationDbContextFactory _dbContextFactory; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; private readonly PullPaymentHostedService _pullPaymentService; @@ -69,6 +70,7 @@ namespace BTCPayServer.Controllers private readonly WalletHistogramService _walletHistogramService; readonly CurrencyNameTable _currencyTable; + public UIWalletsController(StoreRepository repo, WalletRepository walletRepository, CurrencyNameTable currencyTable, @@ -93,7 +95,8 @@ namespace BTCPayServer.Controllers BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, PullPaymentHostedService pullPaymentService, IEnumerable payoutHandlers, - IServiceProvider serviceProvider) + IServiceProvider serviceProvider, + PullPaymentHostedService pullPaymentHostedService) { _currencyTable = currencyTable; Repository = repo; @@ -102,7 +105,6 @@ namespace BTCPayServer.Controllers _authorizationService = authorizationService; NetworkProvider = networkProvider; _userManager = userManager; - _serializerSettings = mvcJsonOptions.SerializerSettings; _dashboard = dashboard; ExplorerClientProvider = explorerProvider; _feeRateProvider = feeRateProvider; @@ -113,6 +115,7 @@ namespace BTCPayServer.Controllers _broadcaster = broadcaster; _payjoinClient = payjoinClient; _labelFactory = labelFactory; + _pullPaymentHostedService = pullPaymentHostedService; _dbContextFactory = dbContextFactory; _jsonSerializerSettings = jsonSerializerSettings; _pullPaymentService = pullPaymentService; @@ -121,7 +124,7 @@ namespace BTCPayServer.Controllers _connectionFactory = connectionFactory; _walletHistogramService = walletHistogramService; } - + [HttpPost] [Route("{walletId}")] public async Task ModifyTransaction( @@ -130,10 +133,10 @@ namespace BTCPayServer.Controllers // does not work [ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, string transactionId, - string addlabel = null, - string addlabelclick = null, - string addcomment = null, - string removelabel = null) + string addlabel = null, + string addlabelclick = null, + string addcomment = null, + string removelabel = null) { addlabel = addlabel ?? addlabelclick; // Hack necessary when the user enter a empty comment and submit. @@ -184,7 +187,8 @@ namespace BTCPayServer.Controllers { if (walletTransactionInfo.Labels.Remove(removelabel)) { - var canDeleteColor = !walletTransactionsInfo.Any(txi => txi.Value.Labels.ContainsKey(removelabel)); + var canDeleteColor = + !walletTransactionsInfo.Any(txi => txi.Value.Labels.ContainsKey(removelabel)); if (canDeleteColor) { walletBlobInfo.LabelColors.Remove(removelabel); @@ -219,18 +223,18 @@ namespace BTCPayServer.Controllers var stores = await Repository.GetStoresByUserId(GetUserId()); var onChainWallets = stores - .SelectMany(s => s.GetSupportedPaymentMethods(NetworkProvider) - .OfType() - .Select(d => ((Wallet: _walletProvider.GetWallet(d.Network), - DerivationStrategy: d.AccountDerivation, - Network: d.Network))) - .Where(_ => _.Wallet != null && _.Network.WalletSupported) - .Select(_ => (Wallet: _.Wallet, - Store: s, - Balance: GetBalanceString(_.Wallet, _.DerivationStrategy), - DerivationStrategy: _.DerivationStrategy, - Network: _.Network))) - .ToList(); + .SelectMany(s => s.GetSupportedPaymentMethods(NetworkProvider) + .OfType() + .Select(d => ((Wallet: _walletProvider.GetWallet(d.Network), + DerivationStrategy: d.AccountDerivation, + Network: d.Network))) + .Where(_ => _.Wallet != null && _.Network.WalletSupported) + .Select(_ => (Wallet: _.Wallet, + Store: s, + Balance: GetBalanceString(_.Wallet, _.DerivationStrategy), + DerivationStrategy: _.DerivationStrategy, + Network: _.Network))) + .ToList(); foreach (var wallet in onChainWallets) { @@ -242,6 +246,7 @@ namespace BTCPayServer.Controllers { walletVm.Balance = ""; } + walletVm.CryptoCode = wallet.Network.CryptoCode; walletVm.StoreId = wallet.Store.Id; walletVm.Id = new WalletId(wallet.Store.Id, wallet.Network.CryptoCode); @@ -276,18 +281,10 @@ namespace BTCPayServer.Controllers var transactions = await wallet.FetchTransactions(paymentMethod.AccountDerivation); var walletBlob = await walletBlobAsync; var walletTransactionsInfo = await walletTransactionsInfoAsync; - var model = new ListTransactionsViewModel - { - Skip = skip, - Count = count, - Total = 0 - }; + var model = new ListTransactionsViewModel { Skip = skip, Count = count, Total = 0 }; if (labelFilter != null) { - model.PaginationQuery = new Dictionary - { - {"labelFilter", labelFilter} - }; + model.PaginationQuery = new Dictionary { { "labelFilter", labelFilter } }; } if (transactions == null) { @@ -302,7 +299,7 @@ namespace BTCPayServer.Controllers else { foreach (var tx in transactions.UnconfirmedTransactions.Transactions - .Concat(transactions.ConfirmedTransactions.Transactions).ToArray()) + .Concat(transactions.ConfirmedTransactions.Transactions).ToArray()) { var vm = new ListTransactionsViewModel.TransactionViewModel(); vm.Id = tx.TransactionId.ToString(); @@ -327,7 +324,8 @@ namespace BTCPayServer.Controllers } model.Total = model.Transactions.Count; - model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).Skip(skip).Take(count).ToList(); + model.Transactions = model.Transactions.OrderByDescending(t => t.Timestamp).Skip(skip).Take(count) + .ToList(); } model.CryptoCode = walletId.CryptoCode; @@ -354,8 +352,7 @@ namespace BTCPayServer.Controllers } [HttpGet("{walletId}/receive")] - public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId) + public IActionResult WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId) { if (walletId?.StoreId == null) return NotFound(); @@ -371,7 +368,9 @@ namespace BTCPayServer.Controllers var bip21 = network.GenerateBIP21(address?.ToString(), null); if (allowedPayjoin) { - bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", new { walletId.CryptoCode }))); + bip21.QueryParams.Add(PayjoinClient.BIP21EndpointKey, + Request.GetAbsoluteUri(Url.Action(nameof(PayJoinEndpointController.Submit), "PayJoinEndpoint", + new { walletId.CryptoCode }))); } return View(new WalletReceiveViewModel() { @@ -384,8 +383,8 @@ namespace BTCPayServer.Controllers [HttpPost] [Route("{walletId}/receive")] - public async Task WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, WalletReceiveViewModel viewModel, string command) + public async Task WalletReceive([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + WalletReceiveViewModel viewModel, string command) { if (walletId?.StoreId == null) return NotFound(); @@ -479,17 +478,13 @@ namespace BTCPayServer.Controllers rateRules.Spread = 0.0m; var currencyPair = new Rating.CurrencyPair(paymentMethod.PaymentId.CryptoCode, storeData.DefaultCurrency); double.TryParse(defaultAmount, out var amount); - var model = new WalletSendModel() - { - CryptoCode = walletId.CryptoCode - }; + var model = new WalletSendModel() { CryptoCode = walletId.CryptoCode }; if (bip21?.Any() is true) { foreach (var link in bip21) { if (!string.IsNullOrEmpty(link)) { - LoadFromBIP21(model, link, network); } } @@ -517,7 +512,10 @@ namespace BTCPayServer.Controllers { var result = await feeProvider.GetFeeRateAsync( (int)network.NBitcoinNetwork.Consensus.GetExpectedBlocksFor(time)); - return new WalletSendModel.FeeRateOption() { Target = time, FeeRate = result.SatoshiPerByte }; + return new WalletSendModel.FeeRateOption() + { + Target = time, FeeRate = result.SatoshiPerByte + }; } catch (Exception) { @@ -547,37 +545,46 @@ namespace BTCPayServer.Controllers try { cts.CancelAfter(TimeSpan.FromSeconds(5)); - var result = await RateFetcher.FetchRate(currencyPair, rateRules, cts.Token).WithCancellation(cts.Token); + var result = await RateFetcher.FetchRate(currencyPair, rateRules, cts.Token) + .WithCancellation(cts.Token); if (result.BidAsk != null) { model.Rate = result.BidAsk.Center; - model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true).CurrencyDecimalDigits; + model.FiatDivisibility = _currencyTable.GetNumberFormatInfo(currencyPair.Right, true) + .CurrencyDecimalDigits; model.Fiat = currencyPair.Right; } else { - model.RateError = $"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType().ToArray())})"; + model.RateError = + $"{result.EvaluatedRule} ({string.Join(", ", result.Errors.OfType().ToArray())})"; } } catch (Exception ex) { model.RateError = ex.Message; } } + return View(model); } private async Task GetSeed(WalletId walletId, BTCPayNetwork network) { return await CanUseHotWallet() && - GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s && - s.IsHotWallet && - ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client && - await client.GetMetadataAsync(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is string seed && - !string.IsNullOrEmpty(seed) ? seed : null; + GetDerivationSchemeSettings(walletId) is DerivationSchemeSettings s && + s.IsHotWallet && + ExplorerClientProvider.GetExplorerClient(network) is ExplorerClient client && + await client.GetMetadataAsync(s.AccountDerivation, WellknownMetadataKeys.MasterHDKey) is + string seed && + !string.IsNullOrEmpty(seed) + ? seed + : null; } + [HttpPost("{walletId}/send")] public async Task WalletSend( [ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default, string bip21 = "") + WalletId walletId, WalletSendModel vm, string command = "", CancellationToken cancellation = default, + string bip21 = "") { if (walletId?.StoreId == null) return NotFound(); @@ -606,7 +613,8 @@ namespace BTCPayServer.Controllers var walletBlobAsync = await WalletRepository.GetWalletInfo(walletId); var walletTransactionsInfoAsync = await WalletRepository.GetWalletTransactionsInfo(walletId); - var utxos = await _walletProvider.GetWallet(network).GetUnspentCoins(schemeSettings.AccountDerivation, cancellation); + var utxos = await _walletProvider.GetWallet(network) + .GetUnspentCoins(schemeSettings.AccountDerivation, cancellation); vm.InputsAvailable = utxos.Select(coin => { walletTransactionsInfoAsync.TryGetValue(coin.OutPoint.Hash.ToString(), out var info); @@ -615,8 +623,12 @@ namespace BTCPayServer.Controllers Outpoint = coin.OutPoint.ToString(), Amount = coin.Value.GetValue(network), Comment = info?.Comment, - Labels = info == null ? null : _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request), - Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, coin.OutPoint.Hash.ToString()), + Labels = + info == null + ? null + : _labelFactory.ColorizeTransactionLabels(walletBlobAsync, info, Request), + Link = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, + coin.OutPoint.Hash.ToString()), Confirmations = coin.Confirmations }; }).ToArray(); @@ -645,7 +657,9 @@ namespace BTCPayServer.Controllers if (command.StartsWith("remove-output", StringComparison.InvariantCultureIgnoreCase)) { ModelState.Clear(); - var index = int.Parse(command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), CultureInfo.InvariantCulture); + var index = int.Parse( + command.Substring(command.IndexOf(":", StringComparison.InvariantCultureIgnoreCase) + 1), + CultureInfo.InvariantCulture); vm.Outputs.RemoveAt(index); return View(vm); } @@ -657,6 +671,8 @@ namespace BTCPayServer.Controllers return View(vm); } + var bypassBalanceChecks = command == "schedule"; + var subtractFeesOutputsCount = new List(); var substractFees = vm.Outputs.Any(o => o.SubtractFeesFromOutput); for (var i = 0; i < vm.Outputs.Count; i++) @@ -669,17 +685,20 @@ namespace BTCPayServer.Controllers transactionOutput.DestinationAddress = transactionOutput.DestinationAddress?.Trim() ?? string.Empty; var inputName = - string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].", i.ToString(CultureInfo.InvariantCulture)) + - nameof(transactionOutput.DestinationAddress); + string.Format(CultureInfo.InvariantCulture, "Outputs[{0}].", + i.ToString(CultureInfo.InvariantCulture)) + + nameof(transactionOutput.DestinationAddress); try { var address = BitcoinAddress.Create(transactionOutput.DestinationAddress, network.NBitcoinNetwork); if (address is TaprootAddress) { - var supportTaproot = _dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanSupportTaproot; + var supportTaproot = _dashboard.Get(network.CryptoCode)?.Status?.BitcoinStatus?.Capabilities + ?.CanSupportTaproot; if (!(supportTaproot is true)) { - ModelState.AddModelError(inputName, "You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address."); + ModelState.AddModelError(inputName, + "You need to update your full node, and/or NBXplorer (Version >= 2.1.56) to be able to send to a taproot address."); } } } @@ -688,7 +707,7 @@ namespace BTCPayServer.Controllers ModelState.AddModelError(inputName, "Invalid address"); } - if (transactionOutput.Amount.HasValue) + if (!bypassBalanceChecks && transactionOutput.Amount.HasValue) { transactionAmountSum += transactionOutput.Amount.Value; @@ -700,41 +719,120 @@ namespace BTCPayServer.Controllers } } - if (subtractFeesOutputsCount.Count > 1) + if (!bypassBalanceChecks) { - foreach (var subtractFeesOutput in subtractFeesOutputsCount) + if (subtractFeesOutputsCount.Count > 1) { - vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput, - "You can only subtract fees from one output", this); + foreach (var subtractFeesOutput in subtractFeesOutputsCount) + { + vm.AddModelError(model => model.Outputs[subtractFeesOutput].SubtractFeesFromOutput, + "You can only subtract fees from one output", this); + } + } + else if (vm.CurrentBalance == transactionAmountSum && !substractFees) + { + ModelState.AddModelError(string.Empty, + "You are sending your entire balance, you should subtract the fees from an output"); + } + + if (vm.CurrentBalance < transactionAmountSum) + { + for (var i = 0; i < vm.Outputs.Count; i++) + { + vm.AddModelError(model => model.Outputs[i].Amount, + "You are sending more than what you own", this); + } + } + + if (vm.FeeSatoshiPerByte is decimal fee) + { + if (fee < 0) + { + vm.AddModelError(model => model.FeeSatoshiPerByte, + "The fee rate should be above 0", this); + } } - } - else if (vm.CurrentBalance == transactionAmountSum && !substractFees) - { - ModelState.AddModelError(string.Empty, - "You are sending your entire balance, you should subtract the fees from an output"); } - if (vm.CurrentBalance < transactionAmountSum) - { - for (var i = 0; i < vm.Outputs.Count; i++) - { - vm.AddModelError(model => model.Outputs[i].Amount, - "You are sending more than what you own", this); - } - } - if (vm.FeeSatoshiPerByte is decimal fee) - { - if (fee < 0) - { - vm.AddModelError(model => model.FeeSatoshiPerByte, - "The fee rate should be above 0", this); - } - } if (!ModelState.IsValid) return View(vm); DerivationSchemeSettings derivationScheme = GetDerivationSchemeSettings(walletId); CreatePSBTResponse psbtResponse; + if (command == "schedule") + { + var pmi = new PaymentMethodId(walletId.CryptoCode, BitcoinPaymentType.Instance); + var claims = + vm.Outputs.Where(output => string.IsNullOrEmpty(output.PayoutId)).Select(output => new ClaimRequest() + { + Destination = new AddressClaimDestination( + BitcoinAddress.Create(output.DestinationAddress, network.NBitcoinNetwork)), + Value = output.Amount.Value, + PaymentMethodId = pmi, + StoreId = walletId.StoreId, + PreApprove = true, + }).ToArray(); + var someFailed = false; + string message = null; + string errorMessage = null; + var result = new Dictionary(); + foreach (ClaimRequest claimRequest in claims) + { + var response = await _pullPaymentHostedService.Claim(claimRequest); + result.Add(claimRequest, response.Result); + if (response.Result == ClaimRequest.ClaimResult.Ok) + { + if (message is null) + { + message = "Payouts scheduled:
"; + } + + message += $"{claimRequest.Value} to {claimRequest.Destination.ToString()}
"; + + } + else + { + someFailed = true; + if (errorMessage is null) + { + errorMessage = "Payouts failed to be scheduled:
"; + } + + switch (response.Result) + { + case ClaimRequest.ClaimResult.Duplicate: + errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString() } - address reuse
"; + break; + case ClaimRequest.ClaimResult.AmountTooLow: + errorMessage += $"{claimRequest.Value} to {claimRequest.Destination.ToString() } - amount too low
"; + break; + } + } + } + + if (message is not null && errorMessage is not null) + { + message += $"

{errorMessage}"; + } + else if(message is null && errorMessage is not null) + { + message = errorMessage; + } + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity =someFailed? StatusMessageModel.StatusSeverity.Warning: + StatusMessageModel.StatusSeverity.Success, + Html = message + }); + return RedirectToAction("Payouts", "UIStorePullPayments", + new + { + storeId = walletId.StoreId, + PaymentMethodId = pmi.ToString(), + payoutState = PayoutState.AwaitingPayment, + }); + } + try { psbtResponse = await CreatePSBT(network, derivationScheme, vm, cancellation); @@ -763,23 +861,17 @@ namespace BTCPayServer.Controllers switch (command) { case "sign": - return await WalletSign(walletId, new WalletPSBTViewModel() - { - SigningContext = signingContext - }); + return await WalletSign(walletId, new WalletPSBTViewModel() { SigningContext = signingContext }); case "analyze-psbt": var name = $"Send-{string.Join('_', vm.Outputs.Select(output => $"{output.Amount}->{output.DestinationAddress}{(output.SubtractFeesFromOutput ? "-Fees" : string.Empty)}"))}.psbt"; - return RedirectToWalletPSBT(new WalletPSBTViewModel - { - PSBT = psbt.ToBase64(), - FileName = name - }); + return RedirectToWalletPSBT(new WalletPSBTViewModel { PSBT = psbt.ToBase64(), FileName = name }); default: return View(vm); } } + private void LoadFromBIP21(WalletSendModel vm, string bip21, BTCPayNetwork network) { vm.Outputs ??= new List(); @@ -791,7 +883,10 @@ namespace BTCPayServer.Controllers { Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC), DestinationAddress = uriBuilder.Address.ToString(), - SubtractFeesFromOutput = false + SubtractFeesFromOutput = false, + PayoutId = uriBuilder.UnknownParameters.ContainsKey("payout") + ? uriBuilder.UnknownParameters["payout"] + : null }); if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message)) { @@ -811,9 +906,9 @@ namespace BTCPayServer.Controllers try { vm.Outputs.Add(new WalletSendModel.TransactionOutput() - { - DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString() - } + { + DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString() + } ); } catch @@ -831,23 +926,22 @@ namespace BTCPayServer.Controllers private IActionResult ViewVault(WalletId walletId, SigningContextModel signingContext) { - return View(nameof(WalletSendVault), new WalletSendVaultModel() - { - SigningContext = signingContext, - WalletId = walletId.ToString(), - WebsocketPath = this.Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault", new { walletId = walletId.ToString() }) - }); + return View(nameof(WalletSendVault), + new WalletSendVaultModel() + { + SigningContext = signingContext, + WalletId = walletId.ToString(), + WebsocketPath = this.Url.Action(nameof(UIVaultController.VaultBridgeConnection), "UIVault", + new { walletId = walletId.ToString() }) + }); } [HttpPost] [Route("{walletId}/vault")] - public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, WalletSendVaultModel model) + public IActionResult WalletSendVault([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + WalletSendVaultModel model) { - return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel() - { - SigningContext = model.SigningContext - }); + return RedirectToWalletPSBTReady(new WalletPSBTReadyViewModel() { SigningContext = model.SigningContext }); } private IActionResult RedirectToWalletPSBTReady(WalletPSBTReadyViewModel vm) @@ -886,7 +980,8 @@ namespace BTCPayServer.Controllers redirectVm.FormParameters.Add("SigningContext.PSBT", signingContext.PSBT); redirectVm.FormParameters.Add("SigningContext.OriginalPSBT", signingContext.OriginalPSBT); redirectVm.FormParameters.Add("SigningContext.PayJoinBIP21", signingContext.PayJoinBIP21); - redirectVm.FormParameters.Add("SigningContext.EnforceLowR", signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); + redirectVm.FormParameters.Add("SigningContext.EnforceLowR", + signingContext.EnforceLowR?.ToString(CultureInfo.InvariantCulture)); redirectVm.FormParameters.Add("SigningContext.ChangeAddress", signingContext.ChangeAddress); } @@ -897,29 +992,21 @@ namespace BTCPayServer.Controllers AspController = "UIWallets", AspAction = nameof(WalletPSBT), RouteParameters = { { "walletId", this.RouteData?.Values["walletId"]?.ToString() } }, - FormParameters = - { - { "psbt", vm.PSBT }, - { "fileName", vm.FileName }, - { "command", "decode" }, - } + FormParameters = { { "psbt", vm.PSBT }, { "fileName", vm.FileName }, { "command", "decode" }, } }; return View("PostRedirect", redirectVm); } [HttpGet("{walletId}/psbt/seed")] - public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, SigningContextModel signingContext) + public IActionResult SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + SigningContextModel signingContext) { - return View(nameof(SignWithSeed), new SignWithSeedViewModel - { - SigningContext = signingContext - }); + return View(nameof(SignWithSeed), new SignWithSeedViewModel { SigningContext = signingContext }); } [HttpPost("{walletId}/psbt/seed")] - public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, SignWithSeedViewModel viewModel) + public async Task SignWithSeed([ModelBinder(typeof(WalletIdModelBinder))] WalletId walletId, + SignWithSeedViewModel viewModel) { if (!ModelState.IsValid) { @@ -957,7 +1044,8 @@ namespace BTCPayServer.Controllers RootedKeyPath rootedKeyPath = signingKeySettings.GetRootedKeyPath(); if (rootedKeyPath == null) { - ModelState.AddModelError(nameof(viewModel.SeedOrKey), "The master fingerprint and/or account key path of your seed are not set in the wallet settings."); + ModelState.AddModelError(nameof(viewModel.SeedOrKey), + "The master fingerprint and/or account key path of your seed are not set in the wallet settings."); return View(nameof(SignWithSeed), viewModel); } // The user gave the root key, let's try to rebase the PSBT, and derive the account private key @@ -968,7 +1056,8 @@ namespace BTCPayServer.Controllers } else { - ModelState.AddModelError(nameof(viewModel.SeedOrKey), "The master fingerprint does not match the one set in your wallet settings. Probable causes are: wrong seed, wrong passphrase or wrong fingerprint in your wallet settings."); + ModelState.AddModelError(nameof(viewModel.SeedOrKey), + "The master fingerprint does not match the one set in your wallet settings. Probable causes are: wrong seed, wrong passphrase or wrong fingerprint in your wallet settings."); return View(nameof(SignWithSeed), viewModel); } @@ -979,17 +1068,15 @@ namespace BTCPayServer.Controllers var changed = psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); if (!changed) { - var update = new UpdatePSBTRequest() - { - PSBT = psbt, - DerivationScheme = settings.AccountDerivation - }; + var update = new UpdatePSBTRequest() { PSBT = psbt, DerivationScheme = settings.AccountDerivation }; update.RebaseKeyPaths = settings.GetPSBTRebaseKeyRules().ToList(); psbt = (await ExplorerClientProvider.GetExplorerClient(network).UpdatePSBTAsync(update))?.PSBT; - changed = psbt is not null && psbt.PSBTChanged(() => psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); + changed = psbt is not null && psbt.PSBTChanged(() => + psbt.SignAll(settings.AccountDerivation, signingKey, rootedKeyPath)); if (!changed) { - ModelState.AddModelError(nameof(viewModel.SeedOrKey), "Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed."); + ModelState.AddModelError(nameof(viewModel.SeedOrKey), + "Impossible to sign the transaction. Probable causes: Incorrect account key path in wallet settings or PSBT already signed."); return View(nameof(SignWithSeed), viewModel); } } @@ -1021,8 +1108,10 @@ namespace BTCPayServer.Controllers var vm = new RescanWalletModel(); vm.IsFullySync = _dashboard.IsFullySynched(walletId.CryptoCode, out var unused); - vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)).Succeeded; - vm.IsSupportedByCurrency = _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true; + vm.IsServerAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)) + .Succeeded; + vm.IsSupportedByCurrency = + _dashboard.Get(walletId.CryptoCode)?.Status?.BitcoinStatus?.Capabilities?.CanScanTxoutSet == true; var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); var scanProgress = await explorer.GetScanUTXOSetInformationAsync(paymentMethod.AccountDerivation); if (scanProgress != null) @@ -1040,12 +1129,15 @@ namespace BTCPayServer.Controllers vm.RemainingTime = TimeSpan.FromSeconds(scanProgress.Progress.RemainingSeconds).PrettyPrint(); } } + if (scanProgress.Status == ScanUTXOStatus.Complete) { vm.LastSuccess = scanProgress.Progress; - vm.TimeOfScan = (scanProgress.Progress.CompletedAt.Value - scanProgress.Progress.StartedAt).PrettyPrint(); + vm.TimeOfScan = (scanProgress.Progress.CompletedAt.Value - scanProgress.Progress.StartedAt) + .PrettyPrint(); } } + return View(vm); } @@ -1063,12 +1155,13 @@ namespace BTCPayServer.Controllers var explorer = ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode); try { - await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit, vm.StartingIndex); + await explorer.ScanUTXOSetAsync(paymentMethod.AccountDerivation, vm.BatchSize, vm.GapLimit, + vm.StartingIndex); } catch (NBXplorerException ex) when (ex.Error.Code == "scanutxoset-in-progress") { - } + return RedirectToAction(); } @@ -1077,7 +1170,8 @@ namespace BTCPayServer.Controllers return GetCurrentStore().GetDerivationSchemeSettings(NetworkProvider, walletId.CryptoCode); } - private static async Task GetBalanceAsMoney(BTCPayWallet wallet, DerivationStrategyBase derivationStrategy) + private static async Task GetBalanceAsMoney(BTCPayWallet wallet, + DerivationStrategyBase derivationStrategy) { using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); try @@ -1117,60 +1211,68 @@ namespace BTCPayServer.Controllers switch (command) { case "cpfp": + { + selectedTransactions ??= Array.Empty(); + if (selectedTransactions.Length == 0) { - selectedTransactions ??= Array.Empty(); - if (selectedTransactions.Length == 0) - { - TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected"; - return RedirectToAction(nameof(WalletTransactions), new { walletId }); - } - var parameters = new MultiValueDictionary(); - parameters.Add("walletId", walletId.ToString()); - int i = 0; - foreach (var tx in selectedTransactions) - { - parameters.Add($"transactionHashes[{i}]", tx); - i++; - } - parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId })); - return View("PostRedirect", new PostRedirectViewModel + TempData[WellKnownTempData.ErrorMessage] = $"No transaction selected"; + return RedirectToAction(nameof(WalletTransactions), new { walletId }); + } + + var parameters = new MultiValueDictionary(); + parameters.Add("walletId", walletId.ToString()); + int i = 0; + foreach (var tx in selectedTransactions) + { + parameters.Add($"transactionHashes[{i}]", tx); + i++; + } + + parameters.Add("returnUrl", Url.Action(nameof(WalletTransactions), new { walletId })); + return View("PostRedirect", + new PostRedirectViewModel { AspController = "UIWallets", AspAction = nameof(UIWalletsController.WalletCPFP), RouteParameters = { { "walletId", walletId.ToString() } }, FormParameters = parameters }); - } + } case "prune": + { + var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) + .PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken); + if (result.TotalPruned == 0) { - var result = await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode).PruneAsync(derivationScheme.AccountDerivation, new PruneRequest(), cancellationToken); - if (result.TotalPruned == 0) - { - TempData[WellKnownTempData.SuccessMessage] = "The wallet is already pruned"; - } - else - { - TempData[WellKnownTempData.SuccessMessage] = - $"The wallet has been successfully pruned ({result.TotalPruned} transactions have been removed from the history)"; - } + TempData[WellKnownTempData.SuccessMessage] = "The wallet is already pruned"; + } + else + { + TempData[WellKnownTempData.SuccessMessage] = + $"The wallet has been successfully pruned ({result.TotalPruned} transactions have been removed from the history)"; + } - return RedirectToAction(nameof(WalletTransactions), new { walletId }); - } + return RedirectToAction(nameof(WalletTransactions), new { walletId }); + } case "clear" when User.IsInRole(Roles.ServerAdmin): + { + if (Version.TryParse(_dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0", + out var v) && + v < new Version(2, 2, 4)) { - if (Version.TryParse(_dashboard.Get(walletId.CryptoCode)?.Status?.Version ?? "0.0.0.0", out var v) && - v < new Version(2, 2, 4)) - { - TempData[WellKnownTempData.ErrorMessage] = "This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above"; - } - else - { - await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) - .WipeAsync(derivationScheme.AccountDerivation, cancellationToken); - TempData[WellKnownTempData.SuccessMessage] = "The transactions have been wiped out, to restore your balance, rescan the wallet."; - } - return RedirectToAction(nameof(WalletTransactions), new { walletId }); + TempData[WellKnownTempData.ErrorMessage] = + "This version of NBXplorer doesn't support this operation, please upgrade to 2.2.4 or above"; } + else + { + await ExplorerClientProvider.GetExplorerClient(walletId.CryptoCode) + .WipeAsync(derivationScheme.AccountDerivation, cancellationToken); + TempData[WellKnownTempData.SuccessMessage] = + "The transactions have been wiped out, to restore your balance, rescan the wallet."; + } + + return RedirectToAction(nameof(WalletTransactions), new { walletId }); + } default: return NotFound(); } @@ -1199,7 +1301,6 @@ namespace BTCPayServer.Controllers public class SendToAddressResult { - [JsonProperty("psbt")] - public string PSBT { get; set; } + [JsonProperty("psbt")] public string PSBT { get; set; } } } diff --git a/BTCPayServer/Data/InvoiceDataExtensions.cs b/BTCPayServer/Data/InvoiceDataExtensions.cs index d1c865a80..8e5ea97c4 100644 --- a/BTCPayServer/Data/InvoiceDataExtensions.cs +++ b/BTCPayServer/Data/InvoiceDataExtensions.cs @@ -4,9 +4,10 @@ namespace BTCPayServer.Data { public static class InvoiceDataExtensions { - public static InvoiceEntity GetBlob(this Data.InvoiceData invoiceData, BTCPayNetworkProvider networks) + public static InvoiceEntity GetBlob(this InvoiceData invoiceData, BTCPayNetworkProvider networks) { - var entity = NBitcoin.JsonConverters.Serializer.ToObject(ZipUtils.Unzip(invoiceData.Blob), null); + + var entity = InvoiceRepository.FromBytes(invoiceData.Blob); entity.Networks = networks; if (entity.Metadata is null) { diff --git a/BTCPayServer/Data/PaymentDataExtensions.cs b/BTCPayServer/Data/PaymentDataExtensions.cs index 199ca9db7..fdfe01d92 100644 --- a/BTCPayServer/Data/PaymentDataExtensions.cs +++ b/BTCPayServer/Data/PaymentDataExtensions.cs @@ -6,7 +6,7 @@ namespace BTCPayServer.Data { public static class PaymentDataExtensions { - public static PaymentEntity GetBlob(this Data.PaymentData paymentData, BTCPayNetworkProvider networks) + public static PaymentEntity GetBlob(this PaymentData paymentData, BTCPayNetworkProvider networks) { var unziped = ZipUtils.Unzip(paymentData.Blob); var cryptoCode = "BTC"; diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index b11cb65c8..a90c95cc4 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -5,6 +5,7 @@ using System.Text; using System.Threading.Tasks; using BTCPayServer; using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; @@ -243,8 +244,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler && data.State == PayoutState.AwaitingPayment) .ToListAsync(); - var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().ToArray(); - var storeId = payouts.First().PullPaymentData.StoreId; + var pullPaymentIds = payouts.Select(data => data.PullPaymentDataId).Distinct().Where(s => s!= null).ToArray(); + var storeId = payouts.First().StoreDataId; var network = _btcPayNetworkProvider.GetNetwork(paymentMethodId.CryptoCode); List bip21 = new List(); foreach (var payout in payouts) @@ -261,10 +262,14 @@ public class BitcoinLikePayoutHandler : IPayoutHandler { case UriClaimDestination uriClaimDestination: uriClaimDestination.BitcoinUrl.Amount = new Money(blob.CryptoAmount.Value, MoneyUnit.BTC); - bip21.Add(uriClaimDestination.ToString()); + var newUri = new UriBuilder(uriClaimDestination.BitcoinUrl.Uri); + BTCPayServerClient.AppendPayloadToQuery(newUri, new KeyValuePair("payout", payout.Id)); + bip21.Add(newUri.Uri.ToString()); break; case AddressClaimDestination addressClaimDestination: - bip21.Add(network.GenerateBIP21(addressClaimDestination.Address.ToString(), new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)).ToString()); + var bip21New = network.GenerateBIP21(addressClaimDestination.Address.ToString(), new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)); + bip21New.QueryParams.Add("payout", payout.Id); + bip21.Add(bip21New.ToString()); break; } } @@ -326,7 +331,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler } } - if (proof.TransactionId is null && !proof.Candidates.Contains(proof.TransactionId)) + if (proof.TransactionId is not null && !proof.Candidates.Contains(proof.TransactionId)) { proof.TransactionId = null; } @@ -366,8 +371,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler await using var ctx = _dbContextFactory.CreateContext(); var payouts = await ctx.Payouts + .Include(o => o.StoreData) .Include(o => o.PullPaymentData) - .ThenInclude(o => o.StoreData) .Where(p => p.State == PayoutState.AwaitingPayment) .Where(p => p.PaymentMethodId == paymentMethodId.ToString()) #pragma warning disable CA1307 // Specify StringComparison @@ -386,7 +391,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler BTCPayServer.Extensions.RoundUp(payoutBlob.CryptoAmount.Value, network.Divisibility)) return; - var derivationSchemeSettings = payout.PullPaymentData.StoreData + var derivationSchemeSettings = payout.StoreData .GetDerivationSchemeSettings(_btcPayNetworkProvider, newTransaction.CryptoCode).AccountDerivation; var storeWalletMatched = (await _explorerClientProvider.GetExplorerClient(newTransaction.CryptoCode) @@ -403,19 +408,19 @@ public class BitcoinLikePayoutHandler : IPayoutHandler if (isInternal) { payout.State = PayoutState.InProgress; - var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode); + var walletId = new WalletId(payout.StoreDataId, newTransaction.CryptoCode); _eventAggregator.Publish(new UpdateTransactionLabel(walletId, newTransaction.NewTransactionEvent.TransactionData.TransactionHash, UpdateTransactionLabel.PayoutTemplate(payout.Id, payout.PullPaymentDataId, walletId.ToString()))); } else { - await _notificationSender.SendNotification(new StoreScope(payout.PullPaymentData.StoreId), + await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId), new ExternalPayoutTransactionNotification() { PaymentMethod = payout.PaymentMethodId, PayoutId = payout.Id, - StoreId = payout.PullPaymentData.StoreId + StoreId = payout.StoreDataId }); } @@ -431,7 +436,7 @@ public class BitcoinLikePayoutHandler : IPayoutHandler } } - private void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob) + public void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob) { var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode))); // We only update the property if the bytes actually changed, this prevent from hammering the DB too much diff --git a/BTCPayServer/Data/Payouts/IPayoutHandler.cs b/BTCPayServer/Data/Payouts/IPayoutHandler.cs index e87891076..0f746b88b 100644 --- a/BTCPayServer/Data/Payouts/IPayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/IPayoutHandler.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -15,8 +16,8 @@ public interface IPayoutHandler public Task TrackClaim(PaymentMethodId paymentMethodId, IClaimDestination claimDestination); //Allows payout handler to parse payout destinations on its own public Task<(IClaimDestination destination, string error)> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination); - public (bool valid, string error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob pullPaymentBlob); - public async Task<(IClaimDestination destination, string error)> ParseAndValidateClaimDestination(PaymentMethodId paymentMethodId, string destination, PullPaymentBlob pullPaymentBlob) + public (bool valid, string? error) ValidateClaimDestination(IClaimDestination claimDestination, PullPaymentBlob? pullPaymentBlob); + public async Task<(IClaimDestination? destination, string? error)> ParseAndValidateClaimDestination(PaymentMethodId paymentMethodId, string destination, PullPaymentBlob? pullPaymentBlob) { var res = await ParseClaimDestination(paymentMethodId, destination); if (res.destination is null) diff --git a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs index e56765aaa..051045716 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs @@ -108,7 +108,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike if (claimDestination is not BoltInvoiceClaimDestination bolt) return (true, null); var invoice = bolt.PaymentRequest; - if ((invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow) < pullPaymentBlob.BOLT11Expiration) + if (pullPaymentBlob is not null && (invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow) < pullPaymentBlob.BOLT11Expiration) { return (false, $"The BOLT11 invoice must have an expiry date of at least {(long)pullPaymentBlob.BOLT11Expiration.TotalDays} days from submission (Provided was only {(invoice.ExpiryDate.UtcDateTime - DateTime.UtcNow).Days})."); diff --git a/BTCPayServer/Data/Payouts/PayoutExtensions.cs b/BTCPayServer/Data/Payouts/PayoutExtensions.cs index 7e85604d8..c1d7d69f7 100644 --- a/BTCPayServer/Data/Payouts/PayoutExtensions.cs +++ b/BTCPayServer/Data/Payouts/PayoutExtensions.cs @@ -19,9 +19,9 @@ namespace BTCPayServer.Data if (includePullPayment) query = query.Include(p => p.PullPaymentData); if (includeStore) - query = query.Include(p => p.PullPaymentData.StoreData); + query = query.Include(p => p.StoreData); var payout = await query.Where(p => p.Id == payoutId && - p.PullPaymentData.StoreId == storeId).FirstOrDefaultAsync(); + p.StoreDataId == storeId).FirstOrDefaultAsync(); if (payout is null) return null; return payout; diff --git a/BTCPayServer/Events/SettingsChanged.cs b/BTCPayServer/Events/SettingsChanged.cs index 9b1675aa0..70e7d0a95 100644 --- a/BTCPayServer/Events/SettingsChanged.cs +++ b/BTCPayServer/Events/SettingsChanged.cs @@ -2,6 +2,7 @@ namespace BTCPayServer.Events { public class SettingsChanged { + public string SettingsName { get; set; } public T Settings { get; set; } public override string ToString() { diff --git a/BTCPayServer/Extensions/UrlHelperExtensions.cs b/BTCPayServer/Extensions/UrlHelperExtensions.cs index 3ecc7c9ed..c9e9bec33 100644 --- a/BTCPayServer/Extensions/UrlHelperExtensions.cs +++ b/BTCPayServer/Extensions/UrlHelperExtensions.cs @@ -1,5 +1,6 @@ using BTCPayServer; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; using BTCPayServer.Services.Apps; using Microsoft.AspNetCore.Http; @@ -63,13 +64,13 @@ namespace Microsoft.AspNetCore.Mvc scheme, host, pathbase); } - public static string PayoutLink(this LinkGenerator urlHelper, string walletIdOrStoreId, string pullPaymentId, string scheme, HostString host, string pathbase) + public static string PayoutLink(this LinkGenerator urlHelper, string walletIdOrStoreId, string pullPaymentId, PayoutState payoutState,string scheme, HostString host, string pathbase) { WalletId.TryParse(walletIdOrStoreId, out var wallet); return urlHelper.GetUriByAction( action: nameof(UIStorePullPaymentsController.Payouts), controller: "UIStorePullPayments", - values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId }, + values: new { storeId = wallet?.StoreId ?? walletIdOrStoreId, pullPaymentId, payoutState }, scheme, host, pathbase); } } diff --git a/BTCPayServer/HostedServices/BaseAsyncService.cs b/BTCPayServer/HostedServices/BaseAsyncService.cs index 54cbe2ee4..fe600df3d 100644 --- a/BTCPayServer/HostedServices/BaseAsyncService.cs +++ b/BTCPayServer/HostedServices/BaseAsyncService.cs @@ -13,11 +13,17 @@ namespace BTCPayServer.HostedServices private CancellationTokenSource _Cts = new CancellationTokenSource(); protected Task[] _Tasks; public readonly Logs Logs; - public BaseAsyncService(Logs logs) + + protected BaseAsyncService(Logs logs) { Logs = logs; } + protected BaseAsyncService(ILogger logger) + { + Logs = new Logs() { PayServer = logger, Events = logger, Configuration = logger}; + } + public virtual Task StartAsync(CancellationToken cancellationToken) { _Tasks = InitializeTasks(); diff --git a/BTCPayServer/HostedServices/EventHostedServiceBase.cs b/BTCPayServer/HostedServices/EventHostedServiceBase.cs index 5b75a0003..40f795ed7 100644 --- a/BTCPayServer/HostedServices/EventHostedServiceBase.cs +++ b/BTCPayServer/HostedServices/EventHostedServiceBase.cs @@ -25,6 +25,12 @@ namespace BTCPayServer.HostedServices _EventAggregator = eventAggregator; Logs = logs; } + + public EventHostedServiceBase(EventAggregator eventAggregator, ILogger logger) + { + _EventAggregator = eventAggregator; + Logs = new Logs() { PayServer = logger, Events = logger, Configuration = logger}; + } readonly Channel _Events = Channel.CreateUnbounded(); public async Task ProcessEvents(CancellationToken cancellationToken) diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index bb35a5a58..3f32fe451 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -37,6 +37,7 @@ namespace BTCPayServer.HostedServices public TimeSpan? Period { get; set; } public TimeSpan? BOLT11Expiration { get; set; } } + public class PullPaymentHostedService : BaseAsyncService { public class CancelRequest @@ -46,15 +47,18 @@ namespace BTCPayServer.HostedServices ArgumentNullException.ThrowIfNull(pullPaymentId); PullPaymentId = pullPaymentId; } + public CancelRequest(string[] payoutIds) { ArgumentNullException.ThrowIfNull(payoutIds); PayoutIds = payoutIds; } + public string PullPaymentId { get; set; } public string[] PayoutIds { get; set; } internal TaskCompletionSource Completion { get; set; } } + public class PayoutApproval { public enum Result @@ -65,6 +69,7 @@ namespace BTCPayServer.HostedServices TooLowAmount, OldRevision } + public string PayoutId { get; set; } public int Revision { get; set; } public decimal Rate { get; set; } @@ -89,6 +94,7 @@ namespace BTCPayServer.HostedServices } } } + public async Task CreatePullPayment(CreatePullPayment create) { ArgumentNullException.ThrowIfNull(create); @@ -96,7 +102,9 @@ namespace BTCPayServer.HostedServices throw new ArgumentException("Amount out of bound", nameof(create)); using var ctx = this._dbContextFactory.CreateContext(); var o = new Data.PullPaymentData(); - o.StartDate = create.StartsAt is DateTimeOffset date ? date : DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0); + o.StartDate = create.StartsAt is DateTimeOffset date + ? date + : DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0); o.EndDate = create.ExpiresAt is DateTimeOffset date2 ? new DateTimeOffset?(date2) : null; o.Period = create.Period is TimeSpan period ? (long?)period.TotalSeconds : null; o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); @@ -136,19 +144,21 @@ namespace BTCPayServer.HostedServices class PayoutRequest { - public PayoutRequest(TaskCompletionSource completionSource, ClaimRequest request) + public PayoutRequest(TaskCompletionSource completionSource, + ClaimRequest request) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(completionSource); Completion = completionSource; ClaimRequest = request; } + public TaskCompletionSource Completion { get; set; } public ClaimRequest ClaimRequest { get; } } + public PullPaymentHostedService(ApplicationDbContextFactory dbContextFactory, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, - CurrencyNameTable currencyNameTable, EventAggregator eventAggregator, BTCPayNetworkProvider networkProvider, NotificationSender notificationSender, @@ -159,7 +169,6 @@ namespace BTCPayServer.HostedServices { _dbContextFactory = dbContextFactory; _jsonSerializerSettings = jsonSerializerSettings; - _currencyNameTable = currencyNameTable; _eventAggregator = eventAggregator; _networkProvider = networkProvider; _notificationSender = notificationSender; @@ -171,7 +180,6 @@ namespace BTCPayServer.HostedServices Channel _Channel; private readonly ApplicationDbContextFactory _dbContextFactory; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; - private readonly CurrencyNameTable _currencyNameTable; private readonly EventAggregator _eventAggregator; private readonly BTCPayNetworkProvider _networkProvider; private readonly NotificationSender _notificationSender; @@ -187,6 +195,7 @@ namespace BTCPayServer.HostedServices { payoutHandler.StartBackgroundCheck(Subscribe); } + return new[] { Loop() }; } @@ -211,14 +220,17 @@ namespace BTCPayServer.HostedServices { await HandleApproval(approv); } + if (o is CancelRequest cancel) { await HandleCancel(cancel); } + if (o is InternalPayoutPaidRequest paid) { await HandleMarkPaid(paid); } + foreach (IPayoutHandler payoutHandler in _payoutHandlers) { try @@ -235,14 +247,16 @@ namespace BTCPayServer.HostedServices public Task GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken) { - var ppBlob = payout.PullPaymentData.GetBlob(); - var currencyPair = new Rating.CurrencyPair(payout.GetPaymentMethodId().CryptoCode, ppBlob.Currency); + var ppBlob = payout.PullPaymentData?.GetBlob(); + var payoutPaymentMethod = payout.GetPaymentMethodId(); + var currencyPair = new Rating.CurrencyPair(payoutPaymentMethod.CryptoCode, + ppBlob?.Currency ?? payoutPaymentMethod.CryptoCode); Rating.RateRule rule = null; try { if (explicitRateRule is null) { - var storeBlob = payout.PullPaymentData.StoreData.GetStoreBlob(); + var storeBlob = payout.StoreData.GetStoreBlob(); var rules = storeBlob.GetRateRules(_networkProvider); rules.Spread = 0.0m; rule = rules.GetRuleFor(currencyPair); @@ -256,60 +270,73 @@ namespace BTCPayServer.HostedServices { throw new FormatException("Invalid RateRule"); } + return _rateFetcher.FetchRate(rule, cancellationToken); } + public Task Approve(PayoutApproval approval) { - approval.Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + approval.Completion = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (!_Channel.Writer.TryWrite(approval)) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return approval.Completion.Task; } + private async Task HandleApproval(PayoutApproval req) { try { using var ctx = _dbContextFactory.CreateContext(); - var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId).FirstOrDefaultAsync(); + var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId) + .FirstOrDefaultAsync(); if (payout is null) { req.Completion.SetResult(PayoutApproval.Result.NotFound); return; } + if (payout.State != PayoutState.AwaitingApproval) { req.Completion.SetResult(PayoutApproval.Result.InvalidState); return; } + var payoutBlob = payout.GetBlob(this._jsonSerializerSettings); if (payoutBlob.Revision != req.Revision) { req.Completion.SetResult(PayoutApproval.Result.OldRevision); return; } + if (!PaymentMethodId.TryParse(payout.PaymentMethodId, out var paymentMethod)) { req.Completion.SetResult(PayoutApproval.Result.NotFound); return; } + payout.State = PayoutState.AwaitingPayment; - if (paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency) + if (payout.PullPaymentData is null || paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency) req.Rate = 1.0m; var cryptoAmount = payoutBlob.Amount / req.Rate; var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethod); if (payoutHandler is null) throw new InvalidOperationException($"No payout handler for {paymentMethod}"); var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination); - decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination); + decimal minimumCryptoAmount = + await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination); if (cryptoAmount < minimumCryptoAmount) { req.Completion.TrySetResult(PayoutApproval.Result.TooLowAmount); return; } - payoutBlob.CryptoAmount = BTCPayServer.Extensions.RoundUp(cryptoAmount, _networkProvider.GetNetwork(paymentMethod.CryptoCode).Divisibility); + + payoutBlob.CryptoAmount = Extensions.RoundUp(cryptoAmount, + _networkProvider.GetNetwork(paymentMethod.CryptoCode).Divisibility); payout.SetBlob(payoutBlob, _jsonSerializerSettings); await ctx.SaveChangesAsync(); + req.Completion.SetResult(PayoutApproval.Result.Ok); } catch (Exception ex) @@ -317,26 +344,31 @@ namespace BTCPayServer.HostedServices req.Completion.TrySetException(ex); } } + private async Task HandleMarkPaid(InternalPayoutPaidRequest req) { try { await using var ctx = _dbContextFactory.CreateContext(); - var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.Request.PayoutId).FirstOrDefaultAsync(); + var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.Request.PayoutId) + .FirstOrDefaultAsync(); if (payout is null) { req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.NotFound); return; } + if (payout.State != PayoutState.AwaitingPayment) { req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.InvalidState); return; } + if (req.Request.Proof != null) { payout.SetProofBlob(req.Request.Proof); } + payout.State = PayoutState.Completed; await ctx.SaveChangesAsync(); req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.Ok); @@ -353,40 +385,67 @@ namespace BTCPayServer.HostedServices { DateTimeOffset now = DateTimeOffset.UtcNow; await using var ctx = _dbContextFactory.CreateContext(); - var pp = await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId); + var withoutPullPayment = req.ClaimRequest.PullPaymentId is null; + var pp = string.IsNullOrEmpty(req.ClaimRequest.PullPaymentId) + ? null + : await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId); - if (pp is null || pp.Archived) + if (!withoutPullPayment && (pp is null || pp.Archived)) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Archived)); return; } - if (pp.IsExpired(now)) + + PullPaymentBlob ppBlob = null; + if (!withoutPullPayment) { - req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Expired)); + if (pp.IsExpired(now)) + { + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Expired)); + return; + } + + if (!pp.HasStarted(now)) + { + req.Completion.TrySetResult( + new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.NotStarted)); + return; + } + + ppBlob = pp.GetBlob(); + + if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId)) + { + req.Completion.TrySetResult( + new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); + return; + } + } + + if (req.ClaimRequest.PreApprove && !withoutPullPayment && + ppBlob.Currency != req.ClaimRequest.PaymentMethodId.CryptoCode) + { + req.Completion.TrySetResult( + new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); return; } - if (!pp.HasStarted(now)) - { - req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.NotStarted)); - return; - } - var ppBlob = pp.GetBlob(); + var payoutHandler = _payoutHandlers.FindPayoutHandler(req.ClaimRequest.PaymentMethodId); - if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId) || payoutHandler is null) + if (payoutHandler is null) { - req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); + req.Completion.TrySetResult( + new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); return; } if (req.ClaimRequest.Destination.Id != null) { if (await ctx.Payouts.AnyAsync(data => - data.Destination.Equals(req.ClaimRequest.Destination.Id) && - data.State != PayoutState.Completed && data.State != PayoutState.Cancelled + data.Destination.Equals(req.ClaimRequest.Destination.Id) && + data.State != PayoutState.Completed && data.State != PayoutState.Cancelled )) { - req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Duplicate)); return; } @@ -400,39 +459,42 @@ namespace BTCPayServer.HostedServices return; } - var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp, now) - .Where(p => p.State != PayoutState.Cancelled) - .ToListAsync()) - .Select(o => new - { - Entity = o, - Blob = o.GetBlob(_jsonSerializerSettings) - }); - var limit = ppBlob.Limit; - var totalPayout = payouts.Select(p => p.Blob.Amount).Sum(); - var claimed = req.ClaimRequest.Value is decimal v ? v : limit - totalPayout; - if (totalPayout + claimed > limit) + var payoutsRaw = withoutPullPayment + ? null + : await ctx.Payouts.GetPayoutInPeriod(pp, now) + .Where(p => p.State != PayoutState.Cancelled).ToListAsync(); + + var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) }); + var limit = ppBlob?.Limit ?? 0; + var totalPayout = payouts?.Select(p => p.Blob.Amount)?.Sum(); + var claimed = req.ClaimRequest.Value is decimal v ? v : limit - (totalPayout ?? 0); + if (totalPayout is not null && totalPayout + claimed > limit) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft)); return; } - var payout = new PayoutData() - { - Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), - Date = now, - State = PayoutState.AwaitingApproval, - PullPaymentDataId = req.ClaimRequest.PullPaymentId, - PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(), - Destination = req.ClaimRequest.Destination.Id - }; - if (claimed < ppBlob.MinimumClaim || claimed == 0.0m) + + if (!withoutPullPayment && (claimed < ppBlob.MinimumClaim || claimed == 0.0m)) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); return; } + + var payout = new PayoutData() + { + Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), + Date = now, + State = + req.ClaimRequest.PreApprove ? PayoutState.AwaitingPayment : PayoutState.AwaitingApproval, + PullPaymentDataId = req.ClaimRequest.PullPaymentId, + PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(), + Destination = req.ClaimRequest.Destination.Id, + StoreDataId = req.ClaimRequest.StoreId ?? pp?.StoreId + }; var payoutBlob = new PayoutBlob() { Amount = claimed, + CryptoAmount = req.ClaimRequest.PreApprove ? claimed : null, Destination = req.ClaimRequest.Destination.ToString() }; payout.SetBlob(payoutBlob, _jsonSerializerSettings); @@ -442,13 +504,15 @@ namespace BTCPayServer.HostedServices await payoutHandler.TrackClaim(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination); await ctx.SaveChangesAsync(); req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout)); - await _notificationSender.SendNotification(new StoreScope(pp.StoreId), new PayoutNotification() - { - StoreId = pp.StoreId, - Currency = ppBlob.Currency, - PaymentMethod = payout.PaymentMethodId, - PayoutId = pp.Id - }); + await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId), + new PayoutNotification() + { + StoreId = payout.StoreDataId, + Currency = ppBlob?.Currency ?? req.ClaimRequest.PaymentMethodId.CryptoCode, + Status = payout.State, + PaymentMethod = payout.PaymentMethodId, + PayoutId = payout.Id + }); } catch (DbUpdateException) { @@ -460,6 +524,7 @@ namespace BTCPayServer.HostedServices req.Completion.TrySetException(ex); } } + private async Task HandleCancel(CancelRequest cancel) { try @@ -471,15 +536,15 @@ namespace BTCPayServer.HostedServices ctx.PullPayments.Attach(new Data.PullPaymentData() { Id = cancel.PullPaymentId, Archived = true }) .Property(o => o.Archived).IsModified = true; payouts = await ctx.Payouts - .Where(p => p.PullPaymentDataId == cancel.PullPaymentId) - .ToListAsync(); + .Where(p => p.PullPaymentDataId == cancel.PullPaymentId) + .ToListAsync(); } else { var payoutIds = cancel.PayoutIds.ToHashSet(); payouts = await ctx.Payouts - .Where(p => payoutIds.Contains(p.Id)) - .ToListAsync(); + .Where(p => payoutIds.Contains(p.Id)) + .ToListAsync(); } foreach (var payout in payouts) @@ -487,6 +552,7 @@ namespace BTCPayServer.HostedServices if (payout.State != PayoutState.Completed && payout.State != PayoutState.InProgress) payout.State = PayoutState.Cancelled; } + await ctx.SaveChangesAsync(); cancel.Completion.TrySetResult(true); } @@ -495,6 +561,7 @@ namespace BTCPayServer.HostedServices cancel.Completion.TrySetException(ex); } } + public Task Cancel(CancelRequest cancelRequest) { CancellationToken.ThrowIfCancellationRequested(); @@ -508,7 +575,8 @@ namespace BTCPayServer.HostedServices public Task Claim(ClaimRequest request) { CancellationToken.ThrowIfCancellationRequested(); - var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cts = new TaskCompletionSource(TaskCreationOptions + .RunContinuationsAsynchronously); if (!_Channel.Writer.TryWrite(new PayoutRequest(cts, request))) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cts.Task; @@ -524,7 +592,8 @@ namespace BTCPayServer.HostedServices public Task MarkPaid(PayoutPaidRequest request) { CancellationToken.ThrowIfCancellationRequested(); - var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cts = new TaskCompletionSource(TaskCreationOptions + .RunContinuationsAsynchronously); if (!_Channel.Writer.TryWrite(new InternalPayoutPaidRequest(cts, request))) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cts.Task; @@ -533,17 +602,18 @@ namespace BTCPayServer.HostedServices class InternalPayoutPaidRequest { - public InternalPayoutPaidRequest(TaskCompletionSource completionSource, PayoutPaidRequest request) + public InternalPayoutPaidRequest(TaskCompletionSource completionSource, + PayoutPaidRequest request) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(completionSource); Completion = completionSource; Request = request; } + public TaskCompletionSource Completion { get; set; } public PayoutPaidRequest Request { get; } } - } public class PayoutPaidRequest @@ -554,6 +624,7 @@ namespace BTCPayServer.HostedServices NotFound, InvalidState } + public string PayoutId { get; set; } public ManualPayoutProof Proof { get; set; } @@ -571,7 +642,6 @@ namespace BTCPayServer.HostedServices throw new NotSupportedException(); } } - } public class ClaimRequest @@ -599,8 +669,10 @@ namespace BTCPayServer.HostedServices default: throw new NotSupportedException("Unsupported ClaimResult"); } + return null; } + public class ClaimResponse { public ClaimResponse(ClaimResult result, PayoutData payoutData = null) @@ -608,9 +680,11 @@ namespace BTCPayServer.HostedServices Result = result; PayoutData = payoutData; } + public ClaimResult Result { get; set; } public PayoutData PayoutData { get; set; } } + public enum ClaimResult { Ok, @@ -627,6 +701,7 @@ namespace BTCPayServer.HostedServices public string PullPaymentId { get; set; } public decimal? Value { get; set; } public IClaimDestination Destination { get; set; } + public string StoreId { get; set; } + public bool PreApprove { get; set; } } - } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index d7269a919..baa9d37bb 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -21,6 +21,7 @@ using BTCPayServer.Payments; using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.PayJoin; +using BTCPayServer.PayoutProcessors; using BTCPayServer.Plugins; using BTCPayServer.Security; using BTCPayServer.Security.Bitpay; @@ -322,7 +323,8 @@ namespace BTCPayServer.Hosting .ConfigurePrimaryHttpMessageHandler(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); services.AddHttpClient(LightningLikePayoutHandler.LightningLikePayoutHandlerOnionNamedClient) @@ -402,6 +404,7 @@ namespace BTCPayServer.Hosting services.AddScoped(); //also provide a factory that can impersonate user/store id services.AddSingleton(); + services.AddPayoutProcesors(); services.AddAPIKeyAuthentication(); services.AddBtcPayServerAuthenticationSchemes(); diff --git a/BTCPayServer/Hosting/MigrationStartupTask.cs b/BTCPayServer/Hosting/MigrationStartupTask.cs index 3292cee3c..088a68c59 100644 --- a/BTCPayServer/Hosting/MigrationStartupTask.cs +++ b/BTCPayServer/Hosting/MigrationStartupTask.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -23,9 +24,14 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using NBitcoin; +using NBitcoin.DataEncoders; using NBXplorer; using Newtonsoft.Json.Linq; using PeterO.Cbor; +using PayoutData = BTCPayServer.Data.PayoutData; +using PullPaymentData = BTCPayServer.Data.PullPaymentData; +using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Hosting { @@ -41,6 +47,7 @@ namespace BTCPayServer.Hosting private readonly IEnumerable _payoutHandlers; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly LightningAddressService _lightningAddressService; + private readonly ILogger _logger; private readonly UserManager _userManager; public IOptions LightningOptions { get; } @@ -56,9 +63,8 @@ namespace BTCPayServer.Hosting IEnumerable payoutHandlers, BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, LightningAddressService lightningAddressService, - Logs logs) + ILogger logger) { - Logs = logs; _DBContextFactory = dbContextFactory; _StoreRepository = storeRepository; _NetworkProvider = networkProvider; @@ -67,6 +73,7 @@ namespace BTCPayServer.Hosting _payoutHandlers = payoutHandlers; _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; _lightningAddressService = lightningAddressService; + _logger = logger; _userManager = userManager; LightningOptions = lightningOptions; } @@ -182,12 +189,17 @@ namespace BTCPayServer.Hosting { await MigrateLighingAddressDatabaseMigration(); settings.LighingAddressDatabaseMigration = true; + } + if (!settings.AddStoreToPayout) + { + await MigrateAddStoreToPayout(); + settings.AddStoreToPayout = true; await _Settings.UpdateSetting(settings); } } catch (Exception ex) { - Logs.PayServer.LogError(ex, "Error on the MigrationStartupTask"); + _logger.LogError(ex, "Error on the MigrationStartupTask"); throw; } } @@ -244,6 +256,41 @@ namespace BTCPayServer.Hosting } } + private async Task MigrateAddStoreToPayout() + { + await using var ctx = _DBContextFactory.CreateContext(); + + if (ctx.Database.IsNpgsql()) + { + await ctx.Database.ExecuteSqlRawAsync(@" +WITH cte AS ( +SELECT DISTINCT p.""Id"", pp.""StoreId"" FROM ""Payouts"" p +JOIN ""PullPayments"" pp ON pp.""Id"" = p.""PullPaymentDataId"" +WHERE p.""StoreDataId"" IS NULL +) +UPDATE ""Payouts"" p +SET ""StoreDataId""=cte.""StoreId"" +FROM cte +WHERE cte.""Id""=p.""Id"" +"); + } + else + { + var queryable = ctx.Payouts.Where(data => data.StoreDataId == null); + var count = await queryable.CountAsync(); + _logger.LogInformation($"Migrating {count} payouts to have a store id explicitly"); + for (int i = 0; i < count; i+=1000) + { + await queryable.Include(data => data.PullPaymentData).Skip(i).Take(1000) + .ForEachAsync(data => data.StoreDataId = data.PullPaymentData.StoreId); + + await ctx.SaveChangesAsync(); + + _logger.LogInformation($"Migrated {i+1000}/{count} payouts to have a store id explicitly"); + } + } + } + private async Task AddInitialUserBlob() { await using var ctx = _DBContextFactory.CreateContext(); diff --git a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs index feee10fd4..882478158 100644 --- a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs +++ b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs @@ -11,6 +11,7 @@ namespace BTCPayServer.Models.WalletViewModels public string PullPaymentId { get; set; } public string Command { get; set; } public Dictionary PayoutStateCount { get; set; } + public Dictionary PaymentMethodCount { get; set; } public string PaymentMethodId { get; set; } public List Payouts { get; set; } diff --git a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs index 534486a2b..c984646de 100644 --- a/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs +++ b/BTCPayServer/Models/WalletViewModels/WalletSendModel.cs @@ -18,8 +18,7 @@ namespace BTCPayServer.Models.WalletViewModels public TimeSpan Target { get; set; } public decimal FeeRate { get; set; } } - public List Outputs { get; set; } = new List(); - + public List Outputs { get; set; } = new(); public class TransactionOutput { [Display(Name = "Destination Address")] @@ -33,6 +32,8 @@ namespace BTCPayServer.Models.WalletViewModels [Display(Name = "Subtract fees from amount")] public bool SubtractFeesFromOutput { get; set; } + + public string PayoutId { get; set; } } public decimal CurrentBalance { get; set; } public decimal ImmatureBalance { get; set; } diff --git a/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs new file mode 100644 index 000000000..7a31a70ff --- /dev/null +++ b/BTCPayServer/PayoutProcessors/BaseAutomatedPayoutProcessor.cs @@ -0,0 +1,88 @@ +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Data.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Stores; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PayoutData = BTCPayServer.Data.PayoutData; +using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData; + +namespace BTCPayServer.PayoutProcessors; + +public abstract class BaseAutomatedPayoutProcessor : BaseAsyncService where T:AutomatedPayoutBlob +{ + protected readonly StoreRepository _storeRepository; + protected readonly PayoutProcessorData _PayoutProcesserSettings; + protected readonly ApplicationDbContextFactory _applicationDbContextFactory; + protected readonly BTCPayNetworkProvider _btcPayNetworkProvider; + protected readonly PaymentMethodId PaymentMethodId; + + protected BaseAutomatedPayoutProcessor( + ILoggerFactory logger, + StoreRepository storeRepository, + PayoutProcessorData payoutProcesserSettings, + ApplicationDbContextFactory applicationDbContextFactory, + BTCPayNetworkProvider btcPayNetworkProvider) : base(logger.CreateLogger($"{payoutProcesserSettings.Processor}:{payoutProcesserSettings.StoreId}:{payoutProcesserSettings.PaymentMethod}")) + { + _storeRepository = storeRepository; + _PayoutProcesserSettings = payoutProcesserSettings; + PaymentMethodId = _PayoutProcesserSettings.GetPaymentMethodId(); + _applicationDbContextFactory = applicationDbContextFactory; + _btcPayNetworkProvider = btcPayNetworkProvider; + } + + internal override Task[] InitializeTasks() + { + return new[] { CreateLoopTask(Act) }; + } + + protected abstract Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts); + + private async Task Act() + { + Logs.PayServer.LogInformation($"Starting to process"); + var store = await _storeRepository.FindStore(_PayoutProcesserSettings.StoreId); + var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider)?.FirstOrDefault( + method => + method.PaymentId == PaymentMethodId); + if (paymentMethod is not null) + { + var payouts = await GetRelevantPayouts(); + Logs.PayServer.LogInformation($"{payouts.Length} found to process"); + await Process(paymentMethod, payouts); + } + else + { + Logs.PayServer.LogInformation($"Payment method not configured."); + } + + var blob = GetBlob(_PayoutProcesserSettings); + + Logs.PayServer.LogInformation($"Sleeping for {blob.Interval}"); + await Task.Delay(blob.Interval, CancellationToken); + } + + + public static T GetBlob(PayoutProcessorData data) + { + return InvoiceRepository.FromBytes(data.Blob); + } + + private async Task GetRelevantPayouts() + { + await using var context = _applicationDbContextFactory.CreateContext(); + var pmi = _PayoutProcesserSettings.PaymentMethod; + return await context.Payouts + .Where(data => data.State == PayoutState.AwaitingPayment) + .Where(data => data.PaymentMethodId == pmi) + .Where(data => data.StoreDataId == _PayoutProcesserSettings.StoreId) + .OrderBy(data => data.Date) + .ToArrayAsync(); + } +} diff --git a/BTCPayServer/PayoutProcessors/IPayoutProcessorFactory.cs b/BTCPayServer/PayoutProcessors/IPayoutProcessorFactory.cs new file mode 100644 index 000000000..d5ad02468 --- /dev/null +++ b/BTCPayServer/PayoutProcessors/IPayoutProcessorFactory.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BTCPayServer.Data.Data; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.PayoutProcessors; + +public interface IPayoutProcessorFactory +{ + public string Processor { get;} + public string FriendlyName { get;} + public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request); + public IEnumerable GetSupportedPaymentMethods(); + public Task ConstructProcessor(PayoutProcessorData settings); +} diff --git a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs new file mode 100644 index 000000000..2699b1ebc --- /dev/null +++ b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutProcessor.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Configuration; +using BTCPayServer.Data; +using BTCPayServer.Data.Data; +using BTCPayServer.Data.Payouts.LightningLike; +using BTCPayServer.Lightning; +using BTCPayServer.Payments; +using BTCPayServer.Payments.Lightning; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using LNURL; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PayoutData = BTCPayServer.Data.PayoutData; +using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData; + +namespace BTCPayServer.PayoutProcessors.Lightning; + +public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor +{ + private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; + private readonly LightningClientFactoryService _lightningClientFactoryService; + private readonly UserService _userService; + private readonly IOptions _options; + private readonly LightningLikePayoutHandler _payoutHandler; + private readonly BTCPayNetwork _network; + + public LightningAutomatedPayoutProcessor( + BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, + LightningClientFactoryService lightningClientFactoryService, + IEnumerable payoutHandlers, + UserService userService, + ILoggerFactory logger, IOptions options, + StoreRepository storeRepository, PayoutProcessorData payoutProcesserSettings, + ApplicationDbContextFactory applicationDbContextFactory, BTCPayNetworkProvider btcPayNetworkProvider) : + base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory, + btcPayNetworkProvider) + { + _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; + _lightningClientFactoryService = lightningClientFactoryService; + _userService = userService; + _options = options; + _payoutHandler = (LightningLikePayoutHandler)payoutHandlers.FindPayoutHandler(PaymentMethodId); + + _network = _btcPayNetworkProvider.GetNetwork(_PayoutProcesserSettings.GetPaymentMethodId().CryptoCode); + } + + protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts) + { + await using var ctx = _applicationDbContextFactory.CreateContext(); + + + var lightningSupportedPaymentMethod = (LightningSupportedPaymentMethod)paymentMethod; + + if (lightningSupportedPaymentMethod.IsInternalNode && + !(await Task.WhenAll((await _storeRepository.GetStoreUsers(_PayoutProcesserSettings.StoreId)) + .Where(user => user.Role == StoreRoles.Owner).Select(user => user.Id) + .Select(s => _userService.IsAdminUser(s)))).Any(b => b)) + { + return; + } + + var client = + lightningSupportedPaymentMethod.CreateLightningClient(_network, _options.Value, + _lightningClientFactoryService); + + + + foreach (var payoutData in payouts) + { + var blob = payoutData.GetBlob(_btcPayNetworkJsonSerializerSettings); + var claim = await _payoutHandler.ParseClaimDestination(PaymentMethodId, blob.Destination); + try + { + switch (claim.destination) + { + case LNURLPayClaimDestinaton lnurlPayClaimDestinaton: + var endpoint = LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out var tag); + var httpClient = _payoutHandler.CreateClient(endpoint); + var lnurlInfo = + (LNURLPayRequest)await LNURL.LNURL.FetchInformation(endpoint, "payRequest", + httpClient); + var lm = new LightMoney(blob.CryptoAmount.Value, LightMoneyUnit.BTC); + if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable) + { + continue; + } + else + { + try + { + var lnurlPayRequestCallbackResponse = + await lnurlInfo.SendRequest(lm, _network.NBitcoinNetwork, httpClient); + + if (await TrypayBolt(client, blob, payoutData, + lnurlPayRequestCallbackResponse + .GetPaymentRequest(_network.NBitcoinNetwork))) + { + ctx.Attach(payoutData); + payoutData.State = PayoutState.Completed; + } + } + catch (LNUrlException) + { + continue; + } + } + + break; + + case BoltInvoiceClaimDestination item1: + if (await TrypayBolt(client, blob, payoutData, item1.PaymentRequest)) + { + ctx.Attach(payoutData); + payoutData.State = PayoutState.Completed; + } + + break; + } + } + catch (Exception e) + { + Logs.PayServer.LogError(e, $"Could not process payout {payoutData.Id}"); + } + } + + + await ctx.SaveChangesAsync(); + } + + //we group per store and init the transfers by each + async Task TrypayBolt(ILightningClient lightningClient, PayoutBlob payoutBlob, PayoutData payoutData, + BOLT11PaymentRequest bolt11PaymentRequest) + { + var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC); + if (boltAmount != payoutBlob.CryptoAmount) + { + return false; + } + + var result = await lightningClient.Pay(bolt11PaymentRequest.ToString()); + return result.Result == PayResult.Ok; + } +} diff --git a/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs new file mode 100644 index 000000000..ec3abda8b --- /dev/null +++ b/BTCPayServer/PayoutProcessors/Lightning/LightningAutomatedPayoutSenderFactory.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data.Data; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.PayoutProcessors.Lightning; + +public class LightningAutomatedPayoutSenderFactory : IPayoutProcessorFactory +{ + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly IServiceProvider _serviceProvider; + private readonly LinkGenerator _linkGenerator; + + public LightningAutomatedPayoutSenderFactory(BTCPayNetworkProvider btcPayNetworkProvider, IServiceProvider serviceProvider, LinkGenerator linkGenerator) + { + _btcPayNetworkProvider = btcPayNetworkProvider; + _serviceProvider = serviceProvider; + _linkGenerator = linkGenerator; + } + + public string FriendlyName { get; } = "Automated Lightning Sender"; + + public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request) + { + return _linkGenerator.GetUriByAction("Configure", + "UILightningAutomatedPayoutProcessors",new + { + storeId, + cryptoCode = paymentMethodId.CryptoCode + }, request.Scheme, request.Host, request.PathBase); + } + public string Processor => ProcessorName; + public static string ProcessorName => nameof(LightningAutomatedPayoutSenderFactory); + public IEnumerable GetSupportedPaymentMethods() + { + return _btcPayNetworkProvider.GetAll().OfType() + .Where(network => network.SupportLightning) + .Select(network => + new PaymentMethodId(network.CryptoCode, LightningPaymentType.Instance)); + } + + public async Task ConstructProcessor(PayoutProcessorData settings) + { + if (settings.Processor != Processor) + { + throw new NotSupportedException("This processor cannot handle the provided requirements"); + } + + return ActivatorUtilities.CreateInstance(_serviceProvider, settings); + + } +} diff --git a/BTCPayServer/PayoutProcessors/Lightning/UILightningAutomatedPayoutProcessorsController.cs b/BTCPayServer/PayoutProcessors/Lightning/UILightningAutomatedPayoutProcessorsController.cs new file mode 100644 index 000000000..f98c45f11 --- /dev/null +++ b/BTCPayServer/PayoutProcessors/Lightning/UILightningAutomatedPayoutProcessorsController.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Data.Data; +using BTCPayServer.Payments; +using BTCPayServer.PayoutProcessors.OnChain; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.PayoutProcessors.Lightning; + +public class UILightningAutomatedPayoutProcessorsController : Controller +{ + private readonly EventAggregator _eventAggregator; + private readonly LightningAutomatedPayoutSenderFactory _lightningAutomatedPayoutSenderFactory; + private readonly PayoutProcessorService _payoutProcessorService; + + public UILightningAutomatedPayoutProcessorsController( + EventAggregator eventAggregator, + LightningAutomatedPayoutSenderFactory lightningAutomatedPayoutSenderFactory, + PayoutProcessorService payoutProcessorService) + { + _eventAggregator = eventAggregator; + _lightningAutomatedPayoutSenderFactory = lightningAutomatedPayoutSenderFactory; + _payoutProcessorService = payoutProcessorService; + } + [HttpGet("~/stores/{storeId}/payout-processors/lightning-automated/{cryptocode}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Configure(string storeId, string cryptoCode) + { + if (!_lightningAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id => + id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase))) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = $"This processor cannot handle {cryptoCode}." + }); + return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors"); + } + var activeProcessor = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] { storeId }, + Processors = new []{ _lightningAutomatedPayoutSenderFactory.Processor}, + PaymentMethods = new[] + { + new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString() + } + })) + .FirstOrDefault(); + + return View (new LightningTransferViewModel(activeProcessor is null? new AutomatedPayoutBlob() : OnChainAutomatedPayoutProcessor.GetBlob(activeProcessor))); + } + + [HttpPost("~/stores/{storeId}/payout-processors/lightning-automated/{cryptocode}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Configure(string storeId, string cryptoCode, LightningTransferViewModel automatedTransferBlob) + { + if (!_lightningAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id => + id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase))) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = $"This processor cannot handle {cryptoCode}." + }); + return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors"); + } + var activeProcessor = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] { storeId }, + Processors = new []{ _lightningAutomatedPayoutSenderFactory.Processor}, + PaymentMethods = new[] + { + new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString() + } + })) + .FirstOrDefault(); + activeProcessor ??= new PayoutProcessorData(); + activeProcessor.Blob = InvoiceRepository.ToBytes(automatedTransferBlob.ToBlob()); + activeProcessor.StoreId = storeId; + activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, LightningPaymentType.Instance).ToString(); + activeProcessor.Processor = _lightningAutomatedPayoutSenderFactory.Processor; + var tcs = new TaskCompletionSource(); + _eventAggregator.Publish(new PayoutProcessorUpdated() + { + Data = activeProcessor, + Id = activeProcessor.Id, + Processed = tcs + }); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = $"Processor updated." + }); + await tcs.Task; + return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors", new {storeId}); + } + + public class LightningTransferViewModel + { + public LightningTransferViewModel() + { + + } + + public LightningTransferViewModel(AutomatedPayoutBlob blob) + { + IntervalMinutes = blob.Interval.TotalMinutes; + } + public double IntervalMinutes { get; set; } + + public AutomatedPayoutBlob ToBlob() + { + return new AutomatedPayoutBlob() { Interval = TimeSpan.FromMinutes(IntervalMinutes) }; + } + } +} diff --git a/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs b/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs new file mode 100644 index 000000000..bdae81016 --- /dev/null +++ b/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutProcessor.cs @@ -0,0 +1,197 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Data.Data; +using BTCPayServer.Events; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBXplorer; +using NBXplorer.DerivationStrategy; +using PayoutData = BTCPayServer.Data.PayoutData; +using PayoutProcessorData = BTCPayServer.Data.Data.PayoutProcessorData; + +namespace BTCPayServer.PayoutProcessors.OnChain +{ + public class OnChainAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor + { + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly BTCPayWalletProvider _btcPayWalletProvider; + private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; + private readonly BitcoinLikePayoutHandler _bitcoinLikePayoutHandler; + private readonly EventAggregator _eventAggregator; + + public OnChainAutomatedPayoutProcessor( + ApplicationDbContextFactory applicationDbContextFactory, + ExplorerClientProvider explorerClientProvider, + BTCPayWalletProvider btcPayWalletProvider, + BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings, + ILoggerFactory logger, + BitcoinLikePayoutHandler bitcoinLikePayoutHandler, + EventAggregator eventAggregator, + StoreRepository storeRepository, + PayoutProcessorData payoutProcesserSettings, + BTCPayNetworkProvider btcPayNetworkProvider) : + base(logger, storeRepository, payoutProcesserSettings, applicationDbContextFactory, + btcPayNetworkProvider) + { + _explorerClientProvider = explorerClientProvider; + _btcPayWalletProvider = btcPayWalletProvider; + _btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings; + _bitcoinLikePayoutHandler = bitcoinLikePayoutHandler; + _eventAggregator = eventAggregator; + } + + protected override async Task Process(ISupportedPaymentMethod paymentMethod, PayoutData[] payouts) + { + var storePaymentMethod = paymentMethod as DerivationSchemeSettings; + if (storePaymentMethod?.IsHotWallet is not true) + { + + Logs.PayServer.LogInformation($"Wallet is not a hot wallet."); + return; + } + + if (!_explorerClientProvider.IsAvailable(PaymentMethodId.CryptoCode)) + { + Logs.PayServer.LogInformation($"{paymentMethod.PaymentId.CryptoCode} node is not available"); + return; + } + var explorerClient = _explorerClientProvider.GetExplorerClient(PaymentMethodId.CryptoCode); + var paymentMethodId = PaymentMethodId.Parse(PaymentMethodId.CryptoCode); + var network = _btcPayNetworkProvider.GetNetwork(paymentMethodId.CryptoCode); + + var extKeyStr = await explorerClient.GetMetadataAsync( + storePaymentMethod.AccountDerivation, + WellknownMetadataKeys.AccountHDKey); + if (extKeyStr == null) + { + Logs.PayServer.LogInformation($"Wallet keys not found."); + return; + } + + var wallet = _btcPayWalletProvider.GetWallet(PaymentMethodId.CryptoCode); + + var reccoins = (await wallet.GetUnspentCoins(storePaymentMethod.AccountDerivation)).ToArray(); + var coins = reccoins.Select(coin => coin.Coin).ToArray(); + + var accountKey = ExtKey.Parse(extKeyStr, network.NBitcoinNetwork); + var keys = reccoins.Select(coin => accountKey.Derive(coin.KeyPath).PrivateKey).ToArray(); + Transaction workingTx = null; + decimal? failedAmount = null; + var changeAddress = await explorerClient.GetUnusedAsync( + storePaymentMethod.AccountDerivation, DerivationFeature.Change, 0, true); + + var feeRate = await explorerClient.GetFeeRateAsync(1, new FeeRate(1m)); + + var transfersProcessing = new List(); + foreach (var transferRequest in payouts) + { + var blob = transferRequest.GetBlob(_btcPayNetworkJsonSerializerSettings); + if (failedAmount.HasValue && blob.CryptoAmount >= failedAmount) + { + continue; + } + + var claimDestination = + await _bitcoinLikePayoutHandler.ParseClaimDestination(paymentMethodId, blob.Destination); + if (!string.IsNullOrEmpty(claimDestination.error)) + { + Logs.PayServer.LogInformation($"Could not process payout {transferRequest.Id} because {claimDestination.error}."); + continue; + } + + var bitcoinClaimDestination = (IBitcoinLikeClaimDestination)claimDestination.destination; + var txBuilder = network.NBitcoinNetwork.CreateTransactionBuilder() + .AddCoins(coins) + .AddKeys(keys); + + if (workingTx is not null) + { + foreach (var txout in workingTx.Outputs.Where(txout => + !txout.IsTo(changeAddress.Address))) + { + txBuilder.Send(txout.ScriptPubKey, txout.Value); + } + } + + txBuilder.Send(bitcoinClaimDestination.Address, + new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)); + + try + { + txBuilder.SetChange(changeAddress.Address); + txBuilder.SendEstimatedFees(feeRate.FeeRate); + workingTx = txBuilder.BuildTransaction(true); + transfersProcessing.Add(transferRequest); + } + catch (NotEnoughFundsException e) + { + + Logs.PayServer.LogInformation($"Could not process payout {transferRequest.Id} because of not enough funds. ({e.Missing.GetValue(network)})"); + failedAmount = blob.CryptoAmount; + //keep going, we prioritize withdraws by time but if there is some other we can fit, we should + } + } + + if (workingTx is not null) + { + try + { + await using var context = _applicationDbContextFactory.CreateContext(); + var txHash = workingTx.GetHash(); + Logs.PayServer.LogInformation($"Processing {transfersProcessing.Count} payouts in tx {txHash}"); + foreach (PayoutData payoutData in transfersProcessing) + { + context.Attach(payoutData); + payoutData.State = PayoutState.InProgress; + _bitcoinLikePayoutHandler.SetProofBlob(payoutData, + new PayoutTransactionOnChainBlob() + { + Accounted = true, + TransactionId = txHash, + Candidates = new HashSet() { txHash } + }); + await context.SaveChangesAsync(); + } + TaskCompletionSource tcs = new(); + var cts = new CancellationTokenSource(); + cts.CancelAfter(TimeSpan.FromSeconds(20)); + var task = _eventAggregator.WaitNext( + e => e.NewTransactionEvent.TransactionData.TransactionHash == txHash, + cts.Token); + var broadcastResult = await explorerClient.BroadcastAsync(workingTx, cts.Token); + if (!broadcastResult.Success) + { + tcs.SetResult(false); + } + var walletId = new WalletId(_PayoutProcesserSettings.StoreId, PaymentMethodId.CryptoCode); + foreach (PayoutData payoutData in transfersProcessing) + { + _eventAggregator.Publish(new UpdateTransactionLabel(walletId, + txHash, + UpdateTransactionLabel.PayoutTemplate(payoutData.Id, payoutData.PullPaymentDataId, + walletId.ToString()))); + } + await Task.WhenAny(tcs.Task, task); + } + catch (OperationCanceledException) + { + } + catch(Exception e) + { + Logs.PayServer.LogError(e, "Could not finalize and broadcast"); + } + } + } + } +} diff --git a/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutSenderFactory.cs b/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutSenderFactory.cs new file mode 100644 index 000000000..bd003f89f --- /dev/null +++ b/BTCPayServer/PayoutProcessors/OnChain/OnChainAutomatedPayoutSenderFactory.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Data.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace BTCPayServer.PayoutProcessors.OnChain; + +public class OnChainAutomatedPayoutSenderFactory : EventHostedServiceBase, IPayoutProcessorFactory +{ + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly IServiceProvider _serviceProvider; + private readonly LinkGenerator _linkGenerator; + + public string FriendlyName { get; } = "Automated Bitcoin Sender"; + public OnChainAutomatedPayoutSenderFactory(EventAggregator eventAggregator, + ILogger logger, + BTCPayNetworkProvider btcPayNetworkProvider, IServiceProvider serviceProvider, LinkGenerator linkGenerator) : base(eventAggregator, logger) + { + _btcPayNetworkProvider = btcPayNetworkProvider; + _serviceProvider = serviceProvider; + _linkGenerator = linkGenerator; + } + + public string Processor => ProcessorName; + public static string ProcessorName => nameof(OnChainAutomatedPayoutSenderFactory); + + public string ConfigureLink(string storeId, PaymentMethodId paymentMethodId, HttpRequest request) + { + return _linkGenerator.GetUriByAction("Configure", + "UIOnChainAutomatedPayoutProcessors",new + { + storeId, + cryptoCode = paymentMethodId.CryptoCode + }, request.Scheme, request.Host, request.PathBase); + } + + public IEnumerable GetSupportedPaymentMethods() + { + return _btcPayNetworkProvider.GetAll().OfType() + .Where(network => !network.ReadonlyWallet && network.WalletSupported) + .Select(network => + new PaymentMethodId(network.CryptoCode, BitcoinPaymentType.Instance)); + } + + public async Task ConstructProcessor(PayoutProcessorData settings) + { + if (settings.Processor != Processor) + { + throw new NotSupportedException("This processor cannot handle the provided requirements"); + } + + return ActivatorUtilities.CreateInstance(_serviceProvider, settings); + } +} diff --git a/BTCPayServer/PayoutProcessors/OnChain/UIOnChainAutomatedPayoutProcessorsController.cs b/BTCPayServer/PayoutProcessors/OnChain/UIOnChainAutomatedPayoutProcessorsController.cs new file mode 100644 index 000000000..ede2d603f --- /dev/null +++ b/BTCPayServer/PayoutProcessors/OnChain/UIOnChainAutomatedPayoutProcessorsController.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Data.Data; +using BTCPayServer.Payments; +using BTCPayServer.PayoutProcessors.Settings; +using BTCPayServer.Services.Invoices; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.PayoutProcessors.OnChain; + +public class UIOnChainAutomatedPayoutProcessorsController : Controller +{ + private readonly EventAggregator _eventAggregator; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly OnChainAutomatedPayoutSenderFactory _onChainAutomatedPayoutSenderFactory; + private readonly PayoutProcessorService _payoutProcessorService; + + public UIOnChainAutomatedPayoutProcessorsController( + EventAggregator eventAggregator, + BTCPayNetworkProvider btcPayNetworkProvider, + OnChainAutomatedPayoutSenderFactory onChainAutomatedPayoutSenderFactory, + PayoutProcessorService payoutProcessorService) + { + _eventAggregator = eventAggregator; + _btcPayNetworkProvider = btcPayNetworkProvider; + _onChainAutomatedPayoutSenderFactory = onChainAutomatedPayoutSenderFactory; + _payoutProcessorService = payoutProcessorService; + } + + + [HttpGet("~/stores/{storeId}/payout-processors/onchain-automated/{cryptocode}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Configure(string storeId, string cryptoCode) + { + if (!_onChainAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id => + id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase))) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = $"This processor cannot handle {cryptoCode}." + }); + return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors"); + } + var wallet = HttpContext.GetStoreData().GetDerivationSchemeSettings(_btcPayNetworkProvider, cryptoCode); + if (wallet?.IsHotWallet is not true) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = $"Either your {cryptoCode} wallet is not configured, or it is not a hot wallet. This processor cannot function until a hot wallet is configured in your store." + }); + } + var activeProcessor = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] { storeId }, + Processors = new []{ _onChainAutomatedPayoutSenderFactory.Processor}, + PaymentMethods = new[] + { + new PaymentMethodId(cryptoCode, BitcoinPaymentType.Instance).ToString() + } + })) + .FirstOrDefault(); + + return View (new OnChainTransferViewModel(activeProcessor is null? new AutomatedPayoutBlob() : OnChainAutomatedPayoutProcessor.GetBlob(activeProcessor))); + } + + [HttpPost("~/stores/{storeId}/payout-processors/onchain-automated/{cryptocode}")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Configure(string storeId, string cryptoCode, OnChainTransferViewModel automatedTransferBlob) + { + if (!_onChainAutomatedPayoutSenderFactory.GetSupportedPaymentMethods().Any(id => + id.CryptoCode.Equals(cryptoCode, StringComparison.InvariantCultureIgnoreCase))) + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Error, + Message = $"This processor cannot handle {cryptoCode}." + }); + return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors"); + } + var activeProcessor = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() + { + Stores = new[] { storeId }, + Processors = new []{ OnChainAutomatedPayoutSenderFactory.ProcessorName}, + PaymentMethods = new[] + { + new PaymentMethodId(cryptoCode, BitcoinPaymentType.Instance).ToString() + } + })) + .FirstOrDefault(); + activeProcessor ??= new PayoutProcessorData(); + activeProcessor.Blob = InvoiceRepository.ToBytes(automatedTransferBlob.ToBlob()); + activeProcessor.StoreId = storeId; + activeProcessor.PaymentMethod = new PaymentMethodId(cryptoCode, BitcoinPaymentType.Instance).ToString(); + activeProcessor.Processor = _onChainAutomatedPayoutSenderFactory.Processor; + var tcs = new TaskCompletionSource(); + _eventAggregator.Publish(new PayoutProcessorUpdated() + { + Data = activeProcessor, + Id = activeProcessor.Id, + Processed = tcs + }); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = $"Processor updated." + }); + await tcs.Task; + return RedirectToAction("ConfigureStorePayoutProcessors", "UiPayoutProcessors", new {storeId}); + } + + public class OnChainTransferViewModel + { + public OnChainTransferViewModel() + { + + } + + public OnChainTransferViewModel(AutomatedPayoutBlob blob) + { + IntervalMinutes = blob.Interval.TotalMinutes; + } + public double IntervalMinutes { get; set; } + + public AutomatedPayoutBlob ToBlob() + { + return new AutomatedPayoutBlob() { Interval = TimeSpan.FromMinutes(IntervalMinutes) }; + } + } +} diff --git a/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs b/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs new file mode 100644 index 000000000..528136a7a --- /dev/null +++ b/BTCPayServer/PayoutProcessors/PayoutProcessorService.cs @@ -0,0 +1,165 @@ +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Data; +using BTCPayServer.Data.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Logging; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; + +namespace BTCPayServer.PayoutProcessors; + +public class PayoutProcessorUpdated +{ + public string Id { get; set; } + public PayoutProcessorData Data { get; set; } + + public TaskCompletionSource Processed { get; set; } +} + +public class PayoutProcessorService : EventHostedServiceBase +{ + private readonly ApplicationDbContextFactory _applicationDbContextFactory; + private readonly IEnumerable _payoutProcessorFactories; + + + private ConcurrentDictionary Services { get; set; } = new(); + public PayoutProcessorService( + ApplicationDbContextFactory applicationDbContextFactory, + EventAggregator eventAggregator, + Logs logs, + IEnumerable payoutProcessorFactories) : base(eventAggregator, logs) + { + _applicationDbContextFactory = applicationDbContextFactory; + _payoutProcessorFactories = payoutProcessorFactories; + } + + public class PayoutProcessorQuery + { + public string[] Stores { get; set; } + public string[] Processors { get; set; } + public string[] PaymentMethods { get; set; } + } + + public async Task> GetProcessors(PayoutProcessorQuery query) + { + + await using var context = _applicationDbContextFactory.CreateContext(); + var queryable = context.PayoutProcessors.AsQueryable(); + if (query.Processors is not null) + { + queryable = queryable.Where(data => query.Processors.Contains(data.Processor)); + } + if (query.Stores is not null) + { + queryable = queryable.Where(data => query.Stores.Contains(data.StoreId)); + } + if (query.PaymentMethods is not null) + { + queryable = queryable.Where(data => query.PaymentMethods.Contains(data.PaymentMethod)); + } + + return await queryable.ToListAsync(); + } + + private async Task RemoveProcessor(string id) + { + await using var context = _applicationDbContextFactory.CreateContext(); + var item = await context.FindAsync(id); + if (item is not null) + context.Remove(item); + await context.SaveChangesAsync(); + await StopProcessor(id, CancellationToken.None); + } + + private async Task AddOrUpdateProcessor(PayoutProcessorData data) + { + + await using var context = _applicationDbContextFactory.CreateContext(); + if (string.IsNullOrEmpty(data.Id)) + { + await context.AddAsync(data); + } + else + { + context.Update(data); + } + await context.SaveChangesAsync(); + await StartOrUpdateProcessor(data, CancellationToken.None); + } + + protected override void SubscribeToEvents() + { + base.SubscribeToEvents(); + Subscribe(); + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + await base.StartAsync(cancellationToken); + var activeProcessors = await GetProcessors(new PayoutProcessorQuery()); + var tasks = activeProcessors.Select(data => StartOrUpdateProcessor(data, cancellationToken)); + await Task.WhenAll(tasks); + } + + private async Task StopProcessor(string id, CancellationToken cancellationToken) + { + if (Services.Remove(id, out var currentService)) + { + await currentService.StopAsync(cancellationToken); + } + + } + + private async Task StartOrUpdateProcessor(PayoutProcessorData data, CancellationToken cancellationToken) + { + var matchedProcessor = _payoutProcessorFactories.FirstOrDefault(factory => + factory.Processor == data.Processor); + + if (matchedProcessor is not null) + { + await StopProcessor(data.Id, cancellationToken); + var processor = await matchedProcessor.ConstructProcessor(data); + await processor.StartAsync(cancellationToken); + Services.TryAdd(data.Id, processor); + } + + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await base.StopAsync(cancellationToken); + await StopAllService(cancellationToken); + } + + private async Task StopAllService(CancellationToken cancellationToken) + { + foreach (KeyValuePair service in Services) + { + await service.Value.StopAsync(cancellationToken); + } + Services.Clear(); + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + await base.ProcessEvent(evt, cancellationToken); + + if (evt is PayoutProcessorUpdated processorUpdated) + { + if (processorUpdated.Data is null) + { + await RemoveProcessor(processorUpdated.Id); + } + else + { + await AddOrUpdateProcessor(processorUpdated.Data); + } + + processorUpdated.Processed?.SetResult(); + } + } +} diff --git a/BTCPayServer/PayoutProcessors/PayoutProcessorsExtensions.cs b/BTCPayServer/PayoutProcessors/PayoutProcessorsExtensions.cs new file mode 100644 index 000000000..0a4638ec8 --- /dev/null +++ b/BTCPayServer/PayoutProcessors/PayoutProcessorsExtensions.cs @@ -0,0 +1,26 @@ +using BTCPayServer.Data.Data; +using BTCPayServer.Payments; +using BTCPayServer.PayoutProcessors.Lightning; +using BTCPayServer.PayoutProcessors.OnChain; +using Microsoft.Extensions.DependencyInjection; + +namespace BTCPayServer.PayoutProcessors; + +public static class PayoutProcessorsExtensions +{ + public static void AddPayoutProcesors(this IServiceCollection serviceCollection) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(provider => provider.GetRequiredService()); + serviceCollection.AddHostedService(); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(s=> s.GetRequiredService()); + } + + public static PaymentMethodId GetPaymentMethodId(this PayoutProcessorData data) + { + return PaymentMethodId.Parse(data.PaymentMethod); + } +} diff --git a/BTCPayServer/PayoutProcessors/Settings/AutomatedPayoutBlob.cs b/BTCPayServer/PayoutProcessors/Settings/AutomatedPayoutBlob.cs new file mode 100644 index 000000000..dd2a2d888 --- /dev/null +++ b/BTCPayServer/PayoutProcessors/Settings/AutomatedPayoutBlob.cs @@ -0,0 +1,8 @@ +using System; + +namespace BTCPayServer.PayoutProcessors.Settings; + +public class AutomatedPayoutBlob +{ + public TimeSpan Interval { get; set; } = TimeSpan.FromHours(1); +} diff --git a/BTCPayServer/PayoutProcessors/UIPayoutProcessorsController.cs b/BTCPayServer/PayoutProcessors/UIPayoutProcessorsController.cs new file mode 100644 index 000000000..7671fdf65 --- /dev/null +++ b/BTCPayServer/PayoutProcessors/UIPayoutProcessorsController.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Abstractions.Models; +using BTCPayServer.Client; +using BTCPayServer.Data; +using BTCPayServer.Data.Data; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.PayoutProcessors; + +public class UIPayoutProcessorsController : Controller +{ + private readonly EventAggregator _eventAggregator; + private readonly BTCPayNetworkProvider _btcPayNetworkProvider; + private readonly IEnumerable _payoutProcessorFactories; + private readonly PayoutProcessorService _payoutProcessorService; + + public UIPayoutProcessorsController( + EventAggregator eventAggregator, + BTCPayNetworkProvider btcPayNetworkProvider, + IEnumerable payoutProcessorFactories, + PayoutProcessorService payoutProcessorService) + { + _eventAggregator = eventAggregator; + _btcPayNetworkProvider = btcPayNetworkProvider; + _payoutProcessorFactories = payoutProcessorFactories; + _payoutProcessorService = payoutProcessorService; + ; + } + + [HttpGet("~/stores/{storeId}/payout-processors")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task ConfigureStorePayoutProcessors(string storeId) + { + var activeProcessors = + (await _payoutProcessorService.GetProcessors( + new PayoutProcessorService.PayoutProcessorQuery() { Stores = new[] { storeId } })) + .GroupBy(data => data.Processor); + + var paymentMethods = HttpContext.GetStoreData().GetEnabledPaymentMethods(_btcPayNetworkProvider) + .Select(method => method.PaymentId).ToList(); + + return View(_payoutProcessorFactories.Select(factory => + { + var conf = activeProcessors.FirstOrDefault(datas => datas.Key == factory.Processor) + ?.ToDictionary(data => data.GetPaymentMethodId(), data => data) ?? + new Dictionary(); + foreach (PaymentMethodId supportedPaymentMethod in factory.GetSupportedPaymentMethods()) + { + conf.TryAdd(supportedPaymentMethod, null); + } + + return new StorePayoutProcessorsView() { Factory = factory, Configured = conf }; + }).ToList()); + } + + [HttpPost("~/stores/{storeId}/payout-processors/{id}/remove")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Remove(string storeId, string id) + { + var tcs = new TaskCompletionSource(); + _eventAggregator.Publish(new PayoutProcessorUpdated() + { + Data = null, + Id = id, + Processed = tcs + }); + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = "Payout Processor removed" + }); + await tcs.Task; + return RedirectToAction("ConfigureStorePayoutProcessors",new {storeId}); + + } + + public class StorePayoutProcessorsView + { + public Dictionary Configured { get; set; } + public IPayoutProcessorFactory Factory { get; set; } + } +} diff --git a/BTCPayServer/Plugins/PluginService.cs b/BTCPayServer/Plugins/PluginService.cs index 85b4d7930..33590cbf4 100644 --- a/BTCPayServer/Plugins/PluginService.cs +++ b/BTCPayServer/Plugins/PluginService.cs @@ -59,7 +59,7 @@ namespace BTCPayServer.Plugins var respObj = JObject.Parse(resp)["tree"] as JArray; - var detectedPlugins = respObj.Where(token => token["path"].ToString().EndsWith(".btcpay")); + var detectedPlugins = respObj.Where(token => token["path"].ToString().EndsWith(".btcpay", StringComparison.OrdinalIgnoreCase)); List> result = new List>(); foreach (JToken detectedPlugin in detectedPlugins) diff --git a/BTCPayServer/Services/Altcoins/Zcash/UI/ZcashLikeStoreController.cs b/BTCPayServer/Services/Altcoins/Zcash/UI/ZcashLikeStoreController.cs index eb139f2ac..0b4832eae 100644 --- a/BTCPayServer/Services/Altcoins/Zcash/UI/ZcashLikeStoreController.cs +++ b/BTCPayServer/Services/Altcoins/Zcash/UI/ZcashLikeStoreController.cs @@ -280,7 +280,9 @@ namespace BTCPayServer.Services.Altcoins.Zcash.UI } }; +#pragma warning disable CA1416 // Validate platform compatibility process.Start(); +#pragma warning restore CA1416 // Validate platform compatibility process.WaitForExit(); } diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index e59ffdf20..071391c90 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -770,18 +770,21 @@ namespace BTCPayServer.Services.Invoices return status; } - internal static byte[] ToBytes(T obj, BTCPayNetworkBase network = null) + public static byte[] ToBytes(T obj, BTCPayNetworkBase network = null) { return ZipUtils.Zip(ToJsonString(obj, network)); } + public static T FromBytes(byte[] blob, BTCPayNetworkBase network = null) + { + return network == null + ? JsonConvert.DeserializeObject(ZipUtils.Unzip(blob), DefaultSerializerSettings) + : network.ToObject(ZipUtils.Unzip(blob)); + } + public static string ToJsonString(T data, BTCPayNetworkBase network) { - if (network == null) - { - return JsonConvert.SerializeObject(data, DefaultSerializerSettings); - } - return network.ToString(data); + return network == null ? JsonConvert.SerializeObject(data, DefaultSerializerSettings) : network.ToString(data); } } diff --git a/BTCPayServer/Services/Labels/LabelFactory.cs b/BTCPayServer/Services/Labels/LabelFactory.cs index 8a4a5cded..58ce14800 100644 --- a/BTCPayServer/Services/Labels/LabelFactory.cs +++ b/BTCPayServer/Services/Labels/LabelFactory.cs @@ -90,11 +90,12 @@ namespace BTCPayServer.Services.Labels } else if (uncoloredLabel is PayoutLabel payoutLabel) { - coloredLabel.Tooltip = $"Paid a payout of a pull payment ({payoutLabel.PullPaymentId})"; - coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.PullPaymentId) || string.IsNullOrEmpty(payoutLabel.WalletId) + coloredLabel.Tooltip = + $"Paid a payout{(payoutLabel.PullPaymentId is null ? string.Empty : $" of a pull payment ({payoutLabel.PullPaymentId})")}"; + coloredLabel.Link = string.IsNullOrEmpty(payoutLabel.WalletId) ? null : _linkGenerator.PayoutLink(payoutLabel.WalletId, - payoutLabel.PullPaymentId, request.Scheme, request.Host, + payoutLabel.PullPaymentId, PayoutState.Completed, request.Scheme, request.Host, request.PathBase); } return coloredLabel; diff --git a/BTCPayServer/Services/MigrationSettings.cs b/BTCPayServer/Services/MigrationSettings.cs index 340ea6d8d..8678a31bd 100644 --- a/BTCPayServer/Services/MigrationSettings.cs +++ b/BTCPayServer/Services/MigrationSettings.cs @@ -30,5 +30,6 @@ namespace BTCPayServer.Services public bool AddInitialUserBlob { get; set; } public bool LighingAddressSettingRename { get; set; } public bool LighingAddressDatabaseMigration { get; set; } + public bool AddStoreToPayout { get; set; } } } diff --git a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs index 614bddcfb..11e3e5e5c 100644 --- a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs +++ b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs @@ -1,4 +1,6 @@ +using System; using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Client.Models; using BTCPayServer.Configuration; using BTCPayServer.Controllers; using BTCPayServer.Models.NotificationViewModels; @@ -32,7 +34,12 @@ namespace BTCPayServer.Services.Notifications.Blobs protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm) { - vm.Body = "A new payout is awaiting for approval"; + vm.Body = (notification.Status ?? PayoutState.AwaitingApproval) switch + { + PayoutState.AwaitingApproval => $"A new payout is awaiting for approval", + PayoutState.AwaitingPayment => $"A new payout is awaiting for payment", + _ => throw new ArgumentOutOfRangeException() + }; vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIStorePullPaymentsController.Payouts), "UIStorePullPayments", new { storeId = notification.StoreId, paymentMethodId = notification.PaymentMethod }, _options.RootPath); @@ -45,5 +52,6 @@ namespace BTCPayServer.Services.Notifications.Blobs public string Currency { get; set; } public override string Identifier => TYPE; public override string NotificationType => TYPE; + public PayoutState? Status { get; set; } } } diff --git a/BTCPayServer/Services/SettingsRepository.cs b/BTCPayServer/Services/SettingsRepository.cs index 4918f44b7..de52df44b 100644 --- a/BTCPayServer/Services/SettingsRepository.cs +++ b/BTCPayServer/Services/SettingsRepository.cs @@ -52,7 +52,8 @@ namespace BTCPayServer.Services _memoryCache.Set(GetCacheKey(name), obj); _EventAggregator.Publish(new SettingsChanged() { - Settings = obj + Settings = obj, + SettingsName = name }); } diff --git a/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml b/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml new file mode 100644 index 000000000..fbe4b0972 --- /dev/null +++ b/BTCPayServer/Views/UILightningAutomatedPayoutProcessors/Configure.cshtml @@ -0,0 +1,32 @@ +@using BTCPayServer.Abstractions.Extensions +@model BTCPayServer.PayoutProcessors.Lightning.UILightningAutomatedPayoutProcessorsController.LightningTransferViewModel +@{ + ViewData["NavPartialName"] = "../UIStores/_Nav"; + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePage("PayoutProcessors", "Lightning Payout Processor", Context.GetStoreData().Id); +} +
+ +
+
+

@ViewData["Title"]

+
+

Payout Processors allow BTCPay Server to handle payouts awaiting payment in an automated way.

+ @if (!ViewContext.ModelState.IsValid) + { +
+ } +
+
+ + +
+ + +
+
+
+ +@section PageFootContent { + +} diff --git a/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml b/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml new file mode 100644 index 000000000..1631b5adc --- /dev/null +++ b/BTCPayServer/Views/UIOnChainAutomatedPayoutProcessors/Configure.cshtml @@ -0,0 +1,32 @@ +@using BTCPayServer.Abstractions.Extensions +@model BTCPayServer.PayoutProcessors.OnChain.UIOnChainAutomatedPayoutProcessorsController.OnChainTransferViewModel +@{ + ViewData["NavPartialName"] = "../UIStores/_Nav"; + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePage("PayoutProcessors", "OnChain Payout Processor", Context.GetStoreData().Id); +} +
+ +
+
+

@ViewData["Title"]

+
+

Payout Processors allow BTCPay Server to handle payouts awaiting payment in an automated way.

+ @if (!ViewContext.ModelState.IsValid) + { +
+ } +
+
+ + +
+ + +
+
+
+ +@section PageFootContent { + +} diff --git a/BTCPayServer/Views/UIPayoutProcessors/ConfigureStorePayoutProcessors.cshtml b/BTCPayServer/Views/UIPayoutProcessors/ConfigureStorePayoutProcessors.cshtml new file mode 100644 index 000000000..7ca599c6d --- /dev/null +++ b/BTCPayServer/Views/UIPayoutProcessors/ConfigureStorePayoutProcessors.cshtml @@ -0,0 +1,76 @@ +@using BTCPayServer.Abstractions.Extensions +@model List +@{ + ViewData["NavPartialName"] = "../UIStores/_Nav"; + Layout = "../Shared/_NavLayout.cshtml"; + var storeId = Context.GetStoreData().Id; + ViewData.SetActivePage("PayoutProcessors", "Payout Processors", storeId); +} +
+
+
+

@ViewData["Title"]

+
+

Payout Processors allow BTCPay Server to handle payouts in an automated way.

+ + @if (Model.Any()) + { + foreach (var processorsView in Model) + { +
+

@processorsView.Factory.FriendlyName

+
+
+ + + + + + + + + @foreach (var conf in processorsView.Configured) + { + + + + + + } + + + +
Payment MethodActions
+ @conf.Key.ToPrettyString() + + + @if (conf.Value is null) + { + Configure + } + else + { + Modify + Remove + + } +
+
+
+ +
+ } + } + else + { +

+ There are no processors available. +

+ } +
+
+ + +@section PageFootContent { + +} diff --git a/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml b/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml index 196d5c26c..65c4f2f90 100644 --- a/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml +++ b/BTCPayServer/Views/UIStorePullPayments/Payouts.cshtml @@ -3,10 +3,14 @@ @using BTCPayServer.Views.Stores @using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Client +@using BTCPayServer.PayoutProcessors @model BTCPayServer.Models.WalletViewModels.PayoutsModel @inject IEnumerable PayoutHandlers; +@inject PayoutProcessorService _payoutProcessorService; +@inject IEnumerable _payoutProcessorFactories; @{ + var storeId = Context.GetRouteValue("storeId") as string; ViewData.SetActivePage(StoreNavPages.Payouts, $"Payouts{(string.IsNullOrEmpty(Model.PullPaymentName) ? string.Empty : " for pull payment " + Model.PullPaymentName)}", Context.GetStoreData().Id); Model.PaginationQuery ??= new Dictionary(); Model.PaginationQuery.Add("pullPaymentId", Model.PullPaymentId); @@ -19,7 +23,6 @@ if (payoutHandler is null) return; stateActions.AddRange(payoutHandler.GetPayoutSpecificActions().Where(pair => pair.Key == Model.PayoutState).SelectMany(pair => pair.Value)); - } switch (Model.PayoutState) { @@ -47,13 +50,28 @@ } - + +@{ + +} + +@if (_payoutProcessorFactories.Any(factory => factory.GetSupportedPaymentMethods().Contains(paymentMethodId)) && !(await _payoutProcessorService.GetProcessors(new PayoutProcessorService.PayoutProcessorQuery() +{ + Stores = new[] {storeId}, + PaymentMethods = new[] {Model.PaymentMethodId} +})).Any()) +{ + +}

@ViewData["Title"]

- - + +
@@ -93,70 +117,72 @@ asp-route-payoutState="@state.Key" asp-route-pullPaymentId="@Model.PullPaymentId" asp-route-paymentMethodId="@Model.PaymentMethodId" - class="nav-link @(state.Key == Model.PayoutState ? "active" : "")" role="tab">@state.Key.GetStateString() (@state.Value) + class="nav-link @(state.Key == Model.PayoutState ? "active" : "")" role="tab"> + @state.Key.GetStateString() (@state.Value) + }
- + @if (Model.Payouts.Any()) {
- - - - - - - @if (Model.PayoutState != PayoutState.AwaitingApproval) - { - - } - + + + + + + + @if (Model.PayoutState != PayoutState.AwaitingApproval) + { + + } + - @for (int i = 0; i < Model.Payouts.Count; i++) - { - var pp = Model.Payouts[i]; - - + + + + + + @if (Model.PayoutState != PayoutState.AwaitingApproval) + { + - - - - - @if (Model.PayoutState != PayoutState.AwaitingApproval) - { - - } - - } + } + + }
- - - Date - SourceDestinationAmountTransaction
+ + + Date + SourceDestinationAmountTransaction
- - - - + @for (int i = 0; i < Model.Payouts.Count; i++) + { + var pp = Model.Payouts[i]; +
+ + + + + + @pp.Date.ToBrowserDate() + + @pp.PullPaymentName + + @pp.Destination + + @pp.Amount + + @if (!(pp.ProofLink is null)) + { + Link + } - @pp.Date.ToBrowserDate() - - @pp.PullPaymentName - - @pp.Destination - - @pp.Amount - - @if (!(pp.ProofLink is null)) - { - Link - } -
- + } else { diff --git a/BTCPayServer/Views/UIStores/_Nav.cshtml b/BTCPayServer/Views/UIStores/_Nav.cshtml index c9b3c5b94..77ebb045b 100644 --- a/BTCPayServer/Views/UIStores/_Nav.cshtml +++ b/BTCPayServer/Views/UIStores/_Nav.cshtml @@ -16,6 +16,7 @@ Users Integrations Webhooks + Payout Processors diff --git a/BTCPayServer/Views/UIWallets/WalletSend.cshtml b/BTCPayServer/Views/UIWallets/WalletSend.cshtml index c68c6794d..39c73c5e1 100644 --- a/BTCPayServer/Views/UIWallets/WalletSend.cshtml +++ b/BTCPayServer/Views/UIWallets/WalletSend.cshtml @@ -94,6 +94,7 @@
@for (var index = 0; index < Model.Outputs.Count; index++) { +
@@ -231,6 +232,7 @@
+ PSBT diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.payout-processors.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.payout-processors.json new file mode 100644 index 000000000..8ea5422bd --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.payout-processors.json @@ -0,0 +1,668 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/payout-processors": { + "get": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Get store configured payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get store configured payout processors", + "operationId": "StorePayoutProcessors_GetStorePayoutProcessors", + "responses": { + "200": { + "description": "configured payout processors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutProcessorData" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payout-processors/{processor}/{paymentMethod}": { + "delete": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Remove store configured payout processor", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store", + "schema": { + "type": "string" + } + }, + { + "name": "processor", + "in": "path", + "required": true, + "description": "The processor", + "schema": { + "type": "string" + } + }, + { + "name": "paymentMethod", + "in": "path", + "required": true, + "description": "The payment method", + "schema": { + "type": "string" + } + } + ], + "description": "Remove store configured payout processor", + "operationId": "StorePayoutProcessors_RemoveStorePayoutProcessor", + "responses": { + "200": { + "description": "removed" + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/payout-processors": { + "get": { + "tags": [ + "Payout Processors" + ], + "summary": "Get payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get payout processors available in this instance", + "operationId": "PayoutProcessors_GetPayoutProcessors", + "responses": { + "200": { + "description": "available payout processors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutProcessorData" + } + } + } + } + } + }, + "security": [ + { + "API_Key": [], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedTransferSenderFactory/{paymentMethod}": { + "get": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Get configured store onchain automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "paymentMethod", + "in": "path", + "required": true, + "description": "A specific payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get configured store onchain automated payout processors", + "operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_GetStoreOnChainAutomatedPayoutProcessors", + "responses": { + "200": { + "description": "configured processors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OnChainAutomatedTransferSettings" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + }, + "put": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Update configured store onchain automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "paymentMethod", + "in": "path", + "required": true, + "description": "A specific payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Update configured store onchain automated payout processors", + "operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_UpdateStoreOnChainAutomatedPayoutProcessor", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOnChainAutomatedTransferSettings" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "configured processor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainAutomatedTransferSettings" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payout-processors/LightningAutomatedTransferSenderFactory/{paymentMethod}": { + "get": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Get configured store Lightning automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "paymentMethod", + "in": "path", + "required": true, + "description": "A specific payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get configured store Lightning automated payout processors", + "operationId": "GreenfieldStoreAutomatedLightningPayoutProcessorsController_GetStoreLightningAutomatedPayoutProcessors", + "responses": { + "200": { + "description": "configured processors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LightningAutomatedTransferSettings" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + }, + "put": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Update configured store Lightning automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "paymentMethod", + "in": "path", + "required": true, + "description": "A specific payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Update configured store Lightning automated payout processors", + "operationId": "GreenfieldStoreAutomatedLightningPayoutProcessorsController_UpdateStoreLightningAutomatedPayoutProcessor", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLightningAutomatedTransferSettings" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "configured processor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LightningAutomatedTransferSettings" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payout-processors/OnChainAutomatedTransferSenderFactory": { + "get": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Get configured store onchain automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get configured store onchain automated payout processors", + "operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_GetStoreOnChainAutomatedPayoutProcessors", + "responses": { + "200": { + "description": "configured processors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OnChainAutomatedTransferSettings" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + }, + "put": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Update configured store onchain automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "paymentMethod", + "in": "path", + "required": true, + "description": "A specific payment method to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Update configured store onchain automated payout processors", + "operationId": "GreenfieldStoreAutomatedOnChainPayoutProcessorsController_UpdateStoreOnChainAutomatedPayoutProcessor", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateOnChainAutomatedTransferSettings" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "configured processor", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OnChainAutomatedTransferSettings" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/payout-processors/LightningAutomatedTransferSenderFactory": { + "get": { + "tags": [ + "Stores (Payout Processors)" + ], + "summary": "Get configured store Lightning automated payout processors", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get configured store Lightning automated payout processors", + "operationId": "GreenfieldStoreAutomatedLightningPayoutProcessorsController_GetStoreLightningAutomatedPayoutProcessors", + "responses": { + "200": { + "description": "configured processors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LightningAutomatedTransferSettings" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + } + } + }, + "components": { + "schemas": { + "PayoutProcessorData": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "description": "unique identifier of the payout processor", + "type": "string" + }, + "friendlyName": { + "description": "Human name of the payout processor", + "type": "string" + }, + "paymentMethods": { + "nullable": true, + "description": "Supported, payment methods by this processor", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "UpdateLightningAutomatedTransferSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "intervalSeconds": { + "description": "How often should the processor run", + "allOf": [ + { + "$ref": "#/components/schemas/TimeSpanSeconds" + } + ] + } + } + }, + "LightningAutomatedTransferSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "paymentMethod": { + "description": "payment method of the payout processor", + "type": "string" + }, + "intervalSeconds": { + "description": "How often should the processor run", + "allOf": [ + { + "$ref": "#/components/schemas/TimeSpanSeconds" + } + ] + } + } + }, + "UpdateOnChainAutomatedTransferSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "intervalSeconds": { + "description": "How often should the processor run", + "allOf": [ + { + "$ref": "#/components/schemas/TimeSpanSeconds" + } + ] + } + } + }, + "OnChainAutomatedTransferSettings": { + "type": "object", + "additionalProperties": false, + "properties": { + "paymentMethod": { + "description": "payment method of the payout processor", + "type": "string" + }, + "intervalSeconds": { + "description": "How often should the processor run", + "allOf": [ + { + "$ref": "#/components/schemas/TimeSpanSeconds" + } + ] + } + } + } + } + }, + "tags": [ + { + "name": "Stores (Payout Processors)" + }, + { + "name": "Payout Processors" + } + ] +} diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json index 5cd05a367..2da846160 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json @@ -7,7 +7,9 @@ "in": "path", "required": true, "description": "The store ID", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "get": { @@ -38,7 +40,9 @@ } } }, - "tags": [ "Pull payments (Management)" ], + "tags": [ + "Pull payments (Management)" + ], "security": [ { "API_Key": [ @@ -142,7 +146,9 @@ } } }, - "tags": [ "Pull payments (Management)" ], + "tags": [ + "Pull payments (Management)" + ], "security": [ { "API_Key": [ @@ -160,7 +166,9 @@ "in": "path", "required": true, "description": "The ID of the pull payment", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "get": { @@ -182,7 +190,9 @@ "description": "Pull payment not found" } }, - "tags": [ "Pull payments (Public)" ], + "tags": [ + "Pull payments (Public)" + ], "security": [] } }, @@ -193,14 +203,18 @@ "in": "path", "required": true, "description": "The ID of the store", - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, { "name": "pullPaymentId", "in": "path", "required": true, "description": "The ID of the pull payment", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "delete": { @@ -215,7 +229,9 @@ "description": "The pull payment has not been found, or does not belong to this store" } }, - "tags": [ "Pull payments (Management)" ], + "tags": [ + "Pull payments (Management)" + ], "security": [ { "API_Key": [ @@ -233,7 +249,9 @@ "in": "path", "required": true, "description": "The ID of the pull payment", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "get": { @@ -267,7 +285,9 @@ "description": "Pull payment not found" } }, - "tags": [ "Pull payments (Public)" ], + "tags": [ + "Pull payments (Public)" + ], "security": [] }, "post": { @@ -321,7 +341,9 @@ } } }, - "tags": [ "Pull payments (Public)" ], + "tags": [ + "Pull payments (Public)" + ], "security": [] } }, @@ -332,14 +354,18 @@ "in": "path", "required": true, "description": "The ID of the pull payment", - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, { "name": "payoutId", "in": "path", "required": true, "description": "The ID of the pull payment payout", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "get": { @@ -361,7 +387,122 @@ "description": "Pull payment payout not found" } }, - "tags": [ "Pull payments (Public)", "Pull payments payout (Public)" ], + "tags": [ + "Pull payments (Public)", + "Pull payments payout (Public)" + ], + "security": [] + } + }, + "/api/v1/stores/{storeId}/payouts": { + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The ID of the store", + "schema": { + "type": "string" + } + } + ], + "post": { + "summary": "Create Payout ", + "description": "Create a new payout", + "operationId": "Payouts_CreatePayoutThroughStore", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreatePayoutThroughStoreRequest" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "A new payout has been created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutData" + } + } + } + }, + "404": { + "description": "store not found" + }, + "422": { + "description": "Unable to validate the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "400": { + "description": "Wellknown error codes are: `duplicate-destination`, `expired`, `not-started`, `archived`, `overdraft`, `amount-too-low`, `payment-method-not-supported`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "tags": [ + "Stores (Payouts)" + ], + "security": [ + { + "API_Key": [ + "btcpay.store.canmanagepullpayments" + ], + "Basic": [] + } + ] + }, + "get": { + "summary": "Get Store Payouts", + "operationId": "PullPayments_GetStorePayouts", + "description": "Get payouts", + "parameters": [ + { + "name": "includeCancelled", + "in": "query", + "required": false, + "description": "Whether this should list cancelled payouts", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "The payouts of the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutDataList" + } + } + } + }, + "404": { + "description": "Pull payment not found" + } + }, + "tags": [ + "Stores (Payouts)" + ], "security": [] } }, @@ -372,18 +513,21 @@ "in": "path", "required": true, "description": "The ID of the store", - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, { "name": "payoutId", "in": "path", "required": true, "description": "The ID of the payout", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "post": { - "summary": "Approve Payout", "operationId": "PullPayments_ApprovePayout", "description": "Approve a payout", @@ -443,7 +587,9 @@ "description": "The payout is not found" } }, - "tags": [ "Pull payments (Management)" ], + "tags": [ + "Stores (Payouts)" + ], "security": [ { "API_Key": [ @@ -465,7 +611,9 @@ "description": "The payout is not found" } }, - "tags": [ "Pull payments (Management)" ], + "tags": [ + "Stores (Payouts)" + ], "security": [ { "API_Key": [ @@ -483,24 +631,27 @@ "in": "path", "required": true, "description": "The ID of the store", - "schema": { "type": "string" } + "schema": { + "type": "string" + } }, { "name": "payoutId", "in": "path", "required": true, "description": "The ID of the payout", - "schema": { "type": "string" } + "schema": { + "type": "string" + } } ], "post": { - "summary": "Mark Payout as Paid", "operationId": "PullPayments_MarkPayoutPaid", "description": "Mark a payout as paid", "responses": { "200": { - "description": "The payout has been marked paid, transitioning to `Completed` state." + "description": "The payout has been marked paid, transitioning to `Completed` state." }, "422": { "description": "Unable to validate the request", @@ -526,7 +677,9 @@ "description": "The payout is not found" } }, - "tags": [ "Pull payments (Management)" ], + "tags": [ + "Stores (Payouts)" + ], "security": [ { "API_Key": [ @@ -573,6 +726,26 @@ } } }, + "CreatePayoutThroughStoreRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/CreatePayoutRequest" + }, + { + "type": "object", + "properties": { + "pullPaymentId": { + "type": "string", + "description": "The pull payment to create this for. Optional." + }, + "approved": { + "type": "boolean", + "description": "Whether to approve this payout automatically upon creation" + } + } + } + ] + }, "PayoutData": { "type": "object", "properties": {