Refactor and decouple Payout logic (#2046)

* Refactor and decouple Payout logic

So that we can support lightning and more complex flows like allowing external payments to payouts.

* fix dropdown align

* switch to simpler buttons

* rebase fixes

add some comments

* rebase fixes

add some comments

* simplify enum caveman logic

* reduce code duplication and db round trips

* Fix pull payment date format

* fix issue with payouts to send page not working correctly

* try fix some style issue

* fix bip21parse
This commit is contained in:
Andrew Camilleri 2021-04-13 10:36:49 +02:00 committed by GitHub
parent 98eee27b93
commit 2e12befb8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 936 additions and 645 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
@ -53,13 +54,4 @@ namespace BTCPayServer.Data
}
}
}
public enum PayoutState
{
AwaitingApproval,
AwaitingPayment,
InProgress,
Completed,
Cancelled
}
}

View file

@ -3,11 +3,13 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using BTCPayServer.Client.Models;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
namespace BTCPayServer.Data
{
public class PullPaymentData
{
[Key]
@ -86,7 +88,6 @@ namespace BTCPayServer.Data
}
}
public static class PayoutExtensions
{
public static IQueryable<PayoutData> GetPayoutInPeriod(this IQueryable<PayoutData> payouts, PullPaymentData pp)

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Text;
@ -964,15 +965,18 @@ namespace BTCPayServer.Tests
var payouts = s.Driver.FindElements(By.ClassName("pp-payout"));
Assert.Equal(2, payouts.Count);
payouts[1].Click();
Assert.Contains("No payout waiting for approval", s.Driver.PageSource);
Assert.Empty(s.Driver.FindElements(By.ClassName("payout")));
// PP2 should have payouts
s.GoToWallet(navPages: WalletsNavPages.PullPayments);
payouts = s.Driver.FindElements(By.ClassName("pp-payout"));
payouts[0].Click();
Assert.DoesNotContain("No payout waiting for approval", s.Driver.PageSource);
s.Driver.FindElement(By.Id("selectAllCheckbox")).Click();
s.Driver.FindElement(By.Id("payCommand")).Click();
Assert.NotEmpty(s.Driver.FindElements(By.ClassName("payout")));
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-selectAllCheckbox")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-actions")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve-pay")).Click();
s.Driver.FindElement(By.Id("SendMenu")).Click();
s.Driver.FindElement(By.CssSelector("button[value=nbx-seed]")).Click();
s.Driver.FindElement(By.CssSelector("button[value=broadcast]")).Click();
@ -987,13 +991,14 @@ namespace BTCPayServer.Tests
Assert.Equal("payout", s.Driver.FindElement(By.ClassName("transactionLabel")).Text);
s.GoToWallet(navPages: WalletsNavPages.Payouts);
ReadOnlyCollection<IWebElement> txs;
TestUtils.Eventually(() =>
{
s.Driver.Navigate().Refresh();
Assert.Contains("No payout waiting for approval", s.Driver.PageSource);
});
var txs = s.Driver.FindElements(By.ClassName("transaction-link"));
txs = s.Driver.FindElements(By.ClassName("transaction-link"));
Assert.Equal(2, txs.Count);
});
s.Driver.Navigate().GoToUrl(viewPullPaymentUrl);
txs = s.Driver.FindElements(By.ClassName("transaction-link"));
@ -1014,7 +1019,7 @@ namespace BTCPayServer.Tests
{
using var ctx = s.Server.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
var payoutsData = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId).ToListAsync();
Assert.True(payoutsData.All(p => p.State == Data.PayoutState.Completed));
Assert.True(payoutsData.All(p => p.State == PayoutState.Completed));
});
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -31,13 +32,15 @@ namespace BTCPayServer.Controllers.GreenField
private readonly CurrencyNameTable _currencyNameTable;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
LinkGenerator linkGenerator,
ApplicationDbContextFactory dbContextFactory,
CurrencyNameTable currencyNameTable,
Services.BTCPayNetworkJsonSerializerSettings serializerSettings,
BTCPayNetworkProvider networkProvider)
BTCPayNetworkProvider networkProvider,
IEnumerable<IPayoutHandler> payoutHandlers)
{
_pullPaymentService = pullPaymentService;
_linkGenerator = linkGenerator;
@ -45,6 +48,7 @@ namespace BTCPayServer.Controllers.GreenField
_currencyNameTable = currencyNameTable;
_serializerSettings = serializerSettings;
_networkProvider = networkProvider;
_payoutHandlers = payoutHandlers;
}
[HttpGet("~/api/v1/stores/{storeId}/pull-payments")]
@ -178,7 +182,7 @@ namespace BTCPayServer.Controllers.GreenField
return NotFound();
using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId)
.Where(p => p.State != Data.PayoutState.Cancelled || includeCancelled)
.Where(p => p.State != PayoutState.Cancelled || includeCancelled)
.ToListAsync();
var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false);
return base.Ok(payouts
@ -196,14 +200,9 @@ namespace BTCPayServer.Controllers.GreenField
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 :
throw new NotSupportedException(),
State = p.State
};
model.Destination = blob.Destination.ToString();
model.Destination = blob.Destination;
model.PaymentMethod = p.PaymentMethodId;
return model;
}
@ -214,10 +213,14 @@ namespace BTCPayServer.Controllers.GreenField
{
if (request is null)
return NotFound();
if (!PaymentMethodId.TryParse(request?.PaymentMethod, out var paymentMethodId))
{
ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method");
return this.CreateValidationError(ModelState);
}
var network = request?.PaymentMethod is string paymentMethod ?
this._networkProvider.GetNetwork<BTCPayNetwork>(paymentMethod) : null;
if (network is null)
var payoutHandler = _payoutHandlers.FirstOrDefault(handler => handler.CanHandle(paymentMethodId));
if (payoutHandler is null)
{
ModelState.AddModelError(nameof(request.PaymentMethod), "Invalid payment method");
return this.CreateValidationError(ModelState);
@ -228,7 +231,8 @@ namespace BTCPayServer.Controllers.GreenField
if (pp is null)
return NotFound();
var ppBlob = pp.GetBlob();
if (request.Destination is null || !ClaimDestination.TryParse(request.Destination, network, out var destination))
IClaimDestination destination = await payoutHandler.ParseClaimDestination(paymentMethodId,request.Destination);
if (destination is null)
{
ModelState.AddModelError(nameof(request.Destination), "The destination must be an address or a BIP21 URI");
return this.CreateValidationError(ModelState);
@ -245,7 +249,7 @@ namespace BTCPayServer.Controllers.GreenField
Destination = destination,
PullPaymentId = pullPaymentId,
Value = request.Amount,
PaymentMethodId = new PaymentMethodId(network.CryptoCode, PaymentTypes.BTCLike)
PaymentMethodId = paymentMethodId
});
switch (result.Result)
{

View file

@ -1,11 +1,11 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Models;
@ -26,18 +26,21 @@ namespace BTCPayServer.Controllers
private readonly CurrencyNameTable _currencyNameTable;
private readonly PullPaymentHostedService _pullPaymentHostedService;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
public PullPaymentController(ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkProvider networkProvider,
CurrencyNameTable currencyNameTable,
PullPaymentHostedService pullPaymentHostedService,
BTCPayServer.Services.BTCPayNetworkJsonSerializerSettings serializerSettings)
BTCPayNetworkJsonSerializerSettings serializerSettings,
IEnumerable<IPayoutHandler> payoutHandlers)
{
_dbContextFactory = dbContextFactory;
_networkProvider = networkProvider;
_currencyNameTable = currencyNameTable;
_pullPaymentHostedService = pullPaymentHostedService;
_serializerSettings = serializerSettings;
_payoutHandlers = payoutHandlers;
}
[Route("pull-payments/{pullPaymentId}")]
public async Task<IActionResult> ViewPullPayment(string pullPaymentId)
@ -55,7 +58,7 @@ namespace BTCPayServer.Controllers
{
Entity = o,
Blob = o.GetBlob(_serializerSettings),
TransactionId = o.GetProofBlob(_serializerSettings)?.TransactionId?.ToString()
ProofBlob = _payoutHandlers.FirstOrDefault(handler => handler.CanHandle(o.GetPaymentMethodId()))?.ParseProof(o)
});
var cd = _currencyNameTable.GetCurrencyData(blob.Currency, false);
var totalPaid = payouts.Where(p => p.Entity.State != PayoutState.Cancelled).Select(p => p.Blob.Amount).Sum();
@ -79,10 +82,10 @@ namespace BTCPayServer.Controllers
Amount = entity.Blob.Amount,
AmountFormatted = _currencyNameTable.FormatCurrency(entity.Blob.Amount, blob.Currency),
Currency = blob.Currency,
Status = entity.Entity.State.GetStateString(),
Destination = entity.Blob.Destination.Address.ToString(),
Link = GetTransactionLink(_networkProvider.GetNetwork<BTCPayNetwork>(entity.Entity.GetPaymentMethodId().CryptoCode), entity.TransactionId),
TransactionId = entity.TransactionId
Status = entity.Entity.State,
Destination = entity.Blob.Destination,
Link = entity.ProofBlob?.Link,
TransactionId = entity.ProofBlob?.Id
}).ToList()
};
vm.IsPending &= vm.AmountDue > 0.0m;
@ -99,11 +102,14 @@ namespace BTCPayServer.Controllers
{
ModelState.AddModelError(nameof(pullPaymentId), "This pull payment does not exists");
}
var ppBlob = pp.GetBlob();
var network = _networkProvider.GetNetwork<BTCPayNetwork>(ppBlob.SupportedPaymentMethods.Single().CryptoCode);
IClaimDestination destination = null;
if (network != null &&
(!ClaimDestination.TryParse(vm.Destination, network, out destination) || destination is null))
var paymentMethodId = ppBlob.SupportedPaymentMethods.Single();
var payoutHandler = _payoutHandlers.FirstOrDefault(handler => handler.CanHandle(paymentMethodId));
IClaimDestination destination = await payoutHandler?.ParseClaimDestination(paymentMethodId, vm.Destination);
if (destination is null)
{
ModelState.AddModelError(nameof(vm.Destination), $"Invalid destination");
}

View file

@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
@ -17,6 +18,7 @@ using BTCPayServer.Views;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using PayoutData = BTCPayServer.Data.PayoutData;
namespace BTCPayServer.Controllers
{
@ -189,7 +191,9 @@ namespace BTCPayServer.Controllers
var storeId = walletId.StoreId;
var paymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike);
var payoutIds = vm.WaitingForApproval.Where(p => p.Selected).Select(p => p.PayoutId).ToArray();
var commandState = Enum.Parse<PayoutState>(vm.Command.Split("-").First());
var payoutIds = vm.GetSelectedPayouts(commandState);
if (payoutIds.Length == 0)
{
this.TempData.SetStatusMessageModel(new StatusMessageModel()
@ -203,18 +207,18 @@ namespace BTCPayServer.Controllers
pullPaymentId = vm.PullPaymentId
});
}
if (vm.Command == "pay")
var command = vm.Command.Substring(vm.Command.IndexOf('-', StringComparison.InvariantCulture) + 1);
switch (command)
{
using var ctx = this._dbContextFactory.CreateContext();
case "approve-pay":
case "approve":
{
await using var ctx = this._dbContextFactory.CreateContext();
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())
.Where(p => p.GetPaymentMethodId() == walletId.GetPaymentMethodId())
.ToList();
var payouts = await GetPayoutsForPaymentMethod(walletId.GetPaymentMethodId(), ctx, payoutIds, storeId, cancellationToken);
for (int i = 0; i < payouts.Count; i++)
{
@ -254,42 +258,70 @@ namespace BTCPayServer.Controllers
pullPaymentId = vm.PullPaymentId
});
}
payouts[i] = await ctx.Payouts.FindAsync(payouts[i].Id);
}
if (command == "approve-pay")
{
goto case "pay";
}
TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts approved", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(Payouts),
new {walletId = walletId.ToString(), pullPaymentId = vm.PullPaymentId});
}
case "pay":
{
await using var ctx = this._dbContextFactory.CreateContext();
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
var payouts = await GetPayoutsForPaymentMethod(walletId.GetPaymentMethodId(), ctx, payoutIds, storeId, cancellationToken);
var walletSend = (WalletSendModel)((ViewResult)(await this.WalletSend(walletId))).Model;
walletSend.Outputs.Clear();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
List<string> bip21 = new List<string>();
foreach (var payout in payouts)
{
var blob = payout.GetBlob(_jsonSerializerSettings);
if (payout.GetPaymentMethodId() != paymentMethodId)
continue;
var output = new WalletSendModel.TransactionOutput()
{
Amount = blob.CryptoAmount,
DestinationAddress = blob.Destination.Address.ToString()
};
walletSend.Outputs.Add(output);
bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)));
}
return View(nameof(walletSend), walletSend);
return RedirectToAction(nameof(WalletSend), new {walletId, bip21});
}
else if (vm.Command == "cancel")
{
await _pullPaymentService.Cancel(new HostedServices.PullPaymentHostedService.CancelRequest(payoutIds));
case "cancel":
await _pullPaymentService.Cancel(
new HostedServices.PullPaymentHostedService.CancelRequest(payoutIds));
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Payouts archived",
Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(Payouts), new
{
walletId = walletId.ToString(),
pullPaymentId = vm.PullPaymentId
Message = "Payouts archived", Severity = StatusMessageModel.StatusSeverity.Success
});
return RedirectToAction(nameof(Payouts),
new {walletId = walletId.ToString(), pullPaymentId = vm.PullPaymentId});
}
else
{
return NotFound();
}
private static async Task<List<PayoutData>> GetPayoutsForPaymentMethod(PaymentMethodId paymentMethodId,
ApplicationDbContext ctx, string[] payoutIds,
string storeId, CancellationToken cancellationToken)
{
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(cancellationToken))
.Where(p => p.GetPaymentMethodId() == paymentMethodId)
.ToList();
return payouts;
}
[HttpGet]
@ -299,9 +331,11 @@ namespace BTCPayServer.Controllers
WalletId walletId, PayoutsModel vm = null)
{
vm ??= new PayoutsModel();
vm.PayoutStateSets ??= ((PayoutState[]) Enum.GetValues(typeof(PayoutState))).Select(state =>
new PayoutsModel.PayoutStateSet() {State = state, Payouts = new List<PayoutsModel.PayoutModel>()}).ToList();
using var ctx = this._dbContextFactory.CreateContext();
var storeId = walletId.StoreId;
var paymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike);
vm.PaymentMethodId = new PaymentMethodId(walletId.CryptoCode, PaymentTypes.BTCLike);
var payoutRequest = ctx.Payouts.Where(p => p.PullPaymentData.StoreId == storeId && !p.PullPaymentData.Archived);
if (vm.PullPaymentId != null)
{
@ -313,12 +347,22 @@ namespace BTCPayServer.Controllers
Payout = o,
PullPayment = o.PullPaymentData
}).ToListAsync();
var network = NetworkProvider.GetNetwork<BTCPayNetwork>(walletId.CryptoCode);
vm.WaitingForApproval = new List<PayoutsModel.PayoutModel>();
vm.Other = new List<PayoutsModel.PayoutModel>();
foreach (var item in payouts)
foreach (var stateSet in payouts.GroupBy(arg => arg.Payout.State))
{
if (item.Payout.GetPaymentMethodId() != paymentMethodId)
var state = vm.PayoutStateSets.SingleOrDefault(set => set.State == stateSet.Key);
if (state == null)
{
state = new PayoutsModel.PayoutStateSet()
{
Payouts = new List<PayoutsModel.PayoutModel>(), State = stateSet.Key
};
vm.PayoutStateSets.Add(state);
}
foreach (var item in stateSet)
{
if (item.Payout.GetPaymentMethodId() != vm.PaymentMethodId)
continue;
var ppBlob = item.PullPayment.GetBlob();
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
@ -328,19 +372,17 @@ namespace BTCPayServer.Controllers
m.Date = item.Payout.Date;
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 || item.Payout.State == PayoutState.AwaitingApproval)
{
vm.WaitingForApproval.Add(m);
}
else
{
if (item.Payout.GetPaymentMethodId().PaymentType == PaymentTypes.BTCLike &&
item.Payout.GetProofBlob(this._jsonSerializerSettings)?.TransactionId is uint256 txId)
m.TransactionLink = string.Format(CultureInfo.InvariantCulture, network.BlockExplorerLink, txId);
vm.Other.Add(m);
m.Destination = payoutBlob.Destination;
var handler = _payoutHandlers
.FirstOrDefault(handler => handler.CanHandle(item.Payout.GetPaymentMethodId()));
var proofBlob = handler?.ParseProof(item.Payout);
m.TransactionLink = proofBlob?.Link;
state.Payouts.Add(m);
}
}
vm.PayoutStateSets = vm.PayoutStateSets.Where(set => set.Payouts?.Any() is true).ToList();
return View(vm);
}
}

View file

@ -8,7 +8,6 @@ using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.ModelBinders;
@ -16,7 +15,6 @@ using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Security;
using BTCPayServer.Services;
using BTCPayServer.Services.Labels;
using BTCPayServer.Services.Rates;
@ -62,6 +60,7 @@ namespace BTCPayServer.Controllers
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly PullPaymentHostedService _pullPaymentService;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
public RateFetcher RateFetcher { get; }
@ -86,7 +85,8 @@ namespace BTCPayServer.Controllers
LabelFactory labelFactory,
ApplicationDbContextFactory dbContextFactory,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
HostedServices.PullPaymentHostedService pullPaymentService)
HostedServices.PullPaymentHostedService pullPaymentService,
IEnumerable<IPayoutHandler> payoutHandlers)
{
_currencyTable = currencyTable;
Repository = repo;
@ -109,6 +109,7 @@ namespace BTCPayServer.Controllers
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
_pullPaymentService = pullPaymentService;
_payoutHandlers = payoutHandlers;
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
@ -426,7 +427,7 @@ namespace BTCPayServer.Controllers
[Route("{walletId}/send")]
public async Task<IActionResult> WalletSend(
[ModelBinder(typeof(WalletIdModelBinder))]
WalletId walletId, string defaultDestination = null, string defaultAmount = null, string bip21 = null)
WalletId walletId, string defaultDestination = null, string defaultAmount = null, string[] bip21 = null)
{
if (walletId?.StoreId == null)
return NotFound();
@ -444,19 +445,29 @@ namespace BTCPayServer.Controllers
double.TryParse(defaultAmount, out var amount);
var model = new WalletSendModel()
{
Outputs = new List<WalletSendModel.TransactionOutput>()
CryptoCode = walletId.CryptoCode
};
if (bip21?.Any() is true)
{
foreach (var link in bip21)
{
if (!string.IsNullOrEmpty(link))
{
LoadFromBIP21(model, link, network);
}
}
}
if (!(model.Outputs?.Any() is true))
{
model.Outputs = new List<WalletSendModel.TransactionOutput>()
{
new WalletSendModel.TransactionOutput()
{
Amount = Convert.ToDecimal(amount),
DestinationAddress = defaultDestination
Amount = Convert.ToDecimal(amount), DestinationAddress = defaultDestination
}
},
CryptoCode = walletId.CryptoCode
};
if (!string.IsNullOrEmpty(bip21))
{
LoadFromBIP21(model, bip21, network);
}
var feeProvider = _feeRateProvider.CreateFeeProvider(network);
var recommendedFees =
@ -540,6 +551,7 @@ namespace BTCPayServer.Controllers
vm.NBXSeedAvailable = await GetSeed(walletId, network) != null;
if (!string.IsNullOrEmpty(bip21))
{
vm.Outputs?.Clear();
LoadFromBIP21(vm, bip21, network);
}
@ -577,6 +589,10 @@ namespace BTCPayServer.Controllers
if (!string.IsNullOrEmpty(bip21))
{
if (!vm.Outputs.Any())
{
vm.Outputs.Add(new WalletSendModel.TransactionOutput());
}
return View(vm);
}
if (command == "add-output")
@ -720,6 +736,7 @@ namespace BTCPayServer.Controllers
private void LoadFromBIP21(WalletSendModel vm, string bip21, BTCPayNetwork network)
{
vm.Outputs ??= new List<WalletSendModel.TransactionOutput>();
try
{
if (bip21.StartsWith(network.UriScheme, StringComparison.InvariantCultureIgnoreCase))
@ -728,15 +745,13 @@ namespace BTCPayServer.Controllers
}
var uriBuilder = new NBitcoin.Payment.BitcoinUrlBuilder(bip21, network.NBitcoinNetwork);
vm.Outputs = new List<WalletSendModel.TransactionOutput>()
{
new WalletSendModel.TransactionOutput()
vm.Outputs.Add(new WalletSendModel.TransactionOutput()
{
Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
DestinationAddress = uriBuilder.Address.ToString(),
SubtractFeesFromOutput = false
}
};
});
if (!string.IsNullOrEmpty(uriBuilder.Label) || !string.IsNullOrEmpty(uriBuilder.Message))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
@ -754,13 +769,11 @@ namespace BTCPayServer.Controllers
{
try
{
vm.Outputs = new List<WalletSendModel.TransactionOutput>()
{
new WalletSendModel.TransactionOutput()
vm.Outputs.Add(new WalletSendModel.TransactionOutput()
{
DestinationAddress = BitcoinAddress.Create(bip21, network.NBitcoinNetwork).ToString()
}
};
);
}
catch
{

View file

@ -0,0 +1,22 @@
using System;
using NBitcoin;
namespace BTCPayServer.Data
{
public class AddressClaimDestination : IBitcoinLikeClaimDestination
{
public BitcoinAddress _bitcoinAddress;
public AddressClaimDestination(BitcoinAddress bitcoinAddress)
{
if (bitcoinAddress == null)
throw new ArgumentNullException(nameof(bitcoinAddress));
_bitcoinAddress = bitcoinAddress;
}
public BitcoinAddress Address => _bitcoinAddress;
public override string ToString()
{
return _bitcoinAddress.ToString();
}
}
}

View file

@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.Payment;
using NBitcoin.RPC;
using NBXplorer.Models;
using Newtonsoft.Json;
using NewBlockEvent = BTCPayServer.Events.NewBlockEvent;
using PayoutData = BTCPayServer.Data.PayoutData;
public class BitcoinLikePayoutHandler : IPayoutHandler
{
private readonly BTCPayNetworkProvider _btcPayNetworkProvider;
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly EventAggregator _eventAggregator;
public BitcoinLikePayoutHandler(BTCPayNetworkProvider btcPayNetworkProvider,
ExplorerClientProvider explorerClientProvider, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
ApplicationDbContextFactory dbContextFactory, EventAggregator eventAggregator)
{
_btcPayNetworkProvider = btcPayNetworkProvider;
_explorerClientProvider = explorerClientProvider;
_jsonSerializerSettings = jsonSerializerSettings;
_dbContextFactory = dbContextFactory;
_eventAggregator = eventAggregator;
}
public bool CanHandle(PaymentMethodId paymentMethod)
{
return paymentMethod.PaymentType == BitcoinPaymentType.Instance &&
_btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethod.CryptoCode)?.ReadonlyWallet is false;
}
public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination)
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
destination = destination.Trim();
try
{
if (destination.StartsWith($"{network.UriScheme}:", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult<IClaimDestination>(new UriClaimDestination(new BitcoinUrlBuilder(destination, network.NBitcoinNetwork)));
}
return Task.FromResult<IClaimDestination>(new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork)));
}
catch
{
return Task.FromResult<IClaimDestination>(null);
}
}
public IPayoutProof ParseProof(PayoutData payout)
{
if (payout?.Proof is null)
return null;
var paymentMethodId = payout.GetPaymentMethodId();
var res = JsonConvert.DeserializeObject<PayoutTransactionOnChainBlob>(Encoding.UTF8.GetString(payout.Proof), _jsonSerializerSettings.GetSerializer(paymentMethodId.CryptoCode));
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
res.LinkTemplate = network.BlockExplorerLink;
return res;
}
public void StartBackgroundCheck(Action<Type[]> subscribe)
{
subscribe(new[] {typeof(NewOnChainTransactionEvent), typeof(NewBlockEvent)});
}
public async Task BackgroundCheck(object o)
{
if (o is NewOnChainTransactionEvent newTransaction)
{
await UpdatePayoutsAwaitingForPayment(newTransaction);
}
if (o is NewBlockEvent || o is NewOnChainTransactionEvent)
{
await UpdatePayoutsInProgress();
}
}
public Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethodId, IClaimDestination claimDestination)
{
if (_btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode)?
.NBitcoinNetwork?
.Consensus?
.ConsensusFactory?
.CreateTxOut() is TxOut txout &&
claimDestination is IBitcoinLikeClaimDestination bitcoinLikeClaimDestination)
{
txout.ScriptPubKey = bitcoinLikeClaimDestination.Address.ScriptPubKey;
return Task.FromResult(txout.GetDustThreshold(new FeeRate(1.0m)).ToDecimal(MoneyUnit.BTC));
}
return Task.FromResult(0m);
}
private async Task UpdatePayoutsInProgress()
{
try
{
using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Include(p => p.PullPaymentData)
.Where(p => p.State == PayoutState.InProgress)
.ToListAsync();
foreach (var payout in payouts)
{
var proof = ParseProof(payout) as PayoutTransactionOnChainBlob;
var payoutBlob = payout.GetBlob(this._jsonSerializerSettings);
if (proof is null || proof.Accounted is false)
{
continue;
}
foreach (var txid in proof.Candidates.ToList())
{
var explorer = _explorerClientProvider.GetExplorerClient(payout.GetPaymentMethodId().CryptoCode);
var tx = await explorer.GetTransactionAsync(txid);
if (tx is null)
{
proof.Candidates.Remove(txid);
}
else if (tx.Confirmations >= payoutBlob.MinimumConfirmation)
{
payout.State = PayoutState.Completed;
proof.TransactionId = tx.TransactionHash;
payout.Destination = null;
break;
}
else
{
var rebroadcasted = await explorer.BroadcastAsync(tx.Transaction);
if (rebroadcasted.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
rebroadcasted.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED)
{
proof.Candidates.Remove(txid);
}
else
{
payout.State = PayoutState.InProgress;
proof.TransactionId = tx.TransactionHash;
continue;
}
}
}
if (proof.TransactionId is null && !proof.Candidates.Contains(proof.TransactionId))
{
proof.TransactionId = null;
}
if (proof.Candidates.Count == 0)
{
payout.State = PayoutState.AwaitingPayment;
}
else if (proof.TransactionId is null)
{
proof.TransactionId = proof.Candidates.First();
}
if (payout.State == PayoutState.Completed)
proof.Candidates = null;
SetProofBlob(payout, proof);
}
await ctx.SaveChangesAsync();
}
catch (Exception ex)
{
Logs.PayServer.LogWarning(ex, "Error while processing an update in the pull payment hosted service");
}
}
private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction)
{
try
{
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(newTransaction.CryptoCode);
Dictionary<string, decimal> destinations;
if (newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource addressTrackedSource)
{
destinations = new Dictionary<string, decimal>()
{
{
addressTrackedSource.Address.ToString(),
newTransaction.NewTransactionEvent.Outputs.Sum(output => output.Value.GetValue(network))
}
};
}
else
{
destinations = newTransaction.NewTransactionEvent.TransactionData.Transaction.Outputs
.GroupBy(txout => txout.ScriptPubKey)
.ToDictionary(
txoutSet => txoutSet.Key.GetDestinationAddress(network.NBitcoinNetwork).ToString(),
txoutSet => txoutSet.Sum(txout => txout.Value.ToDecimal(MoneyUnit.BTC)));
}
var paymentMethodId = new PaymentMethodId(newTransaction.CryptoCode, BitcoinPaymentType.Instance);
using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Include(o => o.PullPaymentData)
.Where(p => p.State == PayoutState.AwaitingPayment)
.Where(p => p.PaymentMethodId == paymentMethodId.ToString())
.Where(p => destinations.Keys.Contains(p.Destination))
.ToListAsync();
var payoutByDestination = payouts.ToDictionary(p => p.Destination);
foreach (var destination in destinations)
{
if (!payoutByDestination.TryGetValue(destination.Key, out var payout))
continue;
var payoutBlob = payout.GetBlob(_jsonSerializerSettings);
if (destination.Value != payoutBlob.CryptoAmount)
continue;
var proof = ParseProof(payout) as PayoutTransactionOnChainBlob;
if (proof is null)
{
proof = new PayoutTransactionOnChainBlob()
{
Accounted = !(newTransaction.NewTransactionEvent.TrackedSource is AddressTrackedSource ),
};
}
var txId = newTransaction.NewTransactionEvent.TransactionData.TransactionHash;
if (proof.Candidates.Add(txId))
{
if (proof.Accounted is true)
{
payout.State = PayoutState.InProgress;
var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode);
_eventAggregator.Publish(new UpdateTransactionLabel(walletId,
newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
UpdateTransactionLabel.PayoutTemplate(payout.Id,payout.PullPaymentDataId, walletId.ToString())));
}
if (proof.TransactionId is null)
proof.TransactionId = txId;
SetProofBlob(payout, proof);
}
}
await ctx.SaveChangesAsync();
}
catch (Exception ex)
{
Logs.PayServer.LogWarning(ex, "Error while processing a transaction in the pull payment hosted service");
}
}
private void SetProofBlob(PayoutData data, PayoutTransactionOnChainBlob blob)
{
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, _jsonSerializerSettings.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much
if (data.Proof is null || bytes.Length != data.Proof.Length || !bytes.SequenceEqual(data.Proof))
{
data.Proof = bytes;
}
}
}

View file

@ -0,0 +1,9 @@
using NBitcoin;
namespace BTCPayServer.Data
{
public interface IBitcoinLikeClaimDestination : IClaimDestination
{
BitcoinAddress Address { get; }
}
}

View file

@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Globalization;
using NBitcoin;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public class PayoutTransactionOnChainBlob: IPayoutProof
{
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 TransactionId { get; set; }
[JsonProperty(ItemConverterType = typeof(NBitcoin.JsonConverters.UInt256JsonConverter), NullValueHandling = NullValueHandling.Ignore)]
public HashSet<uint256> Candidates { get; set; } = new HashSet<uint256>();
[JsonIgnore] public string LinkTemplate { get; set; }
[JsonIgnore]
public string Link
{
get { return Id != null ? string.Format(CultureInfo.InvariantCulture, LinkTemplate, Id) : null; }
}
public bool? Accounted { get; set; }//nullable to be backwards compatible. if null, accounted is true
[JsonIgnore]
public string Id { get { return TransactionId?.ToString(); } }
}
}

View file

@ -0,0 +1,27 @@
using System;
using NBitcoin;
using NBitcoin.Payment;
namespace BTCPayServer.Data
{
public class UriClaimDestination : IBitcoinLikeClaimDestination
{
private readonly BitcoinUrlBuilder _bitcoinUrl;
public UriClaimDestination(BitcoinUrlBuilder bitcoinUrl)
{
if (bitcoinUrl == null)
throw new ArgumentNullException(nameof(bitcoinUrl));
if (bitcoinUrl.Address is null)
throw new ArgumentException(nameof(bitcoinUrl));
_bitcoinUrl = bitcoinUrl;
}
public BitcoinUrlBuilder BitcoinUrl => _bitcoinUrl;
public BitcoinAddress Address => _bitcoinUrl.Address;
public override string ToString()
{
return _bitcoinUrl.ToString();
}
}
}

View file

@ -0,0 +1,12 @@
namespace BTCPayServer.Data
{
public interface IClaimDestination
{
}
public interface IPayoutProof
{
string Link { get; }
string Id { get; }
}
}

View file

@ -0,0 +1,17 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Payments;
public interface IPayoutHandler
{
public bool CanHandle(PaymentMethodId paymentMethod);
//Allows payout handler to parse payout destinations on its own
public Task<IClaimDestination> ParseClaimDestination(PaymentMethodId paymentMethodId, string destination);
public IPayoutProof ParseProof(PayoutData payout);
//Allows you to subscribe the main pull payment hosted service to events and prepare the handler
void StartBackgroundCheck(Action<Type[]> subscribe);
//allows you to process events that the main pull payment hosted service is subscribed to
Task BackgroundCheck(object o);
Task<decimal> GetMinimumPayoutAmount(PaymentMethodId paymentMethod, IClaimDestination claimDestination);
}

View file

@ -0,0 +1,16 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public class PayoutBlob
{
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? CryptoAmount { get; set; }
public int MinimumConfirmation { get; set; } = 1;
public string Destination { get; set; }
public int Revision { get; set; }
}
}

View file

@ -0,0 +1,42 @@
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public static class PayoutExtensions
{
public static async Task<PayoutData> GetPayout(this DbSet<PayoutData> payouts, string payoutId, string storeId, bool includePullPayment = false, bool includeStore = false)
{
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;
return payout;
}
public static PaymentMethodId GetPaymentMethodId(this PayoutData data)
{
return PaymentMethodId.Parse(data.PaymentMethodId);
}
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
{
return JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
}
public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public class PullPaymentBlob
{
public string Name { get; set; }
public string Currency { get; set; }
public int Divisibility { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Limit { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal MinimumClaim { get; set; }
public PullPaymentView View { get; set; } = new PullPaymentView();
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[JsonProperty(ItemConverterType = typeof(PaymentMethodIdJsonConverter))]
public PaymentMethodId[] SupportedPaymentMethods { get; set; }
public class PullPaymentView
{
public string Title { get; set; }
public string Description { get; set; }
public string EmbeddedCSS { get; set; }
public string Email { get; set; }
public string CustomCSSLink { get; set; }
}
}
}

View file

@ -0,0 +1,25 @@
using System.Linq;
using System.Text;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public static class PullPaymentsExtensions
{
public static PullPaymentBlob GetBlob(this PullPaymentData data)
{
return JsonConvert.DeserializeObject<PullPaymentBlob>(Encoding.UTF8.GetString(data.Blob));
}
public static void SetBlob(this PullPaymentData data, PullPaymentBlob blob)
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob));
}
public static bool IsSupported(this PullPaymentData data, BTCPayServer.Payments.PaymentMethodId paymentId)
{
return data.GetBlob().SupportedPaymentMethods.Contains(paymentId);
}
}
}

View file

@ -1,212 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.JsonConverters;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.JsonConverters;
using NBitcoin.Payment;
using Newtonsoft.Json;
namespace BTCPayServer.Data
{
public static class PullPaymentsExtensions
{
public static async Task<PayoutData> GetPayout(this DbSet<PayoutData> payouts, string payoutId, string storeId, bool includePullPayment = false, bool includeStore = false)
{
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;
return payout;
}
public static PullPaymentBlob GetBlob(this PullPaymentData data)
{
return JsonConvert.DeserializeObject<PullPaymentBlob>(Encoding.UTF8.GetString(data.Blob));
}
public static void SetBlob(this PullPaymentData data, PullPaymentBlob blob)
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob));
}
public static PaymentMethodId GetPaymentMethodId(this PayoutData data)
{
return PaymentMethodId.Parse(data.PaymentMethodId);
}
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
{
return JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
}
public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
{
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
}
public static bool IsSupported(this PullPaymentData data, BTCPayServer.Payments.PaymentMethodId paymentId)
{
return data.GetBlob().SupportedPaymentMethods.Contains(paymentId);
}
public static PayoutTransactionOnChainBlob GetProofBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
{
if (data.Proof is null)
return null;
return JsonConvert.DeserializeObject<PayoutTransactionOnChainBlob>(Encoding.UTF8.GetString(data.Proof), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
}
public static void SetProofBlob(this PayoutData data, PayoutTransactionOnChainBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
{
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
// We only update the property if the bytes actually changed, this prevent from hammering the DB too much
if (data.Proof is null || bytes.Length != data.Proof.Length || !bytes.SequenceEqual(data.Proof))
{
data.Proof = bytes;
}
}
}
public class PayoutTransactionOnChainBlob
{
[JsonConverter(typeof(NBitcoin.JsonConverters.UInt256JsonConverter))]
public uint256 TransactionId { get; set; }
[JsonProperty(ItemConverterType = typeof(NBitcoin.JsonConverters.UInt256JsonConverter), NullValueHandling = NullValueHandling.Ignore)]
public HashSet<uint256> Candidates { get; set; } = new HashSet<uint256>();
}
public interface IClaimDestination
{
BitcoinAddress Address { get; }
}
public static class ClaimDestination
{
public static bool TryParse(string destination, BTCPayNetwork network, out IClaimDestination claimDestination)
{
if (destination == null)
throw new ArgumentNullException(nameof(destination));
destination = destination.Trim();
try
{
if (destination.StartsWith($"{network.UriScheme}:", StringComparison.OrdinalIgnoreCase))
{
claimDestination = new UriClaimDestination(new BitcoinUrlBuilder(destination, network.NBitcoinNetwork));
}
else
{
claimDestination = new AddressClaimDestination(BitcoinAddress.Create(destination, network.NBitcoinNetwork));
}
return true;
}
catch
{
claimDestination = null;
return false;
}
}
}
public class AddressClaimDestination : IClaimDestination
{
private readonly BitcoinAddress _bitcoinAddress;
public AddressClaimDestination(BitcoinAddress bitcoinAddress)
{
if (bitcoinAddress == null)
throw new ArgumentNullException(nameof(bitcoinAddress));
_bitcoinAddress = bitcoinAddress;
}
public BitcoinAddress BitcoinAdress => _bitcoinAddress;
public BitcoinAddress Address => _bitcoinAddress;
public override string ToString()
{
return _bitcoinAddress.ToString();
}
}
public class UriClaimDestination : IClaimDestination
{
private readonly BitcoinUrlBuilder _bitcoinUrl;
public UriClaimDestination(BitcoinUrlBuilder bitcoinUrl)
{
if (bitcoinUrl == null)
throw new ArgumentNullException(nameof(bitcoinUrl));
if (bitcoinUrl.Address is null)
throw new ArgumentException(nameof(bitcoinUrl));
_bitcoinUrl = bitcoinUrl;
}
public BitcoinUrlBuilder BitcoinUrl => _bitcoinUrl;
public BitcoinAddress Address => _bitcoinUrl.Address;
public override string ToString()
{
return _bitcoinUrl.ToString();
}
}
public class PayoutBlob
{
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Amount { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
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>
{
private readonly BTCPayNetwork _network;
public ClaimDestinationJsonConverter(BTCPayNetwork network)
{
if (network == null)
throw new ArgumentNullException(nameof(network));
_network = network;
}
public override IClaimDestination ReadJson(JsonReader reader, Type objectType, IClaimDestination existingValue, bool hasExistingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.Null)
return null;
if (reader.TokenType != JsonToken.String)
throw new JsonObjectException("Expected string for IClaimDestination", reader);
if (ClaimDestination.TryParse((string)reader.Value, _network, out var v))
return v;
throw new JsonObjectException("Invalid IClaimDestination", reader);
}
public override void WriteJson(JsonWriter writer, IClaimDestination value, JsonSerializer serializer)
{
if (value is IClaimDestination v)
writer.WriteValue(v.ToString());
}
}
public class PullPaymentBlob
{
public string Name { get; set; }
public string Currency { get; set; }
public int Divisibility { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Limit { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal MinimumClaim { get; set; }
public PullPaymentView View { get; set; } = new PullPaymentView();
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
public TimeSpan? Period { get; set; }
[JsonProperty(ItemConverterType = typeof(PaymentMethodIdJsonConverter))]
public PaymentMethodId[] SupportedPaymentMethods { get; set; }
}
public class PullPaymentView
{
public string Title { get; set; }
public string Description { get; set; }
public string EmbeddedCSS { get; set; }
public string Email { get; set; }
public string CustomCSSLink { get; set; }
}
}

View file

@ -112,6 +112,13 @@ namespace BTCPayServer
return Subscribe(eventType, s);
}
public IEventAggregatorSubscription Subscribe(Type eventType, Action<IEventAggregatorSubscription, object> subscription)
{
var s = new Subscription(this, eventType);
s.Act = (o) => subscription(s, o);
return Subscribe(eventType, s);
}
private IEventAggregatorSubscription Subscribe(Type eventType, Subscription subscription)
{
lock (_Subscriptions)

View file

@ -4,20 +4,19 @@ using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Payments;
using BTCPayServer.Services;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitcoin.RPC;
using NBXplorer;
using PayoutData = BTCPayServer.Data.PayoutData;
namespace BTCPayServer.HostedServices
{
@ -108,7 +107,7 @@ namespace BTCPayServer.HostedServices
Limit = create.Amount,
Period = o.Period is long periodSeconds ? (TimeSpan?)TimeSpan.FromSeconds(periodSeconds) : null,
SupportedPaymentMethods = create.PaymentMethodIds,
View = new PullPaymentView()
View = new PullPaymentBlob.PullPaymentView()
{
Title = create.Name ?? string.Empty,
Description = string.Empty,
@ -146,19 +145,19 @@ namespace BTCPayServer.HostedServices
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
CurrencyNameTable currencyNameTable,
EventAggregator eventAggregator,
ExplorerClientProvider explorerClientProvider,
BTCPayNetworkProvider networkProvider,
NotificationSender notificationSender,
RateFetcher rateFetcher)
RateFetcher rateFetcher,
IEnumerable<IPayoutHandler> payoutHandlers)
{
_dbContextFactory = dbContextFactory;
_jsonSerializerSettings = jsonSerializerSettings;
_currencyNameTable = currencyNameTable;
_eventAggregator = eventAggregator;
_explorerClientProvider = explorerClientProvider;
_networkProvider = networkProvider;
_notificationSender = notificationSender;
_rateFetcher = rateFetcher;
_payoutHandlers = payoutHandlers;
}
Channel<object> _Channel;
@ -166,19 +165,30 @@ namespace BTCPayServer.HostedServices
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly CurrencyNameTable _currencyNameTable;
private readonly EventAggregator _eventAggregator;
private readonly ExplorerClientProvider _explorerClientProvider;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly NotificationSender _notificationSender;
private readonly RateFetcher _rateFetcher;
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly CompositeDisposable _subscriptions = new CompositeDisposable();
internal override Task[] InitializeTasks()
{
_Channel = Channel.CreateUnbounded<object>();
_eventAggregator.Subscribe<NewOnChainTransactionEvent>(o => _Channel.Writer.TryWrite(o));
_eventAggregator.Subscribe<NewBlockEvent>(o => _Channel.Writer.TryWrite(o));
foreach (IPayoutHandler payoutHandler in _payoutHandlers)
{
payoutHandler.StartBackgroundCheck(Subscribe);
}
return new[] { Loop() };
}
private void Subscribe(params Type[] events)
{
foreach (Type @event in events)
{
_eventAggregator.Subscribe(@event, (subscription, o) => _Channel.Writer.TryWrite(o));
}
}
private async Task Loop()
{
await foreach (var o in _Channel.Reader.ReadAllAsync())
@ -192,18 +202,13 @@ namespace BTCPayServer.HostedServices
{
await HandleApproval(approv);
}
if (o is NewOnChainTransactionEvent newTransaction)
{
await UpdatePayoutsAwaitingForPayment(newTransaction);
}
if (o is CancelRequest cancel)
{
await HandleCancel(cancel);
}
if (o is NewBlockEvent || o is NewOnChainTransactionEvent)
foreach (IPayoutHandler payoutHandler in _payoutHandlers)
{
await UpdatePayoutsInProgress();
await payoutHandler.BackgroundCheck(o);
}
}
}
@ -266,15 +271,17 @@ namespace BTCPayServer.HostedServices
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)
var cryptoAmount = payoutBlob.Amount / req.Rate;
var payoutHandler = _payoutHandlers.First(handler => handler.CanHandle(paymentMethod));
var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination);
decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest);
if (cryptoAmount < minimumCryptoAmount)
{
req.Completion.TrySetResult(PayoutApproval.Result.TooLowAmount);
return;
}
payoutBlob.CryptoAmount = cryptoAmount.ToDecimal(MoneyUnit.BTC);
payout.SetBlob(payoutBlob, this._jsonSerializerSettings);
payoutBlob.CryptoAmount = cryptoAmount;
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.SaveChangesAsync();
req.Completion.SetResult(PayoutApproval.Result.Ok);
}
@ -289,7 +296,7 @@ namespace BTCPayServer.HostedServices
try
{
DateTimeOffset now = DateTimeOffset.UtcNow;
using var ctx = _dbContextFactory.CreateContext();
await using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId);
if (pp is null || pp.Archived)
{
@ -307,7 +314,9 @@ namespace BTCPayServer.HostedServices
return;
}
var ppBlob = pp.GetBlob();
if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId))
var payoutHandler =
_payoutHandlers.FirstOrDefault(handler => handler.CanHandle(req.ClaimRequest.PaymentMethodId));
if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId) || payoutHandler is null )
{
req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported));
return;
@ -336,7 +345,7 @@ namespace BTCPayServer.HostedServices
State = PayoutState.AwaitingApproval,
PullPaymentDataId = req.ClaimRequest.PullPaymentId,
PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(),
Destination = GetDestination(req.ClaimRequest.Destination.Address.ScriptPubKey)
Destination = req.ClaimRequest.Destination.ToString()
};
if (claimed < ppBlob.MinimumClaim || claimed == 0.0m)
{
@ -346,11 +355,10 @@ namespace BTCPayServer.HostedServices
var payoutBlob = new PayoutBlob()
{
Amount = claimed,
Destination = req.ClaimRequest.Destination
Destination = req.ClaimRequest.Destination.ToString()
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
payout.SetProofBlob(new PayoutTransactionOnChainBlob(), _jsonSerializerSettings);
ctx.Payouts.Add(payout);
await ctx.Payouts.AddAsync(payout);
try
{
await ctx.SaveChangesAsync();
@ -373,54 +381,6 @@ namespace BTCPayServer.HostedServices
req.Completion.TrySetException(ex);
}
}
private async Task UpdatePayoutsAwaitingForPayment(NewOnChainTransactionEvent newTransaction)
{
try
{
var outputs = newTransaction.
NewTransactionEvent.
TransactionData.
Transaction.
Outputs;
var destinations = outputs.Select(o => GetDestination(o.ScriptPubKey)).ToHashSet();
using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Include(o => o.PullPaymentData)
.Where(p => p.State == PayoutState.AwaitingPayment)
.Where(p => destinations.Contains(p.Destination))
.ToListAsync();
var payoutByDestination = payouts.ToDictionary(p => p.Destination);
foreach (var output in outputs)
{
if (!payoutByDestination.TryGetValue(GetDestination(output.ScriptPubKey), out var payout))
continue;
var payoutBlob = payout.GetBlob(this._jsonSerializerSettings);
if (output.Value.ToDecimal(MoneyUnit.BTC) != payoutBlob.CryptoAmount)
continue;
var proof = payout.GetProofBlob(this._jsonSerializerSettings);
var txId = newTransaction.NewTransactionEvent.TransactionData.TransactionHash;
if (proof.Candidates.Add(txId))
{
payout.State = PayoutState.InProgress;
if (proof.TransactionId is null)
proof.TransactionId = txId;
payout.SetProofBlob(proof, _jsonSerializerSettings);
var walletId = new WalletId(payout.PullPaymentData.StoreId, newTransaction.CryptoCode);
_eventAggregator.Publish(new UpdateTransactionLabel(walletId,
newTransaction.NewTransactionEvent.TransactionData.TransactionHash,
UpdateTransactionLabel.PayoutTemplate(payout.Id,payout.PullPaymentDataId, walletId.ToString())));
}
}
await ctx.SaveChangesAsync();
}
catch (Exception ex)
{
Logs.PayServer.LogWarning(ex, "Error while processing a transaction in the pull payment hosted service");
}
}
private async Task HandleCancel(CancelRequest cancel)
{
try
@ -457,95 +417,6 @@ namespace BTCPayServer.HostedServices
cancel.Completion.TrySetException(ex);
}
}
private async Task UpdatePayoutsInProgress()
{
try
{
using var ctx = _dbContextFactory.CreateContext();
var payouts = await ctx.Payouts
.Include(p => p.PullPaymentData)
.Where(p => p.State == PayoutState.InProgress)
.ToListAsync();
foreach (var payout in payouts)
{
var proof = payout.GetProofBlob(this._jsonSerializerSettings);
var payoutBlob = payout.GetBlob(this._jsonSerializerSettings);
foreach (var txid in proof.Candidates.ToList())
{
var explorer = _explorerClientProvider.GetExplorerClient(payout.GetPaymentMethodId().CryptoCode);
var tx = await explorer.GetTransactionAsync(txid);
if (tx is null)
{
proof.Candidates.Remove(txid);
}
else if (tx.Confirmations >= payoutBlob.MinimumConfirmation)
{
payout.State = PayoutState.Completed;
proof.TransactionId = tx.TransactionHash;
payout.Destination = null;
break;
}
else
{
var rebroadcasted = await explorer.BroadcastAsync(tx.Transaction);
if (rebroadcasted.RPCCode == RPCErrorCode.RPC_TRANSACTION_ERROR ||
rebroadcasted.RPCCode == RPCErrorCode.RPC_TRANSACTION_REJECTED)
{
proof.Candidates.Remove(txid);
}
else
{
payout.State = PayoutState.InProgress;
proof.TransactionId = tx.TransactionHash;
continue;
}
}
}
if (proof.TransactionId is null && !proof.Candidates.Contains(proof.TransactionId))
{
proof.TransactionId = null;
}
if (proof.Candidates.Count == 0)
{
payout.State = PayoutState.AwaitingPayment;
}
else if (proof.TransactionId is null)
{
proof.TransactionId = proof.Candidates.First();
}
if (payout.State == PayoutState.Completed)
proof.Candidates = null;
payout.SetProofBlob(proof, this._jsonSerializerSettings);
}
await ctx.SaveChangesAsync();
}
catch (Exception ex)
{
Logs.PayServer.LogWarning(ex, "Error while processing an update in the pull payment hosted service");
}
}
private Money GetMinimumCryptoAmount(PaymentMethodId paymentMethodId, Script scriptPubKey)
{
Money mininumAmount = Money.Zero;
if (_networkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode)?
.NBitcoinNetwork?
.Consensus?
.ConsensusFactory?
.CreateTxOut() is TxOut txout)
{
txout.ScriptPubKey = scriptPubKey;
mininumAmount = txout.GetDustThreshold(new FeeRate(1.0m));
}
return mininumAmount;
}
private static string GetDestination(Script scriptPubKey)
{
return Encoders.Base64.EncodeData(scriptPubKey.ToBytes(true));
}
public Task Cancel(CancelRequest cancelRequest)
{
CancellationToken.ThrowIfCancellationRequested();
@ -568,6 +439,7 @@ namespace BTCPayServer.HostedServices
public override Task StopAsync(CancellationToken cancellationToken)
{
_Channel?.Writer.Complete();
_subscriptions.Dispose();
return base.StopAsync(cancellationToken);
}
}

View file

@ -83,7 +83,6 @@ namespace BTCPayServer.Hosting
});
services.AddSingleton<BTCPayNetworkJsonSerializerSettings>();
services.RegisterJsonConverter(n => new ClaimDestinationJsonConverter(n));
services.AddPayJoinServices();
#if ALTCOINS
@ -318,6 +317,8 @@ namespace BTCPayServer.Hosting
.ConfigureHttpClient(h => h.DefaultRequestHeaders.ConnectionClose = true)
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
services.AddSingleton<IPayoutHandler, BitcoinLikePayoutHandler>();
services.AddSingleton<HostedServices.PullPaymentHostedService>();
services.AddSingleton<IHostedService, HostedServices.PullPaymentHostedService>(o => o.GetRequiredService<PullPaymentHostedService>());

View file

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Rates;
using BTCPayServer.Views;
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
namespace BTCPayServer.Models
{
@ -88,7 +90,7 @@ namespace BTCPayServer.Models
public string Id { get; set; }
public decimal Amount { get; set; }
public string AmountFormatted { get; set; }
public string Status { get; set; }
public PayoutState Status { get; set; }
public string Destination { get; set; }
public string Currency { get; set; }
public string Link { get; set; }

View file

@ -1,5 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Payments;
namespace BTCPayServer.Models.WalletViewModels
{
@ -7,6 +11,9 @@ namespace BTCPayServer.Models.WalletViewModels
{
public string PullPaymentId { get; set; }
public string Command { get; set; }
public List<PayoutStateSet> PayoutStateSets{ get; set; }
public PaymentMethodId PaymentMethodId { get; set; }
public class PayoutModel
{
public string PayoutId { get; set; }
@ -18,7 +25,18 @@ namespace BTCPayServer.Models.WalletViewModels
public string Amount { get; set; }
public string TransactionLink { get; set; }
}
public List<PayoutModel> WaitingForApproval { get; set; } = new List<PayoutModel>();
public List<PayoutModel> Other { get; set; } = new List<PayoutModel>();
public class PayoutStateSet
{
public PayoutState State { get; set; }
public List<PayoutModel> Payouts { get; set; }
}
public string[] GetSelectedPayouts(PayoutState state)
{
return PayoutStateSets.Where(set => set.State == state)
.SelectMany(set => set.Payouts.Where(model => model.Selected).Select(model => model.PayoutId))
.ToArray();
}
}
}

View file

@ -91,12 +91,12 @@
<div class="d-flex align-items-center">
<span class="text-muted text-nowrap">Start Date</span>
&nbsp;
<span class="text-nowrap">@Model.StartDate.ToBrowserDate()</span>
<span class="text-nowrap">@Model.StartDate.ToString("g")</span>
</div>
<div class="d-flex align-items-center">
<span class="text-muted text-nowrap">Last Updated</span>
&nbsp;
<span class="text-nowrap">@Model.LastRefreshed.ToBrowserDate()</span>
<span class="text-nowrap">@Model.LastRefreshed.ToString("g")</span>
<button type="button" class="btn btn-link d-none d-lg-inline-block d-print-none border-0 p-0 ml-4 only-for-js" id="copyLink">
Copy Link
</button>
@ -164,11 +164,11 @@
<td class="text-right text-nowrap">
@if (!string.IsNullOrEmpty(invoice.Link))
{
<a class="transaction-link text-print-default @StatusTextClass(invoice.Status)" href="@invoice.Link">@invoice.Status</a>
<a class="transaction-link text-print-default @StatusTextClass(invoice.Status.ToString())" href="@invoice.Link">@invoice.Status.GetStateString()</a>
}
else
{
<span class="text-print-default @StatusTextClass(invoice.Status)">@invoice.Status</span>
<span class="text-print-default @StatusTextClass(invoice.Status.ToString())">@invoice.Status.GetStateString()</span>
}
</td>
</tr>

View file

@ -1,100 +1,132 @@
@model PayoutsModel
@using BTCPayServer.Client.Models
@model PayoutsModel
@inject IEnumerable<IPayoutHandler> PayoutHandlers;
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, "Manage payouts", Context.GetStoreData().StoreName);
}
<script type="text/javascript">
function selectAll(e)
function selectAll(e, elementClass)
{
const items = document.getElementsByClassName("selection-item");
const items = document.getElementsByClassName("selection-item-"+elementClass);
for (let i = 0; i < items.length; i++) {
items[i].checked = e.checked;
}
}
</script>
<form method="post">
<h4 class="mb-3">Payouts to process</h4>
<div class="row button-row">
@if (Model.WaitingForApproval.Any())
<h4 class="mb-3">@ViewData["Title"]</h4>
@if (!Model.PayoutStateSets.Any())
{
<div class="col text-right">
<button type="submit" id="payCommand" name="Command" class="btn btn-primary" role="button" value="pay">Confirm selected payouts</button>
<button type="submit" id="payCommand" name="Command" class="btn btn-secondary" role="button" value="cancel">Cancel selected payouts</button>
</div>
<p class="text-secondary mt-3">
There are no payouts yet.
</p>
}
</div>
<div class="row">
<div class="col-md-12">
@if (Model.WaitingForApproval.Any())
<ul class="nav col-md-10 col-sm-12">
@for (var index = 0; index < Model.PayoutStateSets.Count; index++)
{
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th><input id="selectAllCheckbox" type="checkbox" onclick="selectAll(this); return true;" /></th>
<th style="min-width: 90px;" class="col-md-auto">Date</th>
<th class="text-left">Source</th>
<th class="text-left">Destination</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
@for (var i = 0; i < Model.WaitingForApproval.Count; i++)
{
var pp = Model.WaitingForApproval[i];
<tr>
<td>
<span>
<input type="checkbox" class="selection-item" asp-for="WaitingForApproval[i].Selected" />
<input type="hidden" asp-for="WaitingForApproval[i].PayoutId" />
</span>
</td>
<td><span>@pp.Date.ToBrowserDate()</span></td>
<td class="mw-100"><span>@pp.PullPaymentName</span></td>
<td><span>@pp.Destination</span></td>
<td class="text-right"><span>@pp.Amount</span></td>
</tr>
var state = Model.PayoutStateSets[index];
<li class="nav-item py-0">
<a class="nav-link btn btn-secondary btn-sm mr-1 @(index == 0 ? "active" : "")" data-toggle="tab" href="#@state.State" role="tab">@state.State.GetStateString() (@state.Payouts.Count)</a>
</li>
}
</tbody>
</table>
}
else
{
<p class="text-secondary mb-0">No payout waiting for approval.</p>
}
</div>
</ul>
</div>
<h4 class="mt-5 mb-3">Completed payouts</h4>
<div class="row">
<div class="tab-content w-100">
@for (var index = 0; index < Model.PayoutStateSets.Count; index++)
{
var state = Model.PayoutStateSets[index];
var stateActions = new List<(string Action, string Text)>();
switch (state.State)
{
case PayoutState.AwaitingApproval:
stateActions.Add(("approve", "Approve selected payouts"));
stateActions.Add(("approve-pay", "Approve & Send selected payouts"));
stateActions.Add(("cancel", "Cancel selected payouts"));
break;
case PayoutState.AwaitingPayment:
stateActions.Add(("pay", "Send selected payouts"));
stateActions.Add(("cancel", "Cancel selected payouts"));
break;
}
<div class="tab-pane @(index == 0 ? "active" : "") " id="@state.State" role="tabpanel">
<input type="hidden" asp-for="PayoutStateSets[index].State"/>
<input type="hidden" asp-for="PaymentMethodId"/>
<div class="row mt-2 ml-2">
@if (state.Payouts.Any() && stateActions.Any())
{
<button class="btn btn-primary dropdown-toggle" type="button" data-toggle="dropdown" id="@state.State-actions">
Actions
</button>
<div class="dropdown-menu">
@foreach (var action in stateActions)
{
<button type="submit" id="@state.State-@action.Action" name="Command" class="dropdown-item" role="button" value="@state.State-@action.Action">@action.Text</button>
}
</div>
}
</div>
<div class="row">
<div class="col-md-12">
@if (Model.Other.Any())
@if (state.Payouts.Any())
{
<table class="table table-sm table-responsive-lg">
<thead class="thead-inverse">
<tr>
<th style="min-width: 90px;" class="col-md-auto">Date</th>
<th>
<input id="@state.State-selectAllCheckbox" type="checkbox" onclick="selectAll(this, '@state.State.ToString()'); return true;"/>
</th>
<th style="min-width: 90px;" class="col-md-auto">
Date
</th>
<th class="text-left">Source</th>
<th class="text-left">Destination</th>
<th class="text-right">Amount</th>
@if (state.State != PayoutState.AwaitingApproval)
{
<th class="text-right">Transaction</th>
}
</tr>
</thead>
<tbody>
@foreach (var pp in Model.Other)
@for (int i = 0; i < state.Payouts.Count; i++)
{
<tr>
<td><span>@pp.Date.ToBrowserDate()</span></td>
<td class="mw-100"><span>@pp.PullPaymentName</span></td>
<td><span>@pp.Destination</span></td>
<td class="text-right"><span>@pp.Amount</span></td>
@if (pp.TransactionLink is null)
var pp = state.Payouts[i];
<tr class="payout">
<td>
<span>
<input type="checkbox" class="selection-item-@state.State.ToString()" asp-for="PayoutStateSets[index].Payouts[i].Selected"/>
<input type="hidden" asp-for="PayoutStateSets[index].Payouts[i].PayoutId"/>
</span>
</td>
<td>
<span>@pp.Date.ToBrowserDate()</span>
</td>
<td class="mw-100">
<span>@pp.PullPaymentName</span>
</td>
<td>
<span>@pp.Destination</span>
</td>
<td class="text-right">
<span>@pp.Amount</span>
</td>
@if (state.State != PayoutState.AwaitingApproval)
{
<td class="text-right"><span>Cancelled</span></td>
<td class="text-right">
@if (!(pp.TransactionLink is null))
{
<a class="transaction-link" href="@pp.TransactionLink">Link</a>
}
else
{
<td class="text-right"><span><a class="transaction-link" href="@pp.TransactionLink">Link</a></span></td>
</td>
}
</tr>
}
@ -103,7 +135,11 @@
}
else
{
<p class="text-secondary mb-0">No payout in history.</p>
<p class="mb-0 p-4" id="@state.State-no-payouts">No payouts.</p>
}
</div>
</div>
</div>
}
</div>
</div>