mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 22:25:28 +01:00
Add an approval state to pull payments
This commit is contained in:
parent
fdc11bba8d
commit
d03124dfba
16 changed files with 427 additions and 53 deletions
|
@ -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);
|
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", method: HttpMethod.Delete), cancellationToken);
|
||||||
await HandleResponse(response);
|
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
|
public enum PayoutState
|
||||||
{
|
{
|
||||||
|
AwaitingApproval,
|
||||||
AwaitingPayment,
|
AwaitingPayment,
|
||||||
InProgress,
|
InProgress,
|
||||||
Completed,
|
Completed,
|
||||||
|
@ -25,8 +26,9 @@ namespace BTCPayServer.Client.Models
|
||||||
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
||||||
public decimal PaymentMethodAmount { get; set; }
|
public decimal? PaymentMethodAmount { get; set; }
|
||||||
[JsonConverter(typeof(StringEnumConverter))]
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
public PayoutState State { get; set; }
|
public PayoutState State { get; set; }
|
||||||
|
public int Revision { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,6 +56,7 @@ namespace BTCPayServer.Data
|
||||||
|
|
||||||
public enum PayoutState
|
public enum PayoutState
|
||||||
{
|
{
|
||||||
|
AwaitingApproval,
|
||||||
AwaitingPayment,
|
AwaitingPayment,
|
||||||
InProgress,
|
InProgress,
|
||||||
Completed,
|
Completed,
|
||||||
|
|
|
@ -494,6 +494,12 @@ namespace BTCPayServer.Rating
|
||||||
private SyntaxNode expression;
|
private SyntaxNode expression;
|
||||||
FlattenExpressionRewriter flatten;
|
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)
|
public RateRule(RateRules parent, CurrencyPair currencyPair, SyntaxNode candidate)
|
||||||
{
|
{
|
||||||
_CurrencyPair = currencyPair;
|
_CurrencyPair = currencyPair;
|
||||||
|
|
|
@ -70,6 +70,24 @@ namespace BTCPayServer.Services.Rates
|
||||||
return fetchingRates;
|
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)
|
private async Task<RateResult> GetRuleValue(List<Task<QueryRateResult>> dependentQueries, RateRule rateRule)
|
||||||
{
|
{
|
||||||
var result = new RateResult();
|
var result = new RateResult();
|
||||||
|
|
|
@ -303,8 +303,8 @@ namespace BTCPayServer.Tests
|
||||||
Assert.Equal(payout.Amount, payout2.Amount);
|
Assert.Equal(payout.Amount, payout2.Amount);
|
||||||
Assert.Equal(payout.Id, payout2.Id);
|
Assert.Equal(payout.Id, payout2.Id);
|
||||||
Assert.Equal(destination, payout2.Destination);
|
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");
|
Logs.Tester.LogInformation("Can't overdraft");
|
||||||
await this.AssertAPIError("overdraft", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest()
|
await this.AssertAPIError("overdraft", async () => await unauthenticated.CreatePayout(pps[0].Id, new CreatePayoutRequest()
|
||||||
|
@ -330,16 +330,8 @@ namespace BTCPayServer.Tests
|
||||||
payout = Assert.Single(payouts);
|
payout = Assert.Single(payouts);
|
||||||
Assert.Equal(PayoutState.Cancelled, payout.State);
|
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");
|
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,
|
Destination = destination,
|
||||||
PaymentMethod = "BTC"
|
PaymentMethod = "BTC"
|
||||||
|
@ -386,6 +378,43 @@ namespace BTCPayServer.Tests
|
||||||
StartsAt = DateTimeOffset.UtcNow,
|
StartsAt = DateTimeOffset.UtcNow,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(1)
|
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
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -738,7 +738,7 @@ namespace BTCPayServer.Tests
|
||||||
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
|
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
|
||||||
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter);
|
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter);
|
||||||
s.AssertHappyMessage();
|
s.AssertHappyMessage();
|
||||||
Assert.Contains("AwaitingPayment", s.Driver.PageSource);
|
Assert.Contains("AwaitingApproval", s.Driver.PageSource);
|
||||||
|
|
||||||
var viewPullPaymentUrl = s.Driver.Url;
|
var viewPullPaymentUrl = s.Driver.Url;
|
||||||
// This one should have nothing
|
// This one should have nothing
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using BTCPayServer;
|
using BTCPayServer;
|
||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
|
@ -81,13 +82,12 @@ namespace BTCPayServer.Controllers.GreenField
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(request.Name), "The name should be maximum 50 characters.");
|
ModelState.AddModelError(nameof(request.Name), "The name should be maximum 50 characters.");
|
||||||
}
|
}
|
||||||
BTCPayNetwork network = null;
|
|
||||||
if (request.Currency is String currency)
|
if (request.Currency is String currency)
|
||||||
{
|
{
|
||||||
network = _networkProvider.GetNetwork<BTCPayNetwork>(currency);
|
request.Currency = currency.ToUpperInvariant().Trim();
|
||||||
if (network is null)
|
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
|
else
|
||||||
|
@ -102,12 +102,12 @@ namespace BTCPayServer.Controllers.GreenField
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(request.Period), $"The period should be positive");
|
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();
|
||||||
{
|
if (paymentMethods.Any(p => _networkProvider.GetNetwork<BTCPayNetwork>(p.CryptoCode) is null))
|
||||||
ModelState.AddModelError(nameof(request.PaymentMethods), "We expect this array to only contains the same element as the `currency` field. (More will be supported soon)");
|
ModelState.AddModelError(nameof(request.PaymentMethods), "Invalid payment method");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
@ -122,9 +122,9 @@ namespace BTCPayServer.Controllers.GreenField
|
||||||
Period = request.Period,
|
Period = request.Period,
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Amount = request.Amount,
|
Amount = request.Amount,
|
||||||
Currency = network.CryptoCode,
|
Currency = request.Currency,
|
||||||
StoreId = storeId,
|
StoreId = storeId,
|
||||||
PaymentMethodIds = new[] { new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike) }
|
PaymentMethodIds = paymentMethods
|
||||||
});
|
});
|
||||||
var pp = await _pullPaymentService.GetPullPayment(ppId);
|
var pp = await _pullPaymentService.GetPullPayment(ppId);
|
||||||
return this.Ok(CreatePullPaymentData(pp));
|
return this.Ok(CreatePullPaymentData(pp));
|
||||||
|
@ -193,7 +193,9 @@ namespace BTCPayServer.Controllers.GreenField
|
||||||
Date = p.Date,
|
Date = p.Date,
|
||||||
Amount = blob.Amount,
|
Amount = blob.Amount,
|
||||||
PaymentMethodAmount = blob.CryptoAmount,
|
PaymentMethodAmount = blob.CryptoAmount,
|
||||||
|
Revision = blob.Revision,
|
||||||
State = p.State == Data.PayoutState.AwaitingPayment ? Client.Models.PayoutState.AwaitingPayment :
|
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.Cancelled ? Client.Models.PayoutState.Cancelled :
|
||||||
p.State == Data.PayoutState.Completed ? Client.Models.PayoutState.Completed :
|
p.State == Data.PayoutState.Completed ? Client.Models.PayoutState.Completed :
|
||||||
p.State == Data.PayoutState.InProgress ? Client.Models.PayoutState.InProgress :
|
p.State == Data.PayoutState.InProgress ? Client.Models.PayoutState.InProgress :
|
||||||
|
@ -290,5 +292,61 @@ namespace BTCPayServer.Controllers.GreenField
|
||||||
await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId }));
|
await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId }));
|
||||||
return Ok();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ using BTCPayServer.Models;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Rates;
|
using BTCPayServer.Services.Rates;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -18,6 +19,7 @@ using Microsoft.EntityFrameworkCore.Internal;
|
||||||
|
|
||||||
namespace BTCPayServer.Controllers
|
namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
public class PullPaymentController : Controller
|
public class PullPaymentController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContextFactory _dbContextFactory;
|
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||||
|
|
|
@ -22,6 +22,10 @@ using Microsoft.Extensions.Internal;
|
||||||
using NBitcoin.Payment;
|
using NBitcoin.Payment;
|
||||||
using NBitcoin;
|
using NBitcoin;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using System.Threading;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
|
using TwentyTwenty.Storage;
|
||||||
|
|
||||||
namespace BTCPayServer.Controllers
|
namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
|
@ -45,6 +49,7 @@ namespace BTCPayServer.Controllers
|
||||||
WalletId walletId, NewPullPaymentModel model)
|
WalletId walletId, NewPullPaymentModel model)
|
||||||
{
|
{
|
||||||
model.Name ??= string.Empty;
|
model.Name ??= string.Empty;
|
||||||
|
model.Currency = model.Currency.ToUpperInvariant().Trim();
|
||||||
if (_currencyTable.GetCurrencyData(model.Currency, false) is null)
|
if (_currencyTable.GetCurrencyData(model.Currency, false) is null)
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(model.Currency), "Invalid currency");
|
ModelState.AddModelError(nameof(model.Currency), "Invalid currency");
|
||||||
|
@ -63,9 +68,9 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
Name = model.Name,
|
Name = model.Name,
|
||||||
Amount = model.Amount,
|
Amount = model.Amount,
|
||||||
Currency = walletId.CryptoCode,
|
Currency = model.Currency,
|
||||||
StoreId = walletId.StoreId,
|
StoreId = walletId.StoreId,
|
||||||
PaymentMethodIds = new[] { new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike) }
|
PaymentMethodIds = new[] { walletId.GetPaymentMethodId() }
|
||||||
});
|
});
|
||||||
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
this.TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||||
{
|
{
|
||||||
|
@ -90,7 +95,7 @@ namespace BTCPayServer.Controllers
|
||||||
{
|
{
|
||||||
PullPayment = o,
|
PullPayment = o,
|
||||||
Awaiting = o.Payouts
|
Awaiting = o.Payouts
|
||||||
.Where(p => p.State == PayoutState.AwaitingPayment),
|
.Where(p => p.State == PayoutState.AwaitingPayment || p.State == PayoutState.AwaitingApproval),
|
||||||
Completed = o.Payouts
|
Completed = o.Payouts
|
||||||
.Where(p => p.State == PayoutState.Completed || p.State == PayoutState.InProgress)
|
.Where(p => p.State == PayoutState.Completed || p.State == PayoutState.InProgress)
|
||||||
})
|
})
|
||||||
|
@ -169,7 +174,7 @@ namespace BTCPayServer.Controllers
|
||||||
[Route("{walletId}/payouts")]
|
[Route("{walletId}/payouts")]
|
||||||
public async Task<IActionResult> PayoutsPost(
|
public async Task<IActionResult> PayoutsPost(
|
||||||
[ModelBinder(typeof(WalletIdModelBinder))]
|
[ModelBinder(typeof(WalletIdModelBinder))]
|
||||||
WalletId walletId, PayoutsModel vm)
|
WalletId walletId, PayoutsModel vm, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (vm is null)
|
if (vm is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
@ -192,10 +197,56 @@ namespace BTCPayServer.Controllers
|
||||||
if (vm.Command == "pay")
|
if (vm.Command == "pay")
|
||||||
{
|
{
|
||||||
using var ctx = this._dbContextFactory.CreateContext();
|
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 => payoutIds.Contains(p.Id))
|
||||||
.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived)
|
.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;
|
var walletSend = (WalletSendModel)((ViewResult)(await this.WalletSend(walletId))).Model;
|
||||||
walletSend.Outputs.Clear();
|
walletSend.Outputs.Clear();
|
||||||
foreach (var payout in payouts)
|
foreach (var payout in payouts)
|
||||||
|
@ -205,7 +256,7 @@ namespace BTCPayServer.Controllers
|
||||||
continue;
|
continue;
|
||||||
var output = new WalletSendModel.TransactionOutput()
|
var output = new WalletSendModel.TransactionOutput()
|
||||||
{
|
{
|
||||||
Amount = blob.Amount,
|
Amount = blob.CryptoAmount,
|
||||||
DestinationAddress = blob.Destination.Address.ToString()
|
DestinationAddress = blob.Destination.Address.ToString()
|
||||||
};
|
};
|
||||||
walletSend.Outputs.Add(output);
|
walletSend.Outputs.Add(output);
|
||||||
|
@ -268,7 +319,7 @@ namespace BTCPayServer.Controllers
|
||||||
m.PayoutId = item.Payout.Id;
|
m.PayoutId = item.Payout.Id;
|
||||||
m.Amount = _currencyTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency);
|
m.Amount = _currencyTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency);
|
||||||
m.Destination = payoutBlob.Destination.Address.ToString();
|
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);
|
vm.WaitingForApproval.Add(m);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ using BTCPayServer.Client.JsonConverters;
|
||||||
using BTCPayServer.JsonConverters;
|
using BTCPayServer.JsonConverters;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc.NewtonsoftJson;
|
using Microsoft.AspNetCore.Mvc.NewtonsoftJson;
|
||||||
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -22,9 +23,14 @@ namespace BTCPayServer.Data
|
||||||
{
|
{
|
||||||
public static class PullPaymentsExtensions
|
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();
|
p.PullPaymentData.StoreId == storeId).FirstOrDefaultAsync();
|
||||||
if (payout is null)
|
if (payout is null)
|
||||||
return null;
|
return null;
|
||||||
|
@ -152,9 +158,10 @@ namespace BTCPayServer.Data
|
||||||
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
||||||
public decimal CryptoAmount { get; set; }
|
public decimal? CryptoAmount { get; set; }
|
||||||
public int MinimumConfirmation { get; set; } = 1;
|
public int MinimumConfirmation { get; set; } = 1;
|
||||||
public IClaimDestination Destination { get; set; }
|
public IClaimDestination Destination { get; set; }
|
||||||
|
public int Revision { get; set; }
|
||||||
}
|
}
|
||||||
public class ClaimDestinationJsonConverter : JsonConverter<IClaimDestination>
|
public class ClaimDestinationJsonConverter : JsonConverter<IClaimDestination>
|
||||||
{
|
{
|
||||||
|
|
|
@ -10,6 +10,7 @@ using BTCPayServer.Data;
|
||||||
using BTCPayServer.Events;
|
using BTCPayServer.Events;
|
||||||
using BTCPayServer.Logging;
|
using BTCPayServer.Logging;
|
||||||
using BTCPayServer.Payments;
|
using BTCPayServer.Payments;
|
||||||
|
using BTCPayServer.Rating;
|
||||||
using BTCPayServer.Services;
|
using BTCPayServer.Services;
|
||||||
using BTCPayServer.Services.Notifications;
|
using BTCPayServer.Services.Notifications;
|
||||||
using BTCPayServer.Services.Notifications.Blobs;
|
using BTCPayServer.Services.Notifications.Blobs;
|
||||||
|
@ -24,7 +25,9 @@ using NBitcoin.DataEncoders;
|
||||||
using NBitcoin.Payment;
|
using NBitcoin.Payment;
|
||||||
using NBitcoin.RPC;
|
using NBitcoin.RPC;
|
||||||
using NBXplorer;
|
using NBXplorer;
|
||||||
|
using Org.BouncyCastle.Bcpg.OpenPgp;
|
||||||
using Serilog.Configuration;
|
using Serilog.Configuration;
|
||||||
|
using SQLitePCL;
|
||||||
|
|
||||||
namespace BTCPayServer.HostedServices
|
namespace BTCPayServer.HostedServices
|
||||||
{
|
{
|
||||||
|
@ -59,7 +62,40 @@ namespace BTCPayServer.HostedServices
|
||||||
public string[] PayoutIds { get; set; }
|
public string[] PayoutIds { get; set; }
|
||||||
internal TaskCompletionSource<bool> Completion { 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)
|
public async Task<string> CreatePullPayment(CreatePullPayment create)
|
||||||
{
|
{
|
||||||
if (create == null)
|
if (create == null)
|
||||||
|
@ -120,7 +156,8 @@ namespace BTCPayServer.HostedServices
|
||||||
EventAggregator eventAggregator,
|
EventAggregator eventAggregator,
|
||||||
ExplorerClientProvider explorerClientProvider,
|
ExplorerClientProvider explorerClientProvider,
|
||||||
BTCPayNetworkProvider networkProvider,
|
BTCPayNetworkProvider networkProvider,
|
||||||
NotificationSender notificationSender)
|
NotificationSender notificationSender,
|
||||||
|
RateFetcher rateFetcher)
|
||||||
{
|
{
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
_jsonSerializerSettings = jsonSerializerSettings;
|
_jsonSerializerSettings = jsonSerializerSettings;
|
||||||
|
@ -129,6 +166,7 @@ namespace BTCPayServer.HostedServices
|
||||||
_explorerClientProvider = explorerClientProvider;
|
_explorerClientProvider = explorerClientProvider;
|
||||||
_networkProvider = networkProvider;
|
_networkProvider = networkProvider;
|
||||||
_notificationSender = notificationSender;
|
_notificationSender = notificationSender;
|
||||||
|
_rateFetcher = rateFetcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
Channel<object> _Channel;
|
Channel<object> _Channel;
|
||||||
|
@ -139,6 +177,7 @@ namespace BTCPayServer.HostedServices
|
||||||
private readonly ExplorerClientProvider _explorerClientProvider;
|
private readonly ExplorerClientProvider _explorerClientProvider;
|
||||||
private readonly BTCPayNetworkProvider _networkProvider;
|
private readonly BTCPayNetworkProvider _networkProvider;
|
||||||
private readonly NotificationSender _notificationSender;
|
private readonly NotificationSender _notificationSender;
|
||||||
|
private readonly RateFetcher _rateFetcher;
|
||||||
|
|
||||||
internal override Task[] InitializeTasks()
|
internal override Task[] InitializeTasks()
|
||||||
{
|
{
|
||||||
|
@ -157,6 +196,11 @@ namespace BTCPayServer.HostedServices
|
||||||
await HandleCreatePayout(req);
|
await HandleCreatePayout(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (o is PayoutApproval approv)
|
||||||
|
{
|
||||||
|
await HandleApproval(approv);
|
||||||
|
}
|
||||||
|
|
||||||
if (o is NewOnChainTransactionEvent newTransaction)
|
if (o is NewOnChainTransactionEvent newTransaction)
|
||||||
{
|
{
|
||||||
await UpdatePayoutsAwaitingForPayment(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)
|
private async Task HandleCreatePayout(PayoutRequest req)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
@ -221,7 +341,7 @@ namespace BTCPayServer.HostedServices
|
||||||
{
|
{
|
||||||
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
|
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
|
||||||
Date = now,
|
Date = now,
|
||||||
State = PayoutState.AwaitingPayment,
|
State = PayoutState.AwaitingApproval,
|
||||||
PullPaymentDataId = req.ClaimRequest.PullPaymentId,
|
PullPaymentDataId = req.ClaimRequest.PullPaymentId,
|
||||||
PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(),
|
PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(),
|
||||||
Destination = GetDestination(req.ClaimRequest.Destination.Address.ScriptPubKey)
|
Destination = GetDestination(req.ClaimRequest.Destination.Address.ScriptPubKey)
|
||||||
|
@ -231,18 +351,9 @@ namespace BTCPayServer.HostedServices
|
||||||
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow));
|
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow));
|
||||||
return;
|
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()
|
var payoutBlob = new PayoutBlob()
|
||||||
{
|
{
|
||||||
Amount = claimed,
|
Amount = claimed,
|
||||||
// To fix, we should evaluate based on exchange rate
|
|
||||||
CryptoAmount = cryptoAmount.ToDecimal(MoneyUnit.BTC),
|
|
||||||
Destination = req.ClaimRequest.Destination
|
Destination = req.ClaimRequest.Destination
|
||||||
};
|
};
|
||||||
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
||||||
|
@ -447,7 +558,8 @@ namespace BTCPayServer.HostedServices
|
||||||
CancellationToken.ThrowIfCancellationRequested();
|
CancellationToken.ThrowIfCancellationRequested();
|
||||||
var cts = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var cts = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
cancelRequest.Completion = cts;
|
cancelRequest.Completion = cts;
|
||||||
_Channel.Writer.TryWrite(cancelRequest);
|
if(!_Channel.Writer.TryWrite(cancelRequest))
|
||||||
|
throw new ObjectDisposedException(nameof(PullPaymentHostedService));
|
||||||
return cts.Task;
|
return cts.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -455,7 +567,8 @@ namespace BTCPayServer.HostedServices
|
||||||
{
|
{
|
||||||
CancellationToken.ThrowIfCancellationRequested();
|
CancellationToken.ThrowIfCancellationRequested();
|
||||||
var cts = new TaskCompletionSource<ClaimRequest.ClaimResponse>(TaskCreationOptions.RunContinuationsAsynchronously);
|
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;
|
return cts.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -72,7 +72,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="Currency" class="control-label"></label>
|
<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>
|
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Payments;
|
||||||
|
|
||||||
namespace BTCPayServer
|
namespace BTCPayServer
|
||||||
{
|
{
|
||||||
|
@ -35,7 +36,10 @@ namespace BTCPayServer
|
||||||
public string StoreId { get; set; }
|
public string StoreId { get; set; }
|
||||||
public string CryptoCode { get; set; }
|
public string CryptoCode { get; set; }
|
||||||
|
|
||||||
|
public PaymentMethodId GetPaymentMethodId()
|
||||||
|
{
|
||||||
|
return new PaymentMethodId(CryptoCode, PaymentTypes.BTCLike);
|
||||||
|
}
|
||||||
public override bool Equals(object obj)
|
public override bool Equals(object obj)
|
||||||
{
|
{
|
||||||
WalletId item = obj as WalletId;
|
WalletId item = obj as WalletId;
|
||||||
|
|
|
@ -307,6 +307,65 @@
|
||||||
"schema": { "type": "string" }
|
"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": {
|
"delete": {
|
||||||
"description": "Cancel the payout",
|
"description": "Cancel the payout",
|
||||||
"responses": {
|
"responses": {
|
||||||
|
@ -350,6 +409,10 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The id of the payout"
|
"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": {
|
"pullPaymentId": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The id of the pull payment this payout belongs to"
|
"description": "The id of the pull payment this payout belongs to"
|
||||||
|
@ -367,7 +430,7 @@
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "decimal",
|
"format": "decimal",
|
||||||
"example": "10399.18",
|
"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": {
|
"paymentMethod": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
@ -377,20 +440,23 @@
|
||||||
"paymentMethodAmount": {
|
"paymentMethodAmount": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "decimal",
|
"format": "decimal",
|
||||||
|
"nullable": true,
|
||||||
"example": "1.12300000",
|
"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": {
|
"state": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "AwaitingPayment",
|
"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": [
|
"x-enumNames": [
|
||||||
|
"AwaitingApproval",
|
||||||
"AwaitingPayment",
|
"AwaitingPayment",
|
||||||
"InProgress",
|
"InProgress",
|
||||||
"Completed",
|
"Completed",
|
||||||
"Cancelled"
|
"Cancelled"
|
||||||
],
|
],
|
||||||
"enum": [
|
"enum": [
|
||||||
|
"AwaitingApproval",
|
||||||
"AwaitingPayment",
|
"AwaitingPayment",
|
||||||
"InProgress",
|
"InProgress",
|
||||||
"Completed",
|
"Completed",
|
||||||
|
|
Loading…
Add table
Reference in a new issue