Mark Payouts as Paid (#2539)

* Mark Payouts as Paid

This PR allows users to mark payouts as paid manually through the UI  and through the API. It also sets up the payout proof system to be able store a manual proof that will in a later PR allow you to specify a proof of payment (link or text)

* add docs, test and greenfield client

* remove extra docs stuff

* Update BTCPayServer.Tests/GreenfieldAPITests.cs

Co-authored-by: britttttk <39231115+britttttk@users.noreply.github.com>

* clean up pull payment/payouts fetch code

* Ensure payoutis are retrieved with pull payment

Co-authored-by: britttttk <39231115+britttttk@users.noreply.github.com>
This commit is contained in:
Andrew Camilleri 2021-06-10 11:43:45 +02:00 committed by GitHub
parent f1f3dffc97
commit cd9feccf6e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 320 additions and 29 deletions

View file

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

View file

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

View file

@ -164,10 +164,10 @@ namespace BTCPayServer.Controllers.GreenField
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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");
}
}
}

View file

@ -42,6 +42,7 @@ namespace BTCPayServer.Controllers
_serializerSettings = serializerSettings;
_payoutHandlers = payoutHandlers;
}
[Route("pull-payments/{pullPaymentId}")]
public async Task<IActionResult> ViewPullPayment(string pullPaymentId)
{

View file

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

View file

@ -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<PayoutTransactionOnChainBlob>(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<string>() == ManualPayoutProof.Type)
{
return raw.ToObject<ManualPayoutProof>();
}
var res = raw.ToObject<PayoutTransactionOnChainBlob>(
JsonSerializer.Create(_jsonSerializerSettings.GetSerializer(paymentMethodId.CryptoCode)));
var network = _btcPayNetworkProvider.GetNetwork<BTCPayNetwork>(paymentMethodId.CryptoCode);
if (res == null) return null;
res.LinkTemplate = network.BlockExplorerLink;
return res;
}

View file

@ -13,6 +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";
[JsonIgnore]
public string Link
{

View file

@ -1,12 +1,8 @@
using System;
namespace BTCPayServer.Data
{
public interface IClaimDestination
{
}
public interface IPayoutProof
{
string Link { get; }
string Id { get; }
}
}

View file

@ -0,0 +1,9 @@
namespace BTCPayServer.Data
{
public interface IPayoutProof
{
string ProofType { get; }
string Link { get; }
string Id { get; }
}
}

View file

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

View file

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

View file

@ -123,8 +123,8 @@ namespace BTCPayServer.HostedServices
public async Task<Data.PullPaymentData> 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<PayoutPaidRequest.PayoutPaidResult> MarkPaid(PayoutPaidRequest request)
{
CancellationToken.ThrowIfCancellationRequested();
var cts = new TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult>(TaskCreationOptions.RunContinuationsAsynchronously);
if (!_Channel.Writer.TryWrite(new InternalPayoutPaidRequest(cts, request)))
throw new ObjectDisposedException(nameof(PullPaymentHostedService));
return cts.Task;
}
class InternalPayoutPaidRequest
{
public InternalPayoutPaidRequest(TaskCompletionSource<PayoutPaidRequest.PayoutPaidResult> 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<PayoutPaidRequest.PayoutPaidResult> 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

View file

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

View file

@ -139,7 +139,7 @@
<div class="row">
<div class="col">
<div class="bg-tile h-100 m-0 p-3 p-sm-5">
<h2 class="h4 mb-3">Awaiting Claims</h2>
<h2 class="h4 mb-3">Claims</h2>
<div class="table-responsive">
@if (Model.Payouts.Any())
{

View file

@ -4,7 +4,7 @@
@inject IEnumerable<IPayoutHandler> 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;
}
<div class="tab-pane @(index == 0 ? "active" : "") " id="@state.State" role="tabpanel">
@ -122,9 +123,9 @@
@if (state.State != PayoutState.AwaitingApproval)
{
<td class="text-end">
@if (!(pp.TransactionLink is null))
@if (!(pp.ProofLink is null))
{
<a class="transaction-link" href="@pp.TransactionLink">Link</a>
<a class="transaction-link" href="@pp.ProofLink">Link</a>
}
</td>
}

View file

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