Merge pull request #1689 from btcpayserver/invoicerefund

Implement invoice refund
This commit is contained in:
Nicolas Dorier
2020-06-25 15:32:17 +09:00
committed by GitHub
34 changed files with 2155 additions and 102 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -56,6 +56,7 @@ namespace BTCPayServer.Data
public enum PayoutState
{
AwaitingApproval,
AwaitingPayment,
InProgress,
Completed,

View 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);
}
}
}

View 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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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