mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
Greenfield: Allow marking payout status and payment proofs (#4244)
This allows external services to integrate with the payouts system to process payouts. This is also a step to allow plugins to provide payout processors. * It provides the payment proof through the greenfield payoust api. * It allows you to set the state of a payout outside of the usual flow: * When state is awaiting payment, allow setting to In progess or completed * When state is in progress, allow setting back to awaiting payment
This commit is contained in:
@@ -53,6 +53,16 @@ namespace BTCPayServer.Client
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken);
|
||||
return await HandleResponse<PayoutData>(response);
|
||||
}
|
||||
public virtual async Task<PayoutData> GetPullPaymentPayout(string pullPaymentId, string payoutId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/pull-payments/{HttpUtility.UrlEncode(pullPaymentId)}/payouts/{payoutId}", method: HttpMethod.Get), cancellationToken);
|
||||
return await HandleResponse<PayoutData>(response);
|
||||
}
|
||||
public virtual async Task<PayoutData> GetStorePayout(string storeId, string payoutId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payouts/{payoutId}", method: HttpMethod.Get), cancellationToken);
|
||||
return await HandleResponse<PayoutData>(response);
|
||||
}
|
||||
public virtual async Task<PayoutData> CreatePayout(string storeId, CreatePayoutThroughStoreRequest payoutRequest, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/stores/{storeId}/payouts", bodyPayload: payoutRequest, method: HttpMethod.Post), cancellationToken);
|
||||
@@ -69,7 +79,7 @@ namespace BTCPayServer.Client
|
||||
return await HandleResponse<PayoutData>(response);
|
||||
}
|
||||
|
||||
public async Task MarkPayoutPaid(string storeId, string payoutId,
|
||||
public virtual async Task MarkPayoutPaid(string storeId, string payoutId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(
|
||||
@@ -78,5 +88,14 @@ namespace BTCPayServer.Client
|
||||
method: HttpMethod.Post), cancellationToken);
|
||||
await HandleResponse(response);
|
||||
}
|
||||
public virtual async Task MarkPayout(string storeId, string payoutId, MarkPayoutRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var response = await _httpClient.SendAsync(
|
||||
CreateHttpRequest(
|
||||
$"api/v1/stores/{HttpUtility.UrlEncode(storeId)}/payouts/{HttpUtility.UrlEncode(payoutId)}/mark",
|
||||
method: HttpMethod.Post, bodyPayload: request), cancellationToken);
|
||||
await HandleResponse(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
BTCPayServer.Client/Models/MarkPayoutRequest.cs
Normal file
13
BTCPayServer.Client/Models/MarkPayoutRequest.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Client.Models;
|
||||
|
||||
public class MarkPayoutRequest
|
||||
{
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public PayoutState State { get; set; } = PayoutState.Completed;
|
||||
|
||||
public JObject? PaymentProof { get; set; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
@@ -29,5 +30,6 @@ namespace BTCPayServer.Client.Models
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public PayoutState State { get; set; }
|
||||
public int Revision { get; set; }
|
||||
public JObject PaymentProof { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,6 +797,100 @@ namespace BTCPayServer.Tests
|
||||
await AssertAPIError("invalid-state", async () => await client.MarkPayoutPaid(storeId, payout.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Integration", "Integration")]
|
||||
public async Task CanProcessPayoutsExternally()
|
||||
{
|
||||
using var tester = CreateServerTester();
|
||||
await tester.StartAsync();
|
||||
var acc = tester.NewAccount();
|
||||
acc.Register();
|
||||
await acc.CreateStoreAsync();
|
||||
var storeId = (await acc.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true)).StoreId;
|
||||
var client = await acc.CreateClient();
|
||||
var address = await tester.ExplorerNode.GetNewAddressAsync();
|
||||
var payout = await client.CreatePayout(storeId, new CreatePayoutThroughStoreRequest()
|
||||
{
|
||||
Approved = false,
|
||||
PaymentMethod = "BTC",
|
||||
Amount = 0.0001m,
|
||||
Destination = address.ToString()
|
||||
});
|
||||
await AssertAPIError("invalid-state", async () =>
|
||||
{
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = PayoutState.Completed});
|
||||
|
||||
});
|
||||
|
||||
await client.ApprovePayout(storeId, payout.Id, new ApprovePayoutRequest());
|
||||
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = PayoutState.Completed});
|
||||
Assert.Equal(PayoutState.Completed,(await client.GetStorePayouts(storeId,false)).Single(data => data.Id == payout.Id ).State );
|
||||
Assert.Null((await client.GetStorePayouts(storeId,false)).Single(data => data.Id == payout.Id ).PaymentProof );
|
||||
|
||||
foreach (var state in new []{ PayoutState.AwaitingApproval, PayoutState.Cancelled, PayoutState.Completed, PayoutState.AwaitingApproval, PayoutState.InProgress})
|
||||
{
|
||||
await AssertAPIError("invalid-state", async () =>
|
||||
{
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = state});
|
||||
});
|
||||
}
|
||||
payout = await client.CreatePayout(storeId, new CreatePayoutThroughStoreRequest()
|
||||
{
|
||||
Approved = true,
|
||||
PaymentMethod = "BTC",
|
||||
Amount = 0.0001m,
|
||||
Destination = address.ToString()
|
||||
});
|
||||
|
||||
payout = await client.GetStorePayout(storeId, payout.Id);
|
||||
Assert.NotNull(payout);
|
||||
Assert.Equal(PayoutState.AwaitingPayment, payout.State);
|
||||
await AssertValidationError(new []{"PaymentProof"}, async () =>
|
||||
{
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = PayoutState.Completed, PaymentProof = JObject.FromObject(new
|
||||
{
|
||||
test = "zyx"
|
||||
})});
|
||||
});
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = PayoutState.InProgress, PaymentProof = JObject.FromObject(new
|
||||
{
|
||||
proofType = "external-proof"
|
||||
})});
|
||||
payout = await client.GetStorePayout(storeId, payout.Id);
|
||||
Assert.NotNull(payout);
|
||||
Assert.Equal(PayoutState.InProgress, payout.State);
|
||||
Assert.True(payout.PaymentProof.TryGetValue("proofType", out var savedType));
|
||||
Assert.Equal("external-proof",savedType);
|
||||
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = PayoutState.AwaitingPayment, PaymentProof = JObject.FromObject(new
|
||||
{
|
||||
proofType = "external-proof",
|
||||
id="finality proof",
|
||||
link="proof.com"
|
||||
})});
|
||||
payout = await client.GetStorePayout(storeId, payout.Id);
|
||||
Assert.NotNull(payout);
|
||||
Assert.Null(payout.PaymentProof);
|
||||
Assert.Equal(PayoutState.AwaitingPayment, payout.State);
|
||||
|
||||
await client.MarkPayout(storeId, payout.Id, new MarkPayoutRequest() {State = PayoutState.Completed, PaymentProof = JObject.FromObject(new
|
||||
{
|
||||
proofType = "external-proof",
|
||||
id="finality proof",
|
||||
link="proof.com"
|
||||
})});
|
||||
payout = await client.GetStorePayout(storeId, payout.Id);
|
||||
Assert.NotNull(payout);
|
||||
Assert.Equal(PayoutState.Completed, payout.State);
|
||||
Assert.True(payout.PaymentProof.TryGetValue("proofType", out savedType));
|
||||
Assert.True(payout.PaymentProof.TryGetValue("link", out var savedLink));
|
||||
Assert.True(payout.PaymentProof.TryGetValue("id", out var savedId));
|
||||
Assert.Equal("external-proof",savedType);
|
||||
Assert.Equal("finality proof",savedId);
|
||||
Assert.Equal("proof.com",savedLink);
|
||||
}
|
||||
|
||||
private DateTimeOffset RoundSeconds(DateTimeOffset dateTimeOffset)
|
||||
{
|
||||
return new DateTimeOffset(dateTimeOffset.Year, dateTimeOffset.Month, dateTimeOffset.Day, dateTimeOffset.Hour, dateTimeOffset.Minute, dateTimeOffset.Second, dateTimeOffset.Offset);
|
||||
|
||||
@@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
|
||||
namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
@@ -243,6 +244,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
model.Destination = blob.Destination;
|
||||
model.PaymentMethod = p.PaymentMethodId;
|
||||
model.CryptoCode = p.GetPaymentMethodId().CryptoCode;
|
||||
model.PaymentProof = p.GetProofBlobJson();
|
||||
return model;
|
||||
}
|
||||
|
||||
@@ -417,19 +419,15 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
|
||||
|
||||
return base.Ok(payouts
|
||||
.Select(ToModel).ToList());
|
||||
.Select(ToModel).ToArray());
|
||||
}
|
||||
|
||||
[HttpDelete("~/api/v1/stores/{storeId}/payouts/{payoutId}")]
|
||||
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<IActionResult> CancelPayout(string storeId, string payoutId)
|
||||
{
|
||||
using var ctx = _dbContextFactory.CreateContext();
|
||||
var payout = await ctx.Payouts.GetPayout(payoutId, storeId);
|
||||
if (payout is null)
|
||||
return PayoutNotFound();
|
||||
await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId }));
|
||||
return Ok();
|
||||
var res= await _pullPaymentService.Cancel(new PullPaymentHostedService.CancelRequest(new[] { payoutId }, new []{storeId}));
|
||||
return MapResult(res.First().Value);
|
||||
}
|
||||
|
||||
[HttpPost("~/api/v1/stores/{storeId}/payouts/{payoutId}")]
|
||||
@@ -490,29 +488,67 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<IActionResult> MarkPayoutPaid(string storeId, string payoutId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await MarkPayout(storeId, payoutId, new Client.Models.MarkPayoutRequest()
|
||||
{
|
||||
State = PayoutState.Completed,
|
||||
PaymentProof = null
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("~/api/v1/stores/{storeId}/payouts/{payoutId}/mark")]
|
||||
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<IActionResult> MarkPayout(string storeId, string payoutId, Client.Models.MarkPayoutRequest request)
|
||||
{
|
||||
request ??= new();
|
||||
|
||||
if (request.State == PayoutState.Cancelled)
|
||||
{
|
||||
return await CancelPayout(storeId, payoutId);
|
||||
}
|
||||
if (request.PaymentProof is not null &&
|
||||
!BitcoinLikePayoutHandler.TryParseProofType(request.PaymentProof, out string _))
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.PaymentProof), "Payment proof must have a 'proofType' property");
|
||||
}
|
||||
if (!ModelState.IsValid)
|
||||
return this.CreateValidationError(ModelState);
|
||||
|
||||
var result = await _pullPaymentService.MarkPaid(new PayoutPaidRequest()
|
||||
var result = await _pullPaymentService.MarkPaid(new MarkPayoutRequest()
|
||||
{
|
||||
//TODO: Allow API to specify the manual proof object
|
||||
Proof = null,
|
||||
PayoutId = payoutId
|
||||
Proof = request.PaymentProof,
|
||||
PayoutId = payoutId,
|
||||
State = request.State
|
||||
});
|
||||
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();
|
||||
}
|
||||
return MapResult(result);
|
||||
}
|
||||
|
||||
[HttpGet("~/api/v1/stores/{storeId}/payouts/{payoutId}")]
|
||||
[Authorize(Policy = Policies.CanManagePullPayments, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
public async Task<IActionResult> GetStorePayout(string storeId, string payoutId)
|
||||
{
|
||||
await using var ctx = _dbContextFactory.CreateContext();
|
||||
|
||||
var payout = (await _pullPaymentService.GetPayouts(new PullPaymentHostedService.PayoutQuery()
|
||||
{
|
||||
Stores = new[] {storeId}, PayoutIds = new[] {payoutId}
|
||||
})).FirstOrDefault();
|
||||
|
||||
if (payout is null)
|
||||
return PayoutNotFound();
|
||||
return base.Ok(ToModel(payout));
|
||||
}
|
||||
|
||||
private IActionResult MapResult(MarkPayoutRequest.PayoutPaidResult result)
|
||||
{
|
||||
var errorMessage = MarkPayoutRequest.GetErrorMessage(result);
|
||||
return result switch
|
||||
{
|
||||
MarkPayoutRequest.PayoutPaidResult.Ok => Ok(),
|
||||
MarkPayoutRequest.PayoutPaidResult.InvalidState => this.CreateAPIError("invalid-state", errorMessage),
|
||||
MarkPayoutRequest.PayoutPaidResult.NotFound => PayoutNotFound(),
|
||||
_ => throw new NotSupportedException()
|
||||
};
|
||||
}
|
||||
private IActionResult PayoutNotFound()
|
||||
{
|
||||
return this.CreateAPIError(404, "payout-not-found", "The payout was not found");
|
||||
|
||||
@@ -1168,5 +1168,26 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
return GetFromActionResult<StoreRateConfiguration>(await GetController<GreenfieldStoreRateConfigurationController>().UpdateStoreRateConfiguration(request));
|
||||
}
|
||||
|
||||
public override async Task MarkPayoutPaid(string storeId, string payoutId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
HandleActionResult(await GetController<GreenfieldPullPaymentController>().MarkPayoutPaid(storeId, payoutId, cancellationToken));
|
||||
}
|
||||
|
||||
public override async Task MarkPayout(string storeId, string payoutId, MarkPayoutRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
HandleActionResult(await GetController<GreenfieldPullPaymentController>().MarkPayout(storeId, payoutId, request));
|
||||
}
|
||||
|
||||
public override async Task<PayoutData> GetPullPaymentPayout(string pullPaymentId, string payoutId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetFromActionResult<PayoutData>(await GetController<GreenfieldPullPaymentController>().GetPayout(pullPaymentId, payoutId));
|
||||
}
|
||||
|
||||
public override async Task<PayoutData> GetStorePayout(string storeId, string payoutId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetFromActionResult<PayoutData>(await GetController<GreenfieldPullPaymentController>().GetStorePayout(storeId, payoutId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ using Microsoft.AspNetCore.Routing;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Crypto;
|
||||
using Newtonsoft.Json;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
|
||||
namespace BTCPayServer
|
||||
{
|
||||
@@ -167,9 +168,9 @@ namespace BTCPayServer
|
||||
switch (payResult.Result)
|
||||
{
|
||||
case PayResult.Ok:
|
||||
await _pullPaymentHostedService.MarkPaid(new PayoutPaidRequest()
|
||||
await _pullPaymentHostedService.MarkPaid(new MarkPayoutRequest()
|
||||
{
|
||||
PayoutId = claimResponse.PayoutData.Id, Proof = new ManualPayoutProof { }
|
||||
PayoutId = claimResponse.PayoutData.Id, State = PayoutState.Completed
|
||||
});
|
||||
|
||||
return Ok(new LNUrlStatusResponse {Status = "OK"});
|
||||
@@ -178,7 +179,7 @@ namespace BTCPayServer
|
||||
new PullPaymentHostedService.CancelRequest(new string[]
|
||||
{
|
||||
claimResponse.PayoutData.Id
|
||||
}));
|
||||
}, null));
|
||||
|
||||
return Ok(new LNUrlStatusResponse
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
using PayoutData = BTCPayServer.Data.PayoutData;
|
||||
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
@@ -391,12 +392,12 @@ namespace BTCPayServer.Controllers
|
||||
continue;
|
||||
|
||||
var result =
|
||||
await _pullPaymentService.MarkPaid(new PayoutPaidRequest() { PayoutId = payout.Id });
|
||||
if (result != PayoutPaidRequest.PayoutPaidResult.Ok)
|
||||
await _pullPaymentService.MarkPaid(new MarkPayoutRequest() { PayoutId = payout.Id });
|
||||
if (result != MarkPayoutRequest.PayoutPaidResult.Ok)
|
||||
{
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = PayoutPaidRequest.GetErrorMessage(result),
|
||||
Message = MarkPayoutRequest.GetErrorMessage(result),
|
||||
Severity = StatusMessageModel.StatusSeverity.Error
|
||||
});
|
||||
return RedirectToAction(nameof(Payouts),
|
||||
@@ -418,7 +419,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
case "cancel":
|
||||
await _pullPaymentService.Cancel(
|
||||
new PullPaymentHostedService.CancelRequest(payoutIds));
|
||||
new PullPaymentHostedService.CancelRequest(payoutIds, new[] {storeId}));
|
||||
TempData.SetStatusMessageModel(new StatusMessageModel()
|
||||
{
|
||||
Message = "Payouts archived", Severity = StatusMessageModel.StatusSeverity.Success
|
||||
|
||||
@@ -107,10 +107,9 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
|
||||
}
|
||||
|
||||
ParseProofType(payout.Proof, out var raw, out var proofType);
|
||||
if (proofType == ManualPayoutProof.Type)
|
||||
if (proofType == PayoutTransactionOnChainBlob.Type)
|
||||
{
|
||||
return raw.ToObject<ManualPayoutProof>();
|
||||
}
|
||||
|
||||
var res = raw.ToObject<PayoutTransactionOnChainBlob>(
|
||||
JsonSerializer.Create(_jsonSerializerSettings.GetSerializer(paymentMethodId.CryptoCode)));
|
||||
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
|
||||
@@ -119,6 +118,8 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
|
||||
res.LinkTemplate = network.BlockExplorerLink;
|
||||
return res;
|
||||
}
|
||||
return raw.ToObject<ManualPayoutProof>();
|
||||
}
|
||||
|
||||
public static void ParseProofType(byte[] proof, out JObject obj, out string type)
|
||||
{
|
||||
@@ -130,10 +131,21 @@ public class BitcoinLikePayoutHandler : IPayoutHandler
|
||||
}
|
||||
|
||||
obj = JObject.Parse(Encoding.UTF8.GetString(proof));
|
||||
if (obj.TryGetValue("proofType", StringComparison.InvariantCultureIgnoreCase, out var proofType))
|
||||
{
|
||||
type = proofType.Value<string>();
|
||||
TryParseProofType(obj, out type);
|
||||
}
|
||||
|
||||
public static bool TryParseProofType(JObject proof, out string type)
|
||||
{
|
||||
type = null;
|
||||
if (proof is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!proof.TryGetValue("proofType", StringComparison.InvariantCultureIgnoreCase, out var proofType))
|
||||
return false;
|
||||
type = proofType.Value<string>();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void StartBackgroundCheck(Action<Type[]> subscribe)
|
||||
|
||||
@@ -13,7 +13,8 @@ namespace BTCPayServer.Data
|
||||
public HashSet<uint256> Candidates { get; set; } = new HashSet<uint256>();
|
||||
|
||||
[JsonIgnore] public string LinkTemplate { get; set; }
|
||||
public string ProofType { get; } = "PayoutTransactionOnChainBlob";
|
||||
public string ProofType { get; } = Type;
|
||||
public const string Type = "PayoutTransactionOnChainBlob";
|
||||
|
||||
[JsonIgnore]
|
||||
public string Link
|
||||
|
||||
@@ -125,12 +125,12 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (proofType == ManualPayoutProof.Type)
|
||||
if (proofType == PayoutLightningBlob.PayoutLightningBlobProofType)
|
||||
{
|
||||
return raw.ToObject<ManualPayoutProof>();
|
||||
return raw.ToObject<PayoutLightningBlob>();
|
||||
}
|
||||
|
||||
return raw.ToObject<PayoutLightningBlob>();
|
||||
return raw.ToObject<ManualPayoutProof>();
|
||||
}
|
||||
|
||||
public void StartBackgroundCheck(Action<Type[]> subscribe)
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
{
|
||||
public string PaymentHash { get; set; }
|
||||
|
||||
public string ProofType { get; } = "PayoutLightningBlob";
|
||||
public static string PayoutLightningBlobProofType = "PayoutLightningBlob";
|
||||
public string ProofType { get; } = PayoutLightningBlobProofType;
|
||||
public string Link { get; } = null;
|
||||
public string Id => PaymentHash;
|
||||
public string Preimage { get; set; }
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public class ManualPayoutProof : IPayoutProof
|
||||
@@ -6,5 +10,7 @@ namespace BTCPayServer.Data
|
||||
public string ProofType { get; } = Type;
|
||||
public string Link { get; set; }
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
@@ -31,6 +32,7 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
return PaymentMethodId.TryParse(data.PaymentMethodId, out var paymentMethodId) ? paymentMethodId : null;
|
||||
}
|
||||
|
||||
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
|
||||
@@ -40,12 +42,30 @@ namespace BTCPayServer.Data
|
||||
data.Blob = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode)));
|
||||
}
|
||||
|
||||
|
||||
public static JObject? GetProofBlobJson(this PayoutData data)
|
||||
{
|
||||
return data?.Proof is null ? null : JObject.Parse(Encoding.UTF8.GetString(data.Proof));
|
||||
}
|
||||
public static void SetProofBlob(this PayoutData data, IPayoutProof blob, JsonSerializerSettings settings)
|
||||
{
|
||||
if (blob is null)
|
||||
{
|
||||
data.Proof = null;
|
||||
return;
|
||||
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(blob, settings));
|
||||
}
|
||||
|
||||
data.SetProofBlob(settings is null
|
||||
? JObject.FromObject(blob)
|
||||
: JObject.FromObject(blob, JsonSerializer.Create(settings)));
|
||||
}
|
||||
public static void SetProofBlob(this PayoutData data, JObject blob)
|
||||
{
|
||||
if (blob is null)
|
||||
{
|
||||
data.Proof = null;
|
||||
return;
|
||||
}
|
||||
var bytes = Encoding.UTF8.GetBytes(blob.ToString(Formatting.None));
|
||||
// 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))
|
||||
{
|
||||
|
||||
@@ -20,6 +20,7 @@ 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;
|
||||
|
||||
@@ -53,15 +54,18 @@ namespace BTCPayServer.HostedServices
|
||||
PullPaymentId = pullPaymentId;
|
||||
}
|
||||
|
||||
public CancelRequest(string[] payoutIds)
|
||||
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<bool> Completion { get; set; }
|
||||
internal TaskCompletionSource<Dictionary<string, MarkPayoutRequest.PayoutPaidResult>> Completion { get; set; }
|
||||
}
|
||||
|
||||
public class PayoutApproval
|
||||
@@ -410,24 +414,32 @@ namespace BTCPayServer.HostedServices
|
||||
.FirstOrDefaultAsync();
|
||||
if (payout is null)
|
||||
{
|
||||
req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.NotFound);
|
||||
req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.NotFound);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payout.State != PayoutState.AwaitingPayment)
|
||||
if (payout.State == PayoutState.Completed)
|
||||
{
|
||||
req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.InvalidState);
|
||||
req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.InvalidState);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.Request.Proof != null)
|
||||
switch (req.Request.State)
|
||||
{
|
||||
payout.SetProofBlob(req.Request.Proof, null);
|
||||
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 = PayoutState.Completed;
|
||||
payout.State = req.Request.State;
|
||||
await ctx.SaveChangesAsync();
|
||||
req.Completion.SetResult(PayoutPaidRequest.PayoutPaidResult.Ok);
|
||||
req.Completion.SetResult(MarkPayoutRequest.PayoutPaidResult.Ok);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -604,24 +616,42 @@ namespace BTCPayServer.HostedServices
|
||||
.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<string, MarkPayoutRequest.PayoutPaidResult> 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();
|
||||
cancel.Completion.TrySetResult(true);
|
||||
cancel.Completion.TrySetResult(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -629,14 +659,13 @@ namespace BTCPayServer.HostedServices
|
||||
}
|
||||
}
|
||||
|
||||
public Task Cancel(CancelRequest cancelRequest)
|
||||
public Task<Dictionary<string, MarkPayoutRequest.PayoutPaidResult>> Cancel(CancelRequest cancelRequest)
|
||||
{
|
||||
CancellationToken.ThrowIfCancellationRequested();
|
||||
var cts = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
cancelRequest.Completion = cts;
|
||||
cancelRequest.Completion = new TaskCompletionSource<Dictionary<string, MarkPayoutRequest.PayoutPaidResult>>();
|
||||
if (!_Channel.Writer.TryWrite(cancelRequest))
|
||||
throw new ObjectDisposedException(nameof(PullPaymentHostedService));
|
||||
return cts.Task;
|
||||
return cancelRequest.Completion.Task;
|
||||
}
|
||||
|
||||
public Task<ClaimRequest.ClaimResponse> Claim(ClaimRequest request)
|
||||
@@ -656,10 +685,10 @@ namespace BTCPayServer.HostedServices
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PayoutPaidRequest.PayoutPaidResult> MarkPaid(PayoutPaidRequest request)
|
||||
public Task<MarkPayoutRequest.PayoutPaidResult> MarkPaid(MarkPayoutRequest request)
|
||||
{
|
||||
CancellationToken.ThrowIfCancellationRequested();
|
||||
var cts = new TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult>(TaskCreationOptions
|
||||
var cts = new TaskCompletionSource<MarkPayoutRequest.PayoutPaidResult>(TaskCreationOptions
|
||||
.RunContinuationsAsynchronously);
|
||||
if (!_Channel.Writer.TryWrite(new InternalPayoutPaidRequest(cts, request)))
|
||||
throw new ObjectDisposedException(nameof(PullPaymentHostedService));
|
||||
@@ -710,8 +739,8 @@ namespace BTCPayServer.HostedServices
|
||||
|
||||
class InternalPayoutPaidRequest
|
||||
{
|
||||
public InternalPayoutPaidRequest(TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> completionSource,
|
||||
PayoutPaidRequest request)
|
||||
public InternalPayoutPaidRequest(TaskCompletionSource<MarkPayoutRequest.PayoutPaidResult> completionSource,
|
||||
MarkPayoutRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(completionSource);
|
||||
@@ -719,12 +748,12 @@ namespace BTCPayServer.HostedServices
|
||||
Request = request;
|
||||
}
|
||||
|
||||
public TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> Completion { get; set; }
|
||||
public PayoutPaidRequest Request { get; }
|
||||
public TaskCompletionSource<MarkPayoutRequest.PayoutPaidResult> Completion { get; set; }
|
||||
public MarkPayoutRequest Request { get; }
|
||||
}
|
||||
}
|
||||
|
||||
public class PayoutPaidRequest
|
||||
public class MarkPayoutRequest
|
||||
{
|
||||
public enum PayoutPaidResult
|
||||
{
|
||||
@@ -734,7 +763,8 @@ namespace BTCPayServer.HostedServices
|
||||
}
|
||||
|
||||
public string PayoutId { get; set; }
|
||||
public ManualPayoutProof Proof { get; set; }
|
||||
public JObject? Proof { get; set; }
|
||||
public PayoutState State { get; set; }
|
||||
|
||||
public static string GetErrorMessage(PayoutPaidResult result)
|
||||
{
|
||||
@@ -745,7 +775,7 @@ namespace BTCPayServer.HostedServices
|
||||
case PayoutPaidResult.Ok:
|
||||
return "Ok";
|
||||
case PayoutPaidResult.InvalidState:
|
||||
return "The payout is not in a state that can be marked as paid";
|
||||
return "The payout is not in a state that can be marked with the specified state";
|
||||
default:
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
@@ -534,6 +534,37 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"get": {
|
||||
"summary": "Get Payout",
|
||||
"operationId": "GetStorePayout",
|
||||
"description": "Get payout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A specific payout of a store",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PayoutData"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Payout not found"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Stores (Payouts)"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"API_Key": [
|
||||
"btcpay.store.canmanagepullpayments"
|
||||
],
|
||||
"Basic": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"summary": "Approve Payout",
|
||||
"operationId": "PullPayments_ApprovePayout",
|
||||
@@ -696,6 +727,89 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/stores/{storeId}/payouts/{payoutId}/mark": {
|
||||
"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",
|
||||
"operationId": "PullPayments_MarkPayout",
|
||||
"description": "Mark a payout with a state",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"state": {
|
||||
"$ref": "#/components/schemas/PayoutState"
|
||||
},
|
||||
"paymentProof": {
|
||||
"$ref": "#/components/schemas/PayoutPaymentProof"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The payout has been set to the specified 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": [
|
||||
"Stores (Payouts)"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"API_Key": [
|
||||
"btcpay.store.canmanagepullpayments"
|
||||
],
|
||||
"Basic": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -753,6 +867,57 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"PayoutPaymentProof": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "Additional information around how the payout is being or has been paid out. The mentioned properties are all optional (except `proofType`) and you can introduce any json format you wish.",
|
||||
"properties": {
|
||||
"proofType": {
|
||||
"type": "string",
|
||||
"description": "The type of payment proof it is."
|
||||
}
|
||||
},
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
"link": {
|
||||
"type": "string",
|
||||
"format": "url",
|
||||
"nullable": true,
|
||||
"description": "A link to the proof of payout payment."
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "A unique identifier to the proof of payout payment."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"PayoutState": {
|
||||
"type": "string",
|
||||
"example": "AwaitingPayment",
|
||||
"description": "The state of the payout (`AwaitingApproval`, `AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)",
|
||||
"x-enumNames": [
|
||||
"AwaitingApproval",
|
||||
"AwaitingPayment",
|
||||
"InProgress",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
],
|
||||
"enum": [
|
||||
"AwaitingApproval",
|
||||
"AwaitingPayment",
|
||||
"InProgress",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
]
|
||||
},
|
||||
"PayoutData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -801,23 +966,10 @@
|
||||
"description": "The amount of the payout in the currency of the payment method (eg. BTC). This is only available from the `AwaitingPayment` state."
|
||||
},
|
||||
"state": {
|
||||
"type": "string",
|
||||
"example": "AwaitingPayment",
|
||||
"description": "The state of the payout (`AwaitingApproval`, `AwaitingPayment`, `InProgress`, `Completed`, `Cancelled`)",
|
||||
"x-enumNames": [
|
||||
"AwaitingApproval",
|
||||
"AwaitingPayment",
|
||||
"InProgress",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
],
|
||||
"enum": [
|
||||
"AwaitingApproval",
|
||||
"AwaitingPayment",
|
||||
"InProgress",
|
||||
"Completed",
|
||||
"Cancelled"
|
||||
]
|
||||
"$ref": "#/components/schemas/PayoutState"
|
||||
},
|
||||
"paymentProof": {
|
||||
"$ref": "#/components/schemas/PayoutPaymentProof"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user