refinement phase 8

This commit is contained in:
hamid
2026-07-13 21:49:50 +03:30
parent 7edadadea1
commit ef3024ef2f
35 changed files with 2505 additions and 71 deletions
@@ -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
@@ -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));
}
}
@@ -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);
@@ -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);
}
@@ -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>>;