refinement phase 8
This commit is contained in:
@@ -22,6 +22,11 @@ public interface IInvoiceRepository
|
||||
/// Null when none is issued yet.</summary>
|
||||
Task<InvoiceProjection?> GetByBookingIdAsync(long bookingId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tracked invoices still walking the مودیان state (<c>pending</c>/<c>submitted</c>), oldest first,
|
||||
/// capped at <paramref name="max"/> — the refinement-phase-8 reconciliation poll re-submits each until مودیان
|
||||
/// returns the 22-digit reference (or rejects it).</summary>
|
||||
Task<IReadOnlyList<Invoice>> GetUnregisteredMoadianInvoicesAsync(int max, CancellationToken cancellationToken);
|
||||
|
||||
Task AddInvoiceAsync(Invoice invoice, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads and increments the tracked counter row and returns the reserved value. The increment is
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Contracts.Invoices;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Invoices;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
|
||||
|
||||
/// <summary>
|
||||
/// Loads the unregistered invoices (tracked), (re)submits each through <see cref="IMoadianClient"/>, and applies
|
||||
/// the returned status/reference via the entity's guarded <c>ApplyMoadianResult</c>. One commit at the end. A
|
||||
/// transient مودیان error leaves the invoice <c>submitted</c> so the next tick retries — it is never marked failed
|
||||
/// on a transient fault.
|
||||
/// </summary>
|
||||
internal sealed class ReconcileMoadianInvoicesCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMoadianClient moadianClient)
|
||||
: IRequestHandler<ReconcileMoadianInvoicesCommand, OperationResult<ReconcileMoadianResult>>
|
||||
{
|
||||
private const int BatchSize = 100;
|
||||
|
||||
public async ValueTask<OperationResult<ReconcileMoadianResult>> Handle(
|
||||
ReconcileMoadianInvoicesCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var invoices = await unitOfWork.InvoiceRepository.GetUnregisteredMoadianInvoicesAsync(BatchSize, cancellationToken);
|
||||
|
||||
var registered = 0;
|
||||
foreach (var invoice in invoices)
|
||||
{
|
||||
var submission = new InvoiceSubmission(
|
||||
invoice.InvoiceNumber, invoice.BookingId, invoice.GrossIrr, invoice.PlatformCommissionIrr, invoice.VatIrr);
|
||||
|
||||
var result = await moadianClient.SubmitAsync(submission, cancellationToken);
|
||||
invoice.ApplyMoadianResult(result.Status, result.ReferenceNumber);
|
||||
|
||||
if (result.Status == MoadianStatus.Registered)
|
||||
registered++;
|
||||
}
|
||||
|
||||
if (invoices.Count > 0)
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<ReconcileMoadianResult>.SuccessResult(new ReconcileMoadianResult(invoices.Count, registered));
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Invoices.Commands.ReconcileMoadianInvoices;
|
||||
|
||||
/// <summary>
|
||||
/// Walks every invoice still <c>pending</c>/<c>submitted</c> with سامانه مودیان toward its registered 22-digit
|
||||
/// reference (refinement-phase-8, 6.5). Run by the Moadian reconciliation <c>IRecurringJob</c> (and available as an
|
||||
/// admin override); idempotent — a (re)submission of an already-registered invoice is a no-op, and the real
|
||||
/// <c>IMoadianClient</c> dedups on the invoice number so re-submitting a still-<c>submitted</c> one doubles as the
|
||||
/// status poll.
|
||||
/// </summary>
|
||||
public sealed record ReconcileMoadianInvoicesCommand : IRequest<OperationResult<ReconcileMoadianResult>>;
|
||||
|
||||
/// <param name="Scanned">How many unregistered invoices were examined this run.</param>
|
||||
/// <param name="Registered">How many reached <c>registered</c> this run.</param>
|
||||
public sealed record ReconcileMoadianResult(int Scanned, int Registered);
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
#nullable enable
|
||||
using System.Text.Json;
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Contracts.Payments;
|
||||
using Baya.Application.Contracts.Persistence;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Domain.Entities.Payouts;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the callback signature (an invalid signature mutates <b>nothing</b> — an irreversible-money callback
|
||||
/// fails closed), parses the per-transfer outcomes, and under <c>lock(payout:batch)</c> flips each matched
|
||||
/// <c>submitted</c> payout: <c>paid</c> posts the payout ledger + nets clawbacks (reusing <see cref="PayoutSettlement"/>),
|
||||
/// <c>failed</c> records the reason. Matched by <c>transfer_reference</c> (the bank track id the submit persisted).
|
||||
/// Idempotent by the forward-only status machine + the ledger-exists guard, so a replayed callback never
|
||||
/// double-pays or double-posts.
|
||||
/// </summary>
|
||||
internal sealed class ReconcilePayoutBatchCommandHandler(
|
||||
IUnitOfWork unitOfWork,
|
||||
IDistributedLock distributedLock,
|
||||
IWebhookVerifier webhookVerifier,
|
||||
IDateTimeProvider dateTimeProvider)
|
||||
: IRequestHandler<ReconcilePayoutBatchCommand, OperationResult<bool>>
|
||||
{
|
||||
public async ValueTask<OperationResult<bool>> Handle(ReconcilePayoutBatchCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var verification = webhookVerifier.Verify(request.Provider, request.Headers, request.RawBody);
|
||||
if (!verification.SignatureValid)
|
||||
return OperationResult<bool>.FailureResult("signature", "Invalid payout callback signature; nothing was reconciled.");
|
||||
|
||||
if (!TryParse(request.RawBody, out var batchId, out var outcomes))
|
||||
return OperationResult<bool>.FailureResult("body", "Malformed payout reconciliation callback.");
|
||||
|
||||
var now = dateTimeProvider.UtcNow.UtcDateTime;
|
||||
|
||||
await using var _ = await distributedLock.AcquireAsync("payout:batch", cancellationToken);
|
||||
|
||||
var batch = await unitOfWork.PayoutRepository.GetTrackedBatchAsync(batchId, cancellationToken);
|
||||
if (batch is null)
|
||||
return OperationResult<bool>.NotFoundResult("Payout batch not found.");
|
||||
|
||||
foreach (var outcome in outcomes)
|
||||
{
|
||||
var payout = batch.Payouts.FirstOrDefault(p =>
|
||||
p.TransferReference is not null &&
|
||||
string.Equals(p.TransferReference, outcome.TransferReference, StringComparison.Ordinal));
|
||||
|
||||
// Only a still-submitted payout is actionable; an already-paid/failed row (replay) is skipped.
|
||||
if (payout is null || payout.Status != PayoutStatus.Submitted)
|
||||
continue;
|
||||
|
||||
if (outcome.Paid)
|
||||
{
|
||||
payout.MarkPaid(now);
|
||||
await PayoutSettlement.PostPayoutLedgerAsync(unitOfWork, payout, now, cancellationToken);
|
||||
await PayoutSettlement.RecoverClawbacksAsync(unitOfWork, payout, now, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
payout.MarkFailed(outcome.FailureReason ?? "provider_declined");
|
||||
}
|
||||
}
|
||||
|
||||
batch.RecomputeSettlement(now);
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return OperationResult<bool>.SuccessResult(true);
|
||||
}
|
||||
|
||||
private static bool TryParse(string rawBody, out long batchId, out List<TransferOutcome> outcomes)
|
||||
{
|
||||
batchId = 0;
|
||||
outcomes = [];
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(rawBody);
|
||||
var root = doc.RootElement;
|
||||
if (!root.TryGetProperty("batch_id", out var batchEl) || !batchEl.TryGetInt64(out batchId))
|
||||
return false;
|
||||
|
||||
if (root.TryGetProperty("transfers", out var transfers) && transfers.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in transfers.EnumerateArray())
|
||||
{
|
||||
var reference = t.TryGetProperty("transfer_reference", out var r) ? r.GetString() : null;
|
||||
if (string.IsNullOrEmpty(reference))
|
||||
continue;
|
||||
|
||||
var status = t.TryGetProperty("status", out var s) ? s.GetString() : null;
|
||||
var reason = t.TryGetProperty("failure_reason", out var fr) ? fr.GetString() : null;
|
||||
outcomes.Add(new TransferOutcome(reference, string.Equals(status, "paid", StringComparison.OrdinalIgnoreCase), reason));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct TransferOutcome(string TransferReference, bool Paid, string? FailureReason);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#nullable enable
|
||||
using Baya.Application.Models.Common;
|
||||
using Mediator;
|
||||
|
||||
namespace Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
|
||||
|
||||
/// <summary>
|
||||
/// The async PAYA/SATNA <b>reconciliation callback</b> (refinement-phase-8, 6.3). A real payout rail accepts a
|
||||
/// transfer as <c>submitted</c> (a track id, but the money hasn't confirmed) and later calls back with the settled
|
||||
/// outcome; this command flips each <c>submitted</c> payout <c>paid</c>/<c>failed</c>, posting the payout ledger +
|
||||
/// netting clawbacks on a paid one (the same settlement the batch execute would have done had the mock rail
|
||||
/// collapsed the step). Authenticated by <b>signature</b> (not a user session) and idempotent — a replayed callback
|
||||
/// re-driving an already-<c>paid</c>/<c>failed</c> payout is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="Provider">The rail's <c>provider_code</c> (e.g. <c>jibit</c>) — selects the signing secret.</param>
|
||||
/// <param name="Headers">The raw callback headers (carry the signature).</param>
|
||||
/// <param name="RawBody">The verbatim callback body (HMAC-verified, then parsed for the per-transfer outcomes).</param>
|
||||
public sealed record ReconcilePayoutBatchCommand(
|
||||
string Provider,
|
||||
IReadOnlyDictionary<string, string> Headers,
|
||||
string RawBody) : IRequest<OperationResult<bool>>;
|
||||
Reference in New Issue
Block a user