mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-22 14:22:40 +01:00
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:
parent
98eee27b93
commit
2e12befb8b
26 changed files with 936 additions and 645 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
|
||||
txs = s.Driver.FindElements(By.ClassName("transaction-link"));
|
||||
Assert.Equal(2, txs.Count);
|
||||
});
|
||||
var 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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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,93 +207,121 @@ 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();
|
||||
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||
var payouts = (await ctx.Payouts
|
||||
|
||||
case "approve-pay":
|
||||
case "approve":
|
||||
{
|
||||
await using var ctx = this._dbContextFactory.CreateContext();
|
||||
ctx.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||
var payouts = await GetPayoutsForPaymentMethod(walletId.GetPaymentMethodId(), ctx, payoutIds, storeId, cancellationToken);
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
bip21.Add(network.GenerateBIP21(payout.Destination, new Money(blob.CryptoAmount.Value, MoneyUnit.BTC)));
|
||||
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(WalletSend), new {walletId, bip21});
|
||||
}
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
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())
|
||||
.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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
return View(nameof(walletSend), walletSend);
|
||||
}
|
||||
else if (vm.Command == "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
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
.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,34 +347,42 @@ 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)
|
||||
continue;
|
||||
var ppBlob = item.PullPayment.GetBlob();
|
||||
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
|
||||
var m = new PayoutsModel.PayoutModel();
|
||||
m.PullPaymentId = item.PullPayment.Id;
|
||||
m.PullPaymentName = ppBlob.Name ?? item.PullPayment.Id;
|
||||
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)
|
||||
var state = vm.PayoutStateSets.SingleOrDefault(set => set.State == stateSet.Key);
|
||||
if (state == null)
|
||||
{
|
||||
vm.WaitingForApproval.Add(m);
|
||||
state = new PayoutsModel.PayoutStateSet()
|
||||
{
|
||||
Payouts = new List<PayoutsModel.PayoutModel>(), State = stateSet.Key
|
||||
};
|
||||
vm.PayoutStateSets.Add(state);
|
||||
}
|
||||
else
|
||||
|
||||
foreach (var item in stateSet)
|
||||
{
|
||||
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);
|
||||
|
||||
if (item.Payout.GetPaymentMethodId() != vm.PaymentMethodId)
|
||||
continue;
|
||||
var ppBlob = item.PullPayment.GetBlob();
|
||||
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
|
||||
var m = new PayoutsModel.PayoutModel();
|
||||
m.PullPaymentId = item.PullPayment.Id;
|
||||
m.PullPaymentName = ppBlob.Name ?? item.PullPayment.Id;
|
||||
m.Date = item.Payout.Date;
|
||||
m.PayoutId = item.Payout.Id;
|
||||
m.Amount = _currencyTable.DisplayFormatCurrency(payoutBlob.Amount, ppBlob.Currency);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>()
|
||||
|
||||
vm.Outputs.Add(new WalletSendModel.TransactionOutput()
|
||||
{
|
||||
new WalletSendModel.TransactionOutput()
|
||||
{
|
||||
Amount = uriBuilder.Amount?.ToDecimal(MoneyUnit.BTC),
|
||||
DestinationAddress = uriBuilder.Address.ToString(),
|
||||
SubtractFeesFromOutput = false
|
||||
}
|
||||
};
|
||||
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
|
||||
{
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
using NBitcoin;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public interface IBitcoinLikeClaimDestination : IClaimDestination
|
||||
{
|
||||
BitcoinAddress Address { get; }
|
||||
}
|
||||
}
|
|
@ -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(); } }
|
||||
}
|
||||
}
|
27
BTCPayServer/Data/Payouts/BitcoinLike/UriClaimDestination.cs
Normal file
27
BTCPayServer/Data/Payouts/BitcoinLike/UriClaimDestination.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
12
BTCPayServer/Data/Payouts/IClaimDestination.cs
Normal file
12
BTCPayServer/Data/Payouts/IClaimDestination.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
namespace BTCPayServer.Data
|
||||
{
|
||||
public interface IClaimDestination
|
||||
{
|
||||
}
|
||||
|
||||
public interface IPayoutProof
|
||||
{
|
||||
string Link { get; }
|
||||
string Id { get; }
|
||||
}
|
||||
}
|
17
BTCPayServer/Data/Payouts/IPayoutHandler.cs
Normal file
17
BTCPayServer/Data/Payouts/IPayoutHandler.cs
Normal 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);
|
||||
}
|
16
BTCPayServer/Data/Payouts/PayoutBlob.cs
Normal file
16
BTCPayServer/Data/Payouts/PayoutBlob.cs
Normal 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; }
|
||||
}
|
||||
}
|
42
BTCPayServer/Data/Payouts/PayoutExtensions.cs
Normal file
42
BTCPayServer/Data/Payouts/PayoutExtensions.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
}
|
34
BTCPayServer/Data/PullPayments/PullPaymentBlob.cs
Normal file
34
BTCPayServer/Data/PullPayments/PullPaymentBlob.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
25
BTCPayServer/Data/PullPayments/PullPaymentsExtensions.cs
Normal file
25
BTCPayServer/Data/PullPayments/PullPaymentsExtensions.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>());
|
||||
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,12 +91,12 @@
|
|||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted text-nowrap">Start Date</span>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
|
|
@ -1,109 +1,145 @@
|
|||
@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())
|
||||
{
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
<h4 class="mb-3">@ViewData["Title"]</h4>
|
||||
@if (!Model.PayoutStateSets.Any())
|
||||
{
|
||||
<p class="text-secondary mt-3">
|
||||
There are no payouts yet.
|
||||
</p>
|
||||
}
|
||||
<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>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
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>
|
||||
}
|
||||
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="col-md-12">
|
||||
@if (Model.Other.Any())
|
||||
<div class="tab-content w-100">
|
||||
@for (var index = 0; index < Model.PayoutStateSets.Count; index++)
|
||||
{
|
||||
<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 class="text-left">Source</th>
|
||||
<th class="text-left">Destination</th>
|
||||
<th class="text-right">Amount</th>
|
||||
<th class="text-right">Transaction</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var pp in Model.Other)
|
||||
{
|
||||
<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 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 (state.Payouts.Any())
|
||||
{
|
||||
<td class="text-right"><span>Cancelled</span></td>
|
||||
<table class="table table-sm table-responsive-lg">
|
||||
<thead class="thead-inverse">
|
||||
<tr>
|
||||
<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>
|
||||
@for (int i = 0; i < state.Payouts.Count; i++)
|
||||
{
|
||||
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">
|
||||
@if (!(pp.TransactionLink is null))
|
||||
{
|
||||
<a class="transaction-link" href="@pp.TransactionLink">Link</a>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<td class="text-right"><span><a class="transaction-link" href="@pp.TransactionLink">Link</a></span></td>
|
||||
<p class="mb-0 p-4" id="@state.State-no-payouts">No payouts.</p>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-secondary mb-0">No payout in history.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
Loading…
Add table
Reference in a new issue