mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 06:21:44 +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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
|
||||||
namespace BTCPayServer.Client
|
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)
|
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 CanModifyProfile = "btcpay.user.canmodifyprofile";
|
||||||
public const string CanViewProfile = "btcpay.user.canviewprofile";
|
public const string CanViewProfile = "btcpay.user.canviewprofile";
|
||||||
public const string CanCreateUser = "btcpay.server.cancreateuser";
|
public const string CanCreateUser = "btcpay.server.cancreateuser";
|
||||||
|
public const string CanManagePullPayments = "btcpay.store.canmanagepullpayments";
|
||||||
public const string Unrestricted = "unrestricted";
|
public const string Unrestricted = "unrestricted";
|
||||||
public static IEnumerable<string> AllPolicies
|
public static IEnumerable<string> AllPolicies
|
||||||
{
|
{
|
||||||
|
@ -38,6 +39,7 @@ namespace BTCPayServer.Client
|
||||||
yield return CanCreateLightningInvoiceInternalNode;
|
yield return CanCreateLightningInvoiceInternalNode;
|
||||||
yield return CanUseLightningNodeInStore;
|
yield return CanUseLightningNodeInStore;
|
||||||
yield return CanCreateLightningInvoiceInStore;
|
yield return CanCreateLightningInvoiceInStore;
|
||||||
|
yield return CanManagePullPayments;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public static bool IsValidPolicy(string policy)
|
public static bool IsValidPolicy(string policy)
|
||||||
|
@ -53,7 +55,6 @@ namespace BTCPayServer.Client
|
||||||
{
|
{
|
||||||
return policy.StartsWith("btcpay.store.canmodify", StringComparison.OrdinalIgnoreCase);
|
return policy.StartsWith("btcpay.store.canmodify", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static bool IsServerPolicy(string policy)
|
public static bool IsServerPolicy(string policy)
|
||||||
{
|
{
|
||||||
return policy.StartsWith("btcpay.server", StringComparison.OrdinalIgnoreCase);
|
return policy.StartsWith("btcpay.server", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
|
@ -45,6 +45,8 @@ namespace BTCPayServer.Data
|
||||||
public DbSet<RefundAddressesData> RefundAddresses { get; set; }
|
public DbSet<RefundAddressesData> RefundAddresses { get; set; }
|
||||||
public DbSet<PaymentData> Payments { get; set; }
|
public DbSet<PaymentData> Payments { get; set; }
|
||||||
public DbSet<PaymentRequestData> PaymentRequests { 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<WalletData> Wallets { get; set; }
|
||||||
public DbSet<WalletTransactionData> WalletTransactions { get; set; }
|
public DbSet<WalletTransactionData> WalletTransactions { get; set; }
|
||||||
public DbSet<StoreData> Stores { get; set; }
|
public DbSet<StoreData> Stores { get; set; }
|
||||||
|
@ -205,6 +207,9 @@ namespace BTCPayServer.Data
|
||||||
.HasOne(o => o.WalletData)
|
.HasOne(o => o.WalletData)
|
||||||
.WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade);
|
.WithMany(w => w.WalletTransactions).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
PullPaymentData.OnModelCreating(builder);
|
||||||
|
PayoutData.OnModelCreating(builder);
|
||||||
|
|
||||||
if (Database.IsSqlite() && !_designTime)
|
if (Database.IsSqlite() && !_designTime)
|
||||||
{
|
{
|
||||||
// SQLite does not have proper support for DateTimeOffset via Entity Framework Core, see the limitations
|
// 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<PaymentRequestData> PaymentRequests { get; set; }
|
||||||
|
|
||||||
|
public List<PullPaymentData> PullPayments { get; set; }
|
||||||
|
|
||||||
public List<InvoiceData> Invoices { get; set; }
|
public List<InvoiceData> Invoices { get; set; }
|
||||||
|
|
||||||
[Obsolete("Use GetDerivationStrategies instead")]
|
[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");
|
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 =>
|
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
@ -434,6 +477,38 @@ namespace BTCPayServer.Migrations
|
||||||
b.ToTable("PlannedTransactions");
|
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 =>
|
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
|
@ -822,6 +897,14 @@ namespace BTCPayServer.Migrations
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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 =>
|
modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
||||||
|
@ -831,6 +914,14 @@ namespace BTCPayServer.Migrations
|
||||||
.IsRequired();
|
.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 =>
|
modelBuilder.Entity("BTCPayServer.Data.RefundAddressesData", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData")
|
||||||
|
|
|
@ -147,6 +147,8 @@ namespace BTCPayServer.Services.Rates
|
||||||
|
|
||||||
public CurrencyData GetCurrencyData(string currency, bool useFallback)
|
public CurrencyData GetCurrencyData(string currency, bool useFallback)
|
||||||
{
|
{
|
||||||
|
if (currency == null)
|
||||||
|
throw new ArgumentNullException(nameof(currency));
|
||||||
CurrencyData result;
|
CurrencyData result;
|
||||||
if (!_Currencies.TryGetValue(currency.ToUpperInvariant(), out result))
|
if (!_Currencies.TryGetValue(currency.ToUpperInvariant(), out result))
|
||||||
{
|
{
|
||||||
|
|
|
@ -4,6 +4,10 @@ namespace BTCPayServer.Rating
|
||||||
{
|
{
|
||||||
public static class Extensions
|
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)
|
public static decimal RoundToSignificant(this decimal value, ref int divisibility)
|
||||||
{
|
{
|
||||||
if (value != 0m)
|
if (value != 0m)
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Reflection.Metadata;
|
using System.Reflection.Metadata;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Client.Models;
|
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)]
|
[Fact(Timeout = TestTimeout)]
|
||||||
[Trait("Integration", "Integration")]
|
[Trait("Integration", "Integration")]
|
||||||
public async Task StoresControllerTests()
|
public async Task StoresControllerTests()
|
||||||
|
|
|
@ -19,6 +19,7 @@ using BTCPayServer.Services.Invoices;
|
||||||
using BTCPayServer.Services.Wallets;
|
using BTCPayServer.Services.Wallets;
|
||||||
using BTCPayServer.Tests.Logging;
|
using BTCPayServer.Tests.Logging;
|
||||||
using BTCPayServer.Views.Wallets;
|
using BTCPayServer.Views.Wallets;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
@ -706,12 +707,12 @@ namespace BTCPayServer.Tests
|
||||||
await RunVector(true);
|
await RunVector(true);
|
||||||
|
|
||||||
var originalSenderUser = senderUser;
|
var originalSenderUser = senderUser;
|
||||||
retry:
|
retry:
|
||||||
// Additional fee is 96 , minrelaytx is 294
|
// Additional fee is 96 , minrelaytx is 294
|
||||||
// We pay correctly, fees partially taken from what is overpaid
|
// We pay correctly, fees partially taken from what is overpaid
|
||||||
// We paid 510, the receiver pay 10 sat
|
// We paid 510, the receiver pay 10 sat
|
||||||
// The send pay remaining 86 sat from his pocket
|
// 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)
|
// 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)" : "")}");
|
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);
|
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();
|
proposedPSBT = await RunVector();
|
||||||
|
|
|
@ -102,6 +102,7 @@ namespace BTCPayServer.Tests
|
||||||
public string RegisterNewUser(bool isAdmin = false)
|
public string RegisterNewUser(bool isAdmin = false)
|
||||||
{
|
{
|
||||||
var usr = RandomUtils.GetUInt256().ToString().Substring(64 - 20) + "@a.com";
|
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("Email")).SendKeys(usr);
|
||||||
Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
Driver.FindElement(By.Id("Password")).SendKeys("123456");
|
||||||
Driver.FindElement(By.Id("ConfirmPassword")).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("CreateStore")).Click();
|
||||||
Driver.FindElement(By.Id("Name")).SendKeys(usr);
|
Driver.FindElement(By.Id("Name")).SendKeys(usr);
|
||||||
Driver.FindElement(By.Id("Create")).Click();
|
Driver.FindElement(By.Id("Create")).Click();
|
||||||
|
StoreId = Driver.FindElement(By.Id("Id")).GetAttribute("value");
|
||||||
return (usr, 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)
|
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;
|
seed = Driver.FindElements(By.ClassName("alert-success")).First().FindElement(By.TagName("code")).Text;
|
||||||
}
|
}
|
||||||
|
WalletId = new WalletId(StoreId, cryptoCode);
|
||||||
return new Mnemonic(seed);
|
return new Mnemonic(seed);
|
||||||
}
|
}
|
||||||
|
public WalletId WalletId { get; set; }
|
||||||
public void AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "xpub661MyMwAqRbcGABgHMUXDzPzH1tU7eZaAaJQXhDXsSxsqyQzQeU6kznNfSuAyqAK9UaWSaZaMFdNiY5BCF4zBPAzSnwfUAwUhwttuAKwfRX-[legacy]")
|
public void AddDerivationScheme(string cryptoCode = "BTC", string derivationScheme = "xpub661MyMwAqRbcGABgHMUXDzPzH1tU7eZaAaJQXhDXsSxsqyQzQeU6kznNfSuAyqAK9UaWSaZaMFdNiY5BCF4zBPAzSnwfUAwUhwttuAKwfRX-[legacy]")
|
||||||
{
|
{
|
||||||
Driver.FindElement(By.Id($"Modify{cryptoCode}")).ForceClick();
|
Driver.FindElement(By.Id($"Modify{cryptoCode}")).ForceClick();
|
||||||
|
@ -317,8 +319,9 @@ namespace BTCPayServer.Tests
|
||||||
return id;
|
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);
|
GoToWallet(walletId, WalletsNavPages.Receive);
|
||||||
Driver.FindElement(By.Id("generateButton")).Click();
|
Driver.FindElement(By.Id("generateButton")).Click();
|
||||||
var addressStr = Driver.FindElement(By.Id("vue-address")).GetProperty("value");
|
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}"));
|
Driver.Navigate().GoToUrl(new Uri(Server.PayTester.ServerUri, $"wallets/{walletId}"));
|
||||||
if (navPages != WalletsNavPages.Transactions)
|
if (navPages != WalletsNavPages.Transactions)
|
||||||
{
|
{
|
||||||
|
|
|
@ -17,6 +17,9 @@ using BTCPayServer.Services.Wallets;
|
||||||
using BTCPayServer.Views.Wallets;
|
using BTCPayServer.Views.Wallets;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using BTCPayServer.Client.Models;
|
using BTCPayServer.Client.Models;
|
||||||
|
using System.Threading;
|
||||||
|
using ExchangeSharp;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace BTCPayServer.Tests
|
namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
|
@ -517,7 +520,7 @@ namespace BTCPayServer.Tests
|
||||||
{
|
{
|
||||||
await s.StartAsync();
|
await s.StartAsync();
|
||||||
s.RegisterNewUser(true);
|
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
|
// 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
|
// to sign the transaction
|
||||||
|
@ -543,7 +546,7 @@ namespace BTCPayServer.Tests
|
||||||
//generate it again, should be the same one as before as nothign got used in the meantime
|
//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();
|
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).Click();
|
||||||
Assert.True(s.Driver.FindElement(By.ClassName("qr-container")).Displayed);
|
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
|
//send money to addr and ensure it changed
|
||||||
|
|
||||||
|
@ -556,7 +559,7 @@ namespace BTCPayServer.Tests
|
||||||
await Task.Delay(200);
|
await Task.Delay(200);
|
||||||
s.Driver.Navigate().Refresh();
|
s.Driver.Navigate().Refresh();
|
||||||
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).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"));
|
||||||
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
|
//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);
|
s.GoToStore(storeId.storeId);
|
||||||
|
@ -565,7 +568,7 @@ namespace BTCPayServer.Tests
|
||||||
s.Driver.FindElement(By.LinkText("Manage")).Click();
|
s.Driver.FindElement(By.LinkText("Manage")).Click();
|
||||||
s.Driver.FindElement(By.Id("WalletReceive")).Click();
|
s.Driver.FindElement(By.Id("WalletReceive")).Click();
|
||||||
s.Driver.FindElement(By.CssSelector("button[value=generate-new-address]")).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 invoiceId = s.CreateInvoice(storeId.storeName);
|
||||||
|
@ -581,12 +584,12 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
//lets import and save private keys
|
//lets import and save private keys
|
||||||
var root = mnemonic.DeriveExtKey();
|
var root = mnemonic.DeriveExtKey();
|
||||||
invoiceId = s.CreateInvoice(storeId.storeName);
|
invoiceId = s.CreateInvoice(storeId.storeName);
|
||||||
invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice( invoiceId);
|
invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
|
||||||
address = invoice.EntityToDTO().Addresses["BTC"];
|
address = invoice.EntityToDTO().Addresses["BTC"];
|
||||||
result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
|
result = await s.Server.ExplorerNode.GetAddressInfoAsync(BitcoinAddress.Create(address, Network.RegTest));
|
||||||
//spendable from bitcoin core wallet!
|
//spendable from bitcoin core wallet!
|
||||||
Assert.False(result.IsWatchOnly);
|
Assert.False(result.IsWatchOnly);
|
||||||
var tx = s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(address, Network.RegTest), Money.Coins(3.0m));
|
var tx = s.Server.ExplorerNode.SendToAddress(BitcoinAddress.Create(address, Network.RegTest), Money.Coins(3.0m));
|
||||||
s.Server.ExplorerNode.Generate(1);
|
s.Server.ExplorerNode.Generate(1);
|
||||||
|
|
||||||
|
@ -601,8 +604,8 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
// We setup the fingerprint and the account key path
|
// We setup the fingerprint and the account key path
|
||||||
s.Driver.FindElement(By.Id("WalletSettings")).ForceClick();
|
s.Driver.FindElement(By.Id("WalletSettings")).ForceClick();
|
||||||
// s.Driver.FindElement(By.Id("AccountKeys_0__MasterFingerprint")).SendKeys("8bafd160");
|
// 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__AccountKeyPath")).SendKeys("m/49'/0'/0'" + Keys.Enter);
|
||||||
|
|
||||||
// Check the tx sent earlier arrived
|
// Check the tx sent earlier arrived
|
||||||
s.Driver.FindElement(By.Id("WalletTransactions")).ForceClick();
|
s.Driver.FindElement(By.Id("WalletTransactions")).ForceClick();
|
||||||
|
@ -687,5 +690,116 @@ namespace BTCPayServer.Tests
|
||||||
checkboxElement.Click();
|
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)
|
public UnitTest1(ITestOutputHelper helper)
|
||||||
{
|
{
|
||||||
Logs.Tester = new XUnitLog(helper) {Name = "Tests"};
|
Logs.Tester = new XUnitLog(helper) { Name = "Tests" };
|
||||||
Logs.LogProvider = new XUnitLogProvider(helper);
|
Logs.LogProvider = new XUnitLogProvider(helper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -214,19 +214,21 @@ namespace BTCPayServer.Tests
|
||||||
InvoiceEntity invoiceEntity = new InvoiceEntity();
|
InvoiceEntity invoiceEntity = new InvoiceEntity();
|
||||||
invoiceEntity.Networks = networkProvider;
|
invoiceEntity.Networks = networkProvider;
|
||||||
invoiceEntity.Payments = new System.Collections.Generic.List<PaymentEntity>();
|
invoiceEntity.Payments = new System.Collections.Generic.List<PaymentEntity>();
|
||||||
invoiceEntity.ProductInformation = new ProductInformation() {Price = 100};
|
invoiceEntity.ProductInformation = new ProductInformation() { Price = 100 };
|
||||||
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
|
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(
|
.SetPaymentMethodDetails(
|
||||||
new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
|
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(
|
.SetPaymentMethodDetails(
|
||||||
new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
|
new BTCPayServer.Payments.Bitcoin.BitcoinLikeOnChainPaymentMethod()
|
||||||
{
|
{
|
||||||
NextNetworkFee = Money.Coins(0.00010000m), DepositAddress = dummy
|
NextNetworkFee = Money.Coins(0.00010000m),
|
||||||
|
DepositAddress = dummy
|
||||||
}));
|
}));
|
||||||
invoiceEntity.SetPaymentMethods(paymentMethods);
|
invoiceEntity.SetPaymentMethods(paymentMethods);
|
||||||
|
|
||||||
|
@ -235,29 +237,30 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
invoiceEntity.Payments.Add(
|
invoiceEntity.Payments.Add(
|
||||||
new PaymentEntity()
|
new PaymentEntity()
|
||||||
{
|
{
|
||||||
Accounted = true,
|
Accounted = true,
|
||||||
CryptoCode = "BTC",
|
CryptoCode = "BTC",
|
||||||
NetworkFee = 0.00000100m,
|
NetworkFee = 0.00000100m,
|
||||||
Network = networkProvider.GetNetwork("BTC"),
|
Network = networkProvider.GetNetwork("BTC"),
|
||||||
}
|
}
|
||||||
.SetCryptoPaymentData(new BitcoinLikePaymentData()
|
.SetCryptoPaymentData(new BitcoinLikePaymentData()
|
||||||
{
|
{
|
||||||
Network = networkProvider.GetNetwork("BTC"),
|
Network = networkProvider.GetNetwork("BTC"),
|
||||||
Output = new TxOut() {Value = Money.Coins(0.00151263m)}
|
Output = new TxOut() { Value = Money.Coins(0.00151263m) }
|
||||||
}));
|
}));
|
||||||
accounting = btc.Calculate();
|
accounting = btc.Calculate();
|
||||||
invoiceEntity.Payments.Add(
|
invoiceEntity.Payments.Add(
|
||||||
new PaymentEntity()
|
new PaymentEntity()
|
||||||
{
|
{
|
||||||
Accounted = true,
|
Accounted = true,
|
||||||
CryptoCode = "BTC",
|
CryptoCode = "BTC",
|
||||||
NetworkFee = 0.00000100m,
|
NetworkFee = 0.00000100m,
|
||||||
Network = networkProvider.GetNetwork("BTC")
|
Network = networkProvider.GetNetwork("BTC")
|
||||||
}
|
}
|
||||||
.SetCryptoPaymentData(new BitcoinLikePaymentData()
|
.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();
|
accounting = btc.Calculate();
|
||||||
Assert.Equal(Money.Zero, accounting.Due);
|
Assert.Equal(Money.Zero, accounting.Due);
|
||||||
|
@ -330,9 +333,11 @@ namespace BTCPayServer.Tests
|
||||||
entity.Payments = new System.Collections.Generic.List<PaymentEntity>();
|
entity.Payments = new System.Collections.Generic.List<PaymentEntity>();
|
||||||
entity.SetPaymentMethod(new PaymentMethod()
|
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 paymentMethod = entity.GetPaymentMethods().TryGet("BTC", PaymentTypes.BTCLike);
|
||||||
var accounting = paymentMethod.Calculate();
|
var accounting = paymentMethod.Calculate();
|
||||||
|
@ -341,7 +346,9 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
entity.Payments.Add(new PaymentEntity()
|
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();
|
accounting = paymentMethod.Calculate();
|
||||||
|
@ -351,7 +358,9 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
entity.Payments.Add(new PaymentEntity()
|
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();
|
accounting = paymentMethod.Calculate();
|
||||||
|
@ -360,7 +369,9 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
entity.Payments.Add(new PaymentEntity()
|
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();
|
accounting = paymentMethod.Calculate();
|
||||||
|
@ -368,7 +379,7 @@ namespace BTCPayServer.Tests
|
||||||
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
|
Assert.Equal(Money.Coins(1.3m), accounting.TotalDue);
|
||||||
|
|
||||||
entity.Payments.Add(
|
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();
|
accounting = paymentMethod.Calculate();
|
||||||
Assert.Equal(Money.Zero, accounting.Due);
|
Assert.Equal(Money.Zero, accounting.Due);
|
||||||
|
@ -376,12 +387,12 @@ namespace BTCPayServer.Tests
|
||||||
|
|
||||||
entity = new InvoiceEntity();
|
entity = new InvoiceEntity();
|
||||||
entity.Networks = networkProvider;
|
entity.Networks = networkProvider;
|
||||||
entity.ProductInformation = new ProductInformation() {Price = 5000};
|
entity.ProductInformation = new ProductInformation() { Price = 5000 };
|
||||||
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
|
PaymentMethodDictionary paymentMethods = new PaymentMethodDictionary();
|
||||||
paymentMethods.Add(
|
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(
|
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.SetPaymentMethods(paymentMethods);
|
||||||
entity.Payments = new List<PaymentEntity>();
|
entity.Payments = new List<PaymentEntity>();
|
||||||
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
|
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);
|
var remaining = Money.Coins(4.2m - 0.5m + 0.01m / 2);
|
||||||
entity.Payments.Add(new PaymentEntity()
|
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));
|
paymentMethod = entity.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike));
|
||||||
|
@ -518,9 +532,11 @@ namespace BTCPayServer.Tests
|
||||||
entity.Payments = new List<PaymentEntity>();
|
entity.Payments = new List<PaymentEntity>();
|
||||||
entity.SetPaymentMethod(new PaymentMethod()
|
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;
|
entity.PaymentTolerance = 0;
|
||||||
|
|
||||||
|
|
||||||
|
@ -560,7 +576,7 @@ namespace BTCPayServer.Tests
|
||||||
var invoice = user.BitPay.CreateInvoice(
|
var invoice = user.BitPay.CreateInvoice(
|
||||||
new Invoice()
|
new Invoice()
|
||||||
{
|
{
|
||||||
Buyer = new Buyer() {email = "test@fwf.com"},
|
Buyer = new Buyer() { email = "test@fwf.com" },
|
||||||
Price = 5000.0m,
|
Price = 5000.0m,
|
||||||
Currency = "USD",
|
Currency = "USD",
|
||||||
PosData = "posData",
|
PosData = "posData",
|
||||||
|
@ -596,7 +612,7 @@ namespace BTCPayServer.Tests
|
||||||
var invoice = user.BitPay.CreateInvoice(
|
var invoice = user.BitPay.CreateInvoice(
|
||||||
new Invoice()
|
new Invoice()
|
||||||
{
|
{
|
||||||
Buyer = new Buyer() {email = "test@fwf.com"},
|
Buyer = new Buyer() { email = "test@fwf.com" },
|
||||||
Price = 5000.0m,
|
Price = 5000.0m,
|
||||||
Currency = "USD",
|
Currency = "USD",
|
||||||
PosData = "posData",
|
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]
|
[Fact]
|
||||||
[Trait("Fast", "Fast")]
|
[Trait("Fast", "Fast")]
|
||||||
public void RoundupCurrenciesCorrectly()
|
public void RoundupCurrenciesCorrectly()
|
||||||
|
@ -644,7 +699,7 @@ namespace BTCPayServer.Tests
|
||||||
public async Task CanEnumerateTorServices()
|
public async Task CanEnumerateTorServices()
|
||||||
{
|
{
|
||||||
var tor = new TorServices(new BTCPayNetworkProvider(NetworkType.Regtest),
|
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();
|
await tor.Refresh();
|
||||||
|
|
||||||
Assert.Single(tor.Services.Where(t => t.ServiceType == TorServiceType.BTCPayServer));
|
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
|
// Make sure old connection string format does not work
|
||||||
Assert.IsType<ViewResult>(storeController.AddLightningNode(user.StoreId,
|
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());
|
"save", "BTC").GetAwaiter().GetResult());
|
||||||
|
|
||||||
var storeVm =
|
var storeVm =
|
||||||
|
@ -847,7 +902,7 @@ namespace BTCPayServer.Tests
|
||||||
var localInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
|
var localInvoice = await user.BitPay.GetInvoiceAsync(invoice.Id);
|
||||||
Assert.Equal("complete", localInvoice.Status);
|
Assert.Equal("complete", localInvoice.Status);
|
||||||
// C-Lightning may overpay for privacy
|
// 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(
|
var token = (RedirectToActionResult)await controller.CreateToken2(
|
||||||
new Models.StoreViewModels.CreateTokenViewModel()
|
new Models.StoreViewModels.CreateTokenViewModel()
|
||||||
{
|
{
|
||||||
Label = "bla", PublicKey = null, StoreId = acc.StoreId
|
Label = "bla",
|
||||||
|
PublicKey = null,
|
||||||
|
StoreId = acc.StoreId
|
||||||
});
|
});
|
||||||
|
|
||||||
var pairingCode = (string)token.RouteValues["pairingCode"];
|
var pairingCode = (string)token.RouteValues["pairingCode"];
|
||||||
|
@ -980,7 +1037,7 @@ namespace BTCPayServer.Tests
|
||||||
var fetcher = new RateFetcher(factory);
|
var fetcher = new RateFetcher(factory);
|
||||||
|
|
||||||
Assert.True(RateRules.TryParse("X_X=kraken(X_BTC) * kraken(BTC_X)", out var rule));
|
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();
|
var result = fetcher.FetchRate(CurrencyPair.Parse(pair), rule, default).GetAwaiter().GetResult();
|
||||||
Assert.NotNull(result.BidAsk);
|
Assert.NotNull(result.BidAsk);
|
||||||
|
@ -1308,7 +1365,7 @@ namespace BTCPayServer.Tests
|
||||||
user.RegisterDerivationScheme("BTC");
|
user.RegisterDerivationScheme("BTC");
|
||||||
user.SetNetworkFeeMode(NetworkFeeMode.Always);
|
user.SetNetworkFeeMode(NetworkFeeMode.Always);
|
||||||
var invoice =
|
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 payment1 = invoice.BtcDue + Money.Coins(0.0001m);
|
||||||
var payment2 = invoice.BtcDue;
|
var payment2 = invoice.BtcDue;
|
||||||
|
|
||||||
|
@ -1370,7 +1427,7 @@ namespace BTCPayServer.Tests
|
||||||
Logs.Tester.LogInformation(
|
Logs.Tester.LogInformation(
|
||||||
$"Let's test out rbf payments where the payment gets sent elsehwere instead");
|
$"Let's test out rbf payments where the payment gets sent elsehwere instead");
|
||||||
var invoice2 =
|
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 =
|
var invoice2Address =
|
||||||
BitcoinAddress.Create(invoice2.BitcoinAddress, user.SupportedNetwork.NBitcoinNetwork);
|
BitcoinAddress.Create(invoice2.BitcoinAddress, user.SupportedNetwork.NBitcoinNetwork);
|
||||||
|
@ -1408,7 +1465,7 @@ namespace BTCPayServer.Tests
|
||||||
Logs.Tester.LogInformation(
|
Logs.Tester.LogInformation(
|
||||||
$"Let's test if we can RBF a normal payment without adding fees to the invoice");
|
$"Let's test if we can RBF a normal payment without adding fees to the invoice");
|
||||||
user.SetNetworkFeeMode(NetworkFeeMode.MultiplePaymentsOnly);
|
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;
|
payment1 = invoice.BtcDue;
|
||||||
tx1 = new uint256(tester.ExplorerNode.SendCommand("sendtoaddress", new object[]
|
tx1 = new uint256(tester.ExplorerNode.SendCommand("sendtoaddress", new object[]
|
||||||
{
|
{
|
||||||
|
@ -1502,21 +1559,21 @@ namespace BTCPayServer.Tests
|
||||||
using (var tester = ServerTester.Create())
|
using (var tester = ServerTester.Create())
|
||||||
{
|
{
|
||||||
await tester.StartAsync();
|
await tester.StartAsync();
|
||||||
foreach (var req in new[] {"invoices/", "invoices", "rates", "tokens"}.Select(async path =>
|
foreach (var req in new[] { "invoices/", "invoices", "rates", "tokens" }.Select(async path =>
|
||||||
{
|
{
|
||||||
using (HttpClient client = new HttpClient())
|
using (HttpClient client = new HttpClient())
|
||||||
{
|
{
|
||||||
HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Options,
|
HttpRequestMessage message = new HttpRequestMessage(HttpMethod.Options,
|
||||||
tester.PayTester.ServerUri.AbsoluteUri + path);
|
tester.PayTester.ServerUri.AbsoluteUri + path);
|
||||||
message.Headers.Add("Access-Control-Request-Headers", "test");
|
message.Headers.Add("Access-Control-Request-Headers", "test");
|
||||||
var response = await client.SendAsync(message);
|
var response = await client.SendAsync(message);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var val));
|
Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Origin", out var val));
|
||||||
Assert.Equal("*", val.FirstOrDefault());
|
Assert.Equal("*", val.FirstOrDefault());
|
||||||
Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Headers", out val));
|
Assert.True(response.Headers.TryGetValues("Access-Control-Allow-Headers", out val));
|
||||||
Assert.Equal("test", val.FirstOrDefault());
|
Assert.Equal("test", val.FirstOrDefault());
|
||||||
}
|
}
|
||||||
}).ToList())
|
}).ToList())
|
||||||
{
|
{
|
||||||
await req;
|
await req;
|
||||||
}
|
}
|
||||||
|
@ -1547,7 +1604,7 @@ namespace BTCPayServer.Tests
|
||||||
// Test request pairing code client side
|
// Test request pairing code client side
|
||||||
var storeController = user.GetController<StoresController>();
|
var storeController = user.GetController<StoresController>();
|
||||||
storeController
|
storeController
|
||||||
.CreateToken(user.StoreId, new CreateTokenViewModel() {Label = "test2", StoreId = user.StoreId})
|
.CreateToken(user.StoreId, new CreateTokenViewModel() { Label = "test2", StoreId = user.StoreId })
|
||||||
.GetAwaiter().GetResult();
|
.GetAwaiter().GetResult();
|
||||||
Assert.NotNull(storeController.GeneratedPairingCode);
|
Assert.NotNull(storeController.GeneratedPairingCode);
|
||||||
|
|
||||||
|
@ -1586,7 +1643,7 @@ namespace BTCPayServer.Tests
|
||||||
tester.PayTester.ServerUri.AbsoluteUri + "invoices");
|
tester.PayTester.ServerUri.AbsoluteUri + "invoices");
|
||||||
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic",
|
message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic",
|
||||||
Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(apiKey)));
|
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,
|
message.Content = new StringContent(JsonConvert.SerializeObject(invoice), Encoding.UTF8,
|
||||||
"application/json");
|
"application/json");
|
||||||
var result = client.SendAsync(message).GetAwaiter().GetResult();
|
var result = client.SendAsync(message).GetAwaiter().GetResult();
|
||||||
|
@ -2646,7 +2703,7 @@ normal:
|
||||||
Assert.Equal(2, normalInvoice.CryptoInfo.Length);
|
Assert.Equal(2, normalInvoice.CryptoInfo.Length);
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
normalInvoice.CryptoInfo,
|
normalInvoice.CryptoInfo,
|
||||||
s => PaymentTypes.BTCLike.ToString() == s.PaymentType && new[] {"BTC", "LTC"}.Contains(
|
s => PaymentTypes.BTCLike.ToString() == s.PaymentType && new[] { "BTC", "LTC" }.Contains(
|
||||||
s.CryptoCode));
|
s.CryptoCode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2688,7 +2745,7 @@ normal:
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}, TimeSpan.FromSeconds(7.0));
|
}, 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();
|
CancellationTokenSource cts = new CancellationTokenSource();
|
||||||
var processing = client.ProcessJobs(cts.Token);
|
var processing = client.ProcessJobs(cts.Token);
|
||||||
|
|
||||||
|
@ -2697,20 +2754,20 @@ normal:
|
||||||
var waitJobsFinish = client.WaitAllRunning(default);
|
var waitJobsFinish = client.WaitAllRunning(default);
|
||||||
|
|
||||||
await mockDelay.Advance(TimeSpan.FromSeconds(2.0));
|
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));
|
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));
|
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.Equal(1, client.GetExecutingCount());
|
||||||
|
|
||||||
Assert.False(waitJobsFinish.Wait(1));
|
Assert.False(waitJobsFinish.Wait(1));
|
||||||
Assert.False(waitJobsFinish.IsCompletedSuccessfully);
|
Assert.False(waitJobsFinish.IsCompletedSuccessfully);
|
||||||
|
|
||||||
await mockDelay.Advance(TimeSpan.FromSeconds(1.0));
|
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;
|
await waitJobsFinish;
|
||||||
Assert.True(waitJobsFinish.IsCompletedSuccessfully);
|
Assert.True(waitJobsFinish.IsCompletedSuccessfully);
|
||||||
|
@ -2809,7 +2866,7 @@ normal:
|
||||||
var tasks = new List<Task>();
|
var tasks = new List<Task>();
|
||||||
foreach (var valueTuple in testCases)
|
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 =>
|
.ContinueWith(async task =>
|
||||||
{
|
{
|
||||||
var result = await controller.Invoice(task.Result.Id);
|
var result = await controller.Invoice(task.Result.Id);
|
||||||
|
@ -3095,28 +3152,28 @@ normal:
|
||||||
ExpirationTime = expiration
|
ExpirationTime = expiration
|
||||||
}, Facade.Merchant);
|
}, Facade.Merchant);
|
||||||
Assert.Equal(expiration.ToUnixTimeSeconds(), invoice1.ExpirationTime.ToUnixTimeSeconds());
|
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);
|
Facade.Merchant);
|
||||||
Assert.Equal(0.000000012m, invoice1.Price);
|
Assert.Equal(0.000000012m, invoice1.Price);
|
||||||
Assert.Equal(0.000000019m, invoice2.Price);
|
Assert.Equal(0.000000019m, invoice2.Price);
|
||||||
|
|
||||||
// Should round up to 1 because 0.000000019 is unsignificant
|
// Should round up to 1 because 0.000000019 is unsignificant
|
||||||
var invoice3 = user.BitPay.CreateInvoice(
|
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);
|
Assert.Equal(1m, invoice3.Price);
|
||||||
|
|
||||||
// Should not round up at 8 digit because the 9th is insignificant
|
// Should not round up at 8 digit because the 9th is insignificant
|
||||||
var invoice4 = user.BitPay.CreateInvoice(
|
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);
|
Assert.Equal(1.00000002m, invoice4.Price);
|
||||||
|
|
||||||
// But not if the 9th is insignificant
|
// But not if the 9th is insignificant
|
||||||
invoice4 = user.BitPay.CreateInvoice(
|
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);
|
Assert.Equal(0.000000019m, invoice4.Price);
|
||||||
|
|
||||||
var invoice = user.BitPay.CreateInvoice(
|
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);
|
Assert.Equal(0.0m, invoice.Price);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3146,17 +3203,19 @@ normal:
|
||||||
var ctx = tester.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
|
var ctx = tester.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
|
||||||
Assert.Equal(0, invoice.CryptoInfo[0].TxCount);
|
Assert.Equal(0, invoice.CryptoInfo[0].TxCount);
|
||||||
Assert.True(invoice.MinerFees.ContainsKey("BTC"));
|
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(() =>
|
TestUtils.Eventually(() =>
|
||||||
{
|
{
|
||||||
var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
|
var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
|
||||||
{
|
{
|
||||||
StoreId = new[] {user.StoreId}, TextSearch = invoice.OrderId
|
StoreId = new[] { user.StoreId },
|
||||||
|
TextSearch = invoice.OrderId
|
||||||
}).GetAwaiter().GetResult();
|
}).GetAwaiter().GetResult();
|
||||||
Assert.Single(textSearchResult);
|
Assert.Single(textSearchResult);
|
||||||
textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
|
textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
|
||||||
{
|
{
|
||||||
StoreId = new[] {user.StoreId}, TextSearch = invoice.Id
|
StoreId = new[] { user.StoreId },
|
||||||
|
TextSearch = invoice.Id
|
||||||
}).GetAwaiter().GetResult();
|
}).GetAwaiter().GetResult();
|
||||||
|
|
||||||
Assert.Single(textSearchResult);
|
Assert.Single(textSearchResult);
|
||||||
|
@ -3274,7 +3333,8 @@ normal:
|
||||||
|
|
||||||
var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
|
var textSearchResult = tester.PayTester.InvoiceRepository.GetInvoices(new InvoiceQuery()
|
||||||
{
|
{
|
||||||
StoreId = new[] {user.StoreId}, TextSearch = txId.ToString()
|
StoreId = new[] { user.StoreId },
|
||||||
|
TextSearch = txId.ToString()
|
||||||
}).GetAwaiter().GetResult();
|
}).GetAwaiter().GetResult();
|
||||||
Assert.Single(textSearchResult);
|
Assert.Single(textSearchResult);
|
||||||
});
|
});
|
||||||
|
@ -3367,7 +3427,8 @@ normal:
|
||||||
Assert.Single(state.Rates, r => r.Pair == new CurrencyPair("BTC", "EUR"));
|
Assert.Single(state.Rates, r => r.Pair == new CurrencyPair("BTC", "EUR"));
|
||||||
var provider2 = new BackgroundFetcherRateProvider(provider.Inner)
|
var provider2 = new BackgroundFetcherRateProvider(provider.Inner)
|
||||||
{
|
{
|
||||||
RefreshRate = provider.RefreshRate, ValidatyTime = provider.ValidatyTime
|
RefreshRate = provider.RefreshRate,
|
||||||
|
ValidatyTime = provider.ValidatyTime
|
||||||
};
|
};
|
||||||
using (var cts = new CancellationTokenSource())
|
using (var cts = new CancellationTokenSource())
|
||||||
{
|
{
|
||||||
|
@ -3508,9 +3569,9 @@ normal:
|
||||||
Assert.Null(expanded.Macaroons.InvoiceMacaroon);
|
Assert.Null(expanded.Macaroons.InvoiceMacaroon);
|
||||||
Assert.Null(expanded.Macaroons.ReadonlyMacaroon);
|
Assert.Null(expanded.Macaroons.ReadonlyMacaroon);
|
||||||
|
|
||||||
File.WriteAllBytes($"{macaroonDirectory}/admin.macaroon", new byte[] {0xaa});
|
File.WriteAllBytes($"{macaroonDirectory}/admin.macaroon", new byte[] { 0xaa });
|
||||||
File.WriteAllBytes($"{macaroonDirectory}/invoice.macaroon", new byte[] {0xab});
|
File.WriteAllBytes($"{macaroonDirectory}/invoice.macaroon", new byte[] { 0xab });
|
||||||
File.WriteAllBytes($"{macaroonDirectory}/readonly.macaroon", new byte[] {0xac});
|
File.WriteAllBytes($"{macaroonDirectory}/readonly.macaroon", new byte[] { 0xac });
|
||||||
expanded = await connStr.Expand(unusedUri, ExternalServiceTypes.LNDRest, NetworkType.Mainnet);
|
expanded = await connStr.Expand(unusedUri, ExternalServiceTypes.LNDRest, NetworkType.Mainnet);
|
||||||
Assert.NotNull(expanded.Macaroons.AdminMacaroon);
|
Assert.NotNull(expanded.Macaroons.AdminMacaroon);
|
||||||
Assert.NotNull(expanded.Macaroons.InvoiceMacaroon);
|
Assert.NotNull(expanded.Macaroons.InvoiceMacaroon);
|
||||||
|
@ -3697,7 +3758,8 @@ normal:
|
||||||
Assert.Equal(nameof(HomeController.Index),
|
Assert.Equal(nameof(HomeController.Index),
|
||||||
Assert.IsType<RedirectToActionResult>(await accountController.Login(new LoginViewModel()
|
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);
|
})).ActionName);
|
||||||
|
|
||||||
var manageController = user.GetController<ManageController>();
|
var manageController = user.GetController<ManageController>();
|
||||||
|
@ -3744,7 +3806,8 @@ normal:
|
||||||
//check if we are showing the u2f login screen now
|
//check if we are showing the u2f login screen now
|
||||||
var secondLoginResult = Assert.IsType<ViewResult>(await accountController.Login(new LoginViewModel()
|
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);
|
Assert.Equal("SecondaryLogin", secondLoginResult.ViewName);
|
||||||
|
|
|
@ -186,6 +186,12 @@
|
||||||
<Content Update="Views\Wallets\ListWallets.cshtml">
|
<Content Update="Views\Wallets\ListWallets.cshtml">
|
||||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
</Content>
|
</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">
|
<Content Update="Views\Wallets\WalletPSBTCombine.cshtml">
|
||||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
</Content>
|
</Content>
|
||||||
|
@ -201,6 +207,9 @@
|
||||||
<Content Update="Views\Wallets\WalletSendVault.cshtml">
|
<Content Update="Views\Wallets\WalletSendVault.cshtml">
|
||||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
</Content>
|
</Content>
|
||||||
|
<Content Update="Views\Wallets\PullPayments.cshtml">
|
||||||
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
|
</Content>
|
||||||
<Content Update="Views\Wallets\WalletTransactions.cshtml">
|
<Content Update="Views\Wallets\WalletTransactions.cshtml">
|
||||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||||
</Content>
|
</Content>
|
||||||
|
@ -219,5 +228,5 @@
|
||||||
<_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" />
|
<_ContentIncludedByDefault Remove="Views\Authorization\Authorize.cshtml" />
|
||||||
</ItemGroup>
|
</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>
|
</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))
|
var isAdmin = (await _authorizationService.AuthorizeAsync(User, Policies.CanModifyServerSettings))
|
||||||
.Succeeded;
|
.Succeeded;
|
||||||
viewModel.PermissionValues ??= Policies.AllPolicies
|
viewModel.PermissionValues ??= Policies.AllPolicies
|
||||||
|
.Where(p => AddApiKeyViewModel.PermissionValueItem.PermissionDescriptions.ContainsKey(p))
|
||||||
.Select(s => new AddApiKeyViewModel.PermissionValueItem()
|
.Select(s => new AddApiKeyViewModel.PermissionValueItem()
|
||||||
{
|
{
|
||||||
Permission = s,
|
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 DelayedTransactionBroadcaster _broadcaster;
|
||||||
private readonly PayjoinClient _payjoinClient;
|
private readonly PayjoinClient _payjoinClient;
|
||||||
private readonly LabelFactory _labelFactory;
|
private readonly LabelFactory _labelFactory;
|
||||||
|
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||||
|
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
|
||||||
|
private readonly PullPaymentHostedService _pullPaymentService;
|
||||||
|
|
||||||
public RateFetcher RateFetcher { get; }
|
public RateFetcher RateFetcher { get; }
|
||||||
|
|
||||||
|
@ -74,7 +77,10 @@ namespace BTCPayServer.Controllers
|
||||||
SettingsRepository settingsRepository,
|
SettingsRepository settingsRepository,
|
||||||
DelayedTransactionBroadcaster broadcaster,
|
DelayedTransactionBroadcaster broadcaster,
|
||||||
PayjoinClient payjoinClient,
|
PayjoinClient payjoinClient,
|
||||||
LabelFactory labelFactory)
|
LabelFactory labelFactory,
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
|
||||||
|
HostedServices.PullPaymentHostedService pullPaymentService)
|
||||||
{
|
{
|
||||||
_currencyTable = currencyTable;
|
_currencyTable = currencyTable;
|
||||||
Repository = repo;
|
Repository = repo;
|
||||||
|
@ -94,6 +100,9 @@ namespace BTCPayServer.Controllers
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_payjoinClient = payjoinClient;
|
_payjoinClient = payjoinClient;
|
||||||
_labelFactory = labelFactory;
|
_labelFactory = labelFactory;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_jsonSerializerSettings = jsonSerializerSettings;
|
||||||
|
_pullPaymentService = pullPaymentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
|
// 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)
|
public virtual async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (_Cts != null)
|
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.Labels;
|
||||||
using BTCPayServer.Services.Notifications;
|
using BTCPayServer.Services.Notifications;
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace BTCPayServer.Hosting
|
namespace BTCPayServer.Hosting
|
||||||
{
|
{
|
||||||
public static class BTCPayServerServices
|
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)
|
public static IServiceCollection AddBTCPayServer(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
|
services.AddSingleton<MvcNewtonsoftJsonOptions>(o => o.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value);
|
||||||
|
@ -66,6 +72,10 @@ namespace BTCPayServer.Hosting
|
||||||
{
|
{
|
||||||
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
httpClient.Timeout = Timeout.InfiniteTimeSpan;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
services.AddSingleton<BTCPayNetworkJsonSerializerSettings>();
|
||||||
|
services.RegisterJsonConverter(n => new ClaimDestinationJsonConverter(n));
|
||||||
|
|
||||||
services.AddPayJoinServices();
|
services.AddPayJoinServices();
|
||||||
services.AddMoneroLike();
|
services.AddMoneroLike();
|
||||||
services.TryAddSingleton<SettingsRepository>();
|
services.TryAddSingleton<SettingsRepository>();
|
||||||
|
@ -190,6 +200,9 @@ namespace BTCPayServer.Hosting
|
||||||
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
|
services.AddSingleton<HostedServices.CheckConfigurationHostedService>();
|
||||||
services.AddSingleton<IHostedService, HostedServices.CheckConfigurationHostedService>(o => o.GetRequiredService<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<BitcoinLikePaymentHandler>();
|
||||||
services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<BitcoinLikePaymentHandler>());
|
services.AddSingleton<IPaymentMethodHandler>(provider => provider.GetService<BitcoinLikePaymentHandler>());
|
||||||
services.AddSingleton<IHostedService, NBXplorerListener>();
|
services.AddSingleton<IHostedService, NBXplorerListener>();
|
||||||
|
@ -223,6 +236,7 @@ namespace BTCPayServer.Hosting
|
||||||
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
|
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
|
||||||
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
|
||||||
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
|
||||||
|
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
|
||||||
|
|
||||||
services.TryAddSingleton<ExplorerClientProvider>();
|
services.TryAddSingleton<ExplorerClientProvider>();
|
||||||
services.TryAddSingleton<Bitpay>(o =>
|
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 StatusMessageModel()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public string Message { get; set; }
|
public string Message { get; set; }
|
||||||
public string Html { get; set; }
|
public string Html { get; set; }
|
||||||
public StatusSeverity Severity { 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 blob = pr.GetBlob();
|
||||||
var rateRules = pr.StoreData.GetStoreBlob().GetRateRules(_BtcPayNetworkProvider);
|
|
||||||
|
|
||||||
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id);
|
var invoices = await _PaymentRequestRepository.GetInvoicesForPaymentRequest(id);
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
@ -32,13 +33,12 @@ namespace BTCPayServer.Security.GreenField
|
||||||
{
|
{
|
||||||
if (context.User.Identity.AuthenticationType != GreenFieldConstants.AuthenticationType)
|
if (context.User.Identity.AuthenticationType != GreenFieldConstants.AuthenticationType)
|
||||||
return;
|
return;
|
||||||
|
var userid = _userManager.GetUserId(context.User);
|
||||||
bool success = false;
|
bool success = false;
|
||||||
switch (requirement.Policy)
|
switch (requirement.Policy)
|
||||||
{
|
{
|
||||||
case { } policy when Policies.IsStorePolicy(policy):
|
case { } policy when Policies.IsStorePolicy(policy):
|
||||||
var storeId = _HttpContext.GetImplicitStoreId();
|
var storeId = _HttpContext.GetImplicitStoreId();
|
||||||
var userid = _userManager.GetUserId(context.User);
|
|
||||||
// Specific store action
|
// Specific store action
|
||||||
if (storeId != null)
|
if (storeId != null)
|
||||||
{
|
{
|
||||||
|
@ -49,7 +49,7 @@ namespace BTCPayServer.Security.GreenField
|
||||||
var store = await _storeRepository.FindStore((string)storeId, userid);
|
var store = await _storeRepository.FindStore((string)storeId, userid);
|
||||||
if (store == null)
|
if (store == null)
|
||||||
break;
|
break;
|
||||||
if(Policies.IsStoreModifyPolicy(policy))
|
if (Policies.IsStoreModifyPolicy(policy))
|
||||||
{
|
{
|
||||||
if (store.Role != StoreRoles.Owner)
|
if (store.Role != StoreRoles.Owner)
|
||||||
break;
|
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="row">
|
||||||
|
|
||||||
<div class="@(!Model.InputSelection && Model.Outputs.Count==1? "col-lg-6 transaction-output-form": "col-lg-8")">
|
<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="InputSelection" />
|
||||||
<input type="hidden" asp-for="Divisibility" />
|
<input type="hidden" asp-for="Divisibility" />
|
||||||
<input type="hidden" asp-for="NBXSeedAvailable" />
|
<input type="hidden" asp-for="NBXSeedAvailable" />
|
||||||
|
|
|
@ -11,6 +11,8 @@ namespace BTCPayServer.Views.Wallets
|
||||||
Transactions,
|
Transactions,
|
||||||
Rescan,
|
Rescan,
|
||||||
PSBT,
|
PSBT,
|
||||||
|
PullPayments,
|
||||||
|
Payouts,
|
||||||
Settings,
|
Settings,
|
||||||
Receive
|
Receive
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,17 +4,19 @@
|
||||||
var wallet = WalletId.Parse( this.Context.GetRouteValue("walletId").ToString());
|
var wallet = WalletId.Parse( this.Context.GetRouteValue("walletId").ToString());
|
||||||
var network = BtcPayNetworkProvider.GetNetwork<BTCPayNetwork>(wallet.CryptoCode);
|
var network = BtcPayNetworkProvider.GetNetwork<BTCPayNetwork>(wallet.CryptoCode);
|
||||||
}
|
}
|
||||||
<div class="nav flex-column nav-pills">
|
<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>
|
<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)
|
@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.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.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.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.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>
|
||||||
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">PSBT</a>
|
@if (!network.ReadonlyWallet)
|
||||||
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.Settings)" asp-action="WalletSettings" asp-route-walletId="@this.Context.GetRouteValue("walletId")" id="WalletSettings">Settings</a>
|
{
|
||||||
}
|
<a class="nav-link @ViewData.IsActivePage(WalletsNavPages.PSBT)" asp-action="WalletPSBT" asp-route-walletId="@this.Context.GetRouteValue("walletId")">PSBT</a>
|
||||||
</div>
|
<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