diff --git a/BTCPayServer.Client/BTCPayServerClient.Authorization.cs b/BTCPayServer.Client/BTCPayServerClient.Authorization.cs index bfe3c891a..5f2a294b6 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Authorization.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Authorization.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; namespace BTCPayServer.Client { diff --git a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs new file mode 100644 index 000000000..e7f6af845 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public async Task CreatePullPayment(string storeId, CreatePullPaymentRequest request, CancellationToken cancellationToken = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/pull-payments", bodyPayload: request, method: HttpMethod.Post), cancellationToken); + return await HandleResponse(response); + } + public async Task GetPullPayment(string pullPaymentId, CancellationToken cancellationToken = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}", method: HttpMethod.Get), cancellationToken); + return await HandleResponse(response); + } + + public async Task GetPullPayments(string storeId, bool includeArchived = false, CancellationToken cancellationToken = default) + { + Dictionary query = new Dictionary(); + query.Add("includeArchived", includeArchived); + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/pull-payments", queryPayload: query, method: HttpMethod.Get), cancellationToken); + return await HandleResponse(response); + } + + public async Task ArchivePullPayment(string storeId, string pullPaymentId, CancellationToken cancellationToken = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}", method: HttpMethod.Delete), cancellationToken); + await HandleResponse(response); + } + + public async Task GetPayouts(string pullPaymentId, bool includeCancelled = false, CancellationToken cancellationToken = default) + { + Dictionary query = new Dictionary(); + query.Add("includeCancelled", includeCancelled); + 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 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 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); + await HandleResponse(response); + } + } +} diff --git a/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs b/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs index 3947d334c..863e159ed 100644 --- a/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs +++ b/BTCPayServer.Client/JsonConverters/TimeSpanJsonConverter.cs @@ -38,7 +38,7 @@ namespace BTCPayServer.Client.JsonConverters { if (value is TimeSpan s) { - writer.WriteValue((int)s.TotalSeconds); + writer.WriteValue((long)s.TotalSeconds); } } } diff --git a/BTCPayServer.Client/Models/CreatePayoutRequest.cs b/BTCPayServer.Client/Models/CreatePayoutRequest.cs new file mode 100644 index 000000000..7251e4597 --- /dev/null +++ b/BTCPayServer.Client/Models/CreatePayoutRequest.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class CreatePayoutRequest + { + public string Destination { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal? Amount { get; set; } + public string PaymentMethod { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs b/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs new file mode 100644 index 000000000..4adf4c385 --- /dev/null +++ b/BTCPayServer.Client/Models/CreatePullPaymentRequest.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Text; +using BTCPayServer.Client.JsonConverters; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class CreatePullPaymentRequest + { + public string Name { get; set; } + [JsonProperty(ItemConverterType = typeof(DecimalStringJsonConverter))] + public decimal Amount { get; set; } + public string Currency { get; set; } + [JsonConverter(typeof(TimeSpanJsonConverter))] + public TimeSpan? Period { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? ExpiresAt { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? StartsAt { get; set; } + public string[] PaymentMethods { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/PayoutData.cs b/BTCPayServer.Client/Models/PayoutData.cs new file mode 100644 index 000000000..9e5f51edf --- /dev/null +++ b/BTCPayServer.Client/Models/PayoutData.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Text; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace BTCPayServer.Client.Models +{ + public enum PayoutState + { + AwaitingPayment, + InProgress, + Completed, + Cancelled + } + public class PayoutData + { + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset Date { get; set; } + public string Id { get; set; } + public string PullPaymentId { get; set; } + public string Destination { get; set; } + public string PaymentMethod { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal Amount { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal PaymentMethodAmount { get; set; } + [JsonConverter(typeof(StringEnumConverter))] + public PayoutState State { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/PullPaymentBaseData.cs b/BTCPayServer.Client/Models/PullPaymentBaseData.cs new file mode 100644 index 000000000..4170188e6 --- /dev/null +++ b/BTCPayServer.Client/Models/PullPaymentBaseData.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Text; +using BTCPayServer.Client.JsonConverters; +using BTCPayServer.JsonConverters; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models +{ + public class PullPaymentData + { + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset StartsAt { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? ExpiresAt { get; set; } + public string Id { get; set; } + public string Name { get; set; } + public string Currency { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal Amount { get; set; } + [JsonConverter(typeof(TimeSpanJsonConverter))] + public TimeSpan? Period { get; set; } + public bool Archived { get; set; } + public string ViewLink { get; set; } + } +} diff --git a/BTCPayServer.Client/Permissions.cs b/BTCPayServer.Client/Permissions.cs index dbc932d13..54c7281c8 100644 --- a/BTCPayServer.Client/Permissions.cs +++ b/BTCPayServer.Client/Permissions.cs @@ -19,6 +19,7 @@ namespace BTCPayServer.Client public const string CanModifyProfile = "btcpay.user.canmodifyprofile"; public const string CanViewProfile = "btcpay.user.canviewprofile"; public const string CanCreateUser = "btcpay.server.cancreateuser"; + public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments"; public const string Unrestricted = "unrestricted"; public static IEnumerable AllPolicies { @@ -38,6 +39,7 @@ namespace BTCPayServer.Client yield return CanCreateLightningInvoiceInternalNode; yield return CanUseLightningNodeInStore; yield return CanCreateLightningInvoiceInStore; + yield return CanManagePullPayments; } } public static bool IsValidPolicy(string policy) @@ -53,7 +55,6 @@ namespace BTCPayServer.Client { return policy.StartsWith("btcpay.store.canmodify", StringComparison.OrdinalIgnoreCase); } - public static bool IsServerPolicy(string policy) { return policy.StartsWith("btcpay.server", StringComparison.OrdinalIgnoreCase); diff --git a/BTCPayServer.Data/Data/ApplicationDbContext.cs b/BTCPayServer.Data/Data/ApplicationDbContext.cs index 8fa72d6f8..8f61c9b02 100644 --- a/BTCPayServer.Data/Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/Data/ApplicationDbContext.cs @@ -45,6 +45,8 @@ namespace BTCPayServer.Data public DbSet RefundAddresses { get; set; } public DbSet Payments { get; set; } public DbSet PaymentRequests { get; set; } + public DbSet PullPayments { get; set; } + public DbSet Payouts { get; set; } public DbSet Wallets { get; set; } public DbSet WalletTransactions { get; set; } public DbSet Stores { get; set; } @@ -205,6 +207,9 @@ namespace BTCPayServer.Data .HasOne(o => o.WalletData) .WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade); + PullPaymentData.OnModelCreating(builder); + PayoutData.OnModelCreating(builder); + if (Database.IsSqlite() && !_designTime) { // SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations diff --git a/BTCPayServer.Data/Data/PayoutData.cs b/BTCPayServer.Data/Data/PayoutData.cs new file mode 100644 index 000000000..9dedbf0c3 --- /dev/null +++ b/BTCPayServer.Data/Data/PayoutData.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text; +using Microsoft.EntityFrameworkCore; +using NBitcoin; + +namespace BTCPayServer.Data +{ + public class PayoutData + { + [Key] + [MaxLength(30)] + public string Id { get; set; } + public DateTimeOffset Date { get; set; } + public string PullPaymentDataId { get; set; } + public PullPaymentData PullPaymentData { get; set; } + [MaxLength(20)] + public PayoutState State { get; set; } + [MaxLength(20)] + [Required] + public string PaymentMethodId { get; set; } + public string Destination { get; set; } + public byte[] Blob { get; set; } + public byte[] Proof { get; set; } + public bool IsInPeriod(PullPaymentData pp, DateTimeOffset now) + { + var period = pp.GetPeriod(now); + if (period is { } p) + { + return p.Start <= Date && (p.End is DateTimeOffset end ? Date < end : true); + } + else + { + return false; + } + } + + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(o => o.PullPaymentData) + .WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade); + + builder.Entity() + .Property(o => o.State) + .HasConversion(); + builder.Entity() + .HasIndex(o => o.Destination) + .IsUnique(); + builder.Entity() + .HasIndex(o => o.State); + } + } + + public enum PayoutState + { + AwaitingPayment, + InProgress, + Completed, + Cancelled + } +} diff --git a/BTCPayServer.Data/Data/PullPaymentData.cs b/BTCPayServer.Data/Data/PullPaymentData.cs new file mode 100644 index 000000000..89dc0b1dd --- /dev/null +++ b/BTCPayServer.Data/Data/PullPaymentData.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.EntityFrameworkCore; +using NBitcoin; + +namespace BTCPayServer.Data +{ + public static class PayoutExtensions + { + public static IQueryable GetPayoutInPeriod(this IQueryable payouts, PullPaymentData pp) + { + return GetPayoutInPeriod(payouts, pp, DateTimeOffset.UtcNow); + } + public static IQueryable GetPayoutInPeriod(this IQueryable payouts, PullPaymentData pp, DateTimeOffset now) + { + var request = payouts.Where(p => p.PullPaymentDataId == pp.Id); + var period = pp.GetPeriod(now); + if (period is { } p) + { + var start = p.Start; + if (p.End is DateTimeOffset end) + { + return payouts.Where(p => p.Date >= start && p.Date < end); + } + else + { + return payouts.Where(p => p.Date >= start); + } + } + else + { + return payouts.Where(p => false); + } + } + } + public class PullPaymentData + { + [Key] + [MaxLength(30)] + public string Id { get; set; } + [ForeignKey("StoreId")] + public StoreData StoreData { get; set; } + [MaxLength(50)] + public string StoreId { get; set; } + public long? Period { get; set; } + public DateTimeOffset StartDate { get; set; } + public DateTimeOffset? EndDate { get; set; } + public bool Archived { get; set; } + public List Payouts { get; set; } + public byte[] Blob { get; set; } + + public (DateTimeOffset Start, DateTimeOffset? End)? GetPeriod(DateTimeOffset now) + { + if (now < StartDate) + return null; + if (EndDate is DateTimeOffset end && now >= end) + return null; + DateTimeOffset startPeriod = StartDate; + DateTimeOffset? endPeriod = null; + if (Period is long periodSeconds) + { + var period = TimeSpan.FromSeconds(periodSeconds); + var timeToNow = now - StartDate; + var periodCount = (long)timeToNow.TotalSeconds / (long)period.TotalSeconds; + startPeriod = StartDate + (period * periodCount); + endPeriod = startPeriod + period; + } + if (EndDate is DateTimeOffset end2 && + ((endPeriod is null) || + (endPeriod is DateTimeOffset endP && endP > end2))) + endPeriod = end2; + return (startPeriod, endPeriod); + } + + public bool HasStarted() + { + return HasStarted(DateTimeOffset.UtcNow); + } + public bool HasStarted(DateTimeOffset now) + { + return StartDate <= now; + } + + public bool IsExpired() + { + return IsExpired(DateTimeOffset.UtcNow); + } + public bool IsExpired(DateTimeOffset now) + { + return EndDate is DateTimeOffset dt && now > dt; + } + + public bool IsRunning() + { + return IsRunning(DateTimeOffset.UtcNow); + } + + public bool IsRunning(DateTimeOffset now) + { + return !Archived && !IsExpired(now) && HasStarted(now); + } + + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasIndex(o => o.StoreId); + builder.Entity() + .HasOne(o => o.StoreData) + .WithMany(o => o.PullPayments).OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/BTCPayServer.Data/Data/StoreData.cs b/BTCPayServer.Data/Data/StoreData.cs index 4f795f54a..21832ff23 100644 --- a/BTCPayServer.Data/Data/StoreData.cs +++ b/BTCPayServer.Data/Data/StoreData.cs @@ -15,6 +15,8 @@ namespace BTCPayServer.Data public List PaymentRequests { get; set; } + public List PullPayments { get; set; } + public List Invoices { get; set; } [Obsolete("Use GetDerivationStrategies instead")] diff --git a/BTCPayServer.Data/Migrations/20200623042347_pullpayments.cs b/BTCPayServer.Data/Migrations/20200623042347_pullpayments.cs new file mode 100644 index 000000000..71355c569 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20200623042347_pullpayments.cs @@ -0,0 +1,92 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200623042347_pullpayments")] + public partial class pullpayments : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PullPayments", + columns: table => new + { + Id = table.Column(maxLength: 30, nullable: false), + StoreId = table.Column(maxLength: 50, nullable: true), + Period = table.Column(nullable: true), + StartDate = table.Column(nullable: false), + EndDate = table.Column(nullable: true), + Archived = table.Column(nullable: false), + Blob = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PullPayments", x => x.Id); + table.ForeignKey( + name: "FK_PullPayments_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Payouts", + columns: table => new + { + Id = table.Column(maxLength: 30, nullable: false), + Date = table.Column(nullable: false), + PullPaymentDataId = table.Column(nullable: true), + State = table.Column(maxLength: 20, nullable: false), + PaymentMethodId = table.Column(maxLength: 20, nullable: false), + Destination = table.Column(nullable: true), + Blob = table.Column(nullable: true), + Proof = table.Column(nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Payouts", x => x.Id); + table.ForeignKey( + name: "FK_Payouts_PullPayments_PullPaymentDataId", + column: x => x.PullPaymentDataId, + principalTable: "PullPayments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Payouts_Destination", + table: "Payouts", + column: "Destination", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Payouts_PullPaymentDataId", + table: "Payouts", + column: "PullPaymentDataId"); + + migrationBuilder.CreateIndex( + name: "IX_Payouts_State", + table: "Payouts", + column: "State"); + + migrationBuilder.CreateIndex( + name: "IX_PullPayments_StoreId", + table: "PullPayments", + column: "StoreId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Payouts"); + + migrationBuilder.DropTable( + name: "PullPayments"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 3bc9b6310..05aaba31a 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -407,6 +407,49 @@ namespace BTCPayServer.Migrations b.ToTable("PaymentRequests"); }); + modelBuilder.Entity("BTCPayServer.Data.PayoutData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(30); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Destination") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(20); + + b.Property("Proof") + .HasColumnType("BLOB"); + + b.Property("PullPaymentDataId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(20); + + b.HasKey("Id"); + + b.HasIndex("Destination") + .IsUnique(); + + b.HasIndex("PullPaymentDataId"); + + b.HasIndex("State"); + + b.ToTable("Payouts"); + }); + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => { b.Property("Id") @@ -434,6 +477,38 @@ namespace BTCPayServer.Migrations b.ToTable("PlannedTransactions"); }); + modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(30); + + b.Property("Archived") + .HasColumnType("INTEGER"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Period") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT") + .HasMaxLength(50); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("PullPayments"); + }); + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => { b.Property("Id") @@ -822,6 +897,14 @@ namespace BTCPayServer.Migrations .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity("BTCPayServer.Data.PayoutData", b => + { + b.HasOne("BTCPayServer.Data.PullPaymentData", "PullPaymentData") + .WithMany("Payouts") + .HasForeignKey("PullPaymentDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => { b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") @@ -831,6 +914,14 @@ namespace BTCPayServer.Migrations .IsRequired(); }); + modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PullPayments") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + }); + modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b => { b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") diff --git a/BTCPayServer.Rating/CurrencyNameTable.cs b/BTCPayServer.Rating/CurrencyNameTable.cs index cd15846e0..076e8d590 100644 --- a/BTCPayServer.Rating/CurrencyNameTable.cs +++ b/BTCPayServer.Rating/CurrencyNameTable.cs @@ -147,6 +147,8 @@ namespace BTCPayServer.Services.Rates public CurrencyData GetCurrencyData(string currency, bool useFallback) { + if (currency == null) + throw new ArgumentNullException(nameof(currency)); CurrencyData result; if (!_Currencies.TryGetValue(currency.ToUpperInvariant(), out result)) { diff --git a/BTCPayServer.Rating/Extensions.cs b/BTCPayServer.Rating/Extensions.cs index c833c3d5c..a77aaedcf 100644 --- a/BTCPayServer.Rating/Extensions.cs +++ b/BTCPayServer.Rating/Extensions.cs @@ -4,6 +4,10 @@ namespace BTCPayServer.Rating { public static class Extensions { + public static decimal RoundToSignificant(this decimal value, int divisibility) + { + return RoundToSignificant(value, ref divisibility); + } public static decimal RoundToSignificant(this decimal value, ref int divisibility) { if (value != 0m) diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index b9eef7567..cd839b55c 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Reflection.Metadata; +using System.Threading; using System.Threading.Tasks; using BTCPayServer.Client; using BTCPayServer.Client.Models; @@ -207,6 +208,198 @@ namespace BTCPayServer.Tests } } + [Fact] + [Trait("Integration", "Integration")] + public async Task CanUsePullPaymentViaAPI() + { + using (var tester = ServerTester.Create()) + { + await tester.StartAsync(); + var acc = tester.NewAccount(); + acc.Register(); + acc.CreateStore(); + var storeId = (await acc.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true)).StoreId; + var client = await acc.CreateClient(); + var result = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Test", + Amount = 12.3m, + Currency = "BTC", + PaymentMethods = new[] { "BTC" } + }); + + void VerifyResult() + { + Assert.Equal("Test", result.Name); + Assert.Null(result.Period); + // If it contains ? it means that we are resolving an unknown route with the link generator + Assert.DoesNotContain("?", result.ViewLink); + Assert.False(result.Archived); + Assert.Equal("BTC", result.Currency); + Assert.Equal(12.3m, result.Amount); + } + VerifyResult(); + + var unauthenticated = new BTCPayServerClient(tester.PayTester.ServerUri); + result = await unauthenticated.GetPullPayment(result.Id); + VerifyResult(); + await AssertHttpError(404, async () => await unauthenticated.GetPullPayment("lol")); + // Can't list pull payments unauthenticated + await AssertHttpError(401, async () => await unauthenticated.GetPullPayments(storeId)); + + var pullPayments = await client.GetPullPayments(storeId); + result = Assert.Single(pullPayments); + VerifyResult(); + + Thread.Sleep(1000); + var test2 = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Test 2", + Amount = 12.3m, + Currency = "BTC", + PaymentMethods = new[] { "BTC" } + }); + + Logs.Tester.LogInformation("Can't archive without knowing the walletId"); + await Assert.ThrowsAsync(async () => await client.ArchivePullPayment("lol", result.Id)); + Logs.Tester.LogInformation("Can't archive without permission"); + await Assert.ThrowsAsync(async () => await unauthenticated.ArchivePullPayment(storeId, result.Id)); + await client.ArchivePullPayment(storeId, result.Id); + result = await unauthenticated.GetPullPayment(result.Id); + Assert.True(result.Archived); + var pps = await client.GetPullPayments(storeId); + result = Assert.Single(pps); + Assert.Equal("Test 2", result.Name); + pps = await client.GetPullPayments(storeId, true); + Assert.Equal(2, pps.Length); + Assert.Equal("Test 2", pps[0].Name); + Assert.Equal("Test", pps[1].Name); + + var payouts = await unauthenticated.GetPayouts(pps[0].Id); + Assert.Empty(payouts); + + var destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString(); + await this.AssertAPIError("overdraft", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() + { + Destination = destination, + Amount = 1_000_000m, + PaymentMethod = "BTC", + })); + + await this.AssertAPIError("archived", async () => await unauthenticated.CreatePayout(pps[1].Id, new CreatePayoutRequest() + { + Destination = destination, + PaymentMethod = "BTC" + })); + + var payout = await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() + { + Destination = destination, + PaymentMethod = "BTC" + }); + + payouts = await unauthenticated.GetPayouts(pps[0].Id); + var payout2 = Assert.Single(payouts); + Assert.Equal(payout.Amount, payout2.Amount); + Assert.Equal(payout.Id, payout2.Id); + Assert.Equal(destination, payout2.Destination); + Assert.Equal(PayoutState.AwaitingPayment, payout.State); + + + Logs.Tester.LogInformation("Can't overdraft"); + await this.AssertAPIError("overdraft", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() + { + Destination = destination, + Amount = 0.00001m, + PaymentMethod = "BTC" + })); + + Logs.Tester.LogInformation("Can't create too low payout"); + await this.AssertAPIError("amount-too-low", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() + { + Destination = destination, + PaymentMethod = "BTC" + })); + + Logs.Tester.LogInformation("Can archive payout"); + await client.CancelPayout(storeId, payout.Id); + payouts = await unauthenticated.GetPayouts(pps[0].Id); + Assert.Empty(payouts); + + payouts = await client.GetPayouts(pps[0].Id, true); + payout = Assert.Single(payouts); + Assert.Equal(PayoutState.Cancelled, payout.State); + + Logs.Tester.LogInformation("Can't create too low payout (below dust)"); + await this.AssertAPIError("amount-too-low", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() + { + Amount = Money.Satoshis(100).ToDecimal(MoneyUnit.BTC), + Destination = destination, + PaymentMethod = "BTC" + })); + + Logs.Tester.LogInformation("Can create payout after cancelling"); + await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() + { + Destination = destination, + PaymentMethod = "BTC" + }); + + var start = RoundSeconds(DateTimeOffset.Now + TimeSpan.FromDays(7.0)); + var inFuture = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Starts in the future", + Amount = 12.3m, + StartsAt = start, + Currency = "BTC", + PaymentMethods = new[] { "BTC" } + }); + Assert.Equal(start, inFuture.StartsAt); + Assert.Null(inFuture.ExpiresAt); + await this.AssertAPIError("not-started", async () => await unauthenticated.CreatePayout(inFuture.Id, new CreatePayoutRequest() + { + Amount = 1.0m, + Destination = destination, + PaymentMethod = "BTC" + })); + + var expires = RoundSeconds(DateTimeOffset.Now - TimeSpan.FromDays(7.0)); + var inPast = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Will expires", + Amount = 12.3m, + ExpiresAt = expires, + Currency = "BTC", + PaymentMethods = new[] { "BTC" } + }); + await this.AssertAPIError("expired", async () => await unauthenticated.CreatePayout(inPast.Id, new CreatePayoutRequest() + { + Amount = 1.0m, + Destination = destination, + PaymentMethod = "BTC" + })); + + await this.AssertValidationError(new[] { "ExpiresAt" }, async () => await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Test 2", + Amount = 12.3m, + StartsAt = DateTimeOffset.UtcNow, + ExpiresAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(1) + })); + } + } + + private DateTimeOffset RoundSeconds(DateTimeOffset dateTimeOffset) + { + return new DateTimeOffset(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, dateTimeOffset.Hour, dateTimeOffset.Minute, dateTimeOffset.Second, dateTimeOffset.Offset); + } + + private async Task AssertAPIError(string expectedError, Func act) + { + var err = await Assert.ThrowsAsync(async () => await act()); + Assert.Equal(expectedError, err.APIError.Code); + } + [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task StoresControllerTests() diff --git a/BTCPayServer.Tests/PayJoinTests.cs b/BTCPayServer.Tests/PayJoinTests.cs index 29c221ce8..c4b02e8cf 100644 --- a/BTCPayServer.Tests/PayJoinTests.cs +++ b/BTCPayServer.Tests/PayJoinTests.cs @@ -19,6 +19,7 @@ using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Wallets; using BTCPayServer.Tests.Logging; using BTCPayServer.Views.Wallets; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -644,7 +645,7 @@ namespace BTCPayServer.Tests Assert.Equal("paid", invoice.Status); }); } - + psbt.Finalize(); var broadcasted = await tester.PayTester.GetService().GetExplorerClient("BTC").BroadcastAsync(psbt.ExtractTransaction(), true); if (vector.OriginalTxBroadcasted) @@ -706,12 +707,12 @@ namespace BTCPayServer.Tests await RunVector(true); var originalSenderUser = senderUser; - retry: - // Additional fee is 96 , minrelaytx is 294 - // We pay correctly, fees partially taken from what is overpaid - // We paid 510, the receiver pay 10 sat - // The send pay remaining 86 sat from his pocket - // So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back) +retry: +// Additional fee is 96 , minrelaytx is 294 +// We pay correctly, fees partially taken from what is overpaid +// We paid 510, the receiver pay 10 sat +// The send pay remaining 86 sat from his pocket +// So total paid by sender should be 86 + 510 + 200 so we should get 1090 - (86 + 510 + 200) == 294 back) Logs.Tester.LogInformation($"Check if we can take fee on overpaid utxo{(senderUser == receiverUser ? " (to self)" : "")}"); vector = (SpentCoin: Money.Satoshis(1090), InvoiceAmount: Money.Satoshis(500), Paid: Money.Satoshis(510), Fee: Money.Satoshis(200), InvoicePaid: true, ExpectedError: null as string, OriginalTxBroadcasted: false); proposedPSBT = await RunVector(); diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 8a4ccd66f..80e6eda3f 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -102,6 +102,7 @@ namespace BTCPayServer.Tests public string RegisterNewUser(bool isAdmin = false) { var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com"; + Logs.Tester.LogInformation($"User: {usr} with password 123456"); Driver.FindElement(By.Id("Email")).SendKeys(usr); Driver.FindElement(By.Id("Password")).SendKeys("123456"); Driver.FindElement(By.Id("ConfirmPassword")).SendKeys("123456"); @@ -119,10 +120,10 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Id("CreateStore")).Click(); Driver.FindElement(By.Id("Name")).SendKeys(usr); Driver.FindElement(By.Id("Create")).Click(); - - return (usr, Driver.FindElement(By.Id("Id")).GetAttribute("value")); + StoreId = Driver.FindElement(By.Id("Id")).GetAttribute("value"); + return (usr, StoreId); } - + public string StoreId { get; set; } public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool importkeys = false, bool privkeys = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit) { @@ -141,9 +142,10 @@ namespace BTCPayServer.Tests { seed = Driver.FindElements(By.ClassName("alert-success")).First().FindElement(By.TagName("code")).Text; } + WalletId = new WalletId(StoreId, cryptoCode); return new Mnemonic(seed); } - + public WalletId WalletId { get; set; } public void AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "xpub661MyMwAqRbcGABgHMUXDzPzH1tU7eZaAaJQXhDXsSxsqyQzQeU6kznNfSuAyqAK9UaWSaZaMFdNiY5BCF4zBPAzSnwfUAwUhwttuAKwfRX-[legacy]") { Driver.FindElement(By.Id($"Modify{cryptoCode}")).ForceClick(); @@ -317,8 +319,9 @@ namespace BTCPayServer.Tests return id; } - public async Task FundStoreWallet(WalletId walletId, int coins = 1, decimal denomination = 1m) + public async Task FundStoreWallet(WalletId walletId = null, int coins = 1, decimal denomination = 1m) { + walletId ??= WalletId; GoToWallet(walletId, WalletsNavPages.Receive); Driver.FindElement(By.Id("generateButton")).Click(); var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value"); @@ -372,8 +375,9 @@ namespace BTCPayServer.Tests } - public void GoToWallet(WalletId walletId, WalletsNavPages navPages = WalletsNavPages.Send) + public void GoToWallet(WalletId walletId = null, WalletsNavPages navPages = WalletsNavPages.Send) { + walletId ??= WalletId; Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}")); if (navPages != WalletsNavPages.Transactions) { diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 977d8ae1f..b8c17b7d6 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -17,6 +17,9 @@ using BTCPayServer.Services.Wallets; using BTCPayServer.Views.Wallets; using Newtonsoft.Json; using BTCPayServer.Client.Models; +using System.Threading; +using ExchangeSharp; +using Microsoft.EntityFrameworkCore; namespace BTCPayServer.Tests { @@ -246,12 +249,12 @@ namespace BTCPayServer.Tests s.AssertHappyMessage(); s.Driver.FindElement(By.ClassName("invoice-details-link")).Click(); var invoiceUrl = s.Driver.Url; - + //let's test archiving an invoice - Assert.DoesNotContain("Archived", s.Driver.FindElement(By.Id("btn-archive-toggle")).Text); + Assert.DoesNotContain("Archived", s.Driver.FindElement(By.Id("btn-archive-toggle")).Text); s.Driver.FindElement(By.Id("btn-archive-toggle")).Click(); s.AssertHappyMessage(); - Assert.Contains("Archived", s.Driver.FindElement(By.Id("btn-archive-toggle")).Text); + Assert.Contains("Archived", s.Driver.FindElement(By.Id("btn-archive-toggle")).Text); //check that it no longer appears in list s.GoToInvoices(); Assert.DoesNotContain(invoiceId, s.Driver.PageSource); @@ -259,7 +262,7 @@ namespace BTCPayServer.Tests s.Driver.Navigate().GoToUrl(invoiceUrl); s.Driver.FindElement(By.Id("btn-archive-toggle")).Click(); s.AssertHappyMessage(); - Assert.DoesNotContain("Archived", s.Driver.FindElement(By.Id("btn-archive-toggle")).Text); + Assert.DoesNotContain("Archived", s.Driver.FindElement(By.Id("btn-archive-toggle")).Text); s.GoToInvoices(); Assert.Contains(invoiceId, s.Driver.PageSource); @@ -383,17 +386,17 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.Id("DefaultView")).SendKeys("Cart" + Keys.Enter); s.Driver.FindElement(By.Id("SaveSettings")).ForceClick(); s.Driver.FindElement(By.Id("ViewApp")).ForceClick(); - + var posBaseUrl = s.Driver.Url.Replace("/Cart", ""); Assert.True(s.Driver.PageSource.Contains("Tea shop"), "Unable to create PoS"); Assert.True(s.Driver.PageSource.Contains("Cart"), "PoS not showing correct default view"); - + s.Driver.Url = posBaseUrl + "/static"; Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view"); - + s.Driver.Url = posBaseUrl + "/cart"; Assert.True(s.Driver.PageSource.Contains("Cart"), "Cart PoS not showing correct view"); - + s.Driver.Quit(); } } @@ -423,7 +426,7 @@ namespace BTCPayServer.Tests s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last()); Assert.True(s.Driver.PageSource.Contains("Currently Active!"), "Unable to create CF"); s.Driver.Quit(); - } + } } [Fact(Timeout = TestTimeout)] @@ -517,22 +520,22 @@ namespace BTCPayServer.Tests { await s.StartAsync(); s.RegisterNewUser(true); - var storeId = s.CreateNewStore(); + var storeId = s.CreateNewStore(); // In this test, we try to spend from a manual seed. We import the xpub 49'/0'/0', then try to use the seed // to sign the transaction s.GenerateWallet("BTC", "", true, false); - + //let's test quickly the receive wallet page s.Driver.FindElement(By.Id("Wallets")).Click(); s.Driver.FindElement(By.LinkText("Manage")).Click(); - + s.Driver.FindElement(By.Id("WalletSend")).Click(); s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); //you cant use the Sign with NBX option without saving private keys when generating the wallet. Assert.DoesNotContain("nbx-seed", s.Driver.PageSource); - + s.Driver.FindElement(By.Id("WalletReceive")).Click(); //generate a receiving address s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); @@ -543,10 +546,10 @@ namespace BTCPayServer.Tests //generate it again, should be the same one as before as nothign got used in the meantime s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); Assert.True(s.Driver.FindElement(By.ClassName("qr-container")).Displayed); - Assert.Equal( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); - + Assert.Equal(receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); + //send money to addr and ensure it changed - + var sess = await s.Server.ExplorerClient.CreateWebsocketNotificationSessionAsync(); sess.ListenAllTrackedSource(); var nextEvent = sess.NextEventAsync(); @@ -556,7 +559,7 @@ namespace BTCPayServer.Tests await Task.Delay(200); s.Driver.Navigate().Refresh(); s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); - Assert.NotEqual( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); + Assert.NotEqual(receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); receiveAddr = s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value"); //change the wallet and ensure old address is not there and generating a new one does not result in the prev one s.GoToStore(storeId.storeId); @@ -565,28 +568,28 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.LinkText("Manage")).Click(); s.Driver.FindElement(By.Id("WalletReceive")).Click(); s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click(); - Assert.NotEqual( receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); - - + Assert.NotEqual(receiveAddr, s.Driver.FindElement(By.Id("vue-address")).GetAttribute("value")); + + var invoiceId = s.CreateInvoice(storeId.storeName); var invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId); var address = invoice.EntityToDTO().Addresses["BTC"]; - + //wallet should have been imported to bitcoin core wallet in watch only mode. var result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest)); Assert.True(result.IsWatchOnly); s.GoToStore(storeId.storeId); var mnemonic = s.GenerateWallet("BTC", "", true, true); - + //lets import and save private keys var root = mnemonic.DeriveExtKey(); - invoiceId = s.CreateInvoice(storeId.storeName); - invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice( invoiceId); - address = invoice.EntityToDTO().Addresses["BTC"]; - result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest)); - //spendable from bitcoin core wallet! - Assert.False(result.IsWatchOnly); + invoiceId = s.CreateInvoice(storeId.storeName); + invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId); + address = invoice.EntityToDTO().Addresses["BTC"]; + result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest)); + //spendable from bitcoin core wallet! + Assert.False(result.IsWatchOnly); var tx = s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(address, Network.RegTest), Money.Coins(3.0m)); s.Server.ExplorerNode.Generate(1); @@ -601,15 +604,15 @@ namespace BTCPayServer.Tests // We setup the fingerprint and the account key path s.Driver.FindElement(By.Id("WalletSettings")).ForceClick(); -// s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).SendKeys("8bafd160"); -// s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).SendKeys("m/49'/0'/0'" + Keys.Enter); + // s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).SendKeys("8bafd160"); + // s.Driver.FindElement(By.Id("AccountKeys_0__AccountKeyPath")).SendKeys("m/49'/0'/0'" + Keys.Enter); // Check the tx sent earlier arrived s.Driver.FindElement(By.Id("WalletTransactions")).ForceClick(); var walletTransactionLink = s.Driver.Url; Assert.Contains(tx.ToString(), s.Driver.PageSource); - + void SignWith(Mnemonic signingSource) { // Send to bob @@ -629,18 +632,18 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick(); Assert.Equal(walletTransactionLink, s.Driver.Url); } - + SignWith(mnemonic); - + s.Driver.FindElement(By.Id("Wallets")).Click(); s.Driver.FindElement(By.LinkText("Manage")).Click(); s.Driver.FindElement(By.Id("WalletSend")).Click(); - + var jack = new Key().PubKey.Hash.GetAddress(Network.RegTest); SetTransactionOutput(s, 0, jack, 0.01m); s.Driver.ScrollTo(By.Id("SendMenu")); s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); - + s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); Assert.Contains(jack.ToString(), s.Driver.PageSource); Assert.Contains("0.01000000", s.Driver.PageSource); @@ -651,7 +654,7 @@ namespace BTCPayServer.Tests Assert.EndsWith("psbt/ready", s.Driver.Url); s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick(); Assert.Equal(walletTransactionLink, s.Driver.Url); - + var bip21 = invoice.EntityToDTO().CryptoInfo.First().PaymentUrls.BIP21; //let's make bip21 more interesting bip21 += "&label=Solid Snake&message=Snake? Snake? SNAAAAKE!"; @@ -665,10 +668,10 @@ namespace BTCPayServer.Tests s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Info); Assert.Equal(parsedBip21.Amount.ToString(false), s.Driver.FindElement(By.Id($"Outputs_0__Amount")).GetAttribute("value")); Assert.Equal(parsedBip21.Address.ToString(), s.Driver.FindElement(By.Id($"Outputs_0__DestinationAddress")).GetAttribute("value")); - - + + s.GoToWallet(new WalletId(storeId.storeId, "BTC"), WalletsNavPages.Settings); - + s.Driver.FindElement(By.Id("SettingsMenu")).ForceClick(); s.Driver.FindElement(By.CssSelector("button[value=view-seed]")).Click(); s.AssertHappyMessage(); @@ -687,5 +690,116 @@ namespace BTCPayServer.Tests checkboxElement.Click(); } } + + [Fact] + [Trait("Selenium", "Selenium")] + public async Task CanUsePullPaymentsViaUI() + { + using (var s = SeleniumTester.Create()) + { + await s.StartAsync(); + s.RegisterNewUser(true); + var receiver = s.CreateNewStore(); + var receiverSeed = s.GenerateWallet("BTC", "", true, true, ScriptPubKeyType.Segwit); + await s.Server.ExplorerNode.GenerateAsync(1); + await s.FundStoreWallet(denomination: 50.0m); + s.GoToWallet(navPages: WalletsNavPages.PullPayments); + s.Driver.FindElement(By.Id("NewPullPayment")).Click(); + s.Driver.FindElement(By.Id("Name")).SendKeys("PP1"); + s.Driver.FindElement(By.Id("Amount")).Clear(); + s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0" + Keys.Enter); + s.Driver.FindElement(By.LinkText("View")).Click(); + + Thread.Sleep(1000); + s.GoToWallet(navPages: WalletsNavPages.PullPayments); + s.Driver.FindElement(By.Id("NewPullPayment")).Click(); + s.Driver.FindElement(By.Id("Name")).SendKeys("PP2"); + s.Driver.FindElement(By.Id("Amount")).Clear(); + s.Driver.FindElement(By.Id("Amount")).SendKeys("100.0" + Keys.Enter); + // This should select the first View, ie, the last one PP2 + s.Driver.FindElement(By.LinkText("View")).Click(); + + Thread.Sleep(1000); + var address = await s.Server.ExplorerNode.GetNewAddressAsync(); + s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString()); + s.Driver.FindElement(By.Id("ClaimedAmount")).Clear(); + s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("15" + Keys.Enter); + s.AssertHappyMessage(); + + // We should not be able to use an address already used + s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString()); + s.Driver.FindElement(By.Id("ClaimedAmount")).Clear(); + s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter); + s.AssertHappyMessage(StatusMessageModel.StatusSeverity.Error); + + address = await s.Server.ExplorerNode.GetNewAddressAsync(); + s.Driver.FindElement(By.Id("Destination")).Clear(); + s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString()); + s.Driver.FindElement(By.Id("ClaimedAmount")).Clear(); + s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter); + s.AssertHappyMessage(); + Assert.Contains("AwaitingPayment", s.Driver.PageSource); + + var viewPullPaymentUrl = s.Driver.Url; + // This one should have nothing + s.GoToWallet(navPages: WalletsNavPages.PullPayments); + var payouts = s.Driver.FindElements(By.ClassName("pp-payout")); + Assert.Equal(2, payouts.Count); + payouts[1].Click(); + Assert.Contains("No payout waiting for approval", s.Driver.PageSource); + + // PP2 should have payouts + s.GoToWallet(navPages: WalletsNavPages.PullPayments); + payouts = s.Driver.FindElements(By.ClassName("pp-payout")); + payouts[0].Click(); + Assert.DoesNotContain("No payout waiting for approval", s.Driver.PageSource); + s.Driver.FindElement(By.Id("selectAllCheckbox")).Click(); + s.Driver.FindElement(By.Id("payCommand")).Click(); + s.Driver.ScrollTo(By.Id("SendMenu")); + s.Driver.FindElement(By.Id("SendMenu")).ForceClick(); + s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click(); + s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).ForceClick(); + s.AssertHappyMessage(); + + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + Assert.Contains("badge transactionLabel", s.Driver.PageSource); + }); + Assert.Equal("Payout", s.Driver.FindElement(By.ClassName("transactionLabel")).Text); + + s.GoToWallet(navPages: WalletsNavPages.Payouts); + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + Assert.Contains("No payout waiting for approval", s.Driver.PageSource); + }); + var txs = s.Driver.FindElements(By.ClassName("transaction-link")); + Assert.Equal(2, txs.Count); + + s.Driver.Navigate().GoToUrl(viewPullPaymentUrl); + txs = s.Driver.FindElements(By.ClassName("transaction-link")); + Assert.Equal(2, txs.Count); + Assert.Contains("InProgress", s.Driver.PageSource); + + + await s.Server.ExplorerNode.GenerateAsync(1); + + TestUtils.Eventually(() => + { + s.Driver.Navigate().Refresh(); + Assert.Contains("Completed", s.Driver.PageSource); + }); + await s.Server.ExplorerNode.GenerateAsync(10); + var pullPaymentId = viewPullPaymentUrl.Split('/').Last(); + + await TestUtils.EventuallyAsync(async () => + { + using var ctx = s.Server.PayTester.GetService().CreateContext(); + var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync(); + Assert.True(payoutsData.All(p => p.State == Data.PayoutState.Completed)); + }); + } + } } } diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index f8dc9f3d3..d4ed6abce 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -74,7 +74,7 @@ namespace BTCPayServer.Tests public UnitTest1(ITestOutputHelper helper) { - Logs.Tester = new XUnitLog(helper) {Name = "Tests"}; + Logs.Tester = new XUnitLog(helper) { Name = "Tests" }; Logs.LogProvider = new XUnitLogProvider(helper); } @@ -169,7 +169,7 @@ namespace BTCPayServer.Tests { var details = ex is EqualException ? (ex as EqualException).Actual : ex.Message; Logs.Tester.LogInformation($"FAILED: {url} ({file}) {details}"); - + throw; } } @@ -214,19 +214,21 @@ namespace BTCPayServer.Tests InvoiceEntity invoiceEntity = new InvoiceEntity(); invoiceEntity.Networks = networkProvider; invoiceEntity.Payments = new System.Collections.Generic.List(); - invoiceEntity.ProductInformation = new ProductInformation() {Price = 100}; + invoiceEntity.ProductInformation = new ProductInformation() { Price = 100 }; PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary(); - paymentMethods.Add(new PaymentMethod() {Network = networkBTC, CryptoCode = "BTC", Rate = 10513.44m,} + paymentMethods.Add(new PaymentMethod() { Network = networkBTC, CryptoCode = "BTC", Rate = 10513.44m, } .SetPaymentMethodDetails( new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() { - NextNetworkFee = Money.Coins(0.00000100m), DepositAddress = dummy + NextNetworkFee = Money.Coins(0.00000100m), + DepositAddress = dummy })); - paymentMethods.Add(new PaymentMethod() {Network = networkLTC, CryptoCode = "LTC", Rate = 216.79m} + paymentMethods.Add(new PaymentMethod() { Network = networkLTC, CryptoCode = "LTC", Rate = 216.79m } .SetPaymentMethodDetails( new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod() { - NextNetworkFee = Money.Coins(0.00010000m), DepositAddress = dummy + NextNetworkFee = Money.Coins(0.00010000m), + DepositAddress = dummy })); invoiceEntity.SetPaymentMethods(paymentMethods); @@ -235,29 +237,30 @@ namespace BTCPayServer.Tests invoiceEntity.Payments.Add( new PaymentEntity() - { - Accounted = true, - CryptoCode = "BTC", - NetworkFee = 0.00000100m, - Network = networkProvider.GetNetwork("BTC"), - } + { + Accounted = true, + CryptoCode = "BTC", + NetworkFee = 0.00000100m, + Network = networkProvider.GetNetwork("BTC"), + } .SetCryptoPaymentData(new BitcoinLikePaymentData() { Network = networkProvider.GetNetwork("BTC"), - Output = new TxOut() {Value = Money.Coins(0.00151263m)} + Output = new TxOut() { Value = Money.Coins(0.00151263m) } })); accounting = btc.Calculate(); invoiceEntity.Payments.Add( new PaymentEntity() - { - Accounted = true, - CryptoCode = "BTC", - NetworkFee = 0.00000100m, - Network = networkProvider.GetNetwork("BTC") - } + { + Accounted = true, + CryptoCode = "BTC", + NetworkFee = 0.00000100m, + Network = networkProvider.GetNetwork("BTC") + } .SetCryptoPaymentData(new BitcoinLikePaymentData() { - Network = networkProvider.GetNetwork("BTC"), Output = new TxOut() {Value = accounting.Due} + Network = networkProvider.GetNetwork("BTC"), + Output = new TxOut() { Value = accounting.Due } })); accounting = btc.Calculate(); Assert.Equal(Money.Zero, accounting.Due); @@ -330,9 +333,11 @@ namespace BTCPayServer.Tests entity.Payments = new System.Collections.Generic.List(); entity.SetPaymentMethod(new PaymentMethod() { - CryptoCode = "BTC", Rate = 5000, NextNetworkFee = Money.Coins(0.1m) + CryptoCode = "BTC", + Rate = 5000, + NextNetworkFee = Money.Coins(0.1m) }); - entity.ProductInformation = new ProductInformation() {Price = 5000}; + entity.ProductInformation = new ProductInformation() { Price = 5000 }; var paymentMethod = entity.GetPaymentMethods().TryGet("BTC", PaymentTypes.BTCLike); var accounting = paymentMethod.Calculate(); @@ -341,7 +346,9 @@ namespace BTCPayServer.Tests entity.Payments.Add(new PaymentEntity() { - Output = new TxOut(Money.Coins(0.5m), new Key()), Accounted = true, NetworkFee = 0.1m + Output = new TxOut(Money.Coins(0.5m), new Key()), + Accounted = true, + NetworkFee = 0.1m }); accounting = paymentMethod.Calculate(); @@ -351,7 +358,9 @@ namespace BTCPayServer.Tests entity.Payments.Add(new PaymentEntity() { - Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true, NetworkFee = 0.1m + Output = new TxOut(Money.Coins(0.2m), new Key()), + Accounted = true, + NetworkFee = 0.1m }); accounting = paymentMethod.Calculate(); @@ -360,7 +369,9 @@ namespace BTCPayServer.Tests entity.Payments.Add(new PaymentEntity() { - Output = new TxOut(Money.Coins(0.6m), new Key()), Accounted = true, NetworkFee = 0.1m + Output = new TxOut(Money.Coins(0.6m), new Key()), + Accounted = true, + NetworkFee = 0.1m }); accounting = paymentMethod.Calculate(); @@ -368,7 +379,7 @@ namespace BTCPayServer.Tests Assert.Equal(Money.Coins(1.3m), accounting.TotalDue); entity.Payments.Add( - new PaymentEntity() {Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true}); + new PaymentEntity() { Output = new TxOut(Money.Coins(0.2m), new Key()), Accounted = true }); accounting = paymentMethod.Calculate(); Assert.Equal(Money.Zero, accounting.Due); @@ -376,12 +387,12 @@ namespace BTCPayServer.Tests entity = new InvoiceEntity(); entity.Networks = networkProvider; - entity.ProductInformation = new ProductInformation() {Price = 5000}; + entity.ProductInformation = new ProductInformation() { Price = 5000 }; PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary(); paymentMethods.Add( - new PaymentMethod() {CryptoCode = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m)}); + new PaymentMethod() { CryptoCode = "BTC", Rate = 1000, NextNetworkFee = Money.Coins(0.1m) }); paymentMethods.Add( - new PaymentMethod() {CryptoCode = "LTC", Rate = 500, NextNetworkFee = Money.Coins(0.01m)}); + new PaymentMethod() { CryptoCode = "LTC", Rate = 500, NextNetworkFee = Money.Coins(0.01m) }); entity.SetPaymentMethods(paymentMethods); entity.Payments = new List(); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike)); @@ -443,7 +454,10 @@ namespace BTCPayServer.Tests var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2); entity.Payments.Add(new PaymentEntity() { - CryptoCode = "BTC", Output = new TxOut(remaining, new Key()), Accounted = true, NetworkFee = 0.1m + CryptoCode = "BTC", + Output = new TxOut(remaining, new Key()), + Accounted = true, + NetworkFee = 0.1m }); paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike)); @@ -518,9 +532,11 @@ namespace BTCPayServer.Tests entity.Payments = new List(); entity.SetPaymentMethod(new PaymentMethod() { - CryptoCode = "BTC", Rate = 5000, NextNetworkFee = Money.Coins(0.1m) + CryptoCode = "BTC", + Rate = 5000, + NextNetworkFee = Money.Coins(0.1m) }); - entity.ProductInformation = new ProductInformation() {Price = 5000}; + entity.ProductInformation = new ProductInformation() { Price = 5000 }; entity.PaymentTolerance = 0; @@ -560,7 +576,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice( new Invoice() { - Buyer = new Buyer() {email = "test@fwf.com"}, + Buyer = new Buyer() { email = "test@fwf.com" }, Price = 5000.0m, Currency = "USD", PosData = "posData", @@ -596,7 +612,7 @@ namespace BTCPayServer.Tests var invoice = user.BitPay.CreateInvoice( new Invoice() { - Buyer = new Buyer() {email = "test@fwf.com"}, + Buyer = new Buyer() { email = "test@fwf.com" }, Price = 5000.0m, Currency = "USD", PosData = "posData", @@ -622,6 +638,45 @@ namespace BTCPayServer.Tests } } + + [Fact] + [Trait("Fast", "Fast")] + public void CanCalculatePeriod() + { + Data.PullPaymentData data = new Data.PullPaymentData(); + data.StartDate = Date(0); + data.EndDate = null; + var period = data.GetPeriod(Date(1)).Value; + Assert.Equal(Date(0), period.Start); + Assert.Null(period.End); + data.EndDate = Date(7); + period = data.GetPeriod(Date(1)).Value; + Assert.Equal(Date(0), period.Start); + Assert.Equal(Date(7), period.End); + data.Period = (long)TimeSpan.FromDays(2).TotalSeconds; + period = data.GetPeriod(Date(1)).Value; + Assert.Equal(Date(0), period.Start); + Assert.Equal(Date(2), period.End); + period = data.GetPeriod(Date(2)).Value; + Assert.Equal(Date(2), period.Start); + Assert.Equal(Date(4), period.End); + period = data.GetPeriod(Date(6)).Value; + Assert.Equal(Date(6), period.Start); + Assert.Equal(Date(7), period.End); + Assert.Null(data.GetPeriod(Date(7))); + Assert.Null(data.GetPeriod(Date(8))); + data.EndDate = null; + period = data.GetPeriod(Date(6)).Value; + Assert.Equal(Date(6), period.Start); + Assert.Equal(Date(8), period.End); + Assert.Null(data.GetPeriod(Date(-1))); + } + + private DateTimeOffset Date(int days) + { + return new DateTimeOffset(1970, 1, 1, 0, 0, 0, TimeSpan.Zero) + TimeSpan.FromDays(days); + } + [Fact] [Trait("Fast", "Fast")] public void RoundupCurrenciesCorrectly() @@ -644,7 +699,7 @@ namespace BTCPayServer.Tests public async Task CanEnumerateTorServices() { var tor = new TorServices(new BTCPayNetworkProvider(NetworkType.Regtest), - new BTCPayServerOptions() {TorrcFile = TestUtils.GetTestDataFullPath("Tor/torrc")}); + new BTCPayServerOptions() { TorrcFile = TestUtils.GetTestDataFullPath("Tor/torrc") }); await tor.Refresh(); Assert.Single(tor.Services.Where(t => t.ServiceType == TorServiceType.BTCPayServer)); @@ -686,7 +741,7 @@ namespace BTCPayServer.Tests // Make sure old connection string format does not work Assert.IsType(storeController.AddLightningNode(user.StoreId, - new LightningNodeViewModel() {ConnectionString = tester.MerchantCharge.Client.Uri.AbsoluteUri}, + new LightningNodeViewModel() { ConnectionString = tester.MerchantCharge.Client.Uri.AbsoluteUri }, "save", "BTC").GetAwaiter().GetResult()); var storeVm = @@ -762,7 +817,7 @@ namespace BTCPayServer.Tests merchant.RegisterLightningNode("BTC", LightningConnectionType.LndREST); var merchantClient = await merchant.CreateClient($"btcpay.store.canuselightningnode:{merchant.StoreId}"); var merchantInvoice = await merchantClient.CreateLightningInvoice(merchant.StoreId, "BTC", new CreateLightningInvoiceRequest(new LightMoney(1_000), "hey", TimeSpan.FromSeconds(60))); - + // The default client is using charge, so we should not be able to query channels var client = await user.CreateClient("btcpay.server.canuseinternallightningnode"); var err = await Assert.ThrowsAsync(async () => await client.GetLightningNodeChannels("BTC")); @@ -847,7 +902,7 @@ namespace BTCPayServer.Tests var localInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id); Assert.Equal("complete", localInvoice.Status); // C-Lightning may overpay for privacy - Assert.Contains(localInvoice.ExceptionStatus.ToString(), new[] {"False", "paidOver"}); + Assert.Contains(localInvoice.ExceptionStatus.ToString(), new[] { "False", "paidOver" }); }); } @@ -866,7 +921,9 @@ namespace BTCPayServer.Tests var token = (RedirectToActionResult)await controller.CreateToken2( new Models.StoreViewModels.CreateTokenViewModel() { - Label = "bla", PublicKey = null, StoreId = acc.StoreId + Label = "bla", + PublicKey = null, + StoreId = acc.StoreId }); var pairingCode = (string)token.RouteValues["pairingCode"]; @@ -980,7 +1037,7 @@ namespace BTCPayServer.Tests var fetcher = new RateFetcher(factory); Assert.True(RateRules.TryParse("X_X=kraken(X_BTC) * kraken(BTC_X)", out var rule)); - foreach (var pair in new[] {"DOGE_USD", "DOGE_CAD", "DASH_CAD", "DASH_USD", "DASH_EUR"}) + foreach (var pair in new[] { "DOGE_USD", "DOGE_CAD", "DASH_CAD", "DASH_USD", "DASH_EUR" }) { var result = fetcher.FetchRate(CurrencyPair.Parse(pair), rule, default).GetAwaiter().GetResult(); Assert.NotNull(result.BidAsk); @@ -1308,7 +1365,7 @@ namespace BTCPayServer.Tests user.RegisterDerivationScheme("BTC"); user.SetNetworkFeeMode(NetworkFeeMode.Always); var invoice = - user.BitPay.CreateInvoice(new Invoice() {Price = 5000.0m, Currency = "USD"}, Facade.Merchant); + user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0m, Currency = "USD" }, Facade.Merchant); var payment1 = invoice.BtcDue + Money.Coins(0.0001m); var payment2 = invoice.BtcDue; @@ -1370,7 +1427,7 @@ namespace BTCPayServer.Tests Logs.Tester.LogInformation( $"Let's test out rbf payments where the payment gets sent elsehwere instead"); var invoice2 = - user.BitPay.CreateInvoice(new Invoice() {Price = 0.01m, Currency = "BTC"}, Facade.Merchant); + user.BitPay.CreateInvoice(new Invoice() { Price = 0.01m, Currency = "BTC" }, Facade.Merchant); var invoice2Address = BitcoinAddress.Create(invoice2.BitcoinAddress, user.SupportedNetwork.NBitcoinNetwork); @@ -1408,7 +1465,7 @@ namespace BTCPayServer.Tests Logs.Tester.LogInformation( $"Let's test if we can RBF a normal payment without adding fees to the invoice"); user.SetNetworkFeeMode(NetworkFeeMode.MultiplePaymentsOnly); - invoice = user.BitPay.CreateInvoice(new Invoice() {Price = 5000.0m, Currency = "USD"}, Facade.Merchant); + invoice = user.BitPay.CreateInvoice(new Invoice() { Price = 5000.0m, Currency = "USD" }, Facade.Merchant); payment1 = invoice.BtcDue; tx1 = new uint256(tester.ExplorerNode.SendCommand("sendtoaddress", new object[] { @@ -1502,21 +1559,21 @@ namespace BTCPayServer.Tests using (var tester = ServerTester.Create()) { await tester.StartAsync(); - foreach (var req in new[] {"invoices/", "invoices", "rates", "tokens"}.Select(async path => - { - using (HttpClient client = new HttpClient()) - { - HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Options, - tester.PayTester.ServerUri.AbsoluteUri + path); - message.Headers.Add("Access-Control-Request-Headers", "test"); - var response = await client.SendAsync(message); - response.EnsureSuccessStatusCode(); - Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var val)); - Assert.Equal("*", val.FirstOrDefault()); - Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Headers", out val)); - Assert.Equal("test", val.FirstOrDefault()); - } - }).ToList()) + foreach (var req in new[] { "invoices/", "invoices", "rates", "tokens" }.Select(async path => + { + using (HttpClient client = new HttpClient()) + { + HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Options, + tester.PayTester.ServerUri.AbsoluteUri + path); + message.Headers.Add("Access-Control-Request-Headers", "test"); + var response = await client.SendAsync(message); + response.EnsureSuccessStatusCode(); + Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var val)); + Assert.Equal("*", val.FirstOrDefault()); + Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Headers", out val)); + Assert.Equal("test", val.FirstOrDefault()); + } + }).ToList()) { await req; } @@ -1547,7 +1604,7 @@ namespace BTCPayServer.Tests // Test request pairing code client side var storeController = user.GetController(); storeController - .CreateToken(user.StoreId, new CreateTokenViewModel() {Label = "test2", StoreId = user.StoreId}) + .CreateToken(user.StoreId, new CreateTokenViewModel() { Label = "test2", StoreId = user.StoreId }) .GetAwaiter().GetResult(); Assert.NotNull(storeController.GeneratedPairingCode); @@ -1586,7 +1643,7 @@ namespace BTCPayServer.Tests tester.PayTester.ServerUri.AbsoluteUri + "invoices"); message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey))); - var invoice = new Invoice() {Price = 5000.0m, Currency = "USD"}; + var invoice = new Invoice() { Price = 5000.0m, Currency = "USD" }; message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8, "application/json"); var result = client.SendAsync(message).GetAwaiter().GetResult(); @@ -2028,7 +2085,7 @@ namespace BTCPayServer.Tests Assert.Empty(invoice.CryptoInfo.Where(c => c.CryptoCode == "LTC")); } } - + [Fact] [Trait("Fast", "Fast")] public void HasCurrencyDataForNetworks() @@ -2616,7 +2673,7 @@ noninventoryitem: //test payment methods option - + vmpos = Assert.IsType(Assert .IsType(apps.UpdatePointOfSale(appId).Result).Model); vmpos.Title = "hello"; @@ -2646,7 +2703,7 @@ normal: Assert.Equal(2, normalInvoice.CryptoInfo.Length); Assert.Contains( normalInvoice.CryptoInfo, - s => PaymentTypes.BTCLike.ToString() == s.PaymentType && new[] {"BTC", "LTC"}.Contains( + s => PaymentTypes.BTCLike.ToString() == s.PaymentType && new[] { "BTC", "LTC" }.Contains( s.CryptoCode)); } } @@ -2688,7 +2745,7 @@ normal: return Task.CompletedTask; }, TimeSpan.FromSeconds(7.0)); - Assert.True(new[] {false, false, false, false}.SequenceEqual(jobs)); + Assert.True(new[] { false, false, false, false }.SequenceEqual(jobs)); CancellationTokenSource cts = new CancellationTokenSource(); var processing = client.ProcessJobs(cts.Token); @@ -2697,20 +2754,20 @@ normal: var waitJobsFinish = client.WaitAllRunning(default); await mockDelay.Advance(TimeSpan.FromSeconds(2.0)); - Assert.True(new[] {false, true, false, false}.SequenceEqual(jobs)); + Assert.True(new[] { false, true, false, false }.SequenceEqual(jobs)); await mockDelay.Advance(TimeSpan.FromSeconds(3.0)); - Assert.True(new[] {true, true, false, false}.SequenceEqual(jobs)); + Assert.True(new[] { true, true, false, false }.SequenceEqual(jobs)); await mockDelay.Advance(TimeSpan.FromSeconds(1.0)); - Assert.True(new[] {true, true, true, false}.SequenceEqual(jobs)); + Assert.True(new[] { true, true, true, false }.SequenceEqual(jobs)); Assert.Equal(1, client.GetExecutingCount()); Assert.False(waitJobsFinish.Wait(1)); Assert.False(waitJobsFinish.IsCompletedSuccessfully); await mockDelay.Advance(TimeSpan.FromSeconds(1.0)); - Assert.True(new[] {true, true, true, true}.SequenceEqual(jobs)); + Assert.True(new[] { true, true, true, true }.SequenceEqual(jobs)); await waitJobsFinish; Assert.True(waitJobsFinish.IsCompletedSuccessfully); @@ -2809,7 +2866,7 @@ normal: var tasks = new List(); foreach (var valueTuple in testCases) { - tasks.Add(user.BitPay.CreateInvoiceAsync(new Invoice(1, "BTC") {PosData = valueTuple.input}) + tasks.Add(user.BitPay.CreateInvoiceAsync(new Invoice(1, "BTC") { PosData = valueTuple.input }) .ContinueWith(async task => { var result = await controller.Invoice(task.Result.Id); @@ -3095,28 +3152,28 @@ normal: ExpirationTime = expiration }, Facade.Merchant); Assert.Equal(expiration.ToUnixTimeSeconds(), invoice1.ExpirationTime.ToUnixTimeSeconds()); - var invoice2 = user.BitPay.CreateInvoice(new Invoice() {Price = 0.000000019m, Currency = "USD"}, + var invoice2 = user.BitPay.CreateInvoice(new Invoice() { Price = 0.000000019m, Currency = "USD" }, Facade.Merchant); Assert.Equal(0.000000012m, invoice1.Price); Assert.Equal(0.000000019m, invoice2.Price); // Should round up to 1 because 0.000000019 is unsignificant var invoice3 = user.BitPay.CreateInvoice( - new Invoice() {Price = 1.000000019m, Currency = "USD", FullNotifications = true}, Facade.Merchant); + new Invoice() { Price = 1.000000019m, Currency = "USD", FullNotifications = true }, Facade.Merchant); Assert.Equal(1m, invoice3.Price); // Should not round up at 8 digit because the 9th is insignificant var invoice4 = user.BitPay.CreateInvoice( - new Invoice() {Price = 1.000000019m, Currency = "BTC", FullNotifications = true}, Facade.Merchant); + new Invoice() { Price = 1.000000019m, Currency = "BTC", FullNotifications = true }, Facade.Merchant); Assert.Equal(1.00000002m, invoice4.Price); // But not if the 9th is insignificant invoice4 = user.BitPay.CreateInvoice( - new Invoice() {Price = 0.000000019m, Currency = "BTC", FullNotifications = true}, Facade.Merchant); + new Invoice() { Price = 0.000000019m, Currency = "BTC", FullNotifications = true }, Facade.Merchant); Assert.Equal(0.000000019m, invoice4.Price); var invoice = user.BitPay.CreateInvoice( - new Invoice() {Price = -0.1m, Currency = "BTC", FullNotifications = true}, Facade.Merchant); + new Invoice() { Price = -0.1m, Currency = "BTC", FullNotifications = true }, Facade.Merchant); Assert.Equal(0.0m, invoice.Price); } } @@ -3146,17 +3203,19 @@ normal: var ctx = tester.PayTester.GetService().CreateContext(); Assert.Equal(0, invoice.CryptoInfo[0].TxCount); Assert.True(invoice.MinerFees.ContainsKey("BTC")); - Assert.Contains(invoice.MinerFees["BTC"].SatoshiPerBytes, new[] {100.0m, 20.0m}); + Assert.Contains(invoice.MinerFees["BTC"].SatoshiPerBytes, new[] { 100.0m, 20.0m }); TestUtils.Eventually(() => { var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery() { - StoreId = new[] {user.StoreId}, TextSearch = invoice.OrderId + StoreId = new[] { user.StoreId }, + TextSearch = invoice.OrderId }).GetAwaiter().GetResult(); Assert.Single(textSearchResult); textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery() { - StoreId = new[] {user.StoreId}, TextSearch = invoice.Id + StoreId = new[] { user.StoreId }, + TextSearch = invoice.Id }).GetAwaiter().GetResult(); Assert.Single(textSearchResult); @@ -3274,7 +3333,8 @@ normal: var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery() { - StoreId = new[] {user.StoreId}, TextSearch = txId.ToString() + StoreId = new[] { user.StoreId }, + TextSearch = txId.ToString() }).GetAwaiter().GetResult(); Assert.Single(textSearchResult); }); @@ -3367,7 +3427,8 @@ normal: Assert.Single(state.Rates, r => r.Pair == new CurrencyPair("BTC", "EUR")); var provider2 = new BackgroundFetcherRateProvider(provider.Inner) { - RefreshRate = provider.RefreshRate, ValidatyTime = provider.ValidatyTime + RefreshRate = provider.RefreshRate, + ValidatyTime = provider.ValidatyTime }; using (var cts = new CancellationTokenSource()) { @@ -3508,9 +3569,9 @@ normal: Assert.Null(expanded.Macaroons.InvoiceMacaroon); Assert.Null(expanded.Macaroons.ReadonlyMacaroon); - File.WriteAllBytes($"{macaroonDirectory}/admin.macaroon", new byte[] {0xaa}); - File.WriteAllBytes($"{macaroonDirectory}/invoice.macaroon", new byte[] {0xab}); - File.WriteAllBytes($"{macaroonDirectory}/readonly.macaroon", new byte[] {0xac}); + File.WriteAllBytes($"{macaroonDirectory}/admin.macaroon", new byte[] { 0xaa }); + File.WriteAllBytes($"{macaroonDirectory}/invoice.macaroon", new byte[] { 0xab }); + File.WriteAllBytes($"{macaroonDirectory}/readonly.macaroon", new byte[] { 0xac }); expanded = await connStr.Expand(unusedUri, ExternalServiceTypes.LNDRest, NetworkType.Mainnet); Assert.NotNull(expanded.Macaroons.AdminMacaroon); Assert.NotNull(expanded.Macaroons.InvoiceMacaroon); @@ -3697,7 +3758,8 @@ normal: Assert.Equal(nameof(HomeController.Index), Assert.IsType(await accountController.Login(new LoginViewModel() { - Email = user.RegisterDetails.Email, Password = user.RegisterDetails.Password + Email = user.RegisterDetails.Email, + Password = user.RegisterDetails.Password })).ActionName); var manageController = user.GetController(); @@ -3744,7 +3806,8 @@ normal: //check if we are showing the u2f login screen now var secondLoginResult = Assert.IsType(await accountController.Login(new LoginViewModel() { - Email = user.RegisterDetails.Email, Password = user.RegisterDetails.Password + Email = user.RegisterDetails.Email, + Password = user.RegisterDetails.Password })); Assert.Equal("SecondaryLogin", secondLoginResult.ViewName); diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 576960f07..7a0cc3cb5 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -186,6 +186,12 @@ $(IncludeRazorContentInPack) + + $(IncludeRazorContentInPack) + + + $(IncludeRazorContentInPack) + $(IncludeRazorContentInPack) @@ -201,6 +207,9 @@ $(IncludeRazorContentInPack) + + $(IncludeRazorContentInPack) + $(IncludeRazorContentInPack) @@ -219,5 +228,5 @@ <_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" /> - + diff --git a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs new file mode 100644 index 000000000..cf1ce82fe --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using BTCPayServer; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.Lightning; +using BTCPayServer.ModelBinders; +using BTCPayServer.Payments; +using BTCPayServer.Security; +using BTCPayServer.Services; +using BTCPayServer.Services.Invoices; +using BTCPayServer.Services.Rates; +using ExchangeSharp; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using NBitpayClient; +using NUglify.Helpers; +using Org.BouncyCastle.Ocsp; + +namespace BTCPayServer.Controllers.GreenField +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldPullPaymentController : ControllerBase + { + private readonly PullPaymentHostedService _pullPaymentService; + private readonly LinkGenerator _linkGenerator; + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly CurrencyNameTable _currencyNameTable; + private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings; + private readonly BTCPayNetworkProvider _networkProvider; + + public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService, + LinkGenerator linkGenerator, + ApplicationDbContextFactory dbContextFactory, + CurrencyNameTable currencyNameTable, + Services.BTCPayNetworkJsonSerializerSettings serializerSettings, + BTCPayNetworkProvider networkProvider) + { + _pullPaymentService = pullPaymentService; + _linkGenerator = linkGenerator; + _dbContextFactory = dbContextFactory; + _currencyNameTable = currencyNameTable; + _serializerSettings = serializerSettings; + _networkProvider = networkProvider; + } + + [HttpGet("~/api/v1/stores/{storeId}/pull-payments")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task GetPullPayments(string storeId, bool includeArchived = false) + { + using var ctx = _dbContextFactory.CreateContext(); + var pps = await ctx.PullPayments + .Where(p => p.StoreId == storeId && (includeArchived || !p.Archived)) + .OrderByDescending(p => p.StartDate) + .ToListAsync(); + return Ok(pps.Select(CreatePullPaymentData).ToArray()); + } + + [HttpPost("~/api/v1/stores/{storeId}/pull-payments")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task CreatePullPayment(string storeId, CreatePullPaymentRequest request) + { + if (request is null) + { + ModelState.AddModelError(string.Empty, "Missing body"); + return this.CreateValidationError(ModelState); + } + if (request.Amount <= 0.0m) + { + ModelState.AddModelError(nameof(request.Amount), "The amount should more than 0."); + } + if (request.Name is String name && name.Length > 50) + { + ModelState.AddModelError(nameof(request.Name), "The name should be maximum 50 characters."); + } + BTCPayNetwork network = null; + if (request.Currency is String currency) + { + network = _networkProvider.GetNetwork(currency); + if (network is null) + { + ModelState.AddModelError(nameof(request.Currency), $"Only crypto currencies are supported this field. (More will be supported soon)"); + } + } + else + { + ModelState.AddModelError(nameof(request.Currency), "This field is required"); + } + if (request.ExpiresAt is DateTimeOffset expires && request.StartsAt is DateTimeOffset start && expires < start) + { + ModelState.AddModelError(nameof(request.ExpiresAt), $"expiresAt should be higher than startAt"); + } + if (request.Period <= TimeSpan.Zero) + { + ModelState.AddModelError(nameof(request.Period), $"The period should be positive"); + } + if (request.PaymentMethods is string[] paymentMethods) + { + if (paymentMethods.Length != 1 && paymentMethods[0] != request.Currency) + { + ModelState.AddModelError(nameof(request.PaymentMethods), "We expect this array to only contains the same element as the `currency` field. (More will be supported soon)"); + } + } + else + { + ModelState.AddModelError(nameof(request.PaymentMethods), "This field is required"); + } + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + var ppId = await _pullPaymentService.CreatePullPayment(new HostedServices.CreatePullPayment() + { + StartsAt = request.StartsAt, + ExpiresAt = request.ExpiresAt, + Period = request.Period, + Name = request.Name, + Amount = request.Amount, + Currency = network.CryptoCode, + StoreId = storeId, + PaymentMethodIds = new[] { new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike) } + }); + var pp = await _pullPaymentService.GetPullPayment(ppId); + return this.Ok(CreatePullPaymentData(pp)); + } + + private Client.Models.PullPaymentData CreatePullPaymentData(Data.PullPaymentData pp) + { + var ppBlob = pp.GetBlob(); + return new BTCPayServer.Client.Models.PullPaymentData() + { + Id = pp.Id, + StartsAt = pp.StartDate, + ExpiresAt = pp.EndDate, + Amount = ppBlob.Limit, + Name = ppBlob.Name, + Currency = ppBlob.Currency, + Period = ppBlob.Period, + Archived = pp.Archived, + ViewLink = _linkGenerator.GetUriByAction( + nameof(PullPaymentController.ViewPullPayment), + "PullPayment", + new { pullPaymentId = pp.Id }, + Request.Scheme, + Request.Host, + Request.PathBase) + }; + } + + [HttpGet("~/api/v1/pull-payments/{pullPaymentId}")] + [AllowAnonymous] + public async Task GetPullPayment(string pullPaymentId) + { + if (pullPaymentId is null) + return NotFound(); + var pp = await _pullPaymentService.GetPullPayment(pullPaymentId); + if (pp is null) + return NotFound(); + return Ok(CreatePullPaymentData(pp)); + } + + [HttpGet("~/api/v1/pull-payments/{pullPaymentId}/payouts")] + [AllowAnonymous] + public async Task GetPayouts(string pullPaymentId, bool includeCancelled = false) + { + if (pullPaymentId is null) + return NotFound(); + var pp = await _pullPaymentService.GetPullPayment(pullPaymentId); + if (pp is null) + return NotFound(); + using var ctx = _dbContextFactory.CreateContext(); + var payouts = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId) + .Where(p => p.State != Data.PayoutState.Cancelled || includeCancelled) + .ToListAsync(); + var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); + return base.Ok(payouts + .Select(p => ToModel(p, cd)).ToList()); + } + + private Client.Models.PayoutData ToModel(Data.PayoutData p, CurrencyData cd) + { + var blob = p.GetBlob(_serializerSettings); + var model = new Client.Models.PayoutData() + { + Id = p.Id, + PullPaymentId = p.PullPaymentDataId, + Date = p.Date, + Amount = blob.Amount, + PaymentMethodAmount = blob.CryptoAmount, + State = p.State == Data.PayoutState.AwaitingPayment ? Client.Models.PayoutState.AwaitingPayment : + p.State == Data.PayoutState.Cancelled ? Client.Models.PayoutState.Cancelled : + p.State == Data.PayoutState.Completed ? Client.Models.PayoutState.Completed : + p.State == Data.PayoutState.InProgress ? Client.Models.PayoutState.InProgress : + throw new NotSupportedException(), + }; + model.Destination = blob.Destination.ToString(); + model.PaymentMethod = p.PaymentMethodId; + return model; + } + + [HttpPost("~/api/v1/pull-payments/{pullPaymentId}/payouts")] + [AllowAnonymous] + public async Task CreatePayout(string pullPaymentId, CreatePayoutRequest request) + { + if (request is null) + return NotFound(); + + var network = request?.PaymentMethod is string paymentMethod ? + this._networkProvider.GetNetwork(paymentMethod) : null; + if (network is null) + { + ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method"); + return this.CreateValidationError(ModelState); + } + + using var ctx = _dbContextFactory.CreateContext(); + var pp = await ctx.PullPayments.FindAsync(pullPaymentId); + if (pp is null) + return NotFound(); + var ppBlob = pp.GetBlob(); + if (request.Destination is null || !ClaimDestination.TryParse(request.Destination, network, out var destination)) + { + ModelState.AddModelError(nameof(request.Destination), "The destination must be an address or a BIP21 URI"); + return this.CreateValidationError(ModelState); + } + + if (request.Amount is decimal v && (v < ppBlob.MinimumClaim || v == 0.0m)) + { + 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() + { + Destination = destination, + PullPaymentId = pullPaymentId, + Value = request.Amount, + PaymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike) + }); + switch (result.Result) + { + case ClaimRequest.ClaimResult.Ok: + break; + case ClaimRequest.ClaimResult.Duplicate: + return this.CreateAPIError("duplicate-destination", ClaimRequest.GetErrorMessage(result.Result)); + case ClaimRequest.ClaimResult.Expired: + return this.CreateAPIError("expired", ClaimRequest.GetErrorMessage(result.Result)); + case ClaimRequest.ClaimResult.NotStarted: + return this.CreateAPIError("not-started", ClaimRequest.GetErrorMessage(result.Result)); + case ClaimRequest.ClaimResult.Archived: + return this.CreateAPIError("archived", ClaimRequest.GetErrorMessage(result.Result)); + case ClaimRequest.ClaimResult.Overdraft: + return this.CreateAPIError("overdraft", ClaimRequest.GetErrorMessage(result.Result)); + case ClaimRequest.ClaimResult.AmountTooLow: + return this.CreateAPIError("amount-too-low", ClaimRequest.GetErrorMessage(result.Result)); + case ClaimRequest.ClaimResult.PaymentMethodNotSupported: + return this.CreateAPIError("payment-method-not-supported", ClaimRequest.GetErrorMessage(result.Result)); + default: + throw new NotSupportedException("Unsupported ClaimResult"); + } + return Ok(ToModel(result.PayoutData, cd)); + } + + [HttpDelete("~/api/v1/stores/{storeId}/pull-payments/{pullPaymentId}")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task ArchivePullPayment(string storeId, string pullPaymentId) + { + using var ctx = _dbContextFactory.CreateContext(); + var pp = await ctx.PullPayments.FindAsync(pullPaymentId); + if (pp is null || pp.StoreId != storeId) + return NotFound(); + await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(pullPaymentId)); + return Ok(); + } + + [HttpDelete("~/api/v1/stores/{storeId}/payouts/{payoutId}")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task CancelPayout(string storeId, string payoutId) + { + using var ctx = _dbContextFactory.CreateContext(); + var payout = await ctx.Payouts.GetPayout(payoutId, storeId); + if (payout is null) + return NotFound(); + await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId })); + return Ok(); + } + } +} diff --git a/BTCPayServer/Controllers/ManageController.APIKeys.cs b/BTCPayServer/Controllers/ManageController.APIKeys.cs index 7b939e71f..9078d40b5 100644 --- a/BTCPayServer/Controllers/ManageController.APIKeys.cs +++ b/BTCPayServer/Controllers/ManageController.APIKeys.cs @@ -317,6 +317,7 @@ namespace BTCPayServer.Controllers var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings)) .Succeeded; viewModel.PermissionValues ??= Policies.AllPolicies + .Where(p => AddApiKeyViewModel.PermissionValueItem.PermissionDescriptions.ContainsKey(p)) .Select(s => new AddApiKeyViewModel.PermissionValueItem() { Permission = s, diff --git a/BTCPayServer/Controllers/PullPaymentController.cs b/BTCPayServer/Controllers/PullPaymentController.cs new file mode 100644 index 000000000..8d29cc089 --- /dev/null +++ b/BTCPayServer/Controllers/PullPaymentController.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer; +using BTCPayServer.Data; +using BTCPayServer.HostedServices; +using BTCPayServer.ModelBinders; +using BTCPayServer.Models; +using BTCPayServer.Payments; +using BTCPayServer.Services; +using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Internal; + +namespace BTCPayServer.Controllers +{ + public class PullPaymentController : Controller + { + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly BTCPayNetworkProvider _networkProvider; + private readonly CurrencyNameTable _currencyNameTable; + private readonly PullPaymentHostedService _pullPaymentHostedService; + private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings; + + public PullPaymentController(ApplicationDbContextFactory dbContextFactory, + BTCPayNetworkProvider networkProvider, + CurrencyNameTable currencyNameTable, + PullPaymentHostedService pullPaymentHostedService, + BTCPayServer.Services.BTCPayNetworkJsonSerializerSettings serializerSettings) + { + _dbContextFactory = dbContextFactory; + _networkProvider = networkProvider; + _currencyNameTable = currencyNameTable; + _pullPaymentHostedService = pullPaymentHostedService; + _serializerSettings = serializerSettings; + } + [Route("pull-payments/{pullPaymentId}")] + public async Task ViewPullPayment(string pullPaymentId) + { + using var ctx = _dbContextFactory.CreateContext(); + var pp = await ctx.PullPayments.FindAsync(pullPaymentId); + if (pp is null) + return NotFound(); + + var blob = pp.GetBlob(); + var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp) + .OrderByDescending(o => o.Date) + .ToListAsync()) + .Select(o => new + { + Entity = o, + Blob = o.GetBlob(_serializerSettings), + TransactionId = o.GetProofBlob(_serializerSettings)?.TransactionId?.ToString() + }); + var cd = _currencyNameTable.GetCurrencyData(blob.Currency, false); + var totalPaid = payouts.Where(p => p.Entity.State != PayoutState.Cancelled).Select(p => p.Blob.Amount).Sum(); + var amountDue = blob.Limit - totalPaid; + + ViewPullPaymentModel vm = new ViewPullPaymentModel(pp, DateTimeOffset.UtcNow) + { + AmountFormatted = _currencyNameTable.FormatCurrency(blob.Limit, blob.Currency), + AmountCollected = totalPaid, + AmountCollectedFormatted = _currencyNameTable.FormatCurrency(totalPaid, blob.Currency), + AmountDue = amountDue, + ClaimedAmount = amountDue, + AmountDueFormatted = _currencyNameTable.FormatCurrency(amountDue, blob.Currency), + CurrencyData = cd, + LastUpdated = DateTime.Now, + Payouts = payouts + .Select(entity => new ViewPullPaymentModel.PayoutLine() + { + Id = entity.Entity.Id, + Amount = entity.Blob.Amount, + AmountFormatted = _currencyNameTable.FormatCurrency(entity.Blob.Amount, blob.Currency), + Currency = blob.Currency, + Status = entity.Entity.State.ToString(), + Destination = entity.Blob.Destination.Address.ToString(), + Link = GetTransactionLink(_networkProvider.GetNetwork(entity.Entity.GetPaymentMethodId().CryptoCode), entity.TransactionId), + TransactionId = entity.TransactionId + }).ToList() + }; + vm.IsPending &= vm.AmountDue > 0.0m; + return View(nameof(ViewPullPayment), vm); + } + + [Route("pull-payments/{pullPaymentId}/claim")] + [HttpPost] + public async Task ClaimPullPayment(string pullPaymentId, ViewPullPaymentModel vm) + { + using var ctx = _dbContextFactory.CreateContext(); + var pp = await ctx.PullPayments.FindAsync(pullPaymentId); + if (pp is null) + { + ModelState.AddModelError(nameof(pullPaymentId), "This pull payment does not exists"); + } + var ppBlob = pp.GetBlob(); + var network = _networkProvider.GetNetwork(ppBlob.SupportedPaymentMethods.Single().CryptoCode); + IClaimDestination destination = null; + if (network != null && + (!ClaimDestination.TryParse(vm.Destination, network, out destination) || destination is null)) + { + ModelState.AddModelError(nameof(vm.Destination), $"Invalid destination"); + } + + if (!ModelState.IsValid) + { + return await ViewPullPayment(pullPaymentId); + } + + var result = await _pullPaymentHostedService.Claim(new ClaimRequest() + { + Destination = destination, + PullPaymentId = pullPaymentId, + Value = vm.ClaimedAmount, + PaymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike) + }); + + if (result.Result != ClaimRequest.ClaimResult.Ok) + { + if (result.Result == ClaimRequest.ClaimResult.AmountTooLow) + { + ModelState.AddModelError(nameof(vm.ClaimedAmount), ClaimRequest.GetErrorMessage(result.Result)); + } + else + { + ModelState.AddModelError(string.Empty, ClaimRequest.GetErrorMessage(result.Result)); + } + return await ViewPullPayment(pullPaymentId); + } + else + { + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = $"You posted a claim of {_currencyNameTable.DisplayFormatCurrency(vm.ClaimedAmount, ppBlob.Currency)} to {vm.Destination}, this will get fullfilled later.", + Severity = StatusMessageModel.StatusSeverity.Success + }); + } + return RedirectToAction(nameof(ViewPullPayment), new { pullPaymentId = pullPaymentId }); + } + + string GetTransactionLink(BTCPayNetworkBase network, string txId) + { + if (txId is null) + return string.Empty; + return string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId); + } + } +} diff --git a/BTCPayServer/Controllers/WalletsController.PullPayments.cs b/BTCPayServer/Controllers/WalletsController.PullPayments.cs new file mode 100644 index 000000000..43a25cc48 --- /dev/null +++ b/BTCPayServer/Controllers/WalletsController.PullPayments.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.ModelBinders; +using Microsoft.AspNetCore.Mvc; +using BTCPayServer.Data; +using BTCPayServer.Models.WalletViewModels; +using BTCPayServer.Models; +using Microsoft.EntityFrameworkCore; +using System.Net.WebSockets; +using BTCPayServer.Services.Rates; +using System.Dynamic; +using System.Text; +using Amazon.Runtime.Internal.Util; +using BTCPayServer.Views; +using ExchangeSharp; +using System.Globalization; +using Microsoft.AspNetCore.Html; +using BTCPayServer.Rating; +using Microsoft.Extensions.Internal; +using NBitcoin.Payment; +using NBitcoin; +using BTCPayServer.Payments; + +namespace BTCPayServer.Controllers +{ + public partial class WalletsController + { + [HttpGet] + [Route("{walletId}/pull-payments/new")] + public IActionResult NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId) + { + return View(new NewPullPaymentModel() + { + Name = "", + Currency = "BTC" + }); + } + + [HttpPost] + [Route("{walletId}/pull-payments/new")] + public async Task NewPullPayment([ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, NewPullPaymentModel model) + { + model.Name ??= string.Empty; + if (_currencyTable.GetCurrencyData(model.Currency, false) is null) + { + ModelState.AddModelError(nameof(model.Currency), "Invalid currency"); + } + if (model.Amount <= 0.0m) + { + ModelState.AddModelError(nameof(model.Amount), "The amount should be more than zero"); + } + if (model.Name.Length > 50) + { + ModelState.AddModelError(nameof(model.Name), "The name should be maximum 50 characters."); + } + if (!ModelState.IsValid) + return View(model); + await _pullPaymentService.CreatePullPayment(new HostedServices.CreatePullPayment() + { + Name = model.Name, + Amount = model.Amount, + Currency = walletId.CryptoCode, + StoreId = walletId.StoreId, + PaymentMethodIds = new[] { new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike) } + }); + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Pull payment request created", + Severity = StatusMessageModel.StatusSeverity.Success + }); + return RedirectToAction(nameof(PullPayments), new { walletId = walletId.ToString() }); + } + + [HttpGet] + [Route("{walletId}/pull-payments")] + public async Task PullPayments( + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId) + { + using var ctx = this._dbContextFactory.CreateContext(); + var now = DateTimeOffset.UtcNow; + var storeId = walletId.StoreId; + var pps = await ctx.PullPayments.Where(p => p.StoreId == storeId && !p.Archived) + .OrderByDescending(p => p.StartDate) + .Select(o => new + { + PullPayment = o, + Awaiting = o.Payouts + .Where(p => p.State == PayoutState.AwaitingPayment), + Completed = o.Payouts + .Where(p => p.State == PayoutState.Completed || p.State == PayoutState.InProgress) + }) + .ToListAsync(); + var vm = new PullPaymentsModel(); + foreach (var o in pps) + { + var pp = o.PullPayment; + var totalCompleted = o.Completed.Where(o => o.IsInPeriod(pp, now)) + .Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); + var totalAwaiting = o.Awaiting.Where(o => o.IsInPeriod(pp, now)) + .Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum(); + var ppBlob = pp.GetBlob(); + var ni = _currencyTable.GetCurrencyData(ppBlob.Currency, true); + var nfi = _currencyTable.GetNumberFormatInfo(ppBlob.Currency, true); + var period = pp.GetPeriod(now); + vm.PullPayments.Add(new PullPaymentsModel.PullPaymentModel() + { + StartDate = pp.StartDate, + EndDate = pp.EndDate, + Id = pp.Id, + Name = ppBlob.Name, + Progress = new PullPaymentsModel.PullPaymentModel.ProgressModel() + { + CompletedPercent = (int)(totalCompleted / ppBlob.Limit * 100m), + AwaitingPercent = (int)(totalAwaiting / ppBlob.Limit * 100m), + Awaiting = totalAwaiting.RoundToSignificant(ni.Divisibility).ToString("C", nfi), + Completed = totalCompleted.RoundToSignificant(ni.Divisibility).ToString("C", nfi), + Limit = _currencyTable.DisplayFormatCurrency(ppBlob.Limit, ppBlob.Currency), + ResetIn = period?.End is DateTimeOffset nr ? ZeroIfNegative(nr - now).TimeString() : null, + EndIn = pp.EndDate is DateTimeOffset end ? ZeroIfNegative(end - now).TimeString() : null + } + }); + } + return View(vm); + } + public TimeSpan ZeroIfNegative(TimeSpan time) + { + if (time < TimeSpan.Zero) + time = TimeSpan.Zero; + return time; + } + + [HttpGet] + [Route("{walletId}/pull-payments/{pullPaymentId}/archive")] + public IActionResult ArchivePullPayment( + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, + string pullPaymentId) + { + return View("Confirm", new ConfirmModel() + { + Title = "Archive the pull payment", + Description = "Do you really want to archive this pull payment?", + ButtonClass = "btn-danger", + Action = "Archive" + }); + } + [HttpPost] + [Route("{walletId}/pull-payments/{pullPaymentId}/archive")] + public async Task ArchivePullPaymentPost( + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, + string pullPaymentId) + { + await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(pullPaymentId)); + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Pull payment archived", + Severity = StatusMessageModel.StatusSeverity.Success + }); + return RedirectToAction(nameof(PullPayments), new { walletId = walletId.ToString() }); + } + + [HttpPost] + [Route("{walletId}/payouts")] + public async Task PayoutsPost( + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, PayoutsModel vm) + { + if (vm is null) + return NotFound(); + var storeId = walletId.StoreId; + var paymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike); + var payoutIds = vm.WaitingForApproval.Where(p => p.Selected).Select(p => p.PayoutId).ToArray(); + if (payoutIds.Length == 0) + { + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "No payout selected", + Severity = StatusMessageModel.StatusSeverity.Error + }); + return RedirectToAction(nameof(Payouts), new + { + walletId = walletId.ToString(), + pullPaymentId = vm.PullPaymentId + }); + } + if (vm.Command == "pay") + { + using var ctx = this._dbContextFactory.CreateContext(); + var payouts = await ctx.Payouts + .Where(p => payoutIds.Contains(p.Id)) + .Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived) + .ToListAsync(); + var walletSend = (WalletSendModel)((ViewResult)(await this.WalletSend(walletId))).Model; + walletSend.Outputs.Clear(); + foreach (var payout in payouts) + { + var blob = payout.GetBlob(_jsonSerializerSettings); + if (payout.GetPaymentMethodId() != paymentMethodId) + continue; + var output = new WalletSendModel.TransactionOutput() + { + Amount = blob.Amount, + DestinationAddress = blob.Destination.Address.ToString() + }; + walletSend.Outputs.Add(output); + } + return View(nameof(walletSend), walletSend); + } + else if (vm.Command == "cancel") + { + await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(payoutIds)); + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Payouts archived", + Severity = StatusMessageModel.StatusSeverity.Success + }); + return RedirectToAction(nameof(Payouts), new + { + walletId = walletId.ToString(), + pullPaymentId = vm.PullPaymentId + }); + } + else + { + return NotFound(); + } + } + + [HttpGet] + [Route("{walletId}/payouts")] + public async Task Payouts( + [ModelBinder(typeof(WalletIdModelBinder))] + WalletId walletId, PayoutsModel vm = null) + { + vm ??= new PayoutsModel(); + using var ctx = this._dbContextFactory.CreateContext(); + var storeId = walletId.StoreId; + var paymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike); + var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived); + if (vm.PullPaymentId != null) + { + payoutRequest = payoutRequest.Where(p => p.PullPaymentDataId == vm.PullPaymentId); + } + var payouts = await payoutRequest.OrderByDescending(p => p.Date) + .Select(o => new { + Payout = o, + PullPayment = o.PullPaymentData + }).ToListAsync(); + var network = NetworkProvider.GetNetwork(walletId.CryptoCode); + vm.WaitingForApproval = new List(); + vm.Other = new List(); + foreach (var item in payouts) + { + if (item.Payout.GetPaymentMethodId() != paymentMethodId) + continue; + var ppBlob = item.PullPayment.GetBlob(); + var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings); + var m = new PayoutsModel.PayoutModel(); + m.PullPaymentId = item.PullPayment.Id; + m.PullPaymentName = ppBlob.Name ?? item.PullPayment.Id; + m.Date = item.Payout.Date; + m.PayoutId = item.Payout.Id; + m.Amount = _currencyTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency); + m.Destination = payoutBlob.Destination.Address.ToString(); + if (item.Payout.State == PayoutState.AwaitingPayment) + { + vm.WaitingForApproval.Add(m); + } + else + { + if (item.Payout.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike && + item.Payout.GetProofBlob(this._jsonSerializerSettings)?.TransactionId is uint256 txId) + m.TransactionLink = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId); + vm.Other.Add(m); + } + } + return View(vm); + } + } +} diff --git a/BTCPayServer/Controllers/WalletsController.cs b/BTCPayServer/Controllers/WalletsController.cs index 513440cd7..9f593f2e3 100644 --- a/BTCPayServer/Controllers/WalletsController.cs +++ b/BTCPayServer/Controllers/WalletsController.cs @@ -53,6 +53,9 @@ namespace BTCPayServer.Controllers private readonly DelayedTransactionBroadcaster _broadcaster; private readonly PayjoinClient _payjoinClient; private readonly LabelFactory _labelFactory; + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; + private readonly PullPaymentHostedService _pullPaymentService; public RateFetcher RateFetcher { get; } @@ -74,7 +77,10 @@ namespace BTCPayServer.Controllers SettingsRepository settingsRepository, DelayedTransactionBroadcaster broadcaster, PayjoinClient payjoinClient, - LabelFactory labelFactory) + LabelFactory labelFactory, + ApplicationDbContextFactory dbContextFactory, + BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, + HostedServices.PullPaymentHostedService pullPaymentService) { _currencyTable = currencyTable; Repository = repo; @@ -94,6 +100,9 @@ namespace BTCPayServer.Controllers _broadcaster = broadcaster; _payjoinClient = payjoinClient; _labelFactory = labelFactory; + _dbContextFactory = dbContextFactory; + _jsonSerializerSettings = jsonSerializerSettings; + _pullPaymentService = pullPaymentService; } // Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md diff --git a/BTCPayServer/Data/PullPaymentsExtensions.cs b/BTCPayServer/Data/PullPaymentsExtensions.cs new file mode 100644 index 000000000..c2fd41f57 --- /dev/null +++ b/BTCPayServer/Data/PullPaymentsExtensions.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using BTCPayServer.Client.JsonConverters; +using BTCPayServer.JsonConverters; +using BTCPayServer.Payments; +using BTCPayServer.Services; +using Microsoft.AspNetCore.Mvc.NewtonsoftJson; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.EntityFrameworkCore; +using NBitcoin; +using NBitcoin.Crypto; +using NBitcoin.DataEncoders; +using NBitcoin.JsonConverters; +using NBitcoin.Payment; +using Newtonsoft.Json; + +namespace BTCPayServer.Data +{ + public static class PullPaymentsExtensions + { + public static async Task GetPayout(this DbSet payouts, string payoutId, string storeId) + { + var payout = await payouts.Where(p => p.Id == payoutId && + p.PullPaymentData.StoreId == storeId).FirstOrDefaultAsync(); + if (payout is null) + return null; + return payout; + } + public static PullPaymentBlob GetBlob(this PullPaymentData data) + { + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data.Blob)); + } + public static void SetBlob(this PullPaymentData data, PullPaymentBlob blob) + { + data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob)); + } + public static PaymentMethodId GetPaymentMethodId(this PayoutData data) + { + return PaymentMethodId.Parse(data.PaymentMethodId); + } + public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers) + { + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)); + } + public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers) + { + data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode))); + } + + public static bool IsSupported(this PullPaymentData data, BTCPayServer.Payments.PaymentMethodId paymentId) + { + return data.GetBlob().SupportedPaymentMethods.Contains(paymentId); + } + + public static PayoutTransactionOnChainBlob GetProofBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers) + { + if (data.Proof is null) + return null; + return JsonConvert.DeserializeObject(Encoding.UTF8.GetString(data.Proof), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)); + } + public static void SetProofBlob(this PayoutData data, PayoutTransactionOnChainBlob blob, BTCPayNetworkJsonSerializerSettings serializers) + { + var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode))); + // We only update the property if the bytes actually changed, this prevent from hammering the DB too much + if (data.Proof is null || bytes.Length != data.Proof.Length || !bytes.SequenceEqual(data.Proof)) + { + data.Proof = bytes; + } + } + } + + public class PayoutTransactionOnChainBlob + { + [JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))] + public uint256 TransactionId { get; set; } + [JsonProperty(ItemConverterType = typeof(NBitcoin.JsonConverters.UInt256JsonConverter), NullValueHandling = NullValueHandling.Ignore)] + public HashSet Candidates { get; set; } = new HashSet(); + } + public interface IClaimDestination + { + BitcoinAddress Address { get; } + } + public static class ClaimDestination + { + public static bool TryParse(string destination, BTCPayNetwork network, out IClaimDestination claimDestination) + { + if (destination == null) + throw new ArgumentNullException(nameof(destination)); + destination = destination.Trim(); + try + { + if (destination.StartsWith($"{network.UriScheme}:", StringComparison.OrdinalIgnoreCase)) + { + claimDestination = new UriClaimDestination(new BitcoinUrlBuilder(destination, network.NBitcoinNetwork)); + } + else + { + claimDestination = new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork)); + } + return true; + } + catch + { + claimDestination = null; + return false; + } + } + } + public class AddressClaimDestination : IClaimDestination + { + private readonly BitcoinAddress _bitcoinAddress; + + public AddressClaimDestination(BitcoinAddress bitcoinAddress) + { + if (bitcoinAddress == null) + throw new ArgumentNullException(nameof(bitcoinAddress)); + _bitcoinAddress = bitcoinAddress; + } + public BitcoinAddress BitcoinAdress => _bitcoinAddress; + public BitcoinAddress Address => _bitcoinAddress; + public override string ToString() + { + return _bitcoinAddress.ToString(); + } + } + public class UriClaimDestination : IClaimDestination + { + private readonly BitcoinUrlBuilder _bitcoinUrl; + + public UriClaimDestination(BitcoinUrlBuilder bitcoinUrl) + { + if (bitcoinUrl == null) + throw new ArgumentNullException(nameof(bitcoinUrl)); + if (bitcoinUrl.Address is null) + throw new ArgumentException(nameof(bitcoinUrl)); + _bitcoinUrl = bitcoinUrl; + } + public BitcoinUrlBuilder BitcoinUrl => _bitcoinUrl; + + public BitcoinAddress Address => _bitcoinUrl.Address; + public override string ToString() + { + return _bitcoinUrl.ToString(); + } + } + public class PayoutBlob + { + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal Amount { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal CryptoAmount { get; set; } + public int MinimumConfirmation { get; set; } = 1; + public IClaimDestination Destination { get; set; } + } + public class ClaimDestinationJsonConverter : JsonConverter + { + private readonly BTCPayNetwork _network; + + public ClaimDestinationJsonConverter(BTCPayNetwork network) + { + if (network == null) + throw new ArgumentNullException(nameof(network)); + _network = network; + } + + public override IClaimDestination ReadJson(JsonReader reader, Type objectType, IClaimDestination existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + if (reader.TokenType != JsonToken.String) + throw new JsonObjectException("Expected string for IClaimDestination", reader); + if (ClaimDestination.TryParse((string)reader.Value, _network, out var v)) + return v; + throw new JsonObjectException("Invalid IClaimDestination", reader); + } + + public override void WriteJson(JsonWriter writer, IClaimDestination value, JsonSerializer serializer) + { + if (value is IClaimDestination v) + writer.WriteValue(v.ToString()); + } + } + public class PullPaymentBlob + { + public string Name { get; set; } + public string Currency { get; set; } + public int Divisibility { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal Limit { get; set; } + [JsonConverter(typeof(DecimalStringJsonConverter))] + public decimal MinimumClaim { get; set; } + public PullPaymentView View { get; set; } = new PullPaymentView(); + [JsonConverter(typeof(TimeSpanJsonConverter))] + public TimeSpan? Period { get; set; } + + [JsonProperty(ItemConverterType = typeof(PaymentMethodIdJsonConverter))] + public PaymentMethodId[] SupportedPaymentMethods { get; set; } + } + public class PullPaymentView + { + public string Title { get; set; } + public string Description { get; set; } + public string EmbeddedCSS { get; set; } + public string Email { get; set; } + public string CustomCSSLink { get; set; } + } +} diff --git a/BTCPayServer/HostedServices/BaseAsyncService.cs b/BTCPayServer/HostedServices/BaseAsyncService.cs index f99298395..7d3ff83a2 100644 --- a/BTCPayServer/HostedServices/BaseAsyncService.cs +++ b/BTCPayServer/HostedServices/BaseAsyncService.cs @@ -57,6 +57,8 @@ namespace BTCPayServer.HostedServices } } + public CancellationToken CancellationToken => _Cts.Token; + public virtual async Task StopAsync(CancellationToken cancellationToken) { if (_Cts != null) diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs new file mode 100644 index 000000000..f91400447 --- /dev/null +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -0,0 +1,524 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.InteropServices.ComTypes; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using BTCPayServer; +using BTCPayServer.Data; +using BTCPayServer.Events; +using BTCPayServer.Logging; +using BTCPayServer.Payments; +using BTCPayServer.Services; +using BTCPayServer.Services.Notifications; +using BTCPayServer.Services.Notifications.Blobs; +using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using NBitcoin; +using NBitcoin.DataEncoders; +using NBitcoin.Payment; +using NBitcoin.RPC; +using NBXplorer; +using Serilog.Configuration; + +namespace BTCPayServer.HostedServices +{ + public class CreatePullPayment + { + public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset? StartsAt { get; set; } + public string StoreId { get; set; } + public string Name { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } + public PaymentMethodId[] PaymentMethodIds { get; set; } + public TimeSpan? Period { get; set; } + } + public class PullPaymentHostedService : BaseAsyncService + { + public class CancelRequest + { + public CancelRequest(string pullPaymentId) + { + if (pullPaymentId == null) + throw new ArgumentNullException(nameof(pullPaymentId)); + PullPaymentId = pullPaymentId; + } + public CancelRequest(string[] payoutIds) + { + if (payoutIds == null) + throw new ArgumentNullException(nameof(payoutIds)); + PayoutIds = payoutIds; + } + public string PullPaymentId { get; set; } + public string[] PayoutIds { get; set; } + internal TaskCompletionSource Completion { get; set; } + } + + public async Task CreatePullPayment(CreatePullPayment create) + { + if (create == null) + throw new ArgumentNullException(nameof(create)); + if (create.Amount <= 0.0m) + 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; + 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)); + o.StoreId = create.StoreId; + o.SetBlob(new PullPaymentBlob() + { + Name = create.Name ?? string.Empty, + Currency = create.Currency, + Limit = create.Amount, + Period = o.Period is long periodSeconds ? (TimeSpan?)TimeSpan.FromSeconds(periodSeconds) : null, + SupportedPaymentMethods = create.PaymentMethodIds, + View = new PullPaymentView() + { + Title = create.Name ?? string.Empty, + Description = string.Empty, + CustomCSSLink = null, + Email = null, + EmbeddedCSS = null, + } + }); + ctx.PullPayments.Add(o); + await ctx.SaveChangesAsync(); + return o.Id; + } + + public async Task GetPullPayment(string pullPaymentId) + { + using var ctx = _dbContextFactory.CreateContext(); + return await ctx.PullPayments.FindAsync(pullPaymentId); + } + + class PayoutRequest + { + public PayoutRequest(TaskCompletionSource completionSource, ClaimRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (completionSource == null) + throw new ArgumentNullException(nameof(completionSource)); + Completion = completionSource; + ClaimRequest = request; + } + public TaskCompletionSource Completion { get; set; } + public ClaimRequest ClaimRequest { get; } + } + public PullPaymentHostedService(ApplicationDbContextFactory dbContextFactory, + BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, + CurrencyNameTable currencyNameTable, + EventAggregator eventAggregator, + ExplorerClientProvider explorerClientProvider, + BTCPayNetworkProvider networkProvider, + NotificationSender notificationSender) + { + _dbContextFactory = dbContextFactory; + _jsonSerializerSettings = jsonSerializerSettings; + _currencyNameTable = currencyNameTable; + _eventAggregator = eventAggregator; + _explorerClientProvider = explorerClientProvider; + _networkProvider = networkProvider; + _notificationSender = notificationSender; + } + + Channel _Channel; + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; + private readonly CurrencyNameTable _currencyNameTable; + private readonly EventAggregator _eventAggregator; + private readonly ExplorerClientProvider _explorerClientProvider; + private readonly BTCPayNetworkProvider _networkProvider; + private readonly NotificationSender _notificationSender; + + internal override Task[] InitializeTasks() + { + _Channel = Channel.CreateUnbounded(); + _eventAggregator.Subscribe(o => _Channel.Writer.TryWrite(o)); + _eventAggregator.Subscribe(o => _Channel.Writer.TryWrite(o)); + return new[] { Loop() }; + } + + private async Task Loop() + { + await foreach (var o in _Channel.Reader.ReadAllAsync()) + { + if (o is PayoutRequest req) + { + await HandleCreatePayout(req); + } + + if (o is NewOnChainTransactionEvent newTransaction) + { + await UpdatePayoutsAwaitingForPayment(newTransaction); + } + if (o is CancelRequest cancel) + { + await HandleCancel(cancel); + } + if (o is NewBlockEvent || o is NewOnChainTransactionEvent) + { + await UpdatePayoutsInProgress(); + } + } + } + + private async Task HandleCreatePayout(PayoutRequest req) + { + try + { + DateTimeOffset now = DateTimeOffset.UtcNow; + using var ctx = _dbContextFactory.CreateContext(); + var pp = await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId); + if (pp is null || pp.Archived) + { + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Archived)); + return; + } + 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; + } + var ppBlob = pp.GetBlob(); + if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId)) + { + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); + 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 cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); + 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) + { + 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.AwaitingPayment, + PullPaymentDataId = req.ClaimRequest.PullPaymentId, + PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(), + Destination = GetDestination(req.ClaimRequest.Destination.Address.ScriptPubKey) + }; + if (claimed < ppBlob.MinimumClaim || claimed == 0.0m) + { + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); + return; + } + var cryptoAmount = Money.Coins(claimed); + Money mininumCryptoAmount = GetMinimumCryptoAmount(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination.Address.ScriptPubKey); + if (cryptoAmount < mininumCryptoAmount) + { + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); + return; + } + var payoutBlob = new PayoutBlob() + { + Amount = claimed, + // To fix, we should evaluate based on exchange rate + CryptoAmount = cryptoAmount.ToDecimal(MoneyUnit.BTC), + Destination = req.ClaimRequest.Destination + }; + payout.SetBlob(payoutBlob, _jsonSerializerSettings); + payout.SetProofBlob(new PayoutTransactionOnChainBlob(), _jsonSerializerSettings); + ctx.Payouts.Add(payout); + try + { + 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 + }); + } + catch (DbUpdateException) + { + req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Duplicate)); + } + } + catch (Exception ex) + { + req.Completion.TrySetException(ex); + } + } + + private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction) + { + try + { + var outputs = newTransaction. + NewTransactionEvent. + TransactionData. + Transaction. + Outputs; + var destinations = outputs.Select(o => GetDestination(o.ScriptPubKey)).ToHashSet(); + + using var ctx = _dbContextFactory.CreateContext(); + var payouts = await ctx.Payouts + .Include(o => o.PullPaymentData) + .Where(p => p.State == PayoutState.AwaitingPayment) + .Where(p => destinations.Contains(p.Destination)) + .ToListAsync(); + var payoutByDestination = payouts.ToDictionary(p => p.Destination); + foreach (var output in outputs) + { + if (!payoutByDestination.TryGetValue(GetDestination(output.ScriptPubKey), out var payout)) + continue; + var payoutBlob = payout.GetBlob(this._jsonSerializerSettings); + if (output.Value.ToDecimal(MoneyUnit.BTC) != payoutBlob.CryptoAmount) + continue; + var proof = payout.GetProofBlob(this._jsonSerializerSettings); + var txId = newTransaction.NewTransactionEvent.TransactionData.TransactionHash; + if (proof.Candidates.Add(txId)) + { + payout.State = PayoutState.InProgress; + if (proof.TransactionId is null) + proof.TransactionId = txId; + payout.SetProofBlob(proof, _jsonSerializerSettings); + _eventAggregator.Publish(new UpdateTransactionLabel(new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode), + newTransaction.NewTransactionEvent.TransactionData.TransactionHash, + ("#3F88AF", "Payout"))); + } + } + await ctx.SaveChangesAsync(); + } + catch (Exception ex) + { + Logs.PayServer.LogWarning(ex, "Error while processing a transaction in the pull payment hosted service"); + } + } + + private async Task HandleCancel(CancelRequest cancel) + { + try + { + using var ctx = this._dbContextFactory.CreateContext(); + List payouts = null; + if (cancel.PullPaymentId != null) + { + 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(); + } + else + { + var payoutIds = cancel.PayoutIds.ToHashSet(); + payouts = await ctx.Payouts + .Where(p => payoutIds.Contains(p.Id)) + .ToListAsync(); + } + + foreach (var payout in payouts) + { + if (payout.State != PayoutState.Completed && payout.State != PayoutState.InProgress) + payout.State = PayoutState.Cancelled; + payout.Destination = null; + } + await ctx.SaveChangesAsync(); + cancel.Completion.TrySetResult(true); + } + catch (Exception ex) + { + cancel.Completion.TrySetException(ex); + } + } + + private async Task UpdatePayoutsInProgress() + { + try + { + using var ctx = _dbContextFactory.CreateContext(); + var payouts = await ctx.Payouts + .Include(p => p.PullPaymentData) + .Where(p => p.State == PayoutState.InProgress) + .ToListAsync(); + + foreach (var payout in payouts) + { + var proof = payout.GetProofBlob(this._jsonSerializerSettings); + var payoutBlob = payout.GetBlob(this._jsonSerializerSettings); + foreach (var txid in proof.Candidates.ToList()) + { + var explorer = _explorerClientProvider.GetExplorerClient(payout.GetPaymentMethodId().CryptoCode); + var tx = await explorer.GetTransactionAsync(txid); + if (tx is null) + { + proof.Candidates.Remove(txid); + } + else if (tx.Confirmations >= payoutBlob.MinimumConfirmation) + { + payout.State = PayoutState.Completed; + proof.TransactionId = tx.TransactionHash; + payout.Destination = null; + break; + } + else + { + var rebroadcasted = await explorer.BroadcastAsync(tx.Transaction); + if (rebroadcasted.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR || + rebroadcasted.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED) + { + proof.Candidates.Remove(txid); + } + else + { + payout.State = PayoutState.InProgress; + proof.TransactionId = tx.TransactionHash; + continue; + } + } + } + if (proof.TransactionId is null && !proof.Candidates.Contains(proof.TransactionId)) + { + proof.TransactionId = null; + } + if (proof.Candidates.Count == 0) + { + payout.State = PayoutState.AwaitingPayment; + } + else if (proof.TransactionId is null) + { + proof.TransactionId = proof.Candidates.First(); + } + if (payout.State == PayoutState.Completed) + proof.Candidates = null; + payout.SetProofBlob(proof, this._jsonSerializerSettings); + } + await ctx.SaveChangesAsync(); + } + catch (Exception ex) + { + Logs.PayServer.LogWarning(ex, "Error while processing an update in the pull payment hosted service"); + } + } + + private Money GetMinimumCryptoAmount(PaymentMethodId paymentMethodId, Script scriptPubKey) + { + Money mininumAmount = Money.Zero; + if (_networkProvider.GetNetwork(paymentMethodId.CryptoCode)? + .NBitcoinNetwork? + .Consensus? + .ConsensusFactory? + .CreateTxOut() is TxOut txout) + { + txout.ScriptPubKey = scriptPubKey; + mininumAmount = txout.GetDustThreshold(new FeeRate(1.0m)); + } + return mininumAmount; + } + + private static string GetDestination(Script scriptPubKey) + { + return Encoders.Base64.EncodeData(scriptPubKey.ToBytes(true)); + } + public Task Cancel(CancelRequest cancelRequest) + { + CancellationToken.ThrowIfCancellationRequested(); + var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + cancelRequest.Completion = cts; + _Channel.Writer.TryWrite(cancelRequest); + return cts.Task; + } + + public Task Claim(ClaimRequest request) + { + CancellationToken.ThrowIfCancellationRequested(); + var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _Channel.Writer.TryWrite(new PayoutRequest(cts, request)); + return cts.Task; + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + _Channel?.Writer.Complete(); + return base.StopAsync(cancellationToken); + } + } + + public class ClaimRequest + { + public static string GetErrorMessage(ClaimResult result) + { + switch (result) + { + case ClaimResult.Ok: + break; + case ClaimResult.Duplicate: + return "This address is already used for another payout"; + case ClaimResult.Expired: + return "This pull payment is expired"; + case ClaimResult.NotStarted: + return "This pull payment has yet started"; + case ClaimResult.Archived: + return "This pull payment has been archived"; + case ClaimResult.Overdraft: + return "The payout amount overdraft the pull payment's limit"; + case ClaimResult.AmountTooLow: + return "The requested payout amount is too low"; + case ClaimResult.PaymentMethodNotSupported: + return "This payment method is not supported by the pull payment"; + default: + throw new NotSupportedException("Unsupported ClaimResult"); + } + return null; + } + public class ClaimResponse + { + public ClaimResponse(ClaimResult result, PayoutData payoutData = null) + { + Result = result; + PayoutData = payoutData; + } + public ClaimResult Result { get; set; } + public PayoutData PayoutData { get; set; } + } + public enum ClaimResult + { + Ok, + Duplicate, + Expired, + Archived, + NotStarted, + Overdraft, + AmountTooLow, + PaymentMethodNotSupported, + } + + public PaymentMethodId PaymentMethodId { get; set; } + public string PullPaymentId { get; set; } + public decimal? Value { get; set; } + public IClaimDestination Destination { get; set; } + } + +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 2e4a05c61..dc4e5edac 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -48,11 +48,17 @@ using BTCPayServer.Security.GreenField; using BTCPayServer.Services.Labels; using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; +using Newtonsoft.Json; namespace BTCPayServer.Hosting { public static class BTCPayServerServices { + public static IServiceCollection RegisterJsonConverter(this IServiceCollection services, Func create) + { + services.AddSingleton((s) => new JsonConverterRegistration(create)); + return services; + } public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration) { services.AddSingleton(o => o.GetRequiredService>().Value); @@ -66,6 +72,10 @@ namespace BTCPayServer.Hosting { httpClient.Timeout = Timeout.InfiniteTimeSpan; }); + + services.AddSingleton(); + services.RegisterJsonConverter(n => new ClaimDestinationJsonConverter(n)); + services.AddPayJoinServices(); services.AddMoneroLike(); services.TryAddSingleton(); @@ -189,7 +199,10 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(o => o.GetRequiredService()); - + + services.AddSingleton(); + services.AddSingleton(o => o.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(provider => provider.GetService()); services.AddSingleton(); @@ -223,6 +236,7 @@ namespace BTCPayServer.Hosting services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(o => diff --git a/BTCPayServer/JsonConverters/PaymentMethodIdJsonConverter.cs b/BTCPayServer/JsonConverters/PaymentMethodIdJsonConverter.cs new file mode 100644 index 000000000..3b759f072 --- /dev/null +++ b/BTCPayServer/JsonConverters/PaymentMethodIdJsonConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Reflection; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using NBitcoin.JsonConverters; +using BTCPayServer.Rating; +using BTCPayServer.Payments; +using System.Diagnostics.CodeAnalysis; + +namespace BTCPayServer.JsonConverters +{ + public class PaymentMethodIdJsonConverter : JsonConverter + { + public override PaymentMethodId ReadJson(JsonReader reader, Type objectType, PaymentMethodId existingValue, bool hasExistingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) + return null; + if (reader.TokenType != JsonToken.String) + throw new JsonObjectException("A payment method id should be a string", reader); + if (PaymentMethodId.TryParse((string)reader.Value, out var result)) + return result; + throw new JsonObjectException("Invalid payment method id", reader); + } + public override void WriteJson(JsonWriter writer, PaymentMethodId value, JsonSerializer serializer) + { + if (value != null) + writer.WriteValue(value.ToString()); + } + } +} diff --git a/BTCPayServer/ModelBinders/PaymentMethodIdModelBinder.cs b/BTCPayServer/ModelBinders/PaymentMethodIdModelBinder.cs new file mode 100644 index 000000000..f5e3b8fab --- /dev/null +++ b/BTCPayServer/ModelBinders/PaymentMethodIdModelBinder.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace BTCPayServer.ModelBinders +{ + public class PaymentMethodIdModelBinder : IModelBinder + { + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (!typeof(PaymentMethodIdModelBinder).GetTypeInfo().IsAssignableFrom(bindingContext.ModelType)) + { + return Task.CompletedTask; + } + + ValueProviderResult val = bindingContext.ValueProvider.GetValue( + bindingContext.ModelName); + if (val == null) + { + return Task.CompletedTask; + } + + string key = val.FirstValue as string; + if (key == null) + { + return Task.CompletedTask; + } + + if (PaymentMethodId.TryParse(key, out var paymentId)) + { + bindingContext.Result = ModelBindingResult.Success(paymentId); + } + return Task.CompletedTask; + } + } +} diff --git a/BTCPayServer/Models/StatusMessageModel.cs b/BTCPayServer/Models/StatusMessageModel.cs index 0a34008bf..acb09a952 100644 --- a/BTCPayServer/Models/StatusMessageModel.cs +++ b/BTCPayServer/Models/StatusMessageModel.cs @@ -9,7 +9,6 @@ namespace BTCPayServer.Models public StatusMessageModel() { } - public string Message { get; set; } public string Html { get; set; } public StatusSeverity Severity { get; set; } diff --git a/BTCPayServer/Models/ViewPullPaymentModel.cs b/BTCPayServer/Models/ViewPullPaymentModel.cs new file mode 100644 index 000000000..e7ae7fe4e --- /dev/null +++ b/BTCPayServer/Models/ViewPullPaymentModel.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AngleSharp.Dom; +using BTCPayServer.Data; +using BTCPayServer.Payments; +using BTCPayServer.Services.Rates; +using BTCPayServer.Views; + +namespace BTCPayServer.Models +{ + public class ViewPullPaymentModel + { + public ViewPullPaymentModel() + { + + } + public ViewPullPaymentModel(PullPaymentData data, DateTimeOffset now) + { + Id = data.Id; + var blob = data.GetBlob(); + Archived = data.Archived; + Title = blob.View.Title; + Amount = blob.Limit; + Currency = blob.Currency; + Description = blob.View.Description; + ExpiryDate = data.EndDate is DateTimeOffset dt ? (DateTime?)dt.UtcDateTime : null; + Email = blob.View.Email; + MinimumClaim = blob.MinimumClaim; + EmbeddedCSS = blob.View.EmbeddedCSS; + CustomCSSLink = blob.View.CustomCSSLink; + if (!string.IsNullOrEmpty(EmbeddedCSS)) + EmbeddedCSS = $""; + IsPending = !data.IsExpired(); + var period = data.GetPeriod(now); + if (data.Archived) + { + Status = "Archived"; + } + else if (data.IsExpired()) + { + Status = "Expired"; + } + else if (period is null) + { + Status = "Not yet started"; + } + else + { + Status = string.Empty; + } + + ResetIn = string.Empty; + if (period?.End is DateTimeOffset pe) + { + var resetIn = (pe - DateTimeOffset.UtcNow); + if (resetIn < TimeSpan.Zero) + resetIn = TimeSpan.Zero; + ResetIn = resetIn.TimeString(); + } + } + public string HubPath { get; set; } + public string ResetIn { get; set; } + public string Email { get; set; } + public string Status { get; set; } + public bool IsPending { get; set; } + public decimal AmountCollected { get; set; } + public decimal AmountDue { get; set; } + public decimal ClaimedAmount { get; set; } + public decimal MinimumClaim { get; set; } + public string Destination { get; set; } + public string AmountDueFormatted { get; set; } + public decimal Amount { get; set; } + public string Id { get; set; } + public string Currency { get; set; } + public DateTime? ExpiryDate { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string EmbeddedCSS { get; set; } + public string CustomCSSLink { get; set; } + public List Payouts { get; set; } = new List(); + public DateTime LastUpdated { get; set; } + public CurrencyData CurrencyData { get; set; } + public string AmountCollectedFormatted { get; set; } + public string AmountFormatted { get; set; } + public bool Archived { get; set; } + + public class PayoutLine + { + public string Id { get; set; } + public decimal Amount { get; set; } + public string AmountFormatted { get; set; } + public string Status { get; set; } + public string Destination { get; set; } + public string Currency { get; set; } + public string Link { get; set; } + public string TransactionId { get; set; } + } + } +} diff --git a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs new file mode 100644 index 000000000..7a24bbc23 --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.WalletViewModels +{ + public class PayoutsModel + { + public string PullPaymentId { get; set; } + public string Command { get; set; } + public class PayoutModel + { + public string PayoutId { get; set; } + public bool Selected { get; set; } + public DateTimeOffset Date { get; set; } + public string PullPaymentId { get; set; } + public string PullPaymentName { get; set; } + public string Destination { get; set; } + public string Amount { get; set; } + public string TransactionLink { get; set; } + } + public List WaitingForApproval { get; set; } = new List(); + public List Other { get; set; } = new List(); + } +} diff --git a/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs b/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs new file mode 100644 index 000000000..d967eb47b --- /dev/null +++ b/BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Html; + +namespace BTCPayServer.Models.WalletViewModels +{ + public class PullPaymentsModel + { + public class PullPaymentModel + { + public class ProgressModel + { + public int CompletedPercent { get; set; } + public int AwaitingPercent { get; set; } + public string Completed { get; set; } + public string Awaiting { get; set; } + public string Limit { get; set; } + public string ResetIn { get; set; } + public string EndIn { get; set; } + } + public string Id { get; set; } + public string Name { get; set; } + public string ProgressText { get; set; } + public ProgressModel Progress { get; set; } + public DateTimeOffset StartDate { get; set; } + public DateTimeOffset? EndDate { get; set; } + } + + public List PullPayments { get; set; } = new List(); + } + + public class NewPullPaymentModel + { + [MaxLength(30)] + public string Name { get; set; } + [Required] + public decimal Amount + { + get; set; + } + [Required] + [ReadOnly(true)] + public string Currency { get; set; } + } +} diff --git a/BTCPayServer/PaymentRequest/PaymentRequestService.cs b/BTCPayServer/PaymentRequest/PaymentRequestService.cs index 057146fa3..3d53a0e72 100644 --- a/BTCPayServer/PaymentRequest/PaymentRequestService.cs +++ b/BTCPayServer/PaymentRequest/PaymentRequestService.cs @@ -78,7 +78,6 @@ namespace BTCPayServer.PaymentRequest } var blob = pr.GetBlob(); - var rateRules = pr.StoreData.GetStoreBlob().GetRateRules(_BtcPayNetworkProvider); var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id); diff --git a/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs b/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs index f5a872156..0f0be5c45 100644 --- a/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs +++ b/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Microsoft.AspNetCore.Routing; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; @@ -32,13 +33,12 @@ namespace BTCPayServer.Security.GreenField { if (context.User.Identity.AuthenticationType != GreenFieldConstants.AuthenticationType) return; - + var userid = _userManager.GetUserId(context.User); bool success = false; switch (requirement.Policy) { case { } policy when Policies.IsStorePolicy(policy): var storeId = _HttpContext.GetImplicitStoreId(); - var userid = _userManager.GetUserId(context.User); // Specific store action if (storeId != null) { @@ -49,7 +49,7 @@ namespace BTCPayServer.Security.GreenField var store = await _storeRepository.FindStore((string)storeId, userid); if (store == null) break; - if(Policies.IsStoreModifyPolicy(policy)) + if (Policies.IsStoreModifyPolicy(policy)) { if (store.Role != StoreRoles.Owner) break; diff --git a/BTCPayServer/Services/BTCPayNetworkJsonSerializerSettings.cs b/BTCPayServer/Services/BTCPayNetworkJsonSerializerSettings.cs new file mode 100644 index 000000000..f3219d668 --- /dev/null +++ b/BTCPayServer/Services/BTCPayNetworkJsonSerializerSettings.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; +using NBitcoin; +using Newtonsoft.Json; + +namespace BTCPayServer.Services +{ + public interface IJsonConverterRegistration + { + JsonConverter CreateJsonConverter(BTCPayNetwork network); + } + public class JsonConverterRegistration : IJsonConverterRegistration + { + internal readonly Func _createConverter; + public JsonConverterRegistration(Func createConverter) + { + _createConverter = createConverter; + } + public JsonConverter CreateJsonConverter(BTCPayNetwork network) + { + return _createConverter(network); + } + } + public class BTCPayNetworkJsonSerializerSettings + { + public BTCPayNetworkJsonSerializerSettings(BTCPayNetworkProvider networkProvider, IEnumerable jsonSerializers) + { + foreach (var network in networkProvider.UnfilteredNetworks.GetAll().OfType()) + { + var serializer = new JsonSerializerSettings(); + foreach (var jsonSerializer in jsonSerializers) + { + serializer.Converters.Add(jsonSerializer.CreateJsonConverter(network)); + } + foreach (var converter in network.NBXplorerNetwork.JsonSerializerSettings.Converters) + { + serializer.Converters.Add(converter); + } + _Serializers.Add(network.CryptoCode, serializer); + } + } + + Dictionary _Serializers = new Dictionary(); + + public JsonSerializerSettings GetSerializer(Network network) + { + if (network == null) + throw new ArgumentNullException(nameof(network)); + return GetSerializer(network.NetworkSet.CryptoCode); + } + public JsonSerializerSettings GetSerializer(BTCPayNetwork network) + { + if (network == null) + throw new ArgumentNullException(nameof(network)); + return GetSerializer(network.CryptoCode); + } + public JsonSerializerSettings GetSerializer(string cryptoCode) + { + if (cryptoCode == null) + throw new ArgumentNullException(nameof(cryptoCode)); + _Serializers.TryGetValue(cryptoCode, out var serializer); + return serializer; + } + } + +} diff --git a/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs new file mode 100644 index 000000000..b7af725e9 --- /dev/null +++ b/BTCPayServer/Services/Notifications/Blobs/PayoutNotification.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Controllers; +using BTCPayServer.Models.NotificationViewModels; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace BTCPayServer.Services.Notifications.Blobs +{ + public class PayoutNotification + { + internal class Handler : NotificationHandler + { + private readonly LinkGenerator _linkGenerator; + private readonly BTCPayServerOptions _options; + + public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options) + { + _linkGenerator = linkGenerator; + _options = options; + } + public override string NotificationType => "payout"; + protected override void FillViewModel(PayoutNotification notification, NotificationViewModel vm) + { + vm.Body = "A new payout is awaiting for payment"; + vm.ActionLink = _linkGenerator.GetPathByAction(nameof(WalletsController.Payouts), + "Wallets", + new { walletId = new WalletId(notification.StoreId, notification.PaymentMethod) }, _options.RootPath); + } + } + public string PayoutId { get; set; } + public string StoreId { get; set; } + public string PaymentMethod { get; set; } + public string Currency { get; set; } + } +} diff --git a/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml b/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml new file mode 100644 index 000000000..640cac317 --- /dev/null +++ b/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml @@ -0,0 +1,189 @@ +@model BTCPayServer.Models.ViewPullPaymentModel + +@addTagHelper *, BundlerMinifier.TagHelpers +@inject BTCPayServer.HostedServices.CssThemeManager themeManager +@{ + ViewData["Title"] = Model.Title; + Layout = null; +} + + + + + @Model.Title + + + + + + @if (Model.CustomCSSLink != null) + { + + } + + + + @Safe.Raw(Model.EmbeddedCSS) + + +
+ @if (TempData.HasStatusMessage()) + { +
+
+ +
+
+ } + @if (!this.ViewContext.ModelState.IsValid) + { +
+
+ @Html.ValidationSummary(string.Empty, new { @class = "alert alert-danger" }) +
+
+ } +
+
+
+

+ @Model.Title + @Model.Status +

+ @if (Model.IsPending) + { +
+
+
+ +
+
+ +
+ @Model.Currency.ToUpper() +
+
+
+ +
+
+
+ } +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + @if (Model.ResetIn != String.Empty) + { + + + + + } + +
Pull payment details
Claim limit:@Model.AmountFormatted
Already claimed:@Model.AmountCollectedFormatted
Available claim:@Model.AmountDueFormatted
Reset in:@Model.ResetIn
+ @if (Model.Description != null && Model.Description != "" && Model.Description != "
") + { +
@Safe.Raw(Model.Description)
+ } +
+
+
+
+ + + + + + + + + @if (Model.Payouts == null && !Model.Payouts.Any()) + { + + + + } + else + { + foreach (var invoice in Model.Payouts) + { + + + + + + + + + + + + + @if (!String.IsNullOrEmpty(invoice.Link)) + { + + + + + } + + + + + } + } + +
Awaiting claims
No claim made yet
Status@invoice.Status
Amount claimed@invoice.AmountFormatted
Destination@invoice.Destination
Transaction@invoice.TransactionId
+
+
+
+ +
+ +
+
+
+
+ + diff --git a/BTCPayServer/Views/Wallets/NewPullPayment.cshtml b/BTCPayServer/Views/Wallets/NewPullPayment.cshtml new file mode 100644 index 000000000..94290252f --- /dev/null +++ b/BTCPayServer/Views/Wallets/NewPullPayment.cshtml @@ -0,0 +1,83 @@ +@model NewPullPaymentModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Manage pull payments"; + ViewData.SetActivePageAndTitle(WalletsNavPages.PullPayments); +} + +@if (TempData.HasStatusMessage()) +{ +
+
+ +
+
+} +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+
+
+
diff --git a/BTCPayServer/Views/Wallets/Payouts.cshtml b/BTCPayServer/Views/Wallets/Payouts.cshtml new file mode 100644 index 000000000..9c72718d9 --- /dev/null +++ b/BTCPayServer/Views/Wallets/Payouts.cshtml @@ -0,0 +1,123 @@ +@model PayoutsModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["Title"] = "Manage payouts"; + ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts); +} + +@if (TempData.HasStatusMessage()) +{ +
+
+ +
+
+} + + +
+
+
+ List of unprocessed payouts +
+
+ + +
+
+
+
+

Payouts to process

+ + + + + + + + + + + + @for (int i = 0; i < Model.WaitingForApproval.Count; i++) + { + var pp = Model.WaitingForApproval[i]; + + + + + + + + } + @if (Model.WaitingForApproval.Count == 0) + { + + + + } + +
+ Date + SourceDestinationAmount
+ + + + + @pp.Date.ToBrowserDate()@pp.PullPaymentName@pp.Destination@pp.Amount
No payout waiting for approval
+
+
+ +
+
+

Completed payouts

+ + + + + + + + + + + + @for (int i = 0; i < Model.Other.Count; i++) + { + var pp = Model.Other[i]; + + + + + + @if (pp.TransactionLink is null) + { + + } + else + { + + } + + } + @if (Model.Other.Count == 0) + { + + + + } + +
+ Date + SourceDestinationAmountTransaction
@pp.Date.ToBrowserDate()@pp.PullPaymentName@pp.Destination@pp.AmountCancelledLink
No payout in history
+
+
+
diff --git a/BTCPayServer/Views/Wallets/PullPayments.cshtml b/BTCPayServer/Views/Wallets/PullPayments.cshtml new file mode 100644 index 000000000..e61cfbb14 --- /dev/null +++ b/BTCPayServer/Views/Wallets/PullPayments.cshtml @@ -0,0 +1,114 @@ +@model PullPaymentsModel +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData.SetActivePageAndTitle(WalletsNavPages.PullPayments); + ViewData["Title"] = "Pull payments"; +} + +@if (TempData.HasStatusMessage()) +{ +
+
+ +
+
+} +
+
+

+ Pull payments are a way to allow a receiver of your payment the ability to "pull it" from your wallet at a convenient time. + For example, if the receiver of your payment is a freelancer, you allow the freelancer to pull funds from the wallet, at his convience and with in limits, as he completes the job. + + This decreases the need of communication required between you and the freelancer. +

+
+
+ + + +
+ @foreach (var pp in Model.PullPayments) + { + + } +
+ + + + + + + + + + + @foreach (var pp in Model.PullPayments) + { + + + + + + + } + +
+ Start + NameRefundedActions
@pp.StartDate.ToBrowserDate()@pp.Name +
+
+
+
+
+
+
+ View| Payouts | Archive +
+ +
+
diff --git a/BTCPayServer/Views/Wallets/WalletSend.cshtml b/BTCPayServer/Views/Wallets/WalletSend.cshtml index 5a89c291c..1be61e77b 100644 --- a/BTCPayServer/Views/Wallets/WalletSend.cshtml +++ b/BTCPayServer/Views/Wallets/WalletSend.cshtml @@ -19,7 +19,7 @@
-
+ diff --git a/BTCPayServer/Views/Wallets/WalletTransactions.cshtml b/BTCPayServer/Views/Wallets/WalletTransactions.cshtml index 972455f88..7060efafc 100644 --- a/BTCPayServer/Views/Wallets/WalletTransactions.cshtml +++ b/BTCPayServer/Views/Wallets/WalletTransactions.cshtml @@ -46,7 +46,7 @@ background-color: transparent; border: 0; } - + @if (TempData.HasStatusMessage()) {
diff --git a/BTCPayServer/Views/Wallets/WalletsNavPages.cs b/BTCPayServer/Views/Wallets/WalletsNavPages.cs index d7412a65c..fa4438528 100644 --- a/BTCPayServer/Views/Wallets/WalletsNavPages.cs +++ b/BTCPayServer/Views/Wallets/WalletsNavPages.cs @@ -11,6 +11,8 @@ namespace BTCPayServer.Views.Wallets Transactions, Rescan, PSBT, + PullPayments, + Payouts, Settings, Receive } diff --git a/BTCPayServer/Views/Wallets/_Nav.cshtml b/BTCPayServer/Views/Wallets/_Nav.cshtml index dc77bc265..6236b3ec1 100644 --- a/BTCPayServer/Views/Wallets/_Nav.cshtml +++ b/BTCPayServer/Views/Wallets/_Nav.cshtml @@ -4,17 +4,19 @@ var wallet = WalletId.Parse( this.Context.GetRouteValue("walletId").ToString()); var network = BtcPayNetworkProvider.GetNetwork(wallet.CryptoCode); } - + diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json new file mode 100644 index 000000000..62397de02 --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json @@ -0,0 +1,442 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/pull-payments": { + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store ID", + "schema": { "type": "string" } + } + ], + "get": { + "summary": "Get store's pull payments", + "parameters": [ + { + "name": "includeArchived", + "in": "query", + "required": false, + "description": "Whether this should list archived pull payments", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "description": "Get the pull payments of a store", + "responses": { + "200": { + "description": "List of pull payments", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullPaymentDataList" + } + } + } + } + }, + "tags": [ "Pull payments (Management)" ], + "security": [ + { + "API Key": [ + "btcpay.store.canmanagepullpayments" + ], + "Basic": [] + } + ] + }, + "post": { + "summary": "Create a new pull payment", + "description": "A pull payment allows its receiver to ask for payouts up to `amount` of `currency` every `period`.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the pull payment", + "nullable": true + }, + "amount": { + "type": "string", + "format": "decimal", + "example": "0.1", + "description": "The amount in `currency` of this pull payment as a decimal string" + }, + "currency": { + "type": "string", + "example": "BTC", + "description": "The currency of the amount. In this current release, this parameter must be set to a cryptoCode like (`BTC`)." + }, + "period": { + "type": "integer", + "format": "decimal", + "example": 604800, + "nullable": true, + "description": "The length of each period in seconds." + }, + "startsAt": { + "type": "integer", + "example": 1592312018, + "nullable": true, + "description": "The unix timestamp when this pull payment is effective. Starts now if null or unspecified." + }, + "expiresAt": { + "type": "integer", + "example": 1593129600, + "nullable": true, + "description": "The unix timestamp when this pull payment is expired. Never expires if null or unspecified." + }, + "paymentMethods": { + "type": "array", + "description": "The list of supported payment methods supported. In this current release, this must be set to an array with a single entry equals to `currency` (eg. `[ \"BTC\" ]`)", + "items": { + "type": "string", + "example": "BTC" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The create pull payment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullPaymentData" + } + } + } + }, + "422": { + "description": "Unable to validate the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "tags": [ "Pull payments (Management)" ], + "security": [ + { + "API Key": [ + "btcpay.store.canmanagepullpayments" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/pull-payments/{pullPaymentId}": { + "parameters": [ + { + "name": "pullPaymentId", + "in": "path", + "required": true, + "description": "The ID of the pull payment", + "schema": { "type": "string" } + } + ], + "get": { + "description": "Get a pull payment", + "responses": { + "200": { + "description": "Information about the pull payment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullPaymentData" + } + } + } + }, + "404": { + "description": "Pull payment not found" + } + }, + "tags": [ "Pull payments (Public)" ], + "security": [] + } + }, + "/api/v1/stores/{storeId}/pull-payments/{pullPaymentId}": { + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The ID of the store", + "schema": { "type": "string" } + }, + { + "name": "pullPaymentId", + "in": "path", + "required": true, + "description": "The ID of the pull payment", + "schema": { "type": "string" } + } + ], + "delete": { + "summary": "Archive a pull payment", + "description": "Archive this pull payment (Will cancel all payouts awaiting for payment)", + "responses": { + "200": { + "description": "The pull payment has been archived" + }, + "404": { + "description": "The pull payment has not been found, or does not belong to this store" + } + }, + "tags": [ "Pull payments (Management)" ], + "security": [ + { + "API Key": [ + "btcpay.store.canmanagepullpayments" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/pull-payments/{pullPaymentId}/payouts": { + "parameters": [ + { + "name": "pullPaymentId", + "in": "path", + "required": true, + "description": "The ID of the pull payment", + "schema": { "type": "string" } + } + ], + "get": { + "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 pull payment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutDataList" + } + } + } + }, + "404": { + "description": "Pull payment not found" + } + }, + "tags": [ "Pull payments (Public)" ], + "security": [] + }, + "post": { + "description": "Create a new payout", + "responses": { + "200": { + "description": "A new payout has been created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutData" + } + } + } + }, + "404": { + "description": "Pull payment 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": [ "Pull payments (Public)" ], + "security": [] + } + }, + "/api/v1/stores/{storeId}/payouts/{payoutId}": { + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The ID of the store", + "schema": { "type": "string" } + }, + { + "name": "payoutId", + "in": "path", + "required": true, + "description": "The ID of the payout", + "schema": { "type": "string" } + } + ], + "delete": { + "description": "Cancel the payout", + "responses": { + "200": { + "description": "The payout has been cancelled" + }, + "404": { + "description": "The payout is not found" + } + }, + "tags": [ "Pull payments (Management)" ], + "security": [ + { + "API Key": [ + "btcpay.store.canmanagepullpayments" + ], + "Basic": [] + } + ] + } + } + }, + "components": { + "schemas": { + "PullPaymentDataList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PullPaymentData" + } + }, + "PayoutDataList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayoutData" + } + }, + "PayoutData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The id of the payout" + }, + "pullPaymentId": { + "type": "string", + "description": "The id of the pull payment this payout belongs to" + }, + "date": { + "type": "string", + "description": "The creation date of the payout as a unix timestamp" + }, + "destination": { + "type": "string", + "example": "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + "description": "The destination of the payout (can be an address or a BIP21 url)" + }, + "amount": { + "type": "string", + "format": "decimal", + "example": "10399.18", + "description": "The amount of the payout in the currency of the pull payment (eg. USD). In this current release, `amount` is the same as `paymentMethodAmount`." + }, + "paymentMethod": { + "type": "string", + "example": "BTC", + "description": "The payment method of the payout" + }, + "paymentMethodAmount": { + "type": "string", + "format": "decimal", + "example": "1.12300000", + "description": "The amount of the payout in the currency of the payment method (eg. BTC). In this current release, `paymentMethodAmount` is the same as `amount`." + }, + "state": { + "type": "string", + "example": "AwaitingPayment", + "description": "The state of the payout (`AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)", + "x-enumNames": [ + "AwaitingPayment", + "InProgress", + "Completed", + "Cancelled" + ], + "enum": [ + "AwaitingPayment", + "InProgress", + "Completed", + "Cancelled" + ] + } + } + }, + "PullPaymentData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Id of the pull payment" + }, + "name": { + "type": "string", + "description": "Payment method of of the pull payment" + }, + "currency": { + "type": "string", + "example": "BTC", + "description": "The currency of the pull payment's amount" + }, + "amount": { + "type": "string", + "format": "decimal", + "example": "1.12000000", + "description": "The amount in the currency of this pull payment as a decimal string" + }, + "period": { + "type": "integer", + "example": 604800, + "nullable": true, + "description": "The length of each period in seconds" + }, + "archived": { + "type": "boolean", + "description": "Whether this pull payment is archived" + }, + "viewLink": { + "type": "string", + "description": "The link to a page to claim payouts to this pull payment" + } + } + } + } + } +}