mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
Add pull payment feature (#1639)
This commit is contained in:
parent
7805e5cea4
commit
8230a408ac
50 changed files with 3886 additions and 163 deletions
|
@ -1,5 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Client
|
||||
{
|
||||
|
|
58
BTCPayServer.Client/BTCPayServerClient.PullPayments.cs
Normal file
58
BTCPayServer.Client/BTCPayServerClient.PullPayments.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -38,7 +38,7 @@ namespace BTCPayServer.Client.JsonConverters
|
|||
{
|
||||
if (value is TimeSpan s)
|
||||
{
|
||||
writer.WriteValue((int)s.TotalSeconds);
|
||||
writer.WriteValue((long)s.TotalSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
16
BTCPayServer.Client/Models/CreatePayoutRequest.cs
Normal file
16
BTCPayServer.Client/Models/CreatePayoutRequest.cs
Normal 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; }
|
||||
}
|
||||
}
|
24
BTCPayServer.Client/Models/CreatePullPaymentRequest.cs
Normal file
24
BTCPayServer.Client/Models/CreatePullPaymentRequest.cs
Normal 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; }
|
||||
}
|
||||
}
|
32
BTCPayServer.Client/Models/PayoutData.cs
Normal file
32
BTCPayServer.Client/Models/PayoutData.cs
Normal 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; }
|
||||
}
|
||||
}
|
26
BTCPayServer.Client/Models/PullPaymentBaseData.cs
Normal file
26
BTCPayServer.Client/Models/PullPaymentBaseData.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
64
BTCPayServer.Data/Data/PayoutData.cs
Normal file
64
BTCPayServer.Data/Data/PayoutData.cs
Normal 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
|
||||
}
|
||||
}
|
117
BTCPayServer.Data/Data/PullPaymentData.cs
Normal file
117
BTCPayServer.Data/Data/PullPaymentData.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")]
|
||||
|
|
92
BTCPayServer.Data/Migrations/20200623042347_pullpayments.cs
Normal file
92
BTCPayServer.Data/Migrations/20200623042347_pullpayments.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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))
|
||||
{
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
294
BTCPayServer/Controllers/GreenField/PullPaymentController.cs
Normal file
294
BTCPayServer/Controllers/GreenField/PullPaymentController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
152
BTCPayServer/Controllers/PullPaymentController.cs
Normal file
152
BTCPayServer/Controllers/PullPaymentController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
286
BTCPayServer/Controllers/WalletsController.PullPayments.cs
Normal file
286
BTCPayServer/Controllers/WalletsController.PullPayments.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
211
BTCPayServer/Data/PullPaymentsExtensions.cs
Normal file
211
BTCPayServer/Data/PullPaymentsExtensions.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -57,6 +57,8 @@ namespace BTCPayServer.HostedServices
|
|||
}
|
||||
}
|
||||
|
||||
public CancellationToken CancellationToken => _Cts.Token;
|
||||
|
||||
public virtual async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_Cts != null)
|
||||
|
|
524
BTCPayServer/HostedServices/PullPaymentHostedService.cs
Normal file
524
BTCPayServer/HostedServices/PullPaymentHostedService.cs
Normal 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; }
|
||||
}
|
||||
|
||||
}
|
|
@ -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 =>
|
||||
|
|
32
BTCPayServer/JsonConverters/PaymentMethodIdJsonConverter.cs
Normal file
32
BTCPayServer/JsonConverters/PaymentMethodIdJsonConverter.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
40
BTCPayServer/ModelBinders/PaymentMethodIdModelBinder.cs
Normal file
40
BTCPayServer/ModelBinders/PaymentMethodIdModelBinder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,7 +9,6 @@ namespace BTCPayServer.Models
|
|||
public StatusMessageModel()
|
||||
{
|
||||
}
|
||||
|
||||
public string Message { get; set; }
|
||||
public string Html { get; set; }
|
||||
public StatusSeverity Severity { get; set; }
|
||||
|
|
101
BTCPayServer/Models/ViewPullPaymentModel.cs
Normal file
101
BTCPayServer/Models/ViewPullPaymentModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
26
BTCPayServer/Models/WalletViewModels/PayoutsModel.cs
Normal file
26
BTCPayServer/Models/WalletViewModels/PayoutsModel.cs
Normal 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>();
|
||||
}
|
||||
}
|
49
BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs
Normal file
49
BTCPayServer/Models/WalletViewModels/PullPaymentsModel.cs
Normal 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; }
|
||||
}
|
||||
}
|
|
@ -78,7 +78,6 @@ namespace BTCPayServer.PaymentRequest
|
|||
}
|
||||
|
||||
var blob = pr.GetBlob();
|
||||
var rateRules = pr.StoreData.GetStoreBlob().GetRateRules(_BtcPayNetworkProvider);
|
||||
|
||||
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
69
BTCPayServer/Services/BTCPayNetworkJsonSerializerSettings.cs
Normal file
69
BTCPayServer/Services/BTCPayNetworkJsonSerializerSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
189
BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml
Normal file
189
BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml
Normal 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>
|
83
BTCPayServer/Views/Wallets/NewPullPayment.cshtml
Normal file
83
BTCPayServer/Views/Wallets/NewPullPayment.cshtml
Normal 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>
|
123
BTCPayServer/Views/Wallets/Payouts.cshtml
Normal file
123
BTCPayServer/Views/Wallets/Payouts.cshtml
Normal 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>
|
114
BTCPayServer/Views/Wallets/PullPayments.cshtml
Normal file
114
BTCPayServer/Views/Wallets/PullPayments.cshtml
Normal 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: <span class="float-right">@pp.Progress.Awaiting</span></span>
|
||||
<br />
|
||||
<span>Completed: <span class="float-right">@pp.Progress.Completed</span></span>
|
||||
<br />
|
||||
<span>Limit: <span class="float-right">@pp.Progress.Limit</span></span>
|
||||
@if (pp.Progress.ResetIn != null)
|
||||
{
|
||||
<br />
|
||||
<span>Resets in: <span class="float-right">@pp.Progress.ResetIn</span></span>
|
||||
}
|
||||
@if (pp.Progress.EndIn != null)
|
||||
{
|
||||
<br />
|
||||
<span>Expires in: <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>
|
|
@ -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" />
|
||||
|
|
|
@ -46,7 +46,7 @@
|
|||
background-color: transparent;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@if (TempData.HasStatusMessage())
|
||||
{
|
||||
<div class="row">
|
||||
|
|
|
@ -11,6 +11,8 @@ namespace BTCPayServer.Views.Wallets
|
|||
Transactions,
|
||||
Rescan,
|
||||
PSBT,
|
||||
PullPayments,
|
||||
Payouts,
|
||||
Settings,
|
||||
Receive
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue