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:
Andrew Camilleri
2022-11-15 10:40:57 +01:00
committed by GitHub
parent 17f3b4125b
commit 2d23819944
16 changed files with 500 additions and 91 deletions

View File

@@ -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);
}
}
}

View 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; }
}

View File

@@ -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; }
}
}

View File

@@ -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);

View File

@@ -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");

View File

@@ -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));
}
}
}

View File

@@ -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
{

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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; }

View File

@@ -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; }
}
}

View File

@@ -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))
{

View File

@@ -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();
}

View File

@@ -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"
}
}
},