diff --git a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs index e7f6af845..9aa786b64 100644 --- a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs +++ b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs @@ -54,5 +54,10 @@ namespace BTCPayServer.Client var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", method: HttpMethod.Delete), cancellationToken); await HandleResponse(response); } + public async Task ApprovePayout(string storeId, string payoutId, ApprovePayoutRequest request, CancellationToken cancellationToken = default) + { + var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", bodyPayload: request, method: HttpMethod.Post), cancellationToken); + return await HandleResponse(response); + } } } diff --git a/BTCPayServer.Client/Models/ApprovePayoutRequest.cs b/BTCPayServer.Client/Models/ApprovePayoutRequest.cs new file mode 100644 index 000000000..c6a781ef2 --- /dev/null +++ b/BTCPayServer.Client/Models/ApprovePayoutRequest.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace BTCPayServer.Client.Models +{ + public class ApprovePayoutRequest + { + public int Revision { get; set; } + public string RateRule { get; set; } + } +} diff --git a/BTCPayServer.Client/Models/PayoutData.cs b/BTCPayServer.Client/Models/PayoutData.cs index 9e5f51edf..d3a216590 100644 --- a/BTCPayServer.Client/Models/PayoutData.cs +++ b/BTCPayServer.Client/Models/PayoutData.cs @@ -9,6 +9,7 @@ namespace BTCPayServer.Client.Models { public enum PayoutState { + AwaitingApproval, AwaitingPayment, InProgress, Completed, @@ -25,8 +26,9 @@ namespace BTCPayServer.Client.Models [JsonConverter(typeof(DecimalStringJsonConverter))] public decimal Amount { get; set; } [JsonConverter(typeof(DecimalStringJsonConverter))] - public decimal PaymentMethodAmount { get; set; } + public decimal? PaymentMethodAmount { get; set; } [JsonConverter(typeof(StringEnumConverter))] public PayoutState State { get; set; } + public int Revision { get; set; } } } diff --git a/BTCPayServer.Data/Data/ApplicationDbContext.cs b/BTCPayServer.Data/Data/ApplicationDbContext.cs index 7e47f3059..86e030a08 100644 --- a/BTCPayServer.Data/Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/Data/ApplicationDbContext.cs @@ -34,6 +34,10 @@ namespace BTCPayServer.Data { get; set; } + public DbSet Refunds + { + get; set; + } public DbSet PlannedTransactions { get; set; } public DbSet PayjoinLocks { get; set; } @@ -201,6 +205,7 @@ namespace BTCPayServer.Data PullPaymentData.OnModelCreating(builder); PayoutData.OnModelCreating(builder); + RefundData.OnModelCreating(builder); if (Database.IsSqlite() && !_designTime) { diff --git a/BTCPayServer.Data/Data/InvoiceData.cs b/BTCPayServer.Data/Data/InvoiceData.cs index 167f9ca55..ac6644bd9 100644 --- a/BTCPayServer.Data/Data/InvoiceData.cs +++ b/BTCPayServer.Data/Data/InvoiceData.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; namespace BTCPayServer.Data { @@ -25,7 +27,6 @@ namespace BTCPayServer.Data { get; set; } - public List Payments { get; set; @@ -74,8 +75,16 @@ namespace BTCPayServer.Data { get; set; } - public bool Archived { get; set; } public List PendingInvoices { get; set; } + public List Refunds { get; set; } + public string CurrentRefundId { get; set; } + [ForeignKey("Id,CurrentRefundId")] + public RefundData CurrentRefund { get; set; } + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasOne(o => o.CurrentRefund); + } } } diff --git a/BTCPayServer.Data/Data/PayoutData.cs b/BTCPayServer.Data/Data/PayoutData.cs index 9dedbf0c3..8052120d6 100644 --- a/BTCPayServer.Data/Data/PayoutData.cs +++ b/BTCPayServer.Data/Data/PayoutData.cs @@ -56,6 +56,7 @@ namespace BTCPayServer.Data public enum PayoutState { + AwaitingApproval, AwaitingPayment, InProgress, Completed, diff --git a/BTCPayServer.Data/Data/RefundData.cs b/BTCPayServer.Data/Data/RefundData.cs new file mode 100644 index 000000000..71c6eba1d --- /dev/null +++ b/BTCPayServer.Data/Data/RefundData.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Xml.Linq; +using Microsoft.EntityFrameworkCore; + +namespace BTCPayServer.Data +{ + public class RefundData + { + [Required] + public string InvoiceDataId { get; set; } + [Required] + public string PullPaymentDataId { get; set; } + public PullPaymentData PullPaymentData { get; set; } + public InvoiceData InvoiceData { get; set; } + + internal static void OnModelCreating(ModelBuilder builder) + { + builder.Entity() + .HasKey(nameof(InvoiceDataId), nameof(PullPaymentDataId)); + builder.Entity() + .HasOne(o => o.InvoiceData) + .WithMany(o => o.Refunds) + .HasForeignKey(o => o.InvoiceDataId) + .OnDelete(DeleteBehavior.Cascade); + } + } +} diff --git a/BTCPayServer.Data/Migrations/20200624051926_invoicerefund.cs b/BTCPayServer.Data/Migrations/20200624051926_invoicerefund.cs new file mode 100644 index 000000000..8ef93eecf --- /dev/null +++ b/BTCPayServer.Data/Migrations/20200624051926_invoicerefund.cs @@ -0,0 +1,49 @@ +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200624051926_invoicerefund")] + public partial class invoicerefund : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PullPaymentDataId", + table: "Invoices", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_PullPaymentDataId", + table: "Invoices", + column: "PullPaymentDataId"); + if (this.SupportAddForeignKey(migrationBuilder.ActiveProvider)) + { + migrationBuilder.AddForeignKey( + name: "FK_Invoices_PullPayments_PullPaymentDataId", + table: "Invoices", + column: "PullPaymentDataId", + principalTable: "PullPayments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Invoices_PullPayments_PullPaymentDataId", + table: "Invoices"); + + migrationBuilder.DropIndex( + name: "IX_Invoices_PullPaymentDataId", + table: "Invoices"); + + migrationBuilder.DropColumn( + name: "PullPaymentDataId", + table: "Invoices"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.Designer.cs b/BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.Designer.cs new file mode 100644 index 000000000..1ab7331a7 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.Designer.cs @@ -0,0 +1,1039 @@ +// +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200625050738_refundinvoice2")] + partial class refundinvoice2 + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "3.1.4"); + + modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(50); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT") + .HasMaxLength(50); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasMaxLength(50); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKeys"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("CreatedTime") + .HasColumnType("TEXT"); + + b.Property("InvoiceDataId") + .HasColumnType("TEXT"); + + b.HasKey("Address"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("AddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.AppData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AppType") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Settings") + .HasColumnType("TEXT"); + + b.Property("StoreDataId") + .HasColumnType("TEXT"); + + b.Property("TagAllInvoices") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Apps"); + }); + + modelBuilder.Entity("BTCPayServer.Data.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.Property("NormalizedUserName") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RequiresEmailConfirmation") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.Property("InvoiceDataId") + .HasColumnType("TEXT"); + + b.Property("Address") + .HasColumnType("TEXT"); + + b.Property("Assigned") + .HasColumnType("TEXT"); + + b.Property("CryptoCode") + .HasColumnType("TEXT"); + + b.Property("UnAssigned") + .HasColumnType("TEXT"); + + b.HasKey("InvoiceDataId", "Address"); + + b.ToTable("HistoricalAddressInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archived") + .HasColumnType("INTEGER"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CustomerEmail") + .HasColumnType("TEXT"); + + b.Property("ExceptionStatus") + .HasColumnType("TEXT"); + + b.Property("ItemCode") + .HasColumnType("TEXT"); + + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("PullPaymentDataId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("TEXT"); + + b.Property("StoreDataId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PullPaymentDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("Invoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => + { + b.Property("InvoiceDataId") + .HasColumnType("TEXT"); + + b.Property("UniqueId") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("InvoiceDataId", "UniqueId"); + + b.ToTable("InvoiceEvents"); + }); + + modelBuilder.Entity("BTCPayServer.Data.NotificationData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(36); + + b.Property("ApplicationUserId") + .HasColumnType("TEXT") + .HasMaxLength(50); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("NotificationType") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Seen") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("BTCPayServer.Data.OffchainTransactionData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(64); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.ToTable("OffchainTransactions"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("PairingTime") + .HasColumnType("TEXT"); + + b.Property("SIN") + .HasColumnType("TEXT"); + + b.Property("StoreDataId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SIN"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PairedSINData"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairingCodeData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("Expiration") + .HasColumnType("TEXT"); + + b.Property("Facade") + .HasColumnType("TEXT"); + + b.Property("Label") + .HasColumnType("TEXT"); + + b.Property("SIN") + .HasColumnType("TEXT"); + + b.Property("StoreDataId") + .HasColumnType("TEXT"); + + b.Property("TokenValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PairingCodes"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PayjoinLock", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.HasKey("Id"); + + b.ToTable("PayjoinLocks"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Accounted") + .HasColumnType("INTEGER"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("InvoiceDataId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InvoiceDataId"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentRequestData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Archived") + .HasColumnType("INTEGER"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Created") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StoreDataId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StoreDataId"); + + b.ToTable("PaymentRequests"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PayoutData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(30); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Destination") + .HasColumnType("TEXT"); + + b.Property("PaymentMethodId") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(20); + + b.Property("Proof") + .HasColumnType("BLOB"); + + b.Property("PullPaymentDataId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasColumnType("TEXT") + .HasMaxLength(20); + + b.HasKey("Id"); + + b.HasIndex("Destination") + .IsUnique(); + + b.HasIndex("PullPaymentDataId"); + + b.HasIndex("State"); + + b.ToTable("Payouts"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingInvoices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PlannedTransaction", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(100); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("BroadcastAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PlannedTransactions"); + }); + + modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasMaxLength(30); + + b.Property("Archived") + .HasColumnType("INTEGER"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Period") + .HasColumnType("INTEGER"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("StoreId") + .HasColumnType("TEXT") + .HasMaxLength(50); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("PullPayments"); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundData", b => + { + b.Property("InvoiceDataId") + .HasColumnType("TEXT"); + + b.Property("PullPaymentDataId") + .HasColumnType("TEXT"); + + b.HasKey("InvoiceDataId", "PullPaymentDataId"); + + b.HasIndex("PullPaymentDataId"); + + b.ToTable("Refunds"); + }); + + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoreData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DefaultCrypto") + .HasColumnType("TEXT"); + + b.Property("DerivationStrategies") + .HasColumnType("TEXT"); + + b.Property("DerivationStrategy") + .HasColumnType("TEXT"); + + b.Property("SpeedPolicy") + .HasColumnType("INTEGER"); + + b.Property("StoreBlob") + .HasColumnType("BLOB"); + + b.Property("StoreCertificate") + .HasColumnType("BLOB"); + + b.Property("StoreName") + .HasColumnType("TEXT"); + + b.Property("StoreWebsite") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApplicationUserId") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("StorageFileName") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("Files"); + }); + + modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ApplicationUserId") + .HasColumnType("TEXT"); + + b.Property("AttestationCert") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Counter") + .HasColumnType("INTEGER"); + + b.Property("KeyHandle") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationUserId"); + + b.ToTable("U2FDevices"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.Property("ApplicationUserId") + .HasColumnType("TEXT"); + + b.Property("StoreDataId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.HasKey("ApplicationUserId", "StoreDataId"); + + b.HasIndex("StoreDataId"); + + b.ToTable("UserStore"); + }); + + modelBuilder.Entity("BTCPayServer.Data.WalletData", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b => + { + b.Property("WalletDataId") + .HasColumnType("TEXT"); + + b.Property("TransactionId") + .HasColumnType("TEXT"); + + b.Property("Blob") + .HasColumnType("BLOB"); + + b.Property("Labels") + .HasColumnType("TEXT"); + + b.HasKey("WalletDataId", "TransactionId"); + + b.ToTable("WalletTransactions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.Property("NormalizedName") + .HasColumnType("TEXT") + .HasMaxLength(256); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("BTCPayServer.Data.APIKeyData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("APIKeys") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.ApplicationUser", "User") + .WithMany("APIKeys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.AddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("AddressInvoices") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.AppData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("Apps") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.HistoricalAddressInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("HistoricalAddressInvoices") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => + { + b.HasOne("BTCPayServer.Data.PullPaymentData", "PullPaymentData") + .WithMany() + .HasForeignKey("PullPaymentDataId"); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("Invoices") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Events") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BTCPayServer.Data.NotificationData", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") + .WithMany("Notifications") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PairedSINData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PairedSINs") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Payments") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PaymentRequestData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PaymentRequests") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PayoutData", b => + { + b.HasOne("BTCPayServer.Data.PullPaymentData", "PullPaymentData") + .WithMany("Payouts") + .HasForeignKey("PullPaymentDataId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.PendingInvoiceData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("PendingInvoices") + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BTCPayServer.Data.PullPaymentData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("PullPayments") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("BTCPayServer.Data.RefundData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Refunds") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.PullPaymentData", "PullPaymentData") + .WithMany() + .HasForeignKey("PullPaymentDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") + .WithMany("StoredFiles") + .HasForeignKey("ApplicationUserId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") + .WithMany("U2FDevices") + .HasForeignKey("ApplicationUserId"); + }); + + modelBuilder.Entity("BTCPayServer.Data.UserStore", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") + .WithMany("UserStores") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.StoreData", "StoreData") + .WithMany("UserStores") + .HasForeignKey("StoreDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BTCPayServer.Data.WalletTransactionData", b => + { + b.HasOne("BTCPayServer.Data.WalletData", "WalletData") + .WithMany("WalletTransactions") + .HasForeignKey("WalletDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("BTCPayServer.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.cs b/BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.cs new file mode 100644 index 000000000..a18669898 --- /dev/null +++ b/BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + public partial class refundinvoice2 : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Refunds", + columns: table => new + { + InvoiceDataId = table.Column(nullable: false), + PullPaymentDataId = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Refunds", x => new { x.InvoiceDataId, x.PullPaymentDataId }); + table.ForeignKey( + name: "FK_Refunds_Invoices_InvoiceDataId", + column: x => x.InvoiceDataId, + principalTable: "Invoices", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Refunds_PullPayments_PullPaymentDataId", + column: x => x.PullPaymentDataId, + principalTable: "PullPayments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Refunds_PullPaymentDataId", + table: "Refunds", + column: "PullPaymentDataId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Refunds"); + } + } +} diff --git a/BTCPayServer.Data/Migrations/20200625060941_refundinvoice3.cs b/BTCPayServer.Data/Migrations/20200625060941_refundinvoice3.cs new file mode 100644 index 000000000..e1accb1fa --- /dev/null +++ b/BTCPayServer.Data/Migrations/20200625060941_refundinvoice3.cs @@ -0,0 +1,81 @@ +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20200625060941_refundinvoice3")] + public partial class refundinvoice3 : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + if (this.SupportDropForeignKey(migrationBuilder.ActiveProvider)) + migrationBuilder.DropForeignKey( + name: "FK_Invoices_PullPayments_PullPaymentDataId", + table: "Invoices"); + + migrationBuilder.DropIndex( + name: "IX_Invoices_PullPaymentDataId", + table: "Invoices"); + + if (this.SupportDropColumn(migrationBuilder.ActiveProvider)) + migrationBuilder.DropColumn( + name: "PullPaymentDataId", + table: "Invoices"); + + migrationBuilder.AddColumn( + name: "CurrentRefundId", + table: "Invoices", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_Id_CurrentRefundId", + table: "Invoices", + columns: new[] { "Id", "CurrentRefundId" }); + + if (this.SupportAddForeignKey(migrationBuilder.ActiveProvider)) + migrationBuilder.AddForeignKey( + name: "FK_Invoices_Refunds_Id_CurrentRefundId", + table: "Invoices", + columns: new[] { "Id", "CurrentRefundId" }, + principalTable: "Refunds", + principalColumns: new[] { "InvoiceDataId", "PullPaymentDataId" }, + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Invoices_Refunds_Id_CurrentRefundId", + table: "Invoices"); + + migrationBuilder.DropIndex( + name: "IX_Invoices_Id_CurrentRefundId", + table: "Invoices"); + + migrationBuilder.DropColumn( + name: "CurrentRefundId", + table: "Invoices"); + + migrationBuilder.AddColumn( + name: "PullPaymentDataId", + table: "Invoices", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Invoices_PullPaymentDataId", + table: "Invoices", + column: "PullPaymentDataId"); + + migrationBuilder.AddForeignKey( + name: "FK_Invoices_PullPayments_PullPaymentDataId", + table: "Invoices", + column: "PullPaymentDataId", + principalTable: "PullPayments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 6f3afcaeb..bc65c449f 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -199,6 +199,9 @@ namespace BTCPayServer.Migrations b.Property("Created") .HasColumnType("TEXT"); + b.Property("CurrentRefundId") + .HasColumnType("TEXT"); + b.Property("CustomerEmail") .HasColumnType("TEXT"); @@ -221,6 +224,8 @@ namespace BTCPayServer.Migrations b.HasIndex("StoreDataId"); + b.HasIndex("Id", "CurrentRefundId"); + b.ToTable("Invoices"); }); @@ -509,6 +514,21 @@ namespace BTCPayServer.Migrations b.ToTable("PullPayments"); }); + modelBuilder.Entity("BTCPayServer.Data.RefundData", b => + { + b.Property("InvoiceDataId") + .HasColumnType("TEXT"); + + b.Property("PullPaymentDataId") + .HasColumnType("TEXT"); + + b.HasKey("InvoiceDataId", "PullPaymentDataId"); + + b.HasIndex("PullPaymentDataId"); + + b.ToTable("Refunds"); + }); + modelBuilder.Entity("BTCPayServer.Data.SettingData", b => { b.Property("Id") @@ -836,6 +856,10 @@ namespace BTCPayServer.Migrations .WithMany("Invoices") .HasForeignKey("StoreDataId") .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("BTCPayServer.Data.RefundData", "CurrentRefund") + .WithMany() + .HasForeignKey("Id", "CurrentRefundId"); }); modelBuilder.Entity("BTCPayServer.Data.InvoiceEventData", b => @@ -904,6 +928,21 @@ namespace BTCPayServer.Migrations .OnDelete(DeleteBehavior.Cascade); }); + modelBuilder.Entity("BTCPayServer.Data.RefundData", b => + { + b.HasOne("BTCPayServer.Data.InvoiceData", "InvoiceData") + .WithMany("Refunds") + .HasForeignKey("InvoiceDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("BTCPayServer.Data.PullPaymentData", "PullPaymentData") + .WithMany() + .HasForeignKey("PullPaymentDataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BTCPayServer.Data.StoredFile", b => { b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") diff --git a/BTCPayServer.Rating/RateRules.cs b/BTCPayServer.Rating/RateRules.cs index 8d3394f6b..9a3890b2f 100644 --- a/BTCPayServer.Rating/RateRules.cs +++ b/BTCPayServer.Rating/RateRules.cs @@ -494,6 +494,12 @@ namespace BTCPayServer.Rating private SyntaxNode expression; FlattenExpressionRewriter flatten; + public static RateRule CreateFromExpression(string expression, CurrencyPair currencyPair) + { + var ex = RateRules.CreateExpression(expression); + RateRules.TryParse("", out var rules); + return new RateRule(rules, currencyPair, ex); + } public RateRule(RateRules parent, CurrencyPair currencyPair, SyntaxNode candidate) { _CurrencyPair = currencyPair; diff --git a/BTCPayServer.Rating/Services/RateFetcher.cs b/BTCPayServer.Rating/Services/RateFetcher.cs index 16a8b4880..770b28d90 100644 --- a/BTCPayServer.Rating/Services/RateFetcher.cs +++ b/BTCPayServer.Rating/Services/RateFetcher.cs @@ -70,6 +70,24 @@ namespace BTCPayServer.Services.Rates return fetchingRates; } + public Task FetchRate(RateRule rateRule, CancellationToken cancellationToken) + { + if (rateRule == null) + throw new ArgumentNullException(nameof(rateRule)); + var fetchingExchanges = new Dictionary>(); + var dependentQueries = new List>(); + foreach (var requiredExchange in rateRule.ExchangeRates) + { + if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching)) + { + fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, cancellationToken); + fetchingExchanges.Add(requiredExchange.Exchange, fetching); + } + dependentQueries.Add(fetching); + } + return GetRuleValue(dependentQueries, rateRule); + } + private async Task GetRuleValue(List> dependentQueries, RateRule rateRule) { var result = new RateResult(); diff --git a/BTCPayServer.Tests/BTCPayServerTester.cs b/BTCPayServer.Tests/BTCPayServerTester.cs index c252ef857..6e26644a7 100644 --- a/BTCPayServer.Tests/BTCPayServerTester.cs +++ b/BTCPayServer.Tests/BTCPayServerTester.cs @@ -207,7 +207,7 @@ namespace BTCPayServer.Tests var rateProvider = (RateProviderFactory)_Host.Services.GetService(typeof(RateProviderFactory)); rateProvider.Providers.Clear(); - var coinAverageMock = new MockRateProvider(); + coinAverageMock = new MockRateProvider(); coinAverageMock.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("BTC_USD"), new BidAsk(5000m))); coinAverageMock.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("BTC_CAD"), new BidAsk(4500m))); coinAverageMock.ExchangeRates.Add(new PairRate(CurrencyPair.Parse("BTC_LTC"), new BidAsk(162m))); @@ -241,7 +241,7 @@ namespace BTCPayServer.Tests await WaitSiteIsOperational(); Logs.Tester.LogInformation("Site is now operational"); } - + MockRateProvider coinAverageMock; private async Task WaitSiteIsOperational() { _ = HttpClient.GetAsync("/").ConfigureAwait(false); @@ -331,5 +331,12 @@ namespace BTCPayServer.Tests if (_Host != null) _Host.Dispose(); } + + public void ChangeRate(string pair, BidAsk bidAsk) + { + var p = CurrencyPair.Parse(pair); + var index = coinAverageMock.ExchangeRates.FindIndex(o => o.CurrencyPair == p); + coinAverageMock.ExchangeRates[index] = new PairRate(p, bidAsk); + } } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index cd839b55c..debd2d4b7 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -303,8 +303,8 @@ namespace BTCPayServer.Tests Assert.Equal(payout.Amount, payout2.Amount); Assert.Equal(payout.Id, payout2.Id); Assert.Equal(destination, payout2.Destination); - Assert.Equal(PayoutState.AwaitingPayment, payout.State); - + Assert.Equal(PayoutState.AwaitingApproval, payout.State); + Assert.Null(payout.PaymentMethodAmount); Logs.Tester.LogInformation("Can't overdraft"); await this.AssertAPIError("overdraft", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() @@ -330,16 +330,8 @@ namespace BTCPayServer.Tests 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() + payout = await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest() { Destination = destination, PaymentMethod = "BTC" @@ -386,6 +378,43 @@ namespace BTCPayServer.Tests StartsAt = DateTimeOffset.UtcNow, ExpiresAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(1) })); + + + Logs.Tester.LogInformation("Create a pull payment with USD"); + var pp = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest() + { + Name = "Test USD", + Amount = 5000m, + Currency = "USD", + PaymentMethods = new[] { "BTC" } + }); + + destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString(); + Logs.Tester.LogInformation("Try to pay it in BTC"); + payout = await unauthenticated.CreatePayout(pp.Id, new CreatePayoutRequest() + { + Destination = destination, + PaymentMethod = "BTC" + }); + await this.AssertAPIError("old-revision", async () => await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest() + { + Revision = -1 + })); + await this.AssertAPIError("rate-unavailable", async () => await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest() + { + RateRule = "DONOTEXIST(BTC_USD)" + })); + payout = await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest() + { + Revision = payout.Revision + }); + Assert.Equal(PayoutState.AwaitingPayment, payout.State); + Assert.NotNull(payout.PaymentMethodAmount); + Assert.Equal(1.0m, payout.PaymentMethodAmount); // 1 BTC == 5000 USD in tests + await this.AssertAPIError("invalid-state", async () => await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest() + { + Revision = payout.Revision + })); } } diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index 80e6eda3f..3452bea3e 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -384,5 +384,18 @@ namespace BTCPayServer.Tests Driver.FindElement(By.Id($"Wallet{navPages}")).Click(); } } + + public void GoToInvoice(string id) + { + GoToInvoices(); + foreach(var el in Driver.FindElements(By.ClassName("invoice-details-link"))) + { + if (el.GetAttribute("href").Contains(id, StringComparison.OrdinalIgnoreCase)) + { + el.Click(); + break; + } + } + } } } diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index b8c17b7d6..00af3f85f 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -20,6 +20,10 @@ using BTCPayServer.Client.Models; using System.Threading; using ExchangeSharp; using Microsoft.EntityFrameworkCore; +using NBitcoin.RPC; +using NBitpayClient; +using System.Globalization; +using Microsoft.AspNetCore.Components.Web; namespace BTCPayServer.Tests { @@ -691,6 +695,80 @@ namespace BTCPayServer.Tests } } + [Fact] + [Trait("Selenium", "Selenium")] + [Trait("Altcoins", "Altcoins")] + public async Task CanCreateRefunds() + { + using (var s = SeleniumTester.Create()) + { + s.Server.ActivateLTC(); + await s.StartAsync(); + var user = s.Server.NewAccount(); + await user.GrantAccessAsync(); + s.GoToLogin(); + s.Login(user.RegisterDetails.Email, user.RegisterDetails.Password); + user.RegisterDerivationScheme("BTC"); + await s.Server.ExplorerNode.GenerateAsync(1); + + foreach (var multiCurrency in new[] { false, true }) + { + if (multiCurrency) + user.RegisterDerivationScheme("LTC"); + foreach (var rateSelection in new[] { "FiatText", "CurrentRateText", "RateThenText" }) + await CanCreateRefundsCore(s, user, multiCurrency, rateSelection); + } + } + } + + private static async Task CanCreateRefundsCore(SeleniumTester s, TestAccount user, bool multiCurrency, string rateSelection) + { + s.GoToHome(); + s.Server.PayTester.ChangeRate("BTC_USD", new Rating.BidAsk(5000.0m, 5100.0m)); + var invoice = await user.BitPay.CreateInvoiceAsync(new NBitpayClient.Invoice() + { + Currency = "USD", + Price = 5000.0m + }); + var info = invoice.CryptoInfo.First(o => o.CryptoCode == "BTC"); + var totalDue = decimal.Parse(info.TotalDue, CultureInfo.InvariantCulture); + var paid = totalDue + 0.1m; + await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(info.Address, Network.RegTest), Money.Coins(paid)); + await s.Server.ExplorerNode.GenerateAsync(1); + await TestUtils.EventuallyAsync(async () => + { + invoice = await user.BitPay.GetInvoiceAsync(invoice.Id); + Assert.Equal("confirmed", invoice.Status); + }); + + // BTC crash by 50% + s.Server.PayTester.ChangeRate("BTC_USD", new Rating.BidAsk(5000.0m / 2.0m, 5100.0m / 2.0m)); + s.GoToInvoice(invoice.Id); + s.Driver.FindElement(By.Id("refundlink")).Click(); + if (multiCurrency) + { + s.Driver.FindElement(By.Id("SelectedPaymentMethod")).SendKeys("BTC" + Keys.Enter); + s.Driver.FindElement(By.Id("ok")).Click(); + } + Assert.Contains("$5,500.00", s.Driver.PageSource); // Should propose reimburse in fiat + Assert.Contains("1.10000000 ₿", s.Driver.PageSource); // Should propose reimburse in BTC at the rate of before + Assert.Contains("2.20000000 ₿", s.Driver.PageSource); // Should propose reimburse in BTC at the current rate + s.Driver.FindElement(By.Id(rateSelection)).Click(); + s.Driver.FindElement(By.Id("ok")).Click(); + Assert.Contains("pull-payments", s.Driver.Url); + if (rateSelection == "FiatText") + Assert.Contains("$5,500.00", s.Driver.PageSource); + if (rateSelection == "CurrentRateText") + Assert.Contains("2.20000000 ₿", s.Driver.PageSource); + if (rateSelection == "RateThenText") + Assert.Contains("1.10000000 ₿", s.Driver.PageSource); + s.GoToHome(); + s.GoToInvoices(); + s.GoToInvoice(invoice.Id); + s.Driver.FindElement(By.Id("refundlink")).Click(); + Assert.Contains("pull-payments", s.Driver.Url); + } + [Fact] [Trait("Selenium", "Selenium")] public async Task CanUsePullPaymentsViaUI() @@ -738,7 +816,7 @@ namespace BTCPayServer.Tests 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); + Assert.Contains("AwaitingApproval", s.Driver.PageSource); var viewPullPaymentUrl = s.Driver.Url; // This one should have nothing diff --git a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs index cf1ce82fe..97b6527cc 100644 --- a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; +using System.Threading; using System.Threading.Tasks; using BTCPayServer; using BTCPayServer.Client; @@ -81,13 +82,12 @@ namespace BTCPayServer.Controllers.GreenField { ModelState.AddModelError(nameof(request.Name), "The name should be maximum 50 characters."); } - BTCPayNetwork network = null; if (request.Currency is String currency) { - network = _networkProvider.GetNetwork(currency); - if (network is null) + request.Currency = currency.ToUpperInvariant().Trim(); + if (_currencyNameTable.GetCurrencyData(request.Currency, false) is null) { - ModelState.AddModelError(nameof(request.Currency), $"Only crypto currencies are supported this field. (More will be supported soon)"); + ModelState.AddModelError(nameof(request.Currency), "Invalid currency"); } } else @@ -102,12 +102,20 @@ namespace BTCPayServer.Controllers.GreenField { ModelState.AddModelError(nameof(request.Period), $"The period should be positive"); } - if (request.PaymentMethods is string[] paymentMethods) + PaymentMethodId[] paymentMethods = null; + if (request.PaymentMethods is string[] paymentMethodsStr) { - if (paymentMethods.Length != 1 && paymentMethods[0] != request.Currency) + paymentMethods = paymentMethodsStr.Select(p => new PaymentMethodId(p, PaymentTypes.BTCLike)).ToArray(); + foreach (var p in paymentMethods) { - ModelState.AddModelError(nameof(request.PaymentMethods), "We expect this array to only contains the same element as the `currency` field. (More will be supported soon)"); + var n = _networkProvider.GetNetwork(p.CryptoCode); + if (n is null) + ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method"); + if (n.ReadonlyWallet) + ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method (We do not support the crypto currency for refund)"); } + if (paymentMethods.Any(p => _networkProvider.GetNetwork(p.CryptoCode) is null)) + ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method"); } else { @@ -122,9 +130,9 @@ namespace BTCPayServer.Controllers.GreenField Period = request.Period, Name = request.Name, Amount = request.Amount, - Currency = network.CryptoCode, + Currency = request.Currency, StoreId = storeId, - PaymentMethodIds = new[] { new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike) } + PaymentMethodIds = paymentMethods }); var pp = await _pullPaymentService.GetPullPayment(ppId); return this.Ok(CreatePullPaymentData(pp)); @@ -193,7 +201,9 @@ namespace BTCPayServer.Controllers.GreenField Date = p.Date, Amount = blob.Amount, PaymentMethodAmount = blob.CryptoAmount, + Revision = blob.Revision, State = p.State == Data.PayoutState.AwaitingPayment ? Client.Models.PayoutState.AwaitingPayment : + p.State == Data.PayoutState.AwaitingApproval ? Client.Models.PayoutState.AwaitingApproval : 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 : @@ -290,5 +300,61 @@ namespace BTCPayServer.Controllers.GreenField await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId })); return Ok(); } + + [HttpPost("~/api/v1/stores/{storeId}/payouts/{payoutId}")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task ApprovePayout(string storeId, string payoutId, ApprovePayoutRequest approvePayoutRequest, CancellationToken cancellationToken = default) + { + using var ctx = _dbContextFactory.CreateContext(); + ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var revision = approvePayoutRequest?.Revision; + if (revision is null) + { + ModelState.AddModelError(nameof(approvePayoutRequest.Revision), "The `revision` property is required"); + } + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + var payout = await ctx.Payouts.GetPayout(payoutId, storeId, true, true); + if (payout is null) + return NotFound(); + RateResult rateResult = null; + try + { + rateResult = await _pullPaymentService.GetRate(payout, approvePayoutRequest?.RateRule, cancellationToken); + if (rateResult.BidAsk == null) + { + return this.CreateAPIError("rate-unavailable", $"Rate unavailable: {rateResult.EvaluatedRule}"); + } + } + catch (FormatException) + { + ModelState.AddModelError(nameof(approvePayoutRequest.RateRule), "Invalid RateRule"); + return this.CreateValidationError(ModelState); + } + var ppBlob = payout.PullPaymentData.GetBlob(); + var cd = _currencyNameTable.GetCurrencyData(ppBlob.Currency, false); + var result = await _pullPaymentService.Approve(new PullPaymentHostedService.PayoutApproval() + { + PayoutId = payoutId, + Revision = revision.Value, + Rate = rateResult.BidAsk.Ask + }); + var errorMessage = PullPaymentHostedService.PayoutApproval.GetErrorMessage(result); + switch (result) + { + case PullPaymentHostedService.PayoutApproval.Result.Ok: + return Ok(ToModel(await ctx.Payouts.GetPayout(payoutId, storeId, true), cd)); + case PullPaymentHostedService.PayoutApproval.Result.InvalidState: + return this.CreateAPIError("invalid-state", errorMessage); + case PullPaymentHostedService.PayoutApproval.Result.TooLowAmount: + return this.CreateAPIError("amount-too-low", errorMessage); + case PullPaymentHostedService.PayoutApproval.Result.OldRevision: + return this.CreateAPIError("old-revision", errorMessage); + case PullPaymentHostedService.PayoutApproval.Result.NotFound: + return NotFound(); + default: + throw new NotSupportedException(); + } + } } } diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index ce58c4025..f1b86bead 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Configuration; using System.Globalization; using System.Linq; using System.Net.Mime; @@ -20,13 +21,18 @@ using BTCPayServer.Payments.Lightning; using BTCPayServer.Security; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices.Export; +using DBriize.Utils; +using Google.Cloud.Storage.V1; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using NBitcoin; using NBitpayClient; using NBXplorer; using Newtonsoft.Json.Linq; +using TwentyTwenty.Storage; using StoreData = BTCPayServer.Data.StoreData; namespace BTCPayServer.Controllers @@ -75,9 +81,9 @@ namespace BTCPayServer.Controllers StatusException = invoice.ExceptionStatus, Events = invoice.Events, PosData = PosDataParser.ParsePosData(invoice.PosData), - Archived = invoice.Archived + Archived = invoice.Archived, + CanRefund = CanRefund(invoice.GetInvoiceState()), }; - model.Addresses = invoice.HistoricalAddresses.Select(h => new InvoiceDetailsModel.AddressModel { @@ -92,6 +98,153 @@ namespace BTCPayServer.Controllers return View(model); } + + bool CanRefund(InvoiceState invoiceState) + { + return invoiceState.Status == InvoiceStatus.Confirmed || + invoiceState.Status == InvoiceStatus.Complete || + ((invoiceState.Status == InvoiceStatus.Expired || invoiceState.Status == InvoiceStatus.Invalid) && + (invoiceState.ExceptionStatus == InvoiceExceptionStatus.PaidLate || + invoiceState.ExceptionStatus == InvoiceExceptionStatus.PaidOver || + invoiceState.ExceptionStatus == InvoiceExceptionStatus.PaidPartial)); + } + + [HttpGet] + [Route("invoices/{invoiceId}/refund")] + [AllowAnonymous] + public async Task Refund(string invoiceId, CancellationToken cancellationToken) + { + using var ctx = _dbContextFactory.CreateContext(); + ctx.ChangeTracker.QueryTrackingBehavior = Microsoft.EntityFrameworkCore.QueryTrackingBehavior.NoTracking; + var invoice = await ctx.Invoices.Include(i => i.Payments) + .Include(i => i.CurrentRefund) + .Include(i => i.CurrentRefund.PullPaymentData) + .Where(i => i.Id == invoiceId) + .FirstOrDefaultAsync(); + if (invoice is null) + return NotFound(); + if (invoice.CurrentRefund?.PullPaymentDataId is null && GetUserId() is null) + return NotFound(); + if (!CanRefund(invoice.GetInvoiceState())) + return NotFound(); + if (invoice.CurrentRefund?.PullPaymentDataId is string ppId && !invoice.CurrentRefund.PullPaymentData.Archived) + { + // TODO: Having dedicated UI later on + return RedirectToAction(nameof(PullPaymentController.ViewPullPayment), + "PullPayment", + new { pullPaymentId = ppId }); + } + else + { + var paymentMethods = invoice.GetBlob(_NetworkProvider).GetPaymentMethods(); + var options = invoice.GetBlob(_NetworkProvider).GetPaymentMethods() + .Select(o => o.GetId()) + .Select(o => o.CryptoCode) + .Where(o => _NetworkProvider.GetNetwork(o) is BTCPayNetwork n && !n.ReadonlyWallet) + .Distinct() + .OrderBy(o => o) + .Select(o => new PaymentMethodId(o, PaymentTypes.BTCLike)) + .ToList(); + var defaultRefund = invoice.Payments.Select(p => p.GetBlob(_NetworkProvider)) + .Select(p => p.GetPaymentMethodId().CryptoCode) + .FirstOrDefault(); + // TODO: What if no option? + var refund = new RefundModel(); + refund.Title = "Select a payment method"; + refund.AvailablePaymentMethods = new SelectList(options, nameof(PaymentMethodId.CryptoCode), nameof(PaymentMethodId.CryptoCode)); + refund.SelectedPaymentMethod = defaultRefund ?? options.Select(o => o.CryptoCode).First(); + + // Nothing to select, skip to next + if (refund.AvailablePaymentMethods.Count() == 1) + { + return await Refund(invoiceId, refund, cancellationToken); + } + return View(refund); + } + } + [HttpPost] + [Route("invoices/{invoiceId}/refund")] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)] + public async Task Refund(string invoiceId, RefundModel model, CancellationToken cancellationToken) + { + model.RefundStep = RefundSteps.SelectRate; + using var ctx = _dbContextFactory.CreateContext(); + var invoice = await _InvoiceRepository.GetInvoice(invoiceId); + if (invoice is null) + return NotFound(); + var store = await _StoreRepository.FindStore(invoice.StoreId, GetUserId()); + if (store is null) + return NotFound(); + if (!CanRefund(invoice.GetInvoiceState())) + return NotFound(); + var paymentMethodId = new PaymentMethodId(model.SelectedPaymentMethod, PaymentTypes.BTCLike); + var cdCurrency = _CurrencyNameTable.GetCurrencyData(invoice.ProductInformation.Currency, true); + var paymentMethodDivisibility = _CurrencyNameTable.GetCurrencyData(paymentMethodId.CryptoCode, false)?.Divisibility ?? 8; + if (model.SelectedRefundOption is null) + { + model.Title = "What to refund?"; + var paymentMethod = invoice.GetPaymentMethods()[paymentMethodId]; + var paidCurrency = Math.Round(paymentMethod.Calculate().Paid.ToDecimal(MoneyUnit.BTC) * paymentMethod.Rate, cdCurrency.Divisibility); + model.CryptoAmountThen = Math.Round(paidCurrency / paymentMethod.Rate, paymentMethodDivisibility); + model.RateThenText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountThen, paymentMethodId.CryptoCode, true); + var rules = store.GetStoreBlob().GetRateRules(_NetworkProvider); + var rateResult = await _RateProvider.FetchRate(new Rating.CurrencyPair(paymentMethodId.CryptoCode, invoice.ProductInformation.Currency), rules, cancellationToken); + //TODO: What if fetching rate failed? + if (rateResult.BidAsk is null) + { + ModelState.AddModelError(nameof(model.SelectedRefundOption), $"Impossible to fetch rate: {rateResult.EvaluatedRule}"); + return View(model); + } + model.CryptoAmountNow = Math.Round(paidCurrency / rateResult.BidAsk.Bid, paymentMethodDivisibility); + model.CurrentRateText = _CurrencyNameTable.DisplayFormatCurrency(model.CryptoAmountNow, paymentMethodId.CryptoCode, true); + model.FiatAmount = paidCurrency; + model.FiatText = _CurrencyNameTable.DisplayFormatCurrency(model.FiatAmount, invoice.ProductInformation.Currency, true); + return View(model); + } + else + { + var createPullPayment = new HostedServices.CreatePullPayment(); + createPullPayment.Name = $"Refund {invoice.Id}"; + createPullPayment.PaymentMethodIds = new[] { paymentMethodId }; + createPullPayment.StoreId = invoice.StoreId; + switch (model.SelectedRefundOption) + { + case "RateThen": + createPullPayment.Currency = paymentMethodId.CryptoCode; + createPullPayment.Amount = model.CryptoAmountThen; + break; + case "CurrentRate": + createPullPayment.Currency = paymentMethodId.CryptoCode; + createPullPayment.Amount = model.CryptoAmountNow; + break; + case "Fiat": + createPullPayment.Currency = invoice.ProductInformation.Currency; + createPullPayment.Amount = model.FiatAmount; + break; + default: + ModelState.AddModelError(nameof(model.SelectedRefundOption), "Invalid choice"); + return View(model); + } + var ppId = await _paymentHostedService.CreatePullPayment(createPullPayment); + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Html = "Share this page with a customer so they can claim a refund
Once claimed you need to initiate a refund from Wallet > Payouts", + Severity = StatusMessageModel.StatusSeverity.Success + }); + (await ctx.Invoices.FindAsync(invoice.Id)).CurrentRefundId = ppId; + ctx.Refunds.Add(new RefundData() + { + InvoiceDataId = invoice.Id, + PullPaymentDataId = ppId + }); + await ctx.SaveChangesAsync(); + // TODO: Having dedicated UI later on + return RedirectToAction(nameof(PullPaymentController.ViewPullPayment), + "PullPayment", + new { pullPaymentId = ppId }); + } + } + private InvoiceDetailsModel InvoicePopulatePayments(InvoiceEntity invoice) { var model = new InvoiceDetailsModel(); diff --git a/BTCPayServer/Controllers/InvoiceController.cs b/BTCPayServer/Controllers/InvoiceController.cs index b1005e535..0f3992008 100644 --- a/BTCPayServer/Controllers/InvoiceController.cs +++ b/BTCPayServer/Controllers/InvoiceController.cs @@ -8,6 +8,7 @@ using System.Threading.Tasks; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Events; +using BTCPayServer.HostedServices; using BTCPayServer.Logging; using BTCPayServer.Models; using BTCPayServer.Payments; @@ -41,6 +42,8 @@ namespace BTCPayServer.Controllers EventAggregator _EventAggregator; BTCPayNetworkProvider _NetworkProvider; private readonly PaymentMethodHandlerDictionary _paymentMethodHandlerDictionary; + private readonly ApplicationDbContextFactory _dbContextFactory; + private readonly PullPaymentHostedService _paymentHostedService; IServiceProvider _ServiceProvider; public InvoiceController( IServiceProvider serviceProvider, @@ -52,7 +55,9 @@ namespace BTCPayServer.Controllers EventAggregator eventAggregator, ContentSecurityPolicies csp, BTCPayNetworkProvider networkProvider, - PaymentMethodHandlerDictionary paymentMethodHandlerDictionary) + PaymentMethodHandlerDictionary paymentMethodHandlerDictionary, + ApplicationDbContextFactory dbContextFactory, + PullPaymentHostedService paymentHostedService) { _ServiceProvider = serviceProvider; _CurrencyNameTable = currencyNameTable ?? throw new ArgumentNullException(nameof(currencyNameTable)); @@ -63,6 +68,8 @@ namespace BTCPayServer.Controllers _EventAggregator = eventAggregator; _NetworkProvider = networkProvider; _paymentMethodHandlerDictionary = paymentMethodHandlerDictionary; + _dbContextFactory = dbContextFactory; + _paymentHostedService = paymentHostedService; _CSP = csp; } diff --git a/BTCPayServer/Controllers/PullPaymentController.cs b/BTCPayServer/Controllers/PullPaymentController.cs index 8d29cc089..9f963199b 100644 --- a/BTCPayServer/Controllers/PullPaymentController.cs +++ b/BTCPayServer/Controllers/PullPaymentController.cs @@ -11,6 +11,7 @@ using BTCPayServer.Models; using BTCPayServer.Payments; using BTCPayServer.Services; using BTCPayServer.Services.Rates; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.EntityFrameworkCore; @@ -18,6 +19,7 @@ using Microsoft.EntityFrameworkCore.Internal; namespace BTCPayServer.Controllers { + [AllowAnonymous] public class PullPaymentController : Controller { private readonly ApplicationDbContextFactory _dbContextFactory; diff --git a/BTCPayServer/Controllers/WalletsController.PullPayments.cs b/BTCPayServer/Controllers/WalletsController.PullPayments.cs index 43a25cc48..56c07da54 100644 --- a/BTCPayServer/Controllers/WalletsController.PullPayments.cs +++ b/BTCPayServer/Controllers/WalletsController.PullPayments.cs @@ -22,6 +22,10 @@ using Microsoft.Extensions.Internal; using NBitcoin.Payment; using NBitcoin; using BTCPayServer.Payments; +using Microsoft.Extensions.Primitives; +using System.Threading; +using BTCPayServer.HostedServices; +using TwentyTwenty.Storage; namespace BTCPayServer.Controllers { @@ -45,6 +49,7 @@ namespace BTCPayServer.Controllers WalletId walletId, NewPullPaymentModel model) { model.Name ??= string.Empty; + model.Currency = model.Currency.ToUpperInvariant().Trim(); if (_currencyTable.GetCurrencyData(model.Currency, false) is null) { ModelState.AddModelError(nameof(model.Currency), "Invalid currency"); @@ -57,15 +62,19 @@ namespace BTCPayServer.Controllers { ModelState.AddModelError(nameof(model.Name), "The name should be maximum 50 characters."); } + var paymentMethodId = walletId.GetPaymentMethodId(); + var n = this.NetworkProvider.GetNetwork(paymentMethodId.CryptoCode); + if (n is null || paymentMethodId.PaymentType != PaymentTypes.BTCLike || n.ReadonlyWallet) + ModelState.AddModelError(nameof(model.Name), "Pull payments are not supported with this wallet"); if (!ModelState.IsValid) return View(model); await _pullPaymentService.CreatePullPayment(new HostedServices.CreatePullPayment() { Name = model.Name, Amount = model.Amount, - Currency = walletId.CryptoCode, + Currency = model.Currency, StoreId = walletId.StoreId, - PaymentMethodIds = new[] { new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike) } + PaymentMethodIds = new[] { paymentMethodId } }); this.TempData.SetStatusMessageModel(new StatusMessageModel() { @@ -90,7 +99,7 @@ namespace BTCPayServer.Controllers { PullPayment = o, Awaiting = o.Payouts - .Where(p => p.State == PayoutState.AwaitingPayment), + .Where(p => p.State == PayoutState.AwaitingPayment || p.State == PayoutState.AwaitingApproval), Completed = o.Payouts .Where(p => p.State == PayoutState.Completed || p.State == PayoutState.InProgress) }) @@ -169,7 +178,7 @@ namespace BTCPayServer.Controllers [Route("{walletId}/payouts")] public async Task PayoutsPost( [ModelBinder(typeof(WalletIdModelBinder))] - WalletId walletId, PayoutsModel vm) + WalletId walletId, PayoutsModel vm, CancellationToken cancellationToken) { if (vm is null) return NotFound(); @@ -192,10 +201,56 @@ namespace BTCPayServer.Controllers if (vm.Command == "pay") { using var ctx = this._dbContextFactory.CreateContext(); - var payouts = await ctx.Payouts + ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; + var payouts = (await ctx.Payouts + .Include(p => p.PullPaymentData) + .Include(p => p.PullPaymentData.StoreData) .Where(p => payoutIds.Contains(p.Id)) .Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived) - .ToListAsync(); + .ToListAsync()) + .Where(p => p.GetPaymentMethodId() == walletId.GetPaymentMethodId()) + .ToList(); + + for (int i = 0; i < payouts.Count; i ++) + { + var payout = payouts[i]; + if (payout.State != PayoutState.AwaitingApproval) + continue; + var rateResult = await _pullPaymentService.GetRate(payout, null, cancellationToken); + if (rateResult.BidAsk == null) + { + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = $"Rate unavailable: {rateResult.EvaluatedRule}", + Severity = StatusMessageModel.StatusSeverity.Error + }); + return RedirectToAction(nameof(Payouts), new + { + walletId = walletId.ToString(), + pullPaymentId = vm.PullPaymentId + }); + } + var approveResult = await _pullPaymentService.Approve(new HostedServices.PullPaymentHostedService.PayoutApproval() + { + PayoutId = payout.Id, + Revision = payout.GetBlob(_jsonSerializerSettings).Revision, + Rate = rateResult.BidAsk.Ask + }); + if (approveResult != HostedServices.PullPaymentHostedService.PayoutApproval.Result.Ok) + { + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = PullPaymentHostedService.PayoutApproval.GetErrorMessage(approveResult), + Severity = StatusMessageModel.StatusSeverity.Error + }); + return RedirectToAction(nameof(Payouts), new + { + walletId = walletId.ToString(), + pullPaymentId = vm.PullPaymentId + }); + } + payouts[i] = await ctx.Payouts.FindAsync(payouts[i].Id); + } var walletSend = (WalletSendModel)((ViewResult)(await this.WalletSend(walletId))).Model; walletSend.Outputs.Clear(); foreach (var payout in payouts) @@ -205,7 +260,7 @@ namespace BTCPayServer.Controllers continue; var output = new WalletSendModel.TransactionOutput() { - Amount = blob.Amount, + Amount = blob.CryptoAmount, DestinationAddress = blob.Destination.Address.ToString() }; walletSend.Outputs.Add(output); @@ -268,7 +323,7 @@ namespace BTCPayServer.Controllers 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) + if (item.Payout.State == PayoutState.AwaitingPayment || item.Payout.State == PayoutState.AwaitingApproval) { vm.WaitingForApproval.Add(m); } diff --git a/BTCPayServer/Data/PullPaymentsExtensions.cs b/BTCPayServer/Data/PullPaymentsExtensions.cs index c2fd41f57..c5862f9f8 100644 --- a/BTCPayServer/Data/PullPaymentsExtensions.cs +++ b/BTCPayServer/Data/PullPaymentsExtensions.cs @@ -8,6 +8,7 @@ using BTCPayServer.Client.JsonConverters; using BTCPayServer.JsonConverters; using BTCPayServer.Payments; using BTCPayServer.Services; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.NewtonsoftJson; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.EntityFrameworkCore; @@ -22,9 +23,14 @@ namespace BTCPayServer.Data { public static class PullPaymentsExtensions { - public static async Task GetPayout(this DbSet payouts, string payoutId, string storeId) + public static async Task GetPayout(this DbSet payouts, string payoutId, string storeId, bool includePullPayment = false, bool includeStore = false) { - var payout = await payouts.Where(p => p.Id == payoutId && + IQueryable query = payouts; + if (includePullPayment) + query = query.Include(p => p.PullPaymentData); + if (includeStore) + query = query.Include(p => p.PullPaymentData.StoreData); + var payout = await query.Where(p => p.Id == payoutId && p.PullPaymentData.StoreId == storeId).FirstOrDefaultAsync(); if (payout is null) return null; @@ -152,9 +158,10 @@ namespace BTCPayServer.Data [JsonConverter(typeof(DecimalStringJsonConverter))] public decimal Amount { get; set; } [JsonConverter(typeof(DecimalStringJsonConverter))] - public decimal CryptoAmount { get; set; } + public decimal? CryptoAmount { get; set; } public int MinimumConfirmation { get; set; } = 1; public IClaimDestination Destination { get; set; } + public int Revision { get; set; } } public class ClaimDestinationJsonConverter : JsonConverter { diff --git a/BTCPayServer/Extensions.cs b/BTCPayServer/Extensions.cs index b81463e1d..bd66c6a3c 100644 --- a/BTCPayServer/Extensions.cs +++ b/BTCPayServer/Extensions.cs @@ -39,11 +39,39 @@ using Microsoft.AspNetCore.Mvc.ViewFeatures; using Newtonsoft.Json.Linq; using BTCPayServer.Payments.Bitcoin; using NBitcoin.Payment; +using Microsoft.AspNetCore.Routing; namespace BTCPayServer { public static class Extensions { + public static InvoiceEntity GetBlob(this Data.InvoiceData invoiceData, BTCPayNetworkProvider networks) + { + var entity = NBitcoin.JsonConverters.Serializer.ToObject(ZipUtils.Unzip(invoiceData.Blob), null); + entity.Networks = networks; + return entity; + } + public static PaymentEntity GetBlob(this Data.PaymentData paymentData, BTCPayNetworkProvider networks) + { + var unziped = ZipUtils.Unzip(paymentData.Blob); + var cryptoCode = "BTC"; + if (JObject.Parse(unziped).TryGetValue("cryptoCode", out var v) && v.Type == JTokenType.String) + cryptoCode = v.Value(); + var network = networks.GetNetwork(cryptoCode); + PaymentEntity paymentEntity = null; + if (network == null) + { + paymentEntity = NBitcoin.JsonConverters.Serializer.ToObject(unziped, null); + } + else + { + paymentEntity = network.ToObject(unziped); + } + paymentEntity.Network = network; + paymentEntity.Accounted = paymentData.Accounted; + return paymentEntity; + } + public static bool TryGetPayjoinEndpoint(this BitcoinUrlBuilder bip21, out Uri endpoint) { endpoint = bip21.UnknowParameters.TryGetValue($"{PayjoinClient.BIP21EndpointKey}", out var uri) ? new Uri(uri, UriKind.Absolute) : null; diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index f91400447..eea1a2973 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -10,6 +10,7 @@ using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Logging; using BTCPayServer.Payments; +using BTCPayServer.Rating; using BTCPayServer.Services; using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications.Blobs; @@ -24,7 +25,9 @@ using NBitcoin.DataEncoders; using NBitcoin.Payment; using NBitcoin.RPC; using NBXplorer; +using Org.BouncyCastle.Bcpg.OpenPgp; using Serilog.Configuration; +using SQLitePCL; namespace BTCPayServer.HostedServices { @@ -59,7 +62,40 @@ namespace BTCPayServer.HostedServices public string[] PayoutIds { get; set; } internal TaskCompletionSource Completion { get; set; } } + public class PayoutApproval + { + public enum Result + { + Ok, + NotFound, + InvalidState, + TooLowAmount, + OldRevision + } + public string PayoutId { get; set; } + public int Revision { get; set; } + public decimal Rate { get; set; } + internal TaskCompletionSource Completion { get; set; } + public static string GetErrorMessage(Result result) + { + switch (result) + { + case PullPaymentHostedService.PayoutApproval.Result.Ok: + return "Ok"; + case PullPaymentHostedService.PayoutApproval.Result.InvalidState: + return "The payout is not in a state that can be approved"; + case PullPaymentHostedService.PayoutApproval.Result.TooLowAmount: + return "The crypto amount is too small."; + case PullPaymentHostedService.PayoutApproval.Result.OldRevision: + return "The crypto amount is too small."; + case PullPaymentHostedService.PayoutApproval.Result.NotFound: + return "The payout is not found"; + default: + throw new NotSupportedException(); + } + } + } public async Task CreatePullPayment(CreatePullPayment create) { if (create == null) @@ -120,7 +156,8 @@ namespace BTCPayServer.HostedServices EventAggregator eventAggregator, ExplorerClientProvider explorerClientProvider, BTCPayNetworkProvider networkProvider, - NotificationSender notificationSender) + NotificationSender notificationSender, + RateFetcher rateFetcher) { _dbContextFactory = dbContextFactory; _jsonSerializerSettings = jsonSerializerSettings; @@ -129,6 +166,7 @@ namespace BTCPayServer.HostedServices _explorerClientProvider = explorerClientProvider; _networkProvider = networkProvider; _notificationSender = notificationSender; + _rateFetcher = rateFetcher; } Channel _Channel; @@ -139,6 +177,7 @@ namespace BTCPayServer.HostedServices private readonly ExplorerClientProvider _explorerClientProvider; private readonly BTCPayNetworkProvider _networkProvider; private readonly NotificationSender _notificationSender; + private readonly RateFetcher _rateFetcher; internal override Task[] InitializeTasks() { @@ -157,6 +196,11 @@ namespace BTCPayServer.HostedServices await HandleCreatePayout(req); } + if (o is PayoutApproval approv) + { + await HandleApproval(approv); + } + if (o is NewOnChainTransactionEvent newTransaction) { await UpdatePayoutsAwaitingForPayment(newTransaction); @@ -172,6 +216,82 @@ namespace BTCPayServer.HostedServices } } + public Task GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken) + { + var ppBlob = payout.PullPaymentData.GetBlob(); + var currencyPair = new Rating.CurrencyPair(payout.GetPaymentMethodId().CryptoCode, ppBlob.Currency); + Rating.RateRule rule = null; + try + { + if (explicitRateRule is null) + { + var storeBlob = payout.PullPaymentData.StoreData.GetStoreBlob(); + var rules = storeBlob.GetRateRules(_networkProvider); + rules.Spread = 0.0m; + rule = rules.GetRuleFor(currencyPair); + } + else + { + rule = Rating.RateRule.CreateFromExpression(explicitRateRule, currencyPair); + } + } + catch (Exception) + { + throw new FormatException("Invalid RateRule"); + } + return _rateFetcher.FetchRate(rule, cancellationToken); + } + public Task Approve(PayoutApproval approval) + { + approval.Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_Channel.Writer.TryWrite(approval)) + throw new ObjectDisposedException(nameof(PullPaymentHostedService)); + return approval.Completion.Task; + } + private async Task HandleApproval(PayoutApproval req) + { + try + { + using var ctx = _dbContextFactory.CreateContext(); + var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId).FirstOrDefaultAsync(); + if (payout is null) + { + req.Completion.SetResult(PayoutApproval.Result.NotFound); + return; + } + if (payout.State != PayoutState.AwaitingApproval) + { + req.Completion.SetResult(PayoutApproval.Result.InvalidState); + return; + } + var payoutBlob = payout.GetBlob(this._jsonSerializerSettings); + if (payoutBlob.Revision != req.Revision) + { + req.Completion.SetResult(PayoutApproval.Result.OldRevision); + return; + } + payout.State = PayoutState.AwaitingPayment; + var paymentMethod = PaymentMethodId.Parse(payout.PaymentMethodId); + if (paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency) + req.Rate = 1.0m; + var cryptoAmount = Money.Coins(payoutBlob.Amount / req.Rate); + Money mininumCryptoAmount = GetMinimumCryptoAmount(paymentMethod, payoutBlob.Destination.Address.ScriptPubKey); + if (cryptoAmount < mininumCryptoAmount) + { + req.Completion.TrySetResult(PayoutApproval.Result.TooLowAmount); + return; + } + payoutBlob.CryptoAmount = cryptoAmount.ToDecimal(MoneyUnit.BTC); + payout.SetBlob(payoutBlob, this._jsonSerializerSettings); + await ctx.SaveChangesAsync(); + req.Completion.SetResult(PayoutApproval.Result.Ok); + } + catch(Exception ex) + { + req.Completion.TrySetException(ex); + } + } + private async Task HandleCreatePayout(PayoutRequest req) { try @@ -221,7 +341,7 @@ namespace BTCPayServer.HostedServices { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), Date = now, - State = PayoutState.AwaitingPayment, + State = PayoutState.AwaitingApproval, PullPaymentDataId = req.ClaimRequest.PullPaymentId, PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(), Destination = GetDestination(req.ClaimRequest.Destination.Address.ScriptPubKey) @@ -231,18 +351,9 @@ namespace BTCPayServer.HostedServices 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); @@ -447,7 +558,8 @@ namespace BTCPayServer.HostedServices CancellationToken.ThrowIfCancellationRequested(); var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); cancelRequest.Completion = cts; - _Channel.Writer.TryWrite(cancelRequest); + if(!_Channel.Writer.TryWrite(cancelRequest)) + throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cts.Task; } @@ -455,7 +567,8 @@ namespace BTCPayServer.HostedServices { CancellationToken.ThrowIfCancellationRequested(); var cts = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _Channel.Writer.TryWrite(new PayoutRequest(cts, request)); + if(!_Channel.Writer.TryWrite(new PayoutRequest(cts, request))) + throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cts.Task; } diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs index 2d9ca5d88..5a3554193 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceDetailsModel.cs @@ -128,5 +128,6 @@ namespace BTCPayServer.Models.InvoicingModels public Dictionary PosData { get; set; } public List Payments { get; set; } public bool Archived { get; set; } + public bool CanRefund { get; set; } } } diff --git a/BTCPayServer/Models/InvoicingModels/RefundModel.cs b/BTCPayServer/Models/InvoicingModels/RefundModel.cs new file mode 100644 index 000000000..e1243b918 --- /dev/null +++ b/BTCPayServer/Models/InvoicingModels/RefundModel.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; +using BTCPayServer.Payments; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace BTCPayServer.Models.InvoicingModels +{ + public enum RefundSteps + { + SelectPaymentMethod, + SelectRate + } + public class RefundModel + { + public string Title { get; set; } + public SelectList AvailablePaymentMethods { get; set; } + [Display(Name = "Select the payment method used for refund")] + public string SelectedPaymentMethod { get; set; } + public RefundSteps RefundStep { get; set; } + public string SelectedRefundOption { get; set; } + public decimal CryptoAmountNow { get; set; } + public string CurrentRateText { get; set; } + public decimal CryptoAmountThen { get; set; } + public string RateThenText { get; set; } + public string FiatText { get; set; } + public decimal FiatAmount { get; set; } + } +} diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index c7dbd6e61..fb39320d4 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -127,7 +127,7 @@ retry: { var invoiceData = await ctx.Invoices.FindAsync(invoiceId); - var invoice = ToObject(invoiceData.Blob); + var invoice = invoiceData.GetBlob(_Networks); invoice.MonitoringExpiration = invoice.MonitoringExpiration.AddHours(1); invoiceData.Blob = ToBytes(invoice, null); @@ -138,7 +138,7 @@ retry: public async Task CreateInvoiceAsync(string storeId, InvoiceEntity invoice) { List textSearch = new List(); - invoice = ToObject(ToBytes(invoice)); + invoice = Clone(invoice); invoice.Networks = _Networks; invoice.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)); #pragma warning disable CS0618 @@ -199,6 +199,13 @@ retry: return invoice; } + private InvoiceEntity Clone(InvoiceEntity invoice) + { + var temp = new InvoiceData(); + temp.Blob = ToBytes(invoice); + return temp.GetBlob(_Networks); + } + public async Task AddInvoiceLogs(string invoiceId, InvoiceLogs logs) { using (var context = _ContextFactory.CreateContext()) @@ -237,7 +244,7 @@ retry: if (invoice == null) return false; - var invoiceEntity = ToObject(invoice.Blob); + var invoiceEntity = invoice.GetBlob(_Networks); var currencyData = invoiceEntity.GetPaymentMethod(network, paymentMethod.GetPaymentType()); if (currencyData == null) return false; @@ -285,7 +292,7 @@ retry: if (invoice == null) return; var network = paymentMethod.Network; - var invoiceEntity = ToObject(invoice.Blob); + var invoiceEntity = invoice.GetBlob(_Networks); invoiceEntity.SetPaymentMethod(paymentMethod); invoice.Blob = ToBytes(invoiceEntity, network); await context.SaveChangesAsync(); @@ -349,7 +356,7 @@ retry: var invoiceData = await context.FindAsync(invoiceId).ConfigureAwait(false); if (invoiceData == null) return; - var invoiceEntity = ToObject(invoiceData.Blob); + var invoiceEntity = invoiceData.GetBlob(_Networks); MarkUnassigned(invoiceId, invoiceEntity, context, null); try { @@ -454,25 +461,12 @@ retry: private InvoiceEntity ToEntity(Data.InvoiceData invoice) { - var entity = ToObject(invoice.Blob); + var entity = invoice.GetBlob(_Networks); PaymentMethodDictionary paymentMethods = null; #pragma warning disable CS0618 entity.Payments = invoice.Payments.Select(p => { - var unziped = ZipUtils.Unzip(p.Blob); - var cryptoCode = GetCryptoCode(unziped); - var network = _Networks.GetNetwork(cryptoCode); - PaymentEntity paymentEntity = null; - if (network == null) - { - paymentEntity = NBitcoin.JsonConverters.Serializer.ToObject(unziped, null); - } - else - { - paymentEntity = network.ToObject(unziped); - } - paymentEntity.Network = network; - paymentEntity.Accounted = p.Accounted; + var paymentEntity = p.GetBlob(_Networks); // PaymentEntity on version 0 does not have their own fee, because it was assumed that the payment method have fixed fee. // We want to hide this legacy detail in InvoiceRepository, so we fetch the fee from the PaymentMethod and assign it to the PaymentEntity. if (paymentEntity.Version == 0) @@ -514,13 +508,6 @@ retry: return entity; } - private string GetCryptoCode(string json) - { - if (JObject.Parse(json).TryGetValue("cryptoCode", out var v) && v.Type == JTokenType.String) - return v.Value(); - return "BTC"; - } - private IQueryable GetInvoiceQuery(ApplicationDbContext context, InvoiceQuery queryObject) { IQueryable query = context.Invoices; @@ -669,7 +656,7 @@ retry: var invoice = context.Invoices.Find(invoiceId); if (invoice == null) return null; - InvoiceEntity invoiceEntity = ToObject(invoice.Blob); + InvoiceEntity invoiceEntity = invoice.GetBlob(_Networks); PaymentMethod paymentMethod = invoiceEntity.GetPaymentMethod(new PaymentMethodId(network.CryptoCode, paymentData.GetPaymentType())); IPaymentMethodDetails paymentMethodDetails = paymentMethod.GetPaymentMethodDetails(); PaymentEntity entity = new PaymentEntity @@ -735,13 +722,6 @@ retry: } } - private InvoiceEntity ToObject(byte[] value) - { - var entity = NBitcoin.JsonConverters.Serializer.ToObject(ZipUtils.Unzip(value), null); - entity.Networks = _Networks; - return entity; - } - private byte[] ToBytes(T obj, BTCPayNetworkBase network = null) { return ZipUtils.Zip(ToString(obj, network)); diff --git a/BTCPayServer/Views/Invoice/Invoice.cshtml b/BTCPayServer/Views/Invoice/Invoice.cshtml index c4e418b31..d9b29692f 100644 --- a/BTCPayServer/Views/Invoice/Invoice.cshtml +++ b/BTCPayServer/Views/Invoice/Invoice.cshtml @@ -26,15 +26,23 @@

@ViewData["Title"]

- + } +
diff --git a/BTCPayServer/Views/Invoice/Refund.cshtml b/BTCPayServer/Views/Invoice/Refund.cshtml new file mode 100644 index 000000000..a0b235fed --- /dev/null +++ b/BTCPayServer/Views/Invoice/Refund.cshtml @@ -0,0 +1,64 @@ +@model RefundModel +@{ + ViewData["Title"] = "Refund"; +} + +
+
+ +
+
+ diff --git a/BTCPayServer/Views/Wallets/NewPullPayment.cshtml b/BTCPayServer/Views/Wallets/NewPullPayment.cshtml index 94290252f..b10ca6273 100644 --- a/BTCPayServer/Views/Wallets/NewPullPayment.cshtml +++ b/BTCPayServer/Views/Wallets/NewPullPayment.cshtml @@ -72,7 +72,7 @@
- +
diff --git a/BTCPayServer/WalletId.cs b/BTCPayServer/WalletId.cs index a9cbb7823..5cc5447af 100644 --- a/BTCPayServer/WalletId.cs +++ b/BTCPayServer/WalletId.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using BTCPayServer.Payments; namespace BTCPayServer { @@ -35,7 +36,10 @@ namespace BTCPayServer public string StoreId { get; set; } public string CryptoCode { get; set; } - + public PaymentMethodId GetPaymentMethodId() + { + return new PaymentMethodId(CryptoCode, PaymentTypes.BTCLike); + } public override bool Equals(object obj) { WalletId item = obj as WalletId; diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json index 62397de02..6088ec872 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json @@ -307,6 +307,65 @@ "schema": { "type": "string" } } ], + "post": { + "description": "Approve a payout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "revision": { + "type": "integer", + "description": "The revision number of the payout being modified" + }, + "rateRule": { + "type": "string", + "nullable": true, + "example": "kraken(BTC_USD)", + "description": "The rate rule to calculate the rate of the payout. This can also be a fixed decimal. (if null or unspecified, will use the same rate setting as the store's settings)" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The payout has been approved, transitioning to `AwaitingPayment` state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PayoutData" + } + } + } + }, + "422": { + "description": "Unable to validate the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "400": { + "description": "Wellknown error codes are: `rate-unavailable`, `invalid-state`, `amount-too-low`, `old-revision`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The payout is not found" + } + } + }, "delete": { "description": "Cancel the payout", "responses": { @@ -350,6 +409,10 @@ "type": "string", "description": "The id of the payout" }, + "revision": { + "type": "integer", + "description": "The revision number of the payout. This revision number is incremented when the payout amount or destination is modified before the approval." + }, "pullPaymentId": { "type": "string", "description": "The id of the pull payment this payout belongs to" @@ -367,7 +430,7 @@ "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`." + "description": "The amount of the payout in the currency of the pull payment (eg. USD)." }, "paymentMethod": { "type": "string", @@ -377,20 +440,23 @@ "paymentMethodAmount": { "type": "string", "format": "decimal", + "nullable": true, "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`." + "description": "The amount of the payout in the currency of the payment method (eg. BTC). This is only available from the `AwaitingPayment` state." }, "state": { "type": "string", "example": "AwaitingPayment", - "description": "The state of the payout (`AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)", + "description": "The state of the payout (`AwaitingApproval`, `AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)", "x-enumNames": [ + "AwaitingApproval", "AwaitingPayment", "InProgress", "Completed", "Cancelled" ], "enum": [ + "AwaitingApproval", "AwaitingPayment", "InProgress", "Completed",