diff --git a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs index 294c5a120..01a8f03e7 100644 --- a/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs +++ b/BTCPayServer.Client/BTCPayServerClient.PullPayments.cs @@ -57,5 +57,15 @@ namespace BTCPayServer.Client var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}", bodyPayload: request, method: HttpMethod.Post), cancellationToken); return await HandleResponse(response); } + + public async Task MarkPayoutPaid(string storeId, string payoutId, + CancellationToken cancellationToken = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest( + $"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}/mark-paid", + method: HttpMethod.Post), cancellationToken); + await HandleResponse(response); + } } } diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index acd0d84d2..3875172e2 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -529,6 +529,11 @@ namespace BTCPayServer.Tests // The payout should round the value of the payment down to the network of the payment method Assert.Equal(12.30322814m, payout.PaymentMethodAmount); Assert.Equal(12.303228134m, payout.Amount); + + await client.MarkPayoutPaid(storeId, payout.Id); + payout = (await client.GetPayouts(payout.PullPaymentId)).First(data => data.Id == payout.Id); + Assert.Equal(PayoutState.Completed, payout.State); + await AssertAPIError("invalid-state", async () => await client.MarkPayoutPaid(storeId, payout.Id)); } } diff --git a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs index f75b0878d..14d2e63f1 100644 --- a/BTCPayServer/Controllers/GreenField/PullPaymentController.cs +++ b/BTCPayServer/Controllers/GreenField/PullPaymentController.cs @@ -164,10 +164,10 @@ namespace BTCPayServer.Controllers.GreenField public async Task GetPullPayment(string pullPaymentId) { if (pullPaymentId is null) - return NotFound(); + return PullPaymentNotFound(); var pp = await _pullPaymentService.GetPullPayment(pullPaymentId); if (pp is null) - return NotFound(); + return PullPaymentNotFound(); return Ok(CreatePullPaymentData(pp)); } @@ -176,19 +176,33 @@ namespace BTCPayServer.Controllers.GreenField public async Task GetPayouts(string pullPaymentId, bool includeCancelled = false) { if (pullPaymentId is null) - return NotFound(); + return PullPaymentNotFound(); var pp = await _pullPaymentService.GetPullPayment(pullPaymentId); if (pp is null) - return NotFound(); - using var ctx = _dbContextFactory.CreateContext(); - var payouts = await ctx.Payouts.Where(p => p.PullPaymentDataId == pullPaymentId) - .Where(p => p.State != PayoutState.Cancelled || includeCancelled) - .ToListAsync(); + return PullPaymentNotFound(); + var payouts = pp.Payouts .Where(p => p.State != PayoutState.Cancelled || includeCancelled).ToList(); var cd = _currencyNameTable.GetCurrencyData(pp.GetBlob().Currency, false); return base.Ok(payouts .Select(p => ToModel(p, cd)).ToList()); } + [HttpGet("~/api/v1/pull-payments/{pullPaymentId}/payouts/{payoutId}")] + [AllowAnonymous] + public async Task GetPayout(string pullPaymentId, string payoutId) + { + if (payoutId is null) + return PayoutNotFound(); + await using var ctx = _dbContextFactory.CreateContext(); + var pp = await _pullPaymentService.GetPullPayment(pullPaymentId); + if (pp is null) + return PullPaymentNotFound(); + var payout = pp.Payouts.FirstOrDefault(p => p.Id == payoutId); + if(payout is null ) + return PayoutNotFound(); + var cd = _currencyNameTable.GetCurrencyData(payout.PullPaymentData.GetBlob().Currency, false); + return base.Ok(ToModel(payout, cd)); + } + private Client.Models.PayoutData ToModel(Data.PayoutData p, CurrencyData cd) { var blob = p.GetBlob(_serializerSettings); @@ -226,10 +240,10 @@ namespace BTCPayServer.Controllers.GreenField return this.CreateValidationError(ModelState); } - using var ctx = _dbContextFactory.CreateContext(); + await using var ctx = _dbContextFactory.CreateContext(); var pp = await ctx.PullPayments.FindAsync(pullPaymentId); if (pp is null) - return NotFound(); + return PullPaymentNotFound(); var ppBlob = pp.GetBlob(); IClaimDestination destination = await payoutHandler.ParseClaimDestination(paymentMethodId,request.Destination); if (destination is null) @@ -282,7 +296,7 @@ namespace BTCPayServer.Controllers.GreenField using var ctx = _dbContextFactory.CreateContext(); var pp = await ctx.PullPayments.FindAsync(pullPaymentId); if (pp is null || pp.StoreId != storeId) - return NotFound(); + return PullPaymentNotFound(); await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(pullPaymentId)); return Ok(); } @@ -294,7 +308,7 @@ namespace BTCPayServer.Controllers.GreenField using var ctx = _dbContextFactory.CreateContext(); var payout = await ctx.Payouts.GetPayout(payoutId, storeId); if (payout is null) - return NotFound(); + return PayoutNotFound(); await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId })); return Ok(); } @@ -314,7 +328,7 @@ namespace BTCPayServer.Controllers.GreenField return this.CreateValidationError(ModelState); var payout = await ctx.Payouts.GetPayout(payoutId, storeId, true, true); if (payout is null) - return NotFound(); + return PayoutNotFound(); RateResult rateResult = null; try { @@ -349,10 +363,46 @@ namespace BTCPayServer.Controllers.GreenField case PullPaymentHostedService.PayoutApproval.Result.OldRevision: return this.CreateAPIError("old-revision", errorMessage); case PullPaymentHostedService.PayoutApproval.Result.NotFound: - return NotFound(); + return PayoutNotFound(); default: throw new NotSupportedException(); } } + + [HttpPost("~/api/v1/stores/{storeId}/payouts/{payoutId}/mark-paid")] + [Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public async Task MarkPayoutPaid(string storeId, string payoutId, CancellationToken cancellationToken = default) + { + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + var result = await _pullPaymentService.MarkPaid(new PayoutPaidRequest() + { + //TODO: Allow API to specify the manual proof object + Proof = null, + PayoutId = payoutId + }); + var errorMessage = PayoutPaidRequest.GetErrorMessage(result); + switch (result) + { + case PayoutPaidRequest.PayoutPaidResult.Ok: + return Ok(); + case PayoutPaidRequest.PayoutPaidResult.InvalidState: + return this.CreateAPIError("invalid-state", errorMessage); + case PayoutPaidRequest.PayoutPaidResult.NotFound: + return PayoutNotFound(); + default: + throw new NotSupportedException(); + } + } + + private IActionResult PayoutNotFound() + { + return this.CreateAPIError(404, "payout-not-found", "The payout was not found"); + } + private IActionResult PullPaymentNotFound() + { + return this.CreateAPIError(404, "pullpayment-not-found", "The pull payment was not found"); + } } } diff --git a/BTCPayServer/Controllers/PullPaymentController.cs b/BTCPayServer/Controllers/PullPaymentController.cs index b633d71cd..c78f1c0f8 100644 --- a/BTCPayServer/Controllers/PullPaymentController.cs +++ b/BTCPayServer/Controllers/PullPaymentController.cs @@ -42,6 +42,7 @@ namespace BTCPayServer.Controllers _serializerSettings = serializerSettings; _payoutHandlers = payoutHandlers; } + [Route("pull-payments/{pullPaymentId}")] public async Task ViewPullPayment(string pullPaymentId) { diff --git a/BTCPayServer/Controllers/WalletsController.PullPayments.cs b/BTCPayServer/Controllers/WalletsController.PullPayments.cs index 8bc9c273d..5e87fe25a 100644 --- a/BTCPayServer/Controllers/WalletsController.PullPayments.cs +++ b/BTCPayServer/Controllers/WalletsController.PullPayments.cs @@ -295,6 +295,44 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(WalletSend), new {walletId, bip21}); } + case "mark-paid": + { + 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.AwaitingPayment) + continue; + + var result = await _pullPaymentService.MarkPaid(new PayoutPaidRequest() + { + PayoutId = payout.Id + }); + if (result != PayoutPaidRequest.PayoutPaidResult.Ok) + { + this.TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = PayoutPaidRequest.GetErrorMessage(result), + Severity = StatusMessageModel.StatusSeverity.Error + }); + return RedirectToAction(nameof(Payouts), new + { + walletId = walletId.ToString(), + pullPaymentId = vm.PullPaymentId + }); + } + } + + TempData.SetStatusMessageModel(new StatusMessageModel() + { + Message = "Payouts marked as paid", Severity = StatusMessageModel.StatusSeverity.Success + }); + return RedirectToAction(nameof(Payouts), + new {walletId = walletId.ToString(), pullPaymentId = vm.PullPaymentId}); + } + case "cancel": await _pullPaymentService.Cancel( new HostedServices.PullPaymentHostedService.CancelRequest(payoutIds)); @@ -376,7 +414,7 @@ namespace BTCPayServer.Controllers var handler = _payoutHandlers .FirstOrDefault(handler => handler.CanHandle(item.Payout.GetPaymentMethodId())); var proofBlob = handler?.ParseProof(item.Payout); - m.TransactionLink = proofBlob?.Link; + m.ProofLink = proofBlob?.Link; state.Payouts.Add(m); } diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs index 7a3208cb7..df0a6561f 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/BitcoinLikePayoutHandler.cs @@ -18,6 +18,7 @@ using NBitcoin.Payment; using NBitcoin.RPC; using NBXplorer.Models; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using NewBlockEvent = BTCPayServer.Events.NewBlockEvent; using PayoutData = BTCPayServer.Data.PayoutData; @@ -70,8 +71,16 @@ public class BitcoinLikePayoutHandler : IPayoutHandler if (payout?.Proof is null) return null; var paymentMethodId = payout.GetPaymentMethodId(); - var res = JsonConvert.DeserializeObject(Encoding.UTF8.GetString(payout.Proof), _jsonSerializerSettings.GetSerializer(paymentMethodId.CryptoCode)); + var raw = JObject.Parse(Encoding.UTF8.GetString(payout.Proof)); + if (raw.TryGetValue("proofType", StringComparison.InvariantCultureIgnoreCase, out var proofType) && + proofType.Value() == ManualPayoutProof.Type) + { + return raw.ToObject(); + } + var res = raw.ToObject( + JsonSerializer.Create(_jsonSerializerSettings.GetSerializer(paymentMethodId.CryptoCode))); var network = _btcPayNetworkProvider.GetNetwork(paymentMethodId.CryptoCode); + if (res == null) return null; res.LinkTemplate = network.BlockExplorerLink; return res; } diff --git a/BTCPayServer/Data/Payouts/BitcoinLike/PayoutTransactionOnChainBlob.cs b/BTCPayServer/Data/Payouts/BitcoinLike/PayoutTransactionOnChainBlob.cs index a9cc4c9d2..ebfd3dd24 100644 --- a/BTCPayServer/Data/Payouts/BitcoinLike/PayoutTransactionOnChainBlob.cs +++ b/BTCPayServer/Data/Payouts/BitcoinLike/PayoutTransactionOnChainBlob.cs @@ -13,6 +13,8 @@ namespace BTCPayServer.Data public HashSet Candidates { get; set; } = new HashSet(); [JsonIgnore] public string LinkTemplate { get; set; } + public string ProofType { get; } = "PayoutTransactionOnChainBlob"; + [JsonIgnore] public string Link { diff --git a/BTCPayServer/Data/Payouts/IClaimDestination.cs b/BTCPayServer/Data/Payouts/IClaimDestination.cs index f08998859..756f4faac 100644 --- a/BTCPayServer/Data/Payouts/IClaimDestination.cs +++ b/BTCPayServer/Data/Payouts/IClaimDestination.cs @@ -1,12 +1,8 @@ +using System; + namespace BTCPayServer.Data { public interface IClaimDestination { } - - public interface IPayoutProof - { - string Link { get; } - string Id { get; } - } } diff --git a/BTCPayServer/Data/Payouts/IPayoutProof.cs b/BTCPayServer/Data/Payouts/IPayoutProof.cs new file mode 100644 index 000000000..d77a6658b --- /dev/null +++ b/BTCPayServer/Data/Payouts/IPayoutProof.cs @@ -0,0 +1,9 @@ +namespace BTCPayServer.Data +{ + public interface IPayoutProof + { + string ProofType { get; } + string Link { get; } + string Id { get; } + } +} diff --git a/BTCPayServer/Data/Payouts/ManualPayoutProof.cs b/BTCPayServer/Data/Payouts/ManualPayoutProof.cs new file mode 100644 index 000000000..7ac8bf162 --- /dev/null +++ b/BTCPayServer/Data/Payouts/ManualPayoutProof.cs @@ -0,0 +1,10 @@ +namespace BTCPayServer.Data +{ + public class ManualPayoutProof : IPayoutProof + { + public static string Type = "ManualPayoutProof"; + public string ProofType { get; } = Type; + public string Link { get; set; } + public string Id { get; set; } + } +} diff --git a/BTCPayServer/Data/Payouts/PayoutExtensions.cs b/BTCPayServer/Data/Payouts/PayoutExtensions.cs index b1ed0bcb3..fe9ae4549 100644 --- a/BTCPayServer/Data/Payouts/PayoutExtensions.cs +++ b/BTCPayServer/Data/Payouts/PayoutExtensions.cs @@ -38,5 +38,17 @@ namespace BTCPayServer.Data { data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode))); } + + public static void SetProofBlob(this PayoutData data, ManualPayoutProof blob) + { + if(blob is null) + return; + var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob)); + // 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; + } + } } } diff --git a/BTCPayServer/HostedServices/PullPaymentHostedService.cs b/BTCPayServer/HostedServices/PullPaymentHostedService.cs index a52dd1d2c..0f6ab6496 100644 --- a/BTCPayServer/HostedServices/PullPaymentHostedService.cs +++ b/BTCPayServer/HostedServices/PullPaymentHostedService.cs @@ -123,8 +123,8 @@ namespace BTCPayServer.HostedServices public async Task GetPullPayment(string pullPaymentId) { - using var ctx = _dbContextFactory.CreateContext(); - return await ctx.PullPayments.FindAsync(pullPaymentId); + await using var ctx = _dbContextFactory.CreateContext(); + return await ctx.PullPayments.Include(data => data.Payouts).FirstOrDefaultAsync(data => data.Id == pullPaymentId); } class PayoutRequest @@ -206,6 +206,10 @@ namespace BTCPayServer.HostedServices { await HandleCancel(cancel); } + if (o is InternalPayoutPaidRequest paid) + { + await HandleMarkPaid(paid); + } foreach (IPayoutHandler payoutHandler in _payoutHandlers) { await payoutHandler.BackgroundCheck(o); @@ -291,6 +295,35 @@ namespace BTCPayServer.HostedServices 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(PayoutPaidRequest.PayoutPaidResult.NotFound); + return; + } + if (payout.State != PayoutState.AwaitingPayment) + { + req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.InvalidState); + return; + } + if (req.Request.Proof != null) + { + payout.SetProofBlob(req.Request.Proof); + } + payout.State = PayoutState.Completed; + await ctx.SaveChangesAsync(); + req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.Ok); + } + catch (Exception ex) + { + req.Completion.TrySetException(ex); + } + } private async Task HandleCreatePayout(PayoutRequest req) { @@ -444,6 +477,60 @@ namespace BTCPayServer.HostedServices _subscriptions.Dispose(); return base.StopAsync(cancellationToken); } + + public Task MarkPaid(PayoutPaidRequest 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; + } + + + class InternalPayoutPaidRequest + { + public InternalPayoutPaidRequest(TaskCompletionSource completionSource, PayoutPaidRequest request) + { + if (request == null) + throw new ArgumentNullException(nameof(request)); + if (completionSource == null) + throw new ArgumentNullException(nameof(completionSource)); + Completion = completionSource; + Request = request; + } + public TaskCompletionSource Completion { get; set; } + public PayoutPaidRequest Request { get; } + } + + } + + public class PayoutPaidRequest + { + public enum PayoutPaidResult + { + Ok, + NotFound, + InvalidState + } + public string PayoutId { get; set; } + public ManualPayoutProof Proof { get; set; } + + 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 as paid"; + default: + throw new NotSupportedException(); + } + } + } public class ClaimRequest diff --git a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs index b594dab39..e2e26e663 100644 --- a/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs +++ b/BTCPayServer/Models/WalletViewModels/PayoutsModel.cs @@ -23,7 +23,7 @@ namespace BTCPayServer.Models.WalletViewModels public string PullPaymentName { get; set; } public string Destination { get; set; } public string Amount { get; set; } - public string TransactionLink { get; set; } + public string ProofLink { get; set; } } public class PayoutStateSet diff --git a/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml b/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml index 4c6191c53..0ca5676c9 100644 --- a/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml +++ b/BTCPayServer/Views/PullPayment/ViewPullPayment.cshtml @@ -139,7 +139,7 @@
-

Awaiting Claims

+

Claims

@if (Model.Payouts.Any()) { diff --git a/BTCPayServer/Views/Wallets/Payouts.cshtml b/BTCPayServer/Views/Wallets/Payouts.cshtml index 9431492f5..324a0fd06 100644 --- a/BTCPayServer/Views/Wallets/Payouts.cshtml +++ b/BTCPayServer/Views/Wallets/Payouts.cshtml @@ -4,7 +4,7 @@ @inject IEnumerable PayoutHandlers; @{ Layout = "../Shared/_NavLayout.cshtml"; - ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, "Manage payouts", Context.GetStoreData().StoreName); + ViewData.SetActivePageAndTitle(WalletsNavPages.Payouts, $"Manage {Model.PaymentMethodId.ToPrettyString()} payouts", Context.GetStoreData().StoreName); } @section PageFootContent { @@ -55,6 +55,7 @@ case PayoutState.AwaitingPayment: stateActions.Add(("pay", "Send selected payouts")); stateActions.Add(("cancel", "Cancel selected payouts")); + stateActions.Add(("mark-paid", "Mark selected payouts as already paid")); break; }
@@ -122,9 +123,9 @@ @if (state.State != PayoutState.AwaitingApproval) { - @if (!(pp.TransactionLink is null)) + @if (!(pp.ProofLink is null)) { - Link + Link } } diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json index 8b8cf6734..d68d04346 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.pull-payments.json @@ -423,6 +423,67 @@ } ] } + }, + "/api/v1/stores/{storeId}/payouts/{payoutId}/mark-paid": { + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The ID of the store", + "schema": { "type": "string" } + }, + { + "name": "payoutId", + "in": "path", + "required": true, + "description": "The ID of the payout", + "schema": { "type": "string" } + } + ], + "post": { + + "summary": "Mark Payout as Paid", + "operationId": "PullPayments_MarkPayoutPaid", + "description": "Mark a payout as paid", + "responses": { + "200": { + "description": "The payout has been marked paid, transitioning to `Completed` state." + }, + "422": { + "description": "Unable to validate the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + }, + "400": { + "description": "Wellknown error codes are: `invalid-state`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "404": { + "description": "The payout is not found" + } + }, + "tags": [ "Pull payments (Management)" ], + "security": [ + { + "API Key": [ + "btcpay.store.canmanagepullpayments" + ], + "Basic": [] + } + ] + } } }, "components": {