Add pull payment feature (#1639)

This commit is contained in:
Nicolas Dorier 2020-06-24 10:34:09 +09:00 committed by GitHub
parent 7805e5cea4
commit 8230a408ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 3886 additions and 163 deletions

View file

@ -1,5 +1,8 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Client
{

View file

@ -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<PullPaymentData> 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<PullPaymentData>(response);
}
public async Task<PullPaymentData> 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<PullPaymentData>(response);
}
public async Task<PullPaymentData[]> GetPullPayments(string storeId, bool includeArchived = false, CancellationToken cancellationToken = default)
{
Dictionary<string, object> query = new Dictionary<string, object>();
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<PullPaymentData[]>(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<PayoutData[]> GetPayouts(string pullPaymentId, bool includeCancelled = false, CancellationToken cancellationToken = default)
{
Dictionary<string, object> query = new Dictionary<string, object>();
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<PayoutData[]>(response);
}
public async Task<PayoutData> 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<PayoutData>(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);
}
}
}

View file

@ -38,7 +38,7 @@ namespace BTCPayServer.Client.JsonConverters
{
if (value is TimeSpan s)
{
writer.WriteValue((int)s.TotalSeconds);
writer.WriteValue((long)s.TotalSeconds);
}
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -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; }
}
}

View file

@ -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<string> 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);

View file

@ -45,6 +45,8 @@ namespace BTCPayServer.Data
public DbSet<RefundAddressesData> RefundAddresses { get; set; }
public DbSet<PaymentData> Payments { get; set; }
public DbSet<PaymentRequestData> PaymentRequests { get; set; }
public DbSet<PullPaymentData> PullPayments { get; set; }
public DbSet<PayoutData> Payouts { get; set; }
public DbSet<WalletData> Wallets { get; set; }
public DbSet<WalletTransactionData> WalletTransactions { get; set; }
public DbSet<StoreData> 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

View file

@ -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<PayoutData>()
.HasOne(o => o.PullPaymentData)
.WithMany(o => o.Payouts).OnDelete(DeleteBehavior.Cascade);
builder.Entity<PayoutData>()
.Property(o => o.State)
.HasConversion<string>();
builder.Entity<PayoutData>()
.HasIndex(o => o.Destination)
.IsUnique();
builder.Entity<PayoutData>()
.HasIndex(o => o.State);
}
}
public enum PayoutState
{
AwaitingPayment,
InProgress,
Completed,
Cancelled
}
}

View file

@ -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<PayoutData> GetPayoutInPeriod(this IQueryable<PayoutData> payouts, PullPaymentData pp)
{
return GetPayoutInPeriod(payouts, pp, DateTimeOffset.UtcNow);
}
public static IQueryable<PayoutData> GetPayoutInPeriod(this IQueryable<PayoutData> 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<PayoutData> 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<PullPaymentData>()
.HasIndex(o => o.StoreId);
builder.Entity<PullPaymentData>()
.HasOne(o => o.StoreData)
.WithMany(o => o.PullPayments).OnDelete(DeleteBehavior.Cascade);
}
}
}

View file

@ -15,6 +15,8 @@ namespace BTCPayServer.Data
public List<PaymentRequestData> PaymentRequests { get; set; }
public List<PullPaymentData> PullPayments { get; set; }
public List<InvoiceData> Invoices { get; set; }
[Obsolete("Use GetDerivationStrategies instead")]

View file

@ -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<string>(maxLength: 30, nullable: false),
StoreId = table.Column<string>(maxLength: 50, nullable: true),
Period = table.Column<long>(nullable: true),
StartDate = table.Column<DateTimeOffset>(nullable: false),
EndDate = table.Column<DateTimeOffset>(nullable: true),
Archived = table.Column<bool>(nullable: false),
Blob = table.Column<byte[]>(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<string>(maxLength: 30, nullable: false),
Date = table.Column<DateTimeOffset>(nullable: false),
PullPaymentDataId = table.Column<string>(nullable: true),
State = table.Column<string>(maxLength: 20, nullable: false),
PaymentMethodId = table.Column<string>(maxLength: 20, nullable: false),
Destination = table.Column<string>(nullable: true),
Blob = table.Column<byte[]>(nullable: true),
Proof = table.Column<byte[]>(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");
}
}
}

View file

@ -407,6 +407,49 @@ namespace BTCPayServer.Migrations
b.ToTable("PaymentRequests");
});
modelBuilder.Entity("BTCPayServer.Data.PayoutData", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(30);
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<DateTimeOffset>("Date")
.HasColumnType("TEXT");
b.Property<string>("Destination")
.HasColumnType("TEXT");
b.Property<string>("PaymentMethodId")
.IsRequired()
.HasColumnType("TEXT")
.HasMaxLength(20);
b.Property<byte[]>("Proof")
.HasColumnType("BLOB");
b.Property<string>("PullPaymentDataId")
.HasColumnType("TEXT");
b.Property<string>("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<string>("Id")
@ -434,6 +477,38 @@ namespace BTCPayServer.Migrations
b.ToTable("PlannedTransactions");
});
modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasMaxLength(30);
b.Property<bool>("Archived")
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
b.Property<DateTimeOffset?>("EndDate")
.HasColumnType("TEXT");
b.Property<long?>("Period")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("StartDate")
.HasColumnType("TEXT");
b.Property<string>("StoreId")
.HasColumnType("TEXT")
.HasMaxLength(50);
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("PullPayments");
});
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
{
b.Property<string>("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")

View file

@ -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))
{

View file

@ -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)

View file

@ -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<HttpRequestException>(async () => await client.ArchivePullPayment("lol", result.Id));
Logs.Tester.LogInformation("Can't archive without permission");
await Assert.ThrowsAsync<HttpRequestException>(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<Task> act)
{
var err = await Assert.ThrowsAsync<GreenFieldAPIException>(async () => await act());
Assert.Equal(expectedError, err.APIError.Code);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task StoresControllerTests()

View file

@ -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<ExplorerClientProvider>().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();

View file

@ -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)
{

View file

@ -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<ApplicationDbContextFactory>().CreateContext();
var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
Assert.True(payoutsData.All(p => p.State == Data.PayoutState.Completed));
});
}
}
}
}

View file

@ -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<PaymentEntity>();
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<PaymentEntity>();
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<PaymentEntity>();
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<PaymentEntity>();
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<ViewResult>(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<HttpRequestException>(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<StoresController>();
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<UpdatePointOfSaleViewModel>(Assert
.IsType<ViewResult>(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<Task>();
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<ApplicationDbContextFactory>().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<RedirectToActionResult>(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<ManageController>();
@ -3744,7 +3806,8 @@ normal:
//check if we are showing the u2f login screen now
var secondLoginResult = Assert.IsType<ViewResult>(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);

View file

@ -186,6 +186,12 @@
<Content Update="Views\Wallets\ListWallets.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\NewPullPayment.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\Payouts.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\WalletPSBTCombine.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
@ -201,6 +207,9 @@
<Content Update="Views\Wallets\WalletSendVault.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\PullPayments.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\Wallets\WalletTransactions.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
@ -219,5 +228,5 @@
<_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" />
</ItemGroup>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
<ProjectExtensions><VisualStudio><UserProperties wwwroot_4swagger_4v1_4swagger_1template_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1pull-payments_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1serverinfo_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" wwwroot_4swagger_4v1_4swagger_1template_1stores_1json__JsonSchema="https://raw.githubusercontent.com/OAI/OpenAPI-Specification/master/schemas/v3.0/schema.json" /></VisualStudio></ProjectExtensions>
</Project>

View file

@ -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<IActionResult> 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<IActionResult> 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<BTCPayNetwork>(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<IActionResult> 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<IActionResult> 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<IActionResult> CreatePayout(string pullPaymentId, CreatePayoutRequest request)
{
if (request is null)
return NotFound();
var network = request?.PaymentMethod is string paymentMethod ?
this._networkProvider.GetNetwork<BTCPayNetwork>(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<IActionResult> 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<IActionResult> 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();
}
}
}

View file

@ -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,

View file

@ -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<IActionResult> 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<BTCPayNetwork>(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<IActionResult> 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<BTCPayNetwork>(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);
}
}
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<BTCPayNetwork>(walletId.CryptoCode);
vm.WaitingForApproval = new List<PayoutsModel.PayoutModel>();
vm.Other = new List<PayoutsModel.PayoutModel>();
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);
}
}
}

View file

@ -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

View file

@ -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<PayoutData> GetPayout(this DbSet<PayoutData> 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<PullPaymentBlob>(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<PayoutBlob>(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<PayoutTransactionOnChainBlob>(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<uint256> Candidates { get; set; } = new HashSet<uint256>();
}
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<IClaimDestination>
{
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; }
}
}

View file

@ -57,6 +57,8 @@ namespace BTCPayServer.HostedServices
}
}
public CancellationToken CancellationToken => _Cts.Token;
public virtual async Task StopAsync(CancellationToken cancellationToken)
{
if (_Cts != null)

View file

@ -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<bool> Completion { get; set; }
}
public async Task<string> 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<Data.PullPaymentData> GetPullPayment(string pullPaymentId)
{
using var ctx = _dbContextFactory.CreateContext();
return await ctx.PullPayments.FindAsync(pullPaymentId);
}
class PayoutRequest
{
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> 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<ClaimRequest.ClaimResponse> 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<object> _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<object>();
_eventAggregator.Subscribe<NewOnChainTransactionEvent>(o => _Channel.Writer.TryWrite(o));
_eventAggregator.Subscribe<NewBlockEvent>(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<PayoutData> 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<BTCPayNetwork>(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<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
cancelRequest.Completion = cts;
_Channel.Writer.TryWrite(cancelRequest);
return cts.Task;
}
public Task<ClaimRequest.ClaimResponse> Claim(ClaimRequest request)
{
CancellationToken.ThrowIfCancellationRequested();
var cts = new TaskCompletionSource<ClaimRequest.ClaimResponse>(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; }
}
}

View file

@ -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<BTCPayNetwork, JsonConverter> create)
{
services.AddSingleton<IJsonConverterRegistration, JsonConverterRegistration>((s) => new JsonConverterRegistration(create));
return services;
}
public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration)
{
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
@ -66,6 +72,10 @@ namespace BTCPayServer.Hosting
{
httpClient.Timeout = Timeout.InfiniteTimeSpan;
});
services.AddSingleton<BTCPayNetworkJsonSerializerSettings>();
services.RegisterJsonConverter(n => new ClaimDestinationJsonConverter(n));
services.AddPayJoinServices();
services.AddMoneroLike();
services.TryAddSingleton<SettingsRepository>();
@ -189,7 +199,10 @@ namespace BTCPayServer.Hosting
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<CheckConfigurationHostedService>());
services.AddSingleton<HostedServices.PullPaymentHostedService>();
services.AddSingleton<IHostedService, HostedServices.PullPaymentHostedService>(o => o.GetRequiredService<PullPaymentHostedService>());
services.AddSingleton<BitcoinLikePaymentHandler>();
services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<BitcoinLikePaymentHandler>());
services.AddSingleton<IHostedService, NBXplorerListener>();
@ -223,6 +236,7 @@ namespace BTCPayServer.Hosting
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.TryAddSingleton<ExplorerClientProvider>();
services.TryAddSingleton<Bitpay>(o =>

View file

@ -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<PaymentMethodId>
{
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());
}
}
}

View file

@ -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;
}
}
}

View file

@ -9,7 +9,6 @@ namespace BTCPayServer.Models
public StatusMessageModel()
{
}
public string Message { get; set; }
public string Html { get; set; }
public StatusSeverity Severity { get; set; }

View file

@ -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 = $"<style>{EmbeddedCSS}</style>";
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<PayoutLine> Payouts { get; set; } = new List<PayoutLine>();
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; }
}
}
}

View file

@ -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<PayoutModel> WaitingForApproval { get; set; } = new List<PayoutModel>();
public List<PayoutModel> Other { get; set; } = new List<PayoutModel>();
}
}

View file

@ -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<PullPaymentModel> PullPayments { get; set; } = new List<PullPaymentModel>();
}
public class NewPullPaymentModel
{
[MaxLength(30)]
public string Name { get; set; }
[Required]
public decimal Amount
{
get; set;
}
[Required]
[ReadOnly(true)]
public string Currency { get; set; }
}
}

View file

@ -78,7 +78,6 @@ namespace BTCPayServer.PaymentRequest
}
var blob = pr.GetBlob();
var rateRules = pr.StoreData.GetStoreBlob().GetRateRules(_BtcPayNetworkProvider);
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id);

View file

@ -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;

View file

@ -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<BTCPayNetwork, JsonConverter> _createConverter;
public JsonConverterRegistration(Func<BTCPayNetwork, JsonConverter> createConverter)
{
_createConverter = createConverter;
}
public JsonConverter CreateJsonConverter(BTCPayNetwork network)
{
return _createConverter(network);
}
}
public class BTCPayNetworkJsonSerializerSettings
{
public BTCPayNetworkJsonSerializerSettings(BTCPayNetworkProvider networkProvider, IEnumerable<IJsonConverterRegistration> jsonSerializers)
{
foreach (var network in networkProvider.UnfilteredNetworks.GetAll().OfType<BTCPayNetwork>())
{
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<string, JsonSerializerSettings> _Serializers = new Dictionary<string, JsonSerializerSettings>();
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;
}
}
}

View file

@ -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<PayoutNotification>
{
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; }
}
}

View file

@ -0,0 +1,189 @@
@model BTCPayServer.Models.ViewPullPaymentModel
@addTagHelper *, BundlerMinifier.TagHelpers
@inject BTCPayServer.HostedServices.CssThemeManager themeManager
@{
ViewData["Title"] = Model.Title;
Layout = null;
}
<!DOCTYPE html>
<html class="h-100">
<head>
<title>@Model.Title</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<link href="@Context.Request.GetRelativePathOrAbsolute(themeManager.BootstrapUri)" rel="stylesheet" />
<link href="@Context.Request.GetRelativePathOrAbsolute(themeManager.ThemeUri)" rel="stylesheet" />
@if (Model.CustomCSSLink != null)
{
<link href="@Model.CustomCSSLink" rel="stylesheet" />
}
<bundle name="wwwroot/bundles/payment-request-bundle.min.css" asp-append-version="true"></bundle>
@Safe.Raw(Model.EmbeddedCSS)
</head>
<body>
<div class="container">
@if (TempData.HasStatusMessage())
{
<div class="row">
<div class="col-md-12 text-center">
<partial name="_StatusMessage" />
</div>
</div>
}
@if (!this.ViewContext.ModelState.IsValid)
{
<div class="row w-100 p-0 m-0">
<div class="col-md-12 text-center">
@Html.ValidationSummary(string.Empty, new { @class = "alert alert-danger" })
</div>
</div>
}
<div class="row w-100 p-0 m-0" style="height: 100vh">
<div class="mx-auto my-auto w-100">
<div class="card">
<h1 class="card-header px-3">
@Model.Title
<span class="text-muted float-right text-center">@Model.Status</span>
</h1>
@if (Model.IsPending)
{
<div class="card-header">
<form class="form-inline text-right float-right" asp-action="ClaimPullPayment" asp-route-pullPaymentId="@Model.Id">
<div class="input-group" style="max-width: 250px">
<input class="form-control"
asp-for="Destination"
placeholder="Destination address"
required>
</div>
<div class="input-group" style="max-width: 250px">
<input class="form-control"
asp-for="ClaimedAmount"
type="number"
max="@Model.AmountDue"
min="@Model.MinimumClaim"
step="any"
placeholder="Amount"
required>
<div class="input-group-append">
<span class='input-group-text'>@Model.Currency.ToUpper()</span>
</div>
</div>
<div class="input-group" style="max-width: 250px">
<button class="form-control btn btn-primary"
type="submit">
Claim now
</button>
</div>
</form>
</div>
}
<div class="card-body px-0 pt-0 pb-0">
<div class="row">
<div class="col-sm-12 col-md-12 col-lg-6">
<div class="table-responsive">
<table class="table border-top-0">
<thead>
<tr>
<th class="border-top-0">Pull payment details</th>
<th class="border-top-0" style="text-align:right;"></th>
</tr>
</thead>
<tbody>
<tr class="bg-light">
<td class="font-weight-bold">Claim limit:</td>
<td class="text-right">@Model.AmountFormatted</td>
</tr>
<tr class="bg-light">
<td class="font-weight-bold">Already claimed:</td>
<td class="text-right">@Model.AmountCollectedFormatted</td>
</tr>
<tr class="bg-light">
<td class="font-weight-bold">Available claim:</td>
<td class="text-right">@Model.AmountDueFormatted</td>
</tr>
@if (Model.ResetIn != String.Empty)
{
<tr class="bg-light">
<td class="font-weight-bold">Reset in:</td>
<td class="text-right">@Model.ResetIn</td>
</tr>
}
</tbody>
</table>
@if (Model.Description != null && Model.Description != "" && Model.Description != "<br>")
{
<div class="w-100 px-3 pt-4 pb-3">@Safe.Raw(Model.Description)</div>
}
</div>
</div>
<div class="col-sm-12 col-md-12 col-lg-6">
<div class="table-responsive">
<table class="table border-top-0">
<thead>
<tr>
<th class="border-top-0">Awaiting claims</th>
<th class="border-top-0 text-right"></th>
</tr>
</thead>
<tbody>
@if (Model.Payouts == null && !Model.Payouts.Any())
{
<tr>
<td colspan="4" class="text-center">No claim made yet</td>
</tr>
}
else
{
foreach (var invoice in Model.Payouts)
{
<tr class="bg-light">
<td class="font-weight-bold">Status</td>
<td class="text-right">@invoice.Status</td>
</tr>
<tr class="bg-light">
<td class="font-weight-bold">Amount claimed</td>
<td class="text-right">@invoice.AmountFormatted</td>
</tr>
<tr class="bg-light">
<td class="font-weight-bold">Destination</td>
<td class="text-right">@invoice.Destination</td>
</tr>
@if (!String.IsNullOrEmpty(invoice.Link))
{
<tr class="bg-light">
<td class="font-weight-bold">Transaction</td>
<td class="text-right text-truncate" style="max-width: 100px;"><a class="transaction-link" href="@invoice.Link">@invoice.TransactionId</a></td>
</tr>
}
<tr>
<td></td>
<td></td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="card-footer text-muted d-flex justify-content-between">
<div>
<div>Updated @Model.LastUpdated.ToString("g")</div>
</div>
<div>
<span class="text-muted">Powered by </span><a href="https://btcpayserver.org" target="_blank">BTCPay Server</a>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,83 @@
@model NewPullPaymentModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Manage pull payments";
ViewData.SetActivePageAndTitle(WalletsNavPages.PullPayments);
}
<style type="text/css">
.smMaxWidth {
max-width: 200px;
}
@@media (min-width: 768px) {
.smMaxWidth {
max-width: 400px;
}
}
.unconf > * {
opacity: 0.5;
}
.switchTimeFormat {
display: block;
max-width: 150px;
width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.transactionLabel:not(:last-child) {
margin-bottom: 4px;
}
.removeTransactionLabelForm {
display: inline;
position: absolute;
right: 4px;
}
.removeTransactionLabelForm button {
color: #212529;
cursor: pointer;
display: inline;
padding: 0;
background-color: transparent;
border: 0;
}
</style>
@if (TempData.HasStatusMessage())
{
<div class="row">
<div class="col-md-12 text-center">
<partial name="_StatusMessage" />
</div>
</div>
}
<div class="row">
<div class="col-md-6">
<form method="post"
asp-route-walletId="@this.Context.GetRouteValue("walletId")"
asp-action="NewPullPayment">
<div class="form-group">
<label asp-for="Name" class="control-label"></label>
<input asp-for="Name" class="form-control" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Amount" class="control-label"></label>
<input asp-for="Amount" class="form-control" />
<span asp-validation-for="Amount" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Currency" class="control-label"></label>
<input asp-for="Currency" class="form-control" readonly />
<span asp-validation-for="Currency" class="text-danger"></span>
</div>
<div class="form-group">
<input type="submit" value="Create" class="btn btn-primary" id="Create" />
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,123 @@
@model PayoutsModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["Title"] = "Manage payouts";
ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts);
}
@if (TempData.HasStatusMessage())
{
<div class="row">
<div class="col-md-12 text-center">
<partial name="_StatusMessage" />
</div>
</div>
}
<script type="text/javascript">
function selectAll(e)
{
var items = document.getElementsByClassName("selection-item");
for (var i = 0; i < items.length; i++) {
items[i].checked = e.checked;
}
}
</script>
<form method="post">
<div class="row button-row">
<div class="col">
List of unprocessed payouts
</div>
<div class="col text-right">
<button type="submit" id="payCommand" name="Command" class="btn btn-primary" role="button" value="pay">Confirm selected payouts</button>
<button type="submit" id="payCommand" name="Command" class="btn btn-secondary" role="button" value="cancel">Cancel selected payouts</button>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h3>Payouts to process</h3>
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th><input id="selectAllCheckbox" type="checkbox" onclick="selectAll(this); return true;" /></th>
<th style="min-width: 90px;" class="col-md-auto">
Date
</th>
<th class="text-left">Source</th>
<th class="text-left">Destination</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.WaitingForApproval.Count; i++)
{
var pp = Model.WaitingForApproval[i];
<tr>
<td>
<span>
<input type="checkbox" class="selection-item" asp-for="WaitingForApproval[i].Selected" />
<input type="hidden" asp-for="WaitingForApproval[i].PayoutId" />
</span>
</td>
<td><span>@pp.Date.ToBrowserDate()</span></td>
<td class="mw-100"><span>@pp.PullPaymentName</span></td>
<td><span>@pp.Destination</span></td>
<td class="text-right"><span>@pp.Amount</span></td>
</tr>
}
@if (Model.WaitingForApproval.Count == 0)
{
<tr>
<td colspan="5" class="text-center"><span>No payout waiting for approval</span></td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h3>Completed payouts</h3>
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th style="min-width: 90px;" class="col-md-auto">
Date
</th>
<th class="text-left">Source</th>
<th class="text-left">Destination</th>
<th class="text-right">Amount</th>
<th class="text-right">Transaction</th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.Other.Count; i++)
{
var pp = Model.Other[i];
<tr>
<td><span>@pp.Date.ToBrowserDate()</span></td>
<td class="mw-100"><span>@pp.PullPaymentName</span></td>
<td><span>@pp.Destination</span></td>
<td class="text-right"><span>@pp.Amount</span></td>
@if (pp.TransactionLink is null)
{
<td class="text-right"><span>Cancelled</span></td>
}
else
{
<td class="text-right"><span><a class="transaction-link" href="@pp.TransactionLink">Link</a></span></td>
}
</tr>
}
@if (Model.Other.Count == 0)
{
<tr>
<td colspan="5" class="text-center"><span>No payout in history</span></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</form>

View file

@ -0,0 +1,114 @@
@model PullPaymentsModel
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(WalletsNavPages.PullPayments);
ViewData["Title"] = "Pull payments";
}
<style type="text/css">
.tooltip-inner {
text-align: left;
}
</style>
@if (TempData.HasStatusMessage())
{
<div class="row">
<div class="col-md-12 text-center">
<partial name="_StatusMessage" />
</div>
</div>
}
<div class="row">
<div class="col-lg-12">
<p>
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.
</p>
</div>
</div>
<div class="row button-row">
<div class="col">
</div>
<div class="col text-right">
<a asp-action="NewPullPayment"
asp-route-walletId="@this.Context.GetRouteValue("walletId")"
class="btn btn-primary" role="button" id="NewPullPayment"><span class="fa fa-plus"></span> Create a new pull payment</a>
</div>
</div>
<div class="row">
@foreach (var pp in Model.PullPayments)
{
<script id="tooptip_template_@pp.Id" type="text/template">
<span>Awaiting:&nbsp;<span class="float-right">@pp.Progress.Awaiting</span></span>
<br />
<span>Completed:&nbsp;<span class="float-right">@pp.Progress.Completed</span></span>
<br />
<span>Limit:&nbsp;<span class="float-right">@pp.Progress.Limit</span></span>
@if (pp.Progress.ResetIn != null)
{
<br />
<span>Resets in:&nbsp;<span class="float-right">@pp.Progress.ResetIn</span></span>
}
@if (pp.Progress.EndIn != null)
{
<br />
<span>Expires in:&nbsp;<span class="float-right">@pp.Progress.EndIn</span></span>
}
</script>
}
<div class="col-md-12">
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th style="min-width: 90px;" class="col-md-auto">
Start
</th>
<th style="max-width: 90px;" class="text-left">Name</th>
<th class="text-left">Refunded</th>
<th class="text-right">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var pp in Model.PullPayments)
{
<tr>
<td class="col-2"><span>@pp.StartDate.ToBrowserDate()</span></td>
<td class="col-2"><span>@pp.Name</span></td>
<td class="col-4">
<div class="progress ppProgress" data-pp="@pp.Id" data-toggle="tooltip" data-html="true">
<div class="progress-bar" role="progressbar" aria-valuenow="@pp.Progress.CompletedPercent"
aria-valuemin="0" aria-valuemax="100" style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width:@(pp.Progress.CompletedPercent)%;">
</div>
<div class="progress-bar" role="progressbar" aria-valuenow="@pp.Progress.AwaitingPercent"
aria-valuemin="0" aria-valuemax="100" style="background-color:orange; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; width:@(pp.Progress.AwaitingPercent)%;">
</div>
</div>
</td>
<td class="text-right col-3">
<a asp-action="ViewPullPayment"
asp-controller="PullPayment"
asp-route-pullPaymentId="@pp.Id">View</a>| <a class="pp-payout" asp-action="Payouts"
asp-route-walletId="@this.Context.GetRouteValue("walletId")"
asp-route-pullPaymentId="@pp.Id">Payouts</a> | <a asp-action="ArchivePullPayment"
asp-route-walletId="@this.Context.GetRouteValue("walletId")"
asp-route-pullPaymentId="@pp.Id">Archive</a>
</td>
</tr>
}
</tbody>
</table>
<script type="text/javascript">
var ppProgresses = document.getElementsByClassName("ppProgress");
for (var i = 0; i < ppProgresses.length; i++) {
var pp = ppProgresses[i];
var ppId = pp.getAttribute("data-pp");
var template = document.getElementById("tooptip_template_" + ppId);
pp.setAttribute("title", template.innerHTML);
}
</script>
</div>
</div>

View file

@ -19,7 +19,7 @@
<div class="row">
<div class="@(!Model.InputSelection && Model.Outputs.Count==1? "col-lg-6 transaction-output-form": "col-lg-8")">
<form method="post">
<form method="post" asp-action="WalletSend" asp-route-walletId="@this.Context.GetRouteValue("walletId")">
<input type="hidden" asp-for="InputSelection" />
<input type="hidden" asp-for="Divisibility" />
<input type="hidden" asp-for="NBXSeedAvailable" />

View file

@ -46,7 +46,7 @@
background-color: transparent;
border: 0;
}
</style>
</style>
@if (TempData.HasStatusMessage())
{
<div class="row">

View file

@ -11,6 +11,8 @@ namespace BTCPayServer.Views.Wallets
Transactions,
Rescan,
PSBT,
PullPayments,
Payouts,
Settings,
Receive
}

View file

@ -4,17 +4,19 @@
var wallet = WalletId.Parse( this.Context.GetRouteValue("walletId").ToString());
var network = BtcPayNetworkProvider.GetNetwork<BTCPayNetwork>(wallet.CryptoCode);
}
<div class="nav flex-column nav-pills">
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Transactions)" asp-action="WalletTransactions" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletTransactions">Transactions</a>
@if (!network.ReadonlyWallet)
{
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Send)" asp-action="WalletSend" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSend">Send</a>
}
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Receive)" asp-action="WalletReceive" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletReceive">Receive</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Rescan)" asp-action="WalletRescan" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletRescan">Rescan</a>
@if (!network.ReadonlyWallet)
{
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">PSBT</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Settings)" asp-action="WalletSettings" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSettings">Settings</a>
}
</div>
<div class="nav flex-column nav-pills">
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Transactions)" asp-action="WalletTransactions" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletTransactions">Transactions</a>
@if (!network.ReadonlyWallet)
{
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Send)" asp-action="WalletSend" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSend">Send</a>
}
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Receive)" asp-action="WalletReceive" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletReceive">Receive</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Rescan)" asp-action="WalletRescan" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletRescan">Rescan</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PullPayments)" asp-action="PullPayments" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletPullPayments">Pull payments</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Payouts)" asp-action="Payouts" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletPayouts">Payouts</a>
@if (!network.ReadonlyWallet)
{
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">PSBT</a>
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Settings)" asp-action="WalletSettings" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSettings">Settings</a>
}
</div>

View file

@ -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"
}
}
}
}
}
}