mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-21 14:04:12 +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);
|
||||
await HandleResponse(response);
|
||||
}
|
||||
public async Task<PayoutData> ApprovePayout(string storeId, string payoutId, ApprovePayoutRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", bodyPayload: request, method: HttpMethod.Post), cancellationToken);
|
||||
return await HandleResponse<PayoutData>(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
12
BTCPayServer.Client/Models/ApprovePayoutRequest.cs
Normal file
12
BTCPayServer.Client/Models/ApprovePayoutRequest.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class ApprovePayoutRequest
|
||||
{
|
||||
public int Revision { get; set; }
|
||||
public string RateRule { get; set; }
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ namespace BTCPayServer.Client.Models
|
|||
{
|
||||
public enum PayoutState
|
||||
{
|
||||
AwaitingApproval,
|
||||
AwaitingPayment,
|
||||
InProgress,
|
||||
Completed,
|
||||
|
@ -25,8 +26,9 @@ namespace BTCPayServer.Client.Models
|
|||
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
||||
public decimal Amount { get; set; }
|
||||
[JsonConverter(typeof(DecimalStringJsonConverter))]
|
||||
public decimal PaymentMethodAmount { get; set; }
|
||||
public decimal? PaymentMethodAmount { get; set; }
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public PayoutState State { get; set; }
|
||||
public int Revision { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ namespace BTCPayServer.Data
|
|||
|
||||
public enum PayoutState
|
||||
{
|
||||
AwaitingApproval,
|
||||
AwaitingPayment,
|
||||
InProgress,
|
||||
Completed,
|
||||
|
|
|
@ -494,6 +494,12 @@ namespace BTCPayServer.Rating
|
|||
private SyntaxNode expression;
|
||||
FlattenExpressionRewriter flatten;
|
||||
|
||||
public static RateRule CreateFromExpression(string expression, CurrencyPair currencyPair)
|
||||
{
|
||||
var ex = RateRules.CreateExpression(expression);
|
||||
RateRules.TryParse("", out var rules);
|
||||
return new RateRule(rules, currencyPair, ex);
|
||||
}
|
||||
public RateRule(RateRules parent, CurrencyPair currencyPair, SyntaxNode candidate)
|
||||
{
|
||||
_CurrencyPair = currencyPair;
|
||||
|
|
|
@ -70,6 +70,24 @@ namespace BTCPayServer.Services.Rates
|
|||
return fetchingRates;
|
||||
}
|
||||
|
||||
public Task<RateResult> FetchRate(RateRule rateRule, CancellationToken cancellationToken)
|
||||
{
|
||||
if (rateRule == null)
|
||||
throw new ArgumentNullException(nameof(rateRule));
|
||||
var fetchingExchanges = new Dictionary<string, Task<QueryRateResult>>();
|
||||
var dependentQueries = new List<Task<QueryRateResult>>();
|
||||
foreach (var requiredExchange in rateRule.ExchangeRates)
|
||||
{
|
||||
if (!fetchingExchanges.TryGetValue(requiredExchange.Exchange, out var fetching))
|
||||
{
|
||||
fetching = _rateProviderFactory.QueryRates(requiredExchange.Exchange, cancellationToken);
|
||||
fetchingExchanges.Add(requiredExchange.Exchange, fetching);
|
||||
}
|
||||
dependentQueries.Add(fetching);
|
||||
}
|
||||
return GetRuleValue(dependentQueries, rateRule);
|
||||
}
|
||||
|
||||
private async Task<RateResult> GetRuleValue(List<Task<QueryRateResult>> dependentQueries, RateRule rateRule)
|
||||
{
|
||||
var result = new RateResult();
|
||||
|
|
|
@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ using BTCPayServer.Models;
|
|||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -18,6 +19,7 @@ using Microsoft.EntityFrameworkCore.Internal;
|
|||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
[AllowAnonymous]
|
||||
public class PullPaymentController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContextFactory _dbContextFactory;
|
||||
|
|
|
@ -22,6 +22,10 @@ using Microsoft.Extensions.Internal;
|
|||
using NBitcoin.Payment;
|
||||
using NBitcoin;
|
||||
using BTCPayServer.Payments;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.Threading;
|
||||
using BTCPayServer.HostedServices;
|
||||
using TwentyTwenty.Storage;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
|
@ -45,6 +49,7 @@ namespace BTCPayServer.Controllers
|
|||
WalletId walletId, NewPullPaymentModel model)
|
||||
{
|
||||
model.Name ??= string.Empty;
|
||||
model.Currency = model.Currency.ToUpperInvariant().Trim();
|
||||
if (_currencyTable.GetCurrencyData(model.Currency, false) is null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Currency), "Invalid currency");
|
||||
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
{
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -72,7 +72,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="Currency" class="control-label"></label>
|
||||
<input asp-for="Currency" class="form-control" readonly />
|
||||
<input asp-for="Currency" class="form-control" />
|
||||
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Payments;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
|
@ -35,7 +36,10 @@ namespace BTCPayServer
|
|||
public string StoreId { get; set; }
|
||||
public string CryptoCode { get; set; }
|
||||
|
||||
|
||||
public PaymentMethodId GetPaymentMethodId()
|
||||
{
|
||||
return new PaymentMethodId(CryptoCode, PaymentTypes.BTCLike);
|
||||
}
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
WalletId item = obj as WalletId;
|
||||
|
|
|
@ -307,6 +307,65 @@
|
|||
"schema": { "type": "string" }
|
||||
}
|
||||
],
|
||||
"post": {
|
||||
"description": "Approve a payout",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"description": "The revision number of the payout being modified"
|
||||
},
|
||||
"rateRule": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"example": "kraken(BTC_USD)",
|
||||
"description": "The rate rule to calculate the rate of the payout. This can also be a fixed decimal. (if null or unspecified, will use the same rate setting as the store's settings)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The payout has been approved, transitioning to `AwaitingPayment` state.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PayoutData"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unable to validate the request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Wellknown error codes are: `rate-unavailable`, `invalid-state`, `amount-too-low`, `old-revision`",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "The payout is not found"
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"description": "Cancel the payout",
|
||||
"responses": {
|
||||
|
@ -350,6 +409,10 @@
|
|||
"type": "string",
|
||||
"description": "The id of the payout"
|
||||
},
|
||||
"revision": {
|
||||
"type": "integer",
|
||||
"description": "The revision number of the payout. This revision number is incremented when the payout amount or destination is modified before the approval."
|
||||
},
|
||||
"pullPaymentId": {
|
||||
"type": "string",
|
||||
"description": "The id of the pull payment this payout belongs to"
|
||||
|
@ -367,7 +430,7 @@
|
|||
"type": "string",
|
||||
"format": "decimal",
|
||||
"example": "10399.18",
|
||||
"description": "The amount of the payout in the currency of the pull payment (eg. USD). In this current release, `amount` is the same as `paymentMethodAmount`."
|
||||
"description": "The amount of the payout in the currency of the pull payment (eg. USD)."
|
||||
},
|
||||
"paymentMethod": {
|
||||
"type": "string",
|
||||
|
@ -377,20 +440,23 @@
|
|||
"paymentMethodAmount": {
|
||||
"type": "string",
|
||||
"format": "decimal",
|
||||
"nullable": true,
|
||||
"example": "1.12300000",
|
||||
"description": "The amount of the payout in the currency of the payment method (eg. BTC). In this current release, `paymentMethodAmount` is the same as `amount`."
|
||||
"description": "The amount of the payout in the currency of the payment method (eg. BTC). This is only available from the `AwaitingPayment` state."
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"example": "AwaitingPayment",
|
||||
"description": "The state of the payout (`AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)",
|
||||
"description": "The state of the payout (`AwaitingApproval`, `AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)",
|
||||
"x-enumNames": [
|
||||
"AwaitingApproval",
|
||||
"AwaitingPayment",
|
||||
"InProgress",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
],
|
||||
"enum": [
|
||||
"AwaitingApproval",
|
||||
"AwaitingPayment",
|
||||
"InProgress",
|
||||
"Completed",
|
||||
|
|
Loading…
Add table
Reference in a new issue