using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Logging; using BTCPayServer.Models.WalletViewModels; using BTCPayServer.Payments; using BTCPayServer.Rating; 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 NBXplorer; using Newtonsoft.Json.Linq; using PayoutData = BTCPayServer.Data.PayoutData; using PullPaymentData = BTCPayServer.Data.PullPaymentData; namespace BTCPayServer.HostedServices { public class CreatePullPayment { public DateTimeOffset? ExpiresAt { get; set; } public DateTimeOffset? StartsAt { get; set; } public string StoreId { get; set; } public string Name { get; set; } public string Description { get; set; } public decimal Amount { get; set; } public string Currency { get; set; } public string CustomCSSLink { get; set; } public string EmbeddedCSS { get; set; } public PaymentMethodId[] PaymentMethodIds { get; set; } public TimeSpan? Period { get; set; } public bool AutoApproveClaims { get; set; } public TimeSpan? BOLT11Expiration { get; set; } } public class PullPaymentHostedService : BaseAsyncService { private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" }; public class CancelRequest { public CancelRequest(string pullPaymentId) { ArgumentNullException.ThrowIfNull(pullPaymentId); PullPaymentId = pullPaymentId; } public CancelRequest(string[] payoutIds, string[] storeIds) { ArgumentNullException.ThrowIfNull(payoutIds); PayoutIds = payoutIds; StoreIds = storeIds; } public string[] StoreIds { get; set; } public string PullPaymentId { get; set; } public string[] PayoutIds { get; set; } internal TaskCompletionSource> Completion { get; set; } } public class PayoutApproval { public enum Result { Ok, NotFound, InvalidState, TooLowAmount, OldRevision } public record ApprovalResult(Result Result, decimal? CryptoAmount); public string PayoutId { get; set; } public int Revision { get; set; } public decimal Rate { get; set; } internal TaskCompletionSource Completion { get; set; } public static string GetErrorMessage(Result result) { switch (result) { case PullPaymentHostedService.PayoutApproval.Result.Ok: return "Ok"; case PullPaymentHostedService.PayoutApproval.Result.InvalidState: return "The payout is not in a state that can be approved"; case PullPaymentHostedService.PayoutApproval.Result.TooLowAmount: return "The crypto amount is too small."; case PullPaymentHostedService.PayoutApproval.Result.OldRevision: return "The crypto amount is too small."; case PullPaymentHostedService.PayoutApproval.Result.NotFound: return "The payout is not found"; default: throw new NotSupportedException(); } } } public async Task CreatePullPayment(CreatePullPayment create) { ArgumentNullException.ThrowIfNull(create); if (create.Amount <= 0.0m) throw new ArgumentException("Amount out of bound", nameof(create)); using var ctx = this._dbContextFactory.CreateContext(); var o = new Data.PullPaymentData(); o.StartDate = create.StartsAt is DateTimeOffset date ? date : DateTimeOffset.UtcNow - TimeSpan.FromSeconds(1.0); o.EndDate = create.ExpiresAt is DateTimeOffset date2 ? new DateTimeOffset?(date2) : null; o.Period = create.Period is TimeSpan period ? (long?)period.TotalSeconds : null; o.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); o.StoreId = create.StoreId; o.SetBlob(new PullPaymentBlob() { Name = create.Name ?? string.Empty, Description = create.Description ?? string.Empty, Currency = create.Currency, Limit = create.Amount, Period = o.Period is long periodSeconds ? (TimeSpan?)TimeSpan.FromSeconds(periodSeconds) : null, SupportedPaymentMethods = create.PaymentMethodIds, AutoApproveClaims = create.AutoApproveClaims, View = new PullPaymentBlob.PullPaymentView() { Title = create.Name ?? string.Empty, Description = create.Description ?? string.Empty, CustomCSSLink = create.CustomCSSLink, Email = null, EmbeddedCSS = create.EmbeddedCSS, }, BOLT11Expiration = create.BOLT11Expiration ?? TimeSpan.FromDays(30.0) }); ctx.PullPayments.Add(o); await ctx.SaveChangesAsync(); return o.Id; } public class PayoutQuery { public PayoutState[] States { get; set; } public string[] PullPayments { get; set; } public string[] PayoutIds { get; set; } public string[] PaymentMethods { get; set; } public string[] Stores { get; set; } public bool IncludeArchived { get; set; } public bool IncludeStoreData { get; set; } public bool IncludePullPaymentData { get; set; } public DateTimeOffset? From { get; set; } public DateTimeOffset? To { get; set; } } public async Task> GetPayouts(PayoutQuery payoutQuery) { await using var ctx = _dbContextFactory.CreateContext(); return await GetPayouts(payoutQuery, ctx); } public static async Task> GetPayouts(PayoutQuery payoutQuery, ApplicationDbContext ctx, CancellationToken cancellationToken = default) { var query = ctx.Payouts.AsQueryable(); if (payoutQuery.States is not null) { query = query.Where(data => payoutQuery.States.Contains(data.State)); } if (payoutQuery.PullPayments is not null) { query = query.Where(data => payoutQuery.PullPayments.Contains(data.PullPaymentDataId)); } if (payoutQuery.PayoutIds is not null) { if (payoutQuery.PayoutIds.Length == 1) { var payoutId = payoutQuery.PayoutIds[0]; query = query.Where(data => data.Id == payoutId); } else { query = query.Where(data => payoutQuery.PayoutIds.Contains(data.Id)); } } if (payoutQuery.PaymentMethods is not null) { query = query.Where(data => payoutQuery.PaymentMethods.Contains(data.PaymentMethodId)); } if (payoutQuery.Stores is not null) { query = query.Where(data => payoutQuery.Stores.Contains(data.StoreDataId)); } if (payoutQuery.IncludeStoreData) { query = query.Include(data => data.StoreData); } if (payoutQuery.IncludePullPaymentData || !payoutQuery.IncludeArchived) { query = query.Include(data => data.PullPaymentData); } if (!payoutQuery.IncludeArchived) { query = query.Where(data => data.PullPaymentData == null || !data.PullPaymentData.Archived); } if (payoutQuery.From is not null) { query = query.Where(data => data.Date >= payoutQuery.From); } if (payoutQuery.To is not null) { query = query.Where(data => data.Date <= payoutQuery.To); } return await query.ToListAsync(cancellationToken); } public async Task GetPullPayment(string pullPaymentId, bool includePayouts) { await using var ctx = _dbContextFactory.CreateContext(); IQueryable query = ctx.PullPayments; if (includePayouts) query = query.Include(data => data.Payouts); return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId); } class PayoutRequest { public PayoutRequest(TaskCompletionSource completionSource, ClaimRequest request) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(completionSource); Completion = completionSource; ClaimRequest = request; } public TaskCompletionSource Completion { get; set; } public ClaimRequest ClaimRequest { get; } } public PullPaymentHostedService(ApplicationDbContextFactory dbContextFactory, BTCPayNetworkJsonSerializerSettings jsonSerializerSettings, EventAggregator eventAggregator, BTCPayNetworkProvider networkProvider, NotificationSender notificationSender, RateFetcher rateFetcher, IEnumerable payoutHandlers, ILogger logger, Logs logs, DisplayFormatter displayFormatter, CurrencyNameTable currencyNameTable) : base(logs) { _dbContextFactory = dbContextFactory; _jsonSerializerSettings = jsonSerializerSettings; _eventAggregator = eventAggregator; _networkProvider = networkProvider; _notificationSender = notificationSender; _rateFetcher = rateFetcher; _payoutHandlers = payoutHandlers; _logger = logger; _currencyNameTable = currencyNameTable; _displayFormatter = displayFormatter; } Channel _Channel; private readonly ApplicationDbContextFactory _dbContextFactory; private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings; private readonly EventAggregator _eventAggregator; private readonly BTCPayNetworkProvider _networkProvider; private readonly NotificationSender _notificationSender; private readonly RateFetcher _rateFetcher; private readonly IEnumerable _payoutHandlers; private readonly ILogger _logger; private readonly CurrencyNameTable _currencyNameTable; private readonly DisplayFormatter _displayFormatter; private readonly CompositeDisposable _subscriptions = new CompositeDisposable(); internal override Task[] InitializeTasks() { _Channel = Channel.CreateUnbounded(); 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()) { if (o is PayoutRequest req) { await HandleCreatePayout(req); } if (o is PayoutApproval approv) { await HandleApproval(approv); } if (o is CancelRequest cancel) { await HandleCancel(cancel); } if (o is InternalPayoutPaidRequest paid) { await HandleMarkPaid(paid); } foreach (IPayoutHandler payoutHandler in _payoutHandlers) { try { await payoutHandler.BackgroundCheck(o); } catch (Exception e) { _logger.LogError(e, "PayoutHandler failed during BackgroundCheck"); } } } } public bool SupportsLNURL(PullPaymentBlob blob) { var pms = blob.SupportedPaymentMethods.FirstOrDefault(id => id.PaymentType == LightningPaymentType.Instance && _networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode); return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency); } public Task GetRate(PayoutData payout, string explicitRateRule, CancellationToken cancellationToken) { var ppBlob = payout.PullPaymentData?.GetBlob(); var payoutPaymentMethod = payout.GetPaymentMethodId(); var currencyPair = new Rating.CurrencyPair(payoutPaymentMethod.CryptoCode, ppBlob?.Currency ?? payoutPaymentMethod.CryptoCode); Rating.RateRule rule = null; try { if (explicitRateRule is null) { var storeBlob = payout.StoreData.GetStoreBlob(); var rules = storeBlob.GetRateRules(_networkProvider); rules.Spread = 0.0m; rule = rules.GetRuleFor(currencyPair); } else { rule = Rating.RateRule.CreateFromExpression(explicitRateRule, currencyPair); } } catch (Exception) { throw new FormatException("Invalid RateRule"); } return _rateFetcher.FetchRate(rule, cancellationToken); } public Task Approve(PayoutApproval approval) { approval.Completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); if (!_Channel.Writer.TryWrite(approval)) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return approval.Completion.Task; } private async Task HandleApproval(PayoutApproval req) { try { await using var ctx = _dbContextFactory.CreateContext(); var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.PayoutId) .FirstOrDefaultAsync(); if (payout is null) { req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.NotFound, null)); return; } if (payout.State != PayoutState.AwaitingApproval) { req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.InvalidState, null)); return; } var payoutBlob = payout.GetBlob(this._jsonSerializerSettings); if (payoutBlob.Revision != req.Revision) { req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.OldRevision, null)); return; } if (!PaymentMethodId.TryParse(payout.PaymentMethodId, out var paymentMethod)) { req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.NotFound, null)); return; } payout.State = PayoutState.AwaitingPayment; if (payout.PullPaymentData is null || paymentMethod.CryptoCode == payout.PullPaymentData.GetBlob().Currency) req.Rate = 1.0m; var cryptoAmount = payoutBlob.Amount / req.Rate; var payoutHandler = _payoutHandlers.FindPayoutHandler(paymentMethod); if (payoutHandler is null) throw new InvalidOperationException($"No payout handler for {paymentMethod}"); var dest = await payoutHandler.ParseClaimDestination(paymentMethod, payoutBlob.Destination, default); decimal minimumCryptoAmount = await payoutHandler.GetMinimumPayoutAmount(paymentMethod, dest.destination); if (cryptoAmount < minimumCryptoAmount) { req.Completion.TrySetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.TooLowAmount, null)); return; } payoutBlob.CryptoAmount = Extensions.RoundUp(cryptoAmount, _networkProvider.GetNetwork(paymentMethod.CryptoCode).Divisibility); payout.SetBlob(payoutBlob, _jsonSerializerSettings); await ctx.SaveChangesAsync(); _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Approved, payout)); req.Completion.SetResult(new PayoutApproval.ApprovalResult(PayoutApproval.Result.Ok, payoutBlob.CryptoAmount)); } catch (Exception ex) { req.Completion.TrySetException(ex); } } private async Task HandleMarkPaid(InternalPayoutPaidRequest req) { try { await using var ctx = _dbContextFactory.CreateContext(); var payout = await ctx.Payouts.Include(p => p.PullPaymentData).Where(p => p.Id == req.Request.PayoutId) .FirstOrDefaultAsync(); if (payout is null) { req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.NotFound); return; } if (payout.State == PayoutState.Completed) { req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.InvalidState); return; } switch (req.Request.State) { case PayoutState.Completed or PayoutState.InProgress when payout.State is not PayoutState.AwaitingPayment and not PayoutState.Completed and not PayoutState.InProgress: case PayoutState.AwaitingPayment when payout.State is not PayoutState.InProgress: req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.InvalidState); return; case PayoutState.InProgress or PayoutState.Completed: payout.SetProofBlob(req.Request.Proof); break; default: payout.SetProofBlob(null); break; } payout.State = req.Request.State; await ctx.SaveChangesAsync(); _eventAggregator.Publish(new PayoutEvent(null, payout)); req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok); } catch (Exception ex) { req.Completion.TrySetException(ex); } } private async Task HandleCreatePayout(PayoutRequest req) { try { DateTimeOffset now = DateTimeOffset.UtcNow; await using var ctx = _dbContextFactory.CreateContext(); var withoutPullPayment = req.ClaimRequest.PullPaymentId is null; var pp = string.IsNullOrEmpty(req.ClaimRequest.PullPaymentId) ? null : await ctx.PullPayments.FindAsync(req.ClaimRequest.PullPaymentId); if (!withoutPullPayment && (pp is null || pp.Archived)) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Archived)); return; } PullPaymentBlob ppBlob = null; if (!withoutPullPayment) { if (pp.IsExpired(now)) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Expired)); return; } if (!pp.HasStarted(now)) { req.Completion.TrySetResult( new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.NotStarted)); return; } ppBlob = pp.GetBlob(); if (!ppBlob.SupportedPaymentMethods.Contains(req.ClaimRequest.PaymentMethodId)) { req.Completion.TrySetResult( new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); return; } } var payoutHandler = _payoutHandlers.FindPayoutHandler(req.ClaimRequest.PaymentMethodId); if (payoutHandler is null) { req.Completion.TrySetResult( new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.PaymentMethodNotSupported)); return; } if (req.ClaimRequest.Destination.Id != null) { if (await ctx.Payouts.AnyAsync(data => data.Destination.Equals(req.ClaimRequest.Destination.Id) && data.State != PayoutState.Completed && data.State != PayoutState.Cancelled )) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Duplicate)); return; } } if (req.ClaimRequest.Value < await payoutHandler.GetMinimumPayoutAmount(req.ClaimRequest.PaymentMethodId, req.ClaimRequest.Destination)) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); return; } var payoutsRaw = withoutPullPayment ? null : await ctx.Payouts.GetPayoutInPeriod(pp, now) .Where(p => p.State != PayoutState.Cancelled).ToListAsync(); var payouts = payoutsRaw?.Select(o => new { Entity = o, Blob = o.GetBlob(_jsonSerializerSettings) }); var limit = ppBlob?.Limit ?? 0; var totalPayout = payouts?.Select(p => p.Blob.Amount)?.Sum(); var claimed = req.ClaimRequest.Value is decimal v ? v : limit - (totalPayout ?? 0); if (totalPayout is not null && totalPayout + claimed > limit) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Overdraft)); return; } if (!withoutPullPayment && (claimed < ppBlob.MinimumClaim || claimed == 0.0m)) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.AmountTooLow)); return; } var payout = new PayoutData() { Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)), Date = now, State = PayoutState.AwaitingApproval, PullPaymentDataId = req.ClaimRequest.PullPaymentId, PaymentMethodId = req.ClaimRequest.PaymentMethodId.ToString(), Destination = req.ClaimRequest.Destination.Id, StoreDataId = req.ClaimRequest.StoreId ?? pp?.StoreId }; var payoutBlob = new PayoutBlob() { Amount = claimed, Destination = req.ClaimRequest.Destination.ToString(), Metadata = req.ClaimRequest.Metadata?? new JObject(), }; payout.SetBlob(payoutBlob, _jsonSerializerSettings); await ctx.Payouts.AddAsync(payout); try { await payoutHandler.TrackClaim(req.ClaimRequest, payout); await ctx.SaveChangesAsync(); var response = new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Ok, payout); _eventAggregator.Publish(new PayoutEvent(PayoutEvent.PayoutEventType.Created, payout)); if (req.ClaimRequest.PreApprove.GetValueOrDefault(ppBlob?.AutoApproveClaims is true)) { payout.StoreData = await ctx.Stores.FindAsync(payout.StoreDataId); var rateResult = await GetRate(payout, null, CancellationToken.None); if (rateResult.BidAsk != null) { var approveResultTask = new TaskCompletionSource(); await HandleApproval(new PayoutApproval() { PayoutId = payout.Id, Revision = payoutBlob.Revision, Rate = rateResult.BidAsk.Ask, Completion = approveResultTask }); var approveResult = await approveResultTask.Task; if (approveResult.Result == PayoutApproval.Result.Ok) { payout.State = PayoutState.AwaitingPayment; payoutBlob.CryptoAmount = approveResult.CryptoAmount; payout.SetBlob(payoutBlob, _jsonSerializerSettings); } } } req.Completion.TrySetResult(response); await _notificationSender.SendNotification(new StoreScope(payout.StoreDataId), new PayoutNotification() { StoreId = payout.StoreDataId, Currency = ppBlob?.Currency ?? req.ClaimRequest.PaymentMethodId.CryptoCode, Status = payout.State, PaymentMethod = payout.PaymentMethodId, PayoutId = payout.Id }); } catch (DbUpdateException) { req.Completion.TrySetResult(new ClaimRequest.ClaimResponse(ClaimRequest.ClaimResult.Duplicate)); } } catch (Exception ex) { req.Completion.TrySetException(ex); } } private async Task HandleCancel(CancelRequest cancel) { try { using var ctx = this._dbContextFactory.CreateContext(); List payouts = null; if (cancel.PullPaymentId != null) { ctx.PullPayments.Attach(new Data.PullPaymentData() { Id = cancel.PullPaymentId, Archived = true }) .Property(o => o.Archived).IsModified = true; payouts = await ctx.Payouts .Where(p => p.PullPaymentDataId == cancel.PullPaymentId) .Where(p => cancel.StoreIds == null || cancel.StoreIds.Contains(p.StoreDataId)) .ToListAsync(); cancel.PayoutIds = payouts.Select(data => data.Id).ToArray(); } else { var payoutIds = cancel.PayoutIds.ToHashSet(); payouts = await ctx.Payouts .Where(p => payoutIds.Contains(p.Id)) .Where(p => cancel.StoreIds == null || cancel.StoreIds.Contains(p.StoreDataId)) .ToListAsync(); } Dictionary result = new(); foreach (var payout in payouts) { if (payout.State != PayoutState.Completed && payout.State != PayoutState.InProgress) { payout.State = PayoutState.Cancelled; result.Add(payout.Id, MarkPayoutRequest.PayoutPaidResult.Ok); } else { result.Add(payout.Id, MarkPayoutRequest.PayoutPaidResult.InvalidState); } } foreach (string s1 in cancel.PayoutIds.Where(s => !result.ContainsKey(s))) { result.Add(s1, MarkPayoutRequest.PayoutPaidResult.NotFound); } await ctx.SaveChangesAsync(); foreach (var keyValuePair in result.Where(pair => pair.Value == MarkPayoutRequest.PayoutPaidResult.Ok)) { var payout = payouts.First(p => p.Id == keyValuePair.Key); _eventAggregator.Publish(new PayoutEvent(null, payout)); } cancel.Completion.TrySetResult(result); } catch (Exception ex) { cancel.Completion.TrySetException(ex); } } public Task> Cancel(CancelRequest cancelRequest) { CancellationToken.ThrowIfCancellationRequested(); cancelRequest.Completion = new TaskCompletionSource>(); if (!_Channel.Writer.TryWrite(cancelRequest)) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cancelRequest.Completion.Task; } public Task Claim(ClaimRequest request) { CancellationToken.ThrowIfCancellationRequested(); var cts = new TaskCompletionSource(TaskCreationOptions .RunContinuationsAsynchronously); if (!_Channel.Writer.TryWrite(new PayoutRequest(cts, request))) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cts.Task; } public override Task StopAsync(CancellationToken cancellationToken) { _Channel?.Writer.Complete(); _subscriptions.Dispose(); return base.StopAsync(cancellationToken); } public Task MarkPaid(MarkPayoutRequest request) { CancellationToken.ThrowIfCancellationRequested(); var cts = new TaskCompletionSource(TaskCreationOptions .RunContinuationsAsynchronously); if (!_Channel.Writer.TryWrite(new InternalPayoutPaidRequest(cts, request))) throw new ObjectDisposedException(nameof(PullPaymentHostedService)); return cts.Task; } public PullPaymentsModel.PullPaymentModel.ProgressModel CalculatePullPaymentProgress(PullPaymentData pp, DateTimeOffset now) { var ppBlob = pp.GetBlob(); var ni = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true); var nfi = _currencyNameTable.GetNumberFormatInfo(ppBlob.Currency, true); var totalCompleted = pp.Payouts.Where(p => (p.State == PayoutState.Completed || p.State == PayoutState.InProgress) && p.IsInPeriod(pp, now)) .Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum().RoundToSignificant(ni.Divisibility); var period = pp.GetPeriod(now); var totalAwaiting = pp.Payouts.Where(p => (p.State == PayoutState.AwaitingPayment || p.State == PayoutState.AwaitingApproval) && p.IsInPeriod(pp, now)).Select(o => o.GetBlob(_jsonSerializerSettings).Amount).Sum().RoundToSignificant(ni.Divisibility); ; var currencyData = _currencyNameTable.GetCurrencyData(ppBlob.Currency, true); return new PullPaymentsModel.PullPaymentModel.ProgressModel() { CompletedPercent = (int)(totalCompleted / ppBlob.Limit * 100m), AwaitingPercent = (int)(totalAwaiting / ppBlob.Limit * 100m), AwaitingFormatted = totalAwaiting.ToString("C", nfi), Awaiting = totalAwaiting, Completed = totalCompleted, CompletedFormatted = totalCompleted.ToString("C", nfi), Limit = ppBlob.Limit.RoundToSignificant(currencyData.Divisibility), LimitFormatted = _displayFormatter.Currency(ppBlob.Limit, ppBlob.Currency), ResetIn = period?.End is { } nr ? ZeroIfNegative(nr - now).TimeString() : null, EndIn = pp.EndDate is { } end ? ZeroIfNegative(end - now).TimeString() : null, }; } public TimeSpan ZeroIfNegative(TimeSpan time) { if (time < TimeSpan.Zero) time = TimeSpan.Zero; return time; } class InternalPayoutPaidRequest { public InternalPayoutPaidRequest(TaskCompletionSource completionSource, MarkPayoutRequest request) { ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(completionSource); Completion = completionSource; Request = request; } public TaskCompletionSource Completion { get; set; } public MarkPayoutRequest Request { get; } } } public class MarkPayoutRequest { public enum PayoutPaidResult { Ok, NotFound, InvalidState } public string PayoutId { get; set; } public JObject Proof { get; set; } public PayoutState State { get; set; } = PayoutState.Completed; public static string GetErrorMessage(PayoutPaidResult result) { switch (result) { case PayoutPaidResult.NotFound: return "The payout is not found"; case PayoutPaidResult.Ok: return "Ok"; case PayoutPaidResult.InvalidState: return "The payout is not in a state that can be marked with the specified state"; default: throw new NotSupportedException(); } } } public class ClaimRequest { public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null) { return amount switch { null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null), null when destination.Amount is null => (null, null), null when destination.Amount != null => (null,destination.Amount), not null when destination.Amount is null => (null,amount), not null when destination.Amount != null && amount != destination.Amount && destination.IsExplicitAmountMinimum && payoutCurrency == "BTC" && ppCurrency == "SATS" && new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount => ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null), not null when destination.Amount != null && amount != destination.Amount && destination.IsExplicitAmountMinimum && !(payoutCurrency == "BTC" && ppCurrency == "SATS") && amount < destination.Amount => ($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null), not null when destination.Amount != null && amount != destination.Amount && !destination.IsExplicitAmountMinimum => ($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null), _ => (null, amount) }; } public static string GetErrorMessage(ClaimResult result) { switch (result) { case ClaimResult.Ok: break; case ClaimResult.Duplicate: return "This address is already used for another payout"; case ClaimResult.Expired: return "This pull payment is expired"; case ClaimResult.NotStarted: return "This pull payment has yet started"; case ClaimResult.Archived: return "This pull payment has been archived"; case ClaimResult.Overdraft: return "The payout amount overdraft the pull payment's limit"; case ClaimResult.AmountTooLow: return "The requested payout amount is too low"; case ClaimResult.PaymentMethodNotSupported: return "This payment method is not supported by the pull payment"; default: throw new NotSupportedException("Unsupported ClaimResult"); } return null; } public class ClaimResponse { public ClaimResponse(ClaimResult result, PayoutData payoutData = null) { Result = result; PayoutData = payoutData; } public ClaimResult Result { get; set; } public PayoutData PayoutData { get; set; } } public enum ClaimResult { Ok, Duplicate, Expired, Archived, NotStarted, Overdraft, AmountTooLow, PaymentMethodNotSupported, } public PaymentMethodId PaymentMethodId { get; set; } public string PullPaymentId { get; set; } public decimal? Value { get; set; } public IClaimDestination Destination { get; set; } public string StoreId { get; set; } public bool? PreApprove { get; set; } public JObject Metadata { get; set; } } public record PayoutEvent(PayoutEvent.PayoutEventType? Type, PayoutData Payout) { public enum PayoutEventType { Created, Approved, Updated } } }