Add an approval state to pull payments

This commit is contained in:
nicolas.dorier 2020-06-24 13:44:26 +09:00
parent fdc11bba8d
commit d03124dfba
No known key found for this signature in database
GPG key ID: 6618763EF09186FE
16 changed files with 427 additions and 53 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

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

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

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

@ -738,7 +738,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,12 @@ 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)
{
ModelState.AddModelError(nameof(request.PaymentMethods), "We expect this array to only contains the same element as the `currency` field. (More will be supported soon)");
}
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), "Invalid payment method");
}
else
{
@ -122,9 +122,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 +193,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 +292,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

@ -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");
@ -63,9 +68,9 @@ namespace BTCPayServer.Controllers
{
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[] { walletId.GetPaymentMethodId() }
});
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
@ -90,7 +95,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 +174,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 +197,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 +256,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 +319,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

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

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