mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2026-02-18 20:54:25 +01:00
Merge pull request #1689 from btcpayserver/invoicerefund
Implement invoice refund
This commit is contained in:
@@ -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<PayoutData> 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<PayoutData>(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
BTCPayServer.Client/Models/ApprovePayoutRequest.cs
Normal file
12
BTCPayServer.Client/Models/ApprovePayoutRequest.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
public DbSet<RefundData> Refunds
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public DbSet<PlannedTransaction> PlannedTransactions { get; set; }
|
||||
public DbSet<PayjoinLock> PayjoinLocks { get; set; }
|
||||
@@ -201,6 +205,7 @@ namespace BTCPayServer.Data
|
||||
|
||||
PullPaymentData.OnModelCreating(builder);
|
||||
PayoutData.OnModelCreating(builder);
|
||||
RefundData.OnModelCreating(builder);
|
||||
|
||||
if (Database.IsSqlite() && !_designTime)
|
||||
{
|
||||
|
||||
@@ -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<PaymentData> Payments
|
||||
{
|
||||
get; set;
|
||||
@@ -74,8 +75,16 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
get; set;
|
||||
}
|
||||
|
||||
public bool Archived { get; set; }
|
||||
public List<PendingInvoiceData> PendingInvoices { get; set; }
|
||||
public List<RefundData> Refunds { get; set; }
|
||||
public string CurrentRefundId { get; set; }
|
||||
[ForeignKey("Id,CurrentRefundId")]
|
||||
public RefundData CurrentRefund { get; set; }
|
||||
internal static void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
builder.Entity<InvoiceData>()
|
||||
.HasOne(o => o.CurrentRefund);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ namespace BTCPayServer.Data
|
||||
|
||||
public enum PayoutState
|
||||
{
|
||||
AwaitingApproval,
|
||||
AwaitingPayment,
|
||||
InProgress,
|
||||
Completed,
|
||||
|
||||
30
BTCPayServer.Data/Data/RefundData.cs
Normal file
30
BTCPayServer.Data/Data/RefundData.cs
Normal file
@@ -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<RefundData>()
|
||||
.HasKey(nameof(InvoiceDataId), nameof(PullPaymentDataId));
|
||||
builder.Entity<RefundData>()
|
||||
.HasOne(o => o.InvoiceData)
|
||||
.WithMany(o => o.Refunds)
|
||||
.HasForeignKey(o => o.InvoiceDataId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
BTCPayServer.Data/Migrations/20200624051926_invoicerefund.cs
Normal file
49
BTCPayServer.Data/Migrations/20200624051926_invoicerefund.cs
Normal file
@@ -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<string>(
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1039
BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.Designer.cs
generated
Normal file
1039
BTCPayServer.Data/Migrations/20200625050738_refundinvoice2.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<string>(nullable: false),
|
||||
PullPaymentDataId = table.Column<string>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string>(
|
||||
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<string>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,9 @@ namespace BTCPayServer.Migrations
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrentRefundId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<string>("InvoiceDataId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PullPaymentDataId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("InvoiceDataId", "PullPaymentDataId");
|
||||
|
||||
b.HasIndex("PullPaymentDataId");
|
||||
|
||||
b.ToTable("Refunds");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BTCPayServer.Data.SettingData", b =>
|
||||
{
|
||||
b.Property<string>("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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -70,6 +70,24 @@ namespace BTCPayServer.Services.Rates
|
||||
return fetchingRates;
|
||||
}
|
||||
|
||||
public Task<RateResult> FetchRate(RateRule rateRule, CancellationToken cancellationToken)
|
||||
{
|
||||
if (rateRule == null)
|
||||
throw new ArgumentNullException(nameof(rateRule));
|
||||
var fetchingExchanges = new Dictionary<string, Task<QueryRateResult>>();
|
||||
var dependentQueries = new List<Task<QueryRateResult>>();
|
||||
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<RateResult> GetRuleValue(List<Task<QueryRateResult>> dependentQueries, RateRule rateRule)
|
||||
{
|
||||
var result = new RateResult();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<BTCPayNetwork>(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<BTCPayNetwork>(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<BTCPayNetwork>(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<IActionResult> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IActionResult> 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<BTCPayNetwork>(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<IActionResult> 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 <br />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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BTCPayNetwork>(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<IActionResult> 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);
|
||||
}
|
||||
|
||||
@@ -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<PayoutData> GetPayout(this DbSet<PayoutData> payouts, string payoutId, string storeId)
|
||||
public static async Task<PayoutData> GetPayout(this DbSet<PayoutData> payouts, string payoutId, string storeId, bool includePullPayment = false, bool includeStore = false)
|
||||
{
|
||||
var payout = await payouts.Where(p => p.Id == payoutId &&
|
||||
IQueryable<PayoutData> 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<IClaimDestination>
|
||||
{
|
||||
|
||||
@@ -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<InvoiceEntity>(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<string>();
|
||||
var network = networks.GetNetwork<BTCPayNetworkBase>(cryptoCode);
|
||||
PaymentEntity paymentEntity = null;
|
||||
if (network == null)
|
||||
{
|
||||
paymentEntity = NBitcoin.JsonConverters.Serializer.ToObject<PaymentEntity>(unziped, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentEntity = network.ToObject<PaymentEntity>(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;
|
||||
|
||||
@@ -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<bool> 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<Result> 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<string> 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<object> _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<RateResult> 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<PayoutApproval.Result> Approve(PayoutApproval approval)
|
||||
{
|
||||
approval.Completion = new TaskCompletionSource<PayoutApproval.Result>(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<bool>(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<ClaimRequest.ClaimResponse>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,5 +128,6 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public Dictionary<string, object> PosData { get; set; }
|
||||
public List<PaymentEntity> Payments { get; set; }
|
||||
public bool Archived { get; set; }
|
||||
public bool CanRefund { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
31
BTCPayServer/Models/InvoicingModels/RefundModel.cs
Normal file
31
BTCPayServer/Models/InvoicingModels/RefundModel.cs
Normal file
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<InvoiceEntity> CreateInvoiceAsync(string storeId, InvoiceEntity invoice)
|
||||
{
|
||||
List<string> textSearch = new List<string>();
|
||||
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<Data.InvoiceData>(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<BTCPayNetworkBase>(cryptoCode);
|
||||
PaymentEntity paymentEntity = null;
|
||||
if (network == null)
|
||||
{
|
||||
paymentEntity = NBitcoin.JsonConverters.Serializer.ToObject<PaymentEntity>(unziped, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
paymentEntity = network.ToObject<PaymentEntity>(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<string>();
|
||||
return "BTC";
|
||||
}
|
||||
|
||||
private IQueryable<Data.InvoiceData> GetInvoiceQuery(ApplicationDbContext context, InvoiceQuery queryObject)
|
||||
{
|
||||
IQueryable<Data.InvoiceData> 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<InvoiceEntity>(ZipUtils.Unzip(value), null);
|
||||
entity.Networks = _Networks;
|
||||
return entity;
|
||||
}
|
||||
|
||||
private byte[] ToBytes<T>(T obj, BTCPayNetworkBase network = null)
|
||||
{
|
||||
return ZipUtils.Zip(ToString(obj, network));
|
||||
|
||||
@@ -26,15 +26,23 @@
|
||||
<div class="d-flex justify-content-between">
|
||||
<h2>@ViewData["Title"]</h2>
|
||||
<form asp-action="ToggleArchive" asp-route-invoiceId="@Model.Id" method="post">
|
||||
<button type="submit" class="@(Model.Archived ? "btn badge badge-warning" : "btn btn-link")" id="btn-archive-toggle">
|
||||
@if (Model.Archived)
|
||||
{
|
||||
<span data-toggle="tooltip" title="Unarchive this invoice">Archived <i class=" ml-1 fa fa-close" ></i></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span data-toggle="tooltip" title="Archive this invoice so that it does not appear in the invoice list by default">Archive</span>
|
||||
}
|
||||
@if (Model.CanRefund)
|
||||
{
|
||||
<a id="refundlink" class="btn btn-success" asp-action="Refund" asp-route-invoiceId="@this.Context.GetRouteValue("invoiceId")">Issue refund <span class="fa fa-undo"></span></a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button href="#" class="btn btn-secondary" data-toggle="tooltip" title="You can only issue refunds on invoices with confirmed payments" disabled>Issue refund <span class="fa fa-undo"></span></button>
|
||||
}
|
||||
<button type="submit" class="@(Model.Archived ? "btn btn btn-warning" : "btn btn-danger")" id="btn-archive-toggle">
|
||||
@if (Model.Archived)
|
||||
{
|
||||
<span data-toggle="tooltip" title="Unarchive this invoice">Archived <i class=" ml-1 fa fa-close"></i></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span data-toggle="tooltip" title="Archive this invoice so that it does not appear in the invoice list by default">Archive <i class=" ml-1 fa fa-archive"></i></span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
64
BTCPayServer/Views/Invoice/Refund.cshtml
Normal file
64
BTCPayServer/Views/Invoice/Refund.cshtml
Normal file
@@ -0,0 +1,64 @@
|
||||
@model RefundModel
|
||||
@{
|
||||
ViewData["Title"] = "Refund";
|
||||
}
|
||||
|
||||
<section>
|
||||
<div class="row">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">@Model.Title</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form method="post">
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
@if (Model.RefundStep == RefundSteps.SelectPaymentMethod)
|
||||
{
|
||||
<div class="form-group">
|
||||
<label asp-for="SelectedPaymentMethod"></label>
|
||||
<select asp-items="Model.AvailablePaymentMethods" asp-for="SelectedPaymentMethod" class="form-control"></select>
|
||||
<span asp-validation-for="SelectedPaymentMethod" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Next</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input type="hidden" asp-for="SelectedPaymentMethod" />
|
||||
<input type="hidden" asp-for="CryptoAmountThen" />
|
||||
<input type="hidden" asp-for="FiatAmount" />
|
||||
<input type="hidden" asp-for="CryptoAmountNow" />
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="RateThenText" asp-for="SelectedRefundOption" type="radio" value="RateThen" class="form-check-input" />
|
||||
<label for="RateThenText" class="form-check-label">@Model.RateThenText</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">The crypto currency price, at the rate the invoice got paid.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="CurrentRateText" asp-for="SelectedRefundOption" type="radio" value="CurrentRate" class="form-check-input" />
|
||||
<label for="CurrentRateText" class="form-check-label">@Model.CurrentRateText</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">The crypto currency price, at the current rate.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check-inline">
|
||||
<input id="FiatText" asp-for="SelectedRefundOption" type="radio" value="Fiat" class="form-check-input" />
|
||||
<label for="FiatText" class="form-check-label">@Model.FiatText</label>
|
||||
</div>
|
||||
<small class="form-text text-muted">the invoice currency, at the rate when the refund will be sent.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button id="ok" type="submit" class="btn btn-primary btn-block btn-lg">Create refund</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Currency" class="control-label"></label>
|
||||
<input asp-for="Currency" class="form-control" readonly />
|
||||
<input asp-for="Currency" class="form-control" />
|
||||
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user