frontend phase 5 & backend phase 12

This commit is contained in:
hamid
2026-07-09 03:05:14 +03:30
parent 465f75c29e
commit dc64472631
98 changed files with 11847 additions and 136 deletions
@@ -2,25 +2,90 @@
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// The Buy-Now-Pay-Later provider seam (SnappPay / Tara / …). <b>b12 owns the real, full definition of this
/// seam</b>; b11 introduces this minimal shape (revert/update) plus a thin local mock so the <c>bnpl_revert</c>
/// refund path is exercised before b12 merges. Money <b>always</b> flows <c>customer ↔ provider ↔ Balinyaar</c>
/// — never nurse→customer or Balinyaar→customer direct. A <b>full</b> reversal is <see cref="RevertAsync"/>; a
/// <b>partial/shortened</b> one is <see cref="UpdateAsync"/> with a strictly-lower amount. Every amount is IRR
/// <c>long</c>; the <paramref name="idempotencyKey"/> makes a retried revert a no-op rather than a double refund.
/// The Buy-Now-Pay-Later provider seam (SnappPay / Digipay / Tara / Torob Pay) — the SnappPay-superset verb set
/// that drives the full <c>eligible → token_issued → verified → settled → reverted/cancelled</c> state machine.
/// <b>One impl per <c>provider_code</c></b>, selected through <see cref="IBnplProviderResolver"/>. A BNPL order
/// is, in our books, a card payment landing net-of-fee: the <b>settle</b> returns the net amount + the provider's
/// merchant commission (read from the actual settlement, never hardcoded) + a <b>per-transaction</b>
/// <c>settled_at</c> that is never assumed instant. Money <b>always</b> flows <c>customer ↔ provider ↔
/// Balinyaar</c>; every amount is IRR <c>long</c> (Toman is converted only in a real adapter's boundary via
/// <see cref="ICurrencyNormalizer"/>); an <c>idempotencyKey</c> makes a retried settle/revert a no-op.
/// </summary>
public interface IBnplProvider
{
/// <summary>Full reversal of a BNPL order back through the provider.</summary>
/// <summary>Checks whether the customer may finance this order — records <c>eligibility_status</c>.</summary>
ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default);
/// <summary>Issues the <c>external_payment_token</c> + the redirect URL that starts the customer's order.</summary>
ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Confirms the order — echoes the amount so the handler can re-check it server-side (never trust the callback alone).</summary>
ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default);
/// <summary>Settles the full order to Balinyaar net of the provider commission — returns the net amount, the
/// commission (a platform expense), and the per-transaction <c>settled_at</c>.</summary>
ValueTask<BnplSettleResult> SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>The provider's current view of the order (reconciliation/support).</summary>
ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default);
/// <summary>Cancels an order before settle.</summary>
ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Full reversal of a settled BNPL order back through the provider (a <c>bnpl_revert</c> refund).</summary>
ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
/// <summary>Partial revert — reduces the order to a strictly-lower <paramref name="newAmountIrr"/>.</summary>
ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default);
}
/// <summary>The outcome of a BNPL revert/update.</summary>
/// <summary>The eligibility outcome + plan summary the client shows (or falls back to card on).</summary>
/// <param name="EligibilityStatus">A <c>BnplEligibilityStatus</c> code (<c>eligible</c>/<c>not_eligible</c>/<c>ceiling_exceeded</c>).</param>
/// <param name="InstallmentCount">Informational plan length (default 4 — owned by the provider).</param>
/// <param name="CreditCeilingIrr">The customer's provider credit ceiling (IRR), when known.</param>
/// <param name="PlanSummary">Human copy, e.g. "4 interest-free installments, provider-financed".</param>
public sealed record BnplEligibilityResult(
string EligibilityStatus,
int InstallmentCount,
long? CreditCeilingIrr,
string PlanSummary);
/// <param name="Status">Whether a token was issued.</param>
/// <param name="ExternalPaymentToken">The deterministic token persisted for verify/settle/revert.</param>
/// <param name="RedirectUrl">Where the customer is sent to complete the BNPL order.</param>
/// <param name="ExternalTransactionId">The provider's order/txn id, when issued.</param>
public sealed record BnplTokenResult(
PaymentProviderStatus Status,
string ExternalPaymentToken,
string RedirectUrl,
string? ExternalTransactionId);
/// <param name="Status">The re-verified outcome — only <see cref="PaymentProviderStatus.Succeeded"/> confirms.</param>
/// <param name="OrderAmountIrr">The order amount the provider reports, re-checked against the stored order.</param>
/// <param name="ExternalTransactionId">The provider's order/txn id, when returned.</param>
public sealed record BnplVerifyResult(
PaymentProviderStatus Status,
long OrderAmountIrr,
string? ExternalTransactionId);
/// <param name="Status">Whether the provider settled.</param>
/// <param name="SettledAmountIrr">Net of the provider commission actually received (IRR).</param>
/// <param name="BnplCommissionIrr">The provider's merchant discount (IRR) = a platform expense.</param>
/// <param name="SettledAt">The per-transaction settlement time — <b>nullable</b>, contract-defined, never assumed instant.</param>
/// <param name="ExternalTransactionId">The provider's order/txn id, when returned.</param>
public sealed record BnplSettleResult(
PaymentProviderStatus Status,
long SettledAmountIrr,
long BnplCommissionIrr,
DateTime? SettledAt,
string? ExternalTransactionId);
/// <param name="ProviderStatus">The provider's own status string for the order.</param>
public sealed record BnplStatusResult(string ProviderStatus);
/// <summary>The outcome of a BNPL revert/update/cancel.</summary>
/// <param name="Status">Whether the provider accepted the reversal.</param>
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund.</param>
/// <param name="ExternalRevertReference">The provider's revert id, persisted on the refund + the BNPL row.</param>
/// <param name="ProviderCommissionReversedAmount">The provider's own commission it returned — <b>nullable</b>,
/// reconciled from the response, never hardcoded (some providers keep their fee on a refund).</param>
public sealed record BnplRevertResult(
@@ -0,0 +1,14 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// Resolves the <see cref="IBnplProvider"/> impl for a <c>provider_code</c> (<c>snapppay</c>/<c>digipay</c>/
/// <c>tara</c>/<c>torobpay</c>) — config-driven selection, <b>never an <c>if (mock)</c> branch in a handler</b>.
/// A real system maps each code to its concrete adapter (<c>SnappPayBnplProvider</c>, <c>DigipayBnplProvider</c>,
/// …); this phase ships the mock behind every known code and a single active route. Returns <c>null</c> for an
/// unknown/unconfigured code so the handler can reject it cleanly rather than throw.
/// </summary>
public interface IBnplProviderResolver
{
IBnplProvider? Resolve(string providerCode);
}
@@ -0,0 +1,17 @@
#nullable enable
namespace Baya.Application.Contracts.Payments;
/// <summary>
/// Toman ↔ Rial (IRR) conversion <b>at the provider boundary only</b> — the provider speaks Toman, our books
/// speak IRR. Conversion happens <b>solely</b> here, never internally: everything inside the domain is already
/// IRR <c>long</c>. The mock multiplies Toman ×10 → IRR (and divides back for display); the multiplier is
/// config-driven so a currency redenomination is a config change, not a code change.
/// </summary>
public interface ICurrencyNormalizer
{
/// <summary>Normalizes an amount in <paramref name="currency"/> (<c>IRR</c>/<c>TOMAN</c>) to IRR.</summary>
long ToIrr(long amount, string currency);
/// <summary>Converts an IRR amount back to Toman for display only.</summary>
long ToDisplayToman(long amountIrr);
}
@@ -0,0 +1,43 @@
#nullable enable
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Application.Contracts.Persistence;
/// <summary>
/// The BNPL aggregate — one <c>bnpl_transactions</c> row per order (1:1 with its <c>payment_transaction</c>).
/// Writes load tracked rows; reads project to DTOs. The ledger legs themselves are appended through
/// <see cref="IPaymentRepository.AddLedgerEntriesAsync"/> (b10's helper); this repo owns only the BNPL row, the
/// order-context read, and the settle-ledger idempotency probe. Money is IRR <c>long</c>. The
/// <c>UNIQUE(payment_transaction_id)</c> guard is the structural one-BNPL-row-per-order backstop.
/// </summary>
public interface IBnplRepository
{
/// <summary>Everything eligibility/initiate need for a booking request (owner + mobile + status + gross).
/// Null when the request does not exist.</summary>
Task<BnplOrderContext?> GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken);
Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken);
/// <summary>The tracked BNPL row for a payment transaction — the 1:1 lookup eligibility/initiate upsert on.
/// Null when none exists yet.</summary>
Task<BnplTransaction?> GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken);
Task<BnplTransaction?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken);
/// <summary>The tracked BNPL row for a booking request (via its payment transaction) — the find-or-create
/// anchor eligibility and initiate share so a request has at most one BNPL order. Null when none exists.</summary>
Task<BnplTransaction?> GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken);
/// <summary>The tracked BNPL row carrying <paramref name="externalPaymentToken"/> — the callback dispatch
/// resolves the order from the token in the payload. Null when absent.</summary>
Task<BnplTransaction?> GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken);
/// <summary>Whether the balanced settle group already exists for this BNPL row — makes the settle post
/// idempotent so a replayed settle never writes a second net-of-fee group.</summary>
Task<bool> SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken);
/// <summary>The BNPL order view + the owning customer's user id for tenancy, and the linked refund's ETA
/// when reverted. Null when absent.</summary>
Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
}
@@ -34,4 +34,8 @@ public interface IRefundRepository
/// <summary>The customer-facing status of a single refund, with the owning customer's user id for tenancy.
/// Null when absent.</summary>
Task<RefundStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken);
/// <summary>The provider revert reference on a <c>bnpl_revert</c> refund — the BNPL revert path records it
/// as <c>revert_transaction_id</c> on the <c>bnpl_transactions</c> row. Null when absent.</summary>
Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken);
}
@@ -21,6 +21,7 @@ public interface IUnitOfWork
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
Task CommitAsync();
ValueTask RollBackAsync();
}
@@ -0,0 +1,62 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
namespace Baya.Application.Features.Bnpl;
/// <summary>
/// The single find-or-create for a BNPL order's skeleton — the pending <c>payment_transaction</c> (bnpl gateway)
/// plus the 1:1 <c>bnpl_transactions</c> row — shared by eligibility and initiate so a booking request has at
/// most one BNPL order. The <c>UNIQUE(payment_transaction_id)</c> guard is the structural backstop; this helper
/// is the friendly pre-check. It commits the two rows (the payment transaction id must exist before the BNPL FK)
/// and returns the tracked BNPL row; the caller owns the provider call, the state transition and any lock.
/// </summary>
internal static class BnplOrderInitializer
{
public static async Task<BnplTransaction> EnsureAsync(
IUnitOfWork unitOfWork,
BnplOrderContext context,
long gatewayId,
string providerCode,
string merchantOfRecord,
long orderAmountIrr,
string currency,
string? ipAddress,
CancellationToken cancellationToken)
{
var existing = await unitOfWork.BnplRepository.GetTrackedByBookingRequestIdAsync(context.RequestId, cancellationToken);
if (existing is not null)
return existing;
// A BNPL order is a card payment landing net-of-fee, so it rides the same payment_transactions rail
// (booking_id null until settle binds it, exactly like the card path).
var transaction = new PaymentTransaction
{
BookingRequestId = context.RequestId,
CustomerId = context.CustomerId,
GatewayId = gatewayId,
Amount = orderAmountIrr,
Currency = "IRR",
IsInstallment = true,
IpAddress = ipAddress
};
await unitOfWork.PaymentRepository.AddTransactionAsync(transaction, cancellationToken);
await unitOfWork.CommitAsync();
var bnpl = new BnplTransaction
{
PaymentTransactionId = transaction.Id,
ProviderCode = providerCode,
MerchantOfRecord = merchantOfRecord,
OrderAmountIrr = orderAmountIrr,
Currency = currency,
EligibilityStatus = null
};
await unitOfWork.BnplRepository.AddAsync(bnpl, cancellationToken);
await unitOfWork.CommitAsync();
return bnpl;
}
}
@@ -0,0 +1,140 @@
#nullable enable
using System;
using System.Collections.Generic;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
internal sealed class HandleBnplCallbackCommandHandler(
ISender sender,
IUnitOfWork unitOfWork,
IWebhookVerifier webhookVerifier,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<HandleBnplCallbackCommand, OperationResult<BnplCallbackResult>>
{
private enum CallbackAction { None, Verify, Settle, Revert }
public async ValueTask<OperationResult<BnplCallbackResult>> Handle(HandleBnplCallbackCommand request, CancellationToken cancellationToken)
{
var headers = request.Headers ?? new Dictionary<string, string>();
var rawBody = request.RawBody ?? string.Empty;
var verification = webhookVerifier.Verify(request.Provider, headers, rawBody);
var now = dateTimeProvider.UtcNow.UtcDateTime;
// Dedup FIRST on the idempotency key: a duplicate replay never mutates money state again.
if (!string.IsNullOrEmpty(verification.ExternalEventId))
{
var duplicate = await unitOfWork.PaymentRepository.GetWebhookEventByKeyAsync(request.Provider, verification.ExternalEventId, cancellationToken);
if (duplicate is not null)
return Success(duplicate.ProcessingStatus, isDuplicate: true);
}
var webhookEvent = new PaymentWebhookEvent
{
ProviderCode = request.Provider,
ExternalEventId = verification.ExternalEventId,
EventType = verification.EventType,
SignatureValid = verification.SignatureValid,
PayloadJson = rawBody,
ReceivedAt = now
};
// An unverified-signature callback mutates nothing — stored ignored and stopped.
if (!verification.SignatureValid)
{
webhookEvent.MarkIgnored(now);
return await PersistNewEventAsync(webhookEvent, cancellationToken);
}
var action = ResolveAction(verification.EventType);
if (action == CallbackAction.None || string.IsNullOrEmpty(verification.GatewayReferenceCode))
{
// Nothing to drive (unknown event / no token) — acknowledged, no money moves.
webhookEvent.MarkProcessed(null, now);
return await PersistNewEventAsync(webhookEvent, cancellationToken);
}
var bnpl = await unitOfWork.BnplRepository.GetTrackedByTokenAsync(verification.GatewayReferenceCode!, cancellationToken);
if (bnpl is null)
{
// No matching order for the token — retryable (failed), never a silent success.
webhookEvent.MarkFailed(now);
return await PersistNewEventAsync(webhookEvent, cancellationToken);
}
// Claim the idempotency key first (inside the same context that mutates state). A racing duplicate insert
// loses on the unique index and is treated as a no-op replay.
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
await unitOfWork.RollBackAsync();
return Success(WebhookProcessingStatus.Processed, isDuplicate: true);
}
var dispatched = await DispatchAsync(action, bnpl.Id, verification.ExternalEventId, rawBody, cancellationToken);
if (dispatched)
webhookEvent.MarkProcessed(bnpl.PaymentTransactionId, now);
else
webhookEvent.MarkFailed(now);
await unitOfWork.CommitAsync();
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
}
private async Task<bool> DispatchAsync(CallbackAction action, long bnplTransactionId, string eventId, string rawBody, CancellationToken cancellationToken)
{
var result = action switch
{
CallbackAction.Verify => (await sender.Send(new VerifyBnplOrderCommand(bnplTransactionId, rawBody), cancellationToken)).IsSuccess,
CallbackAction.Settle => (await sender.Send(new SettleBnplOrderCommand(bnplTransactionId, $"bnpl-settle-{bnplTransactionId}-{eventId}", rawBody), cancellationToken)).IsSuccess,
CallbackAction.Revert => (await sender.Send(new RevertBnplOrderCommand(bnplTransactionId, RefundPercentage: 1m, TicketId: null, ReasonNotes: "provider_callback", rawBody), cancellationToken)).IsSuccess,
_ => false
};
return result;
}
private static CallbackAction ResolveAction(string eventType)
{
if (eventType.Contains("settl", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Settle;
if (eventType.Contains("verif", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Verify;
if (eventType.Contains("revert", StringComparison.OrdinalIgnoreCase) || eventType.Contains("refund", StringComparison.OrdinalIgnoreCase))
return CallbackAction.Revert;
return CallbackAction.None;
}
private async Task<OperationResult<BnplCallbackResult>> PersistNewEventAsync(PaymentWebhookEvent webhookEvent, CancellationToken cancellationToken)
{
await unitOfWork.PaymentRepository.AddWebhookEventAsync(webhookEvent, cancellationToken);
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
await unitOfWork.RollBackAsync();
return Success(webhookEvent.ProcessingStatus, isDuplicate: true);
}
return Success(webhookEvent.ProcessingStatus, isDuplicate: false);
}
private static OperationResult<BnplCallbackResult> Success(string processingStatus, bool isDuplicate)
=> OperationResult<BnplCallbackResult>.SuccessResult(new BnplCallbackResult(processingStatus, isDuplicate));
}
@@ -0,0 +1,16 @@
#nullable enable
using System.Collections.Generic;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
/// <summary>
/// The inbound BNPL provider-callback entry point. Authenticated by <b>signature</b> (not a session);
/// at-least-once tolerant and deduplicated on <c>(provider_code, external_event_id)</c> in
/// <c>payment_webhook_events</c> before any money moves, then dispatched to verify/settle/revert by event type —
/// all gated by the BNPL status state machine so a re-delivered callback never double-settles or double-posts.
/// </summary>
public record HandleBnplCallbackCommand(string Provider, IReadOnlyDictionary<string, string>? Headers, string? RawBody)
: IRequest<OperationResult<BnplCallbackResult>>;
@@ -0,0 +1,103 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
internal sealed class InitiateBnplOrderCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver,
ICurrencyNormalizer currencyNormalizer,
IPlatformConfig platformConfig,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<InitiateBnplOrderCommand, OperationResult<InitiateBnplResult>>
{
public async ValueTask<OperationResult<InitiateBnplResult>> Handle(InitiateBnplOrderCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<InitiateBnplResult>.UnauthorizedResult("Not authenticated.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is null)
return OperationResult<InitiateBnplResult>.NotFoundResult("Booking request not found.");
var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken);
if (ctx is null || ctx.CustomerId != customerId)
return OperationResult<InitiateBnplResult>.NotFoundResult("Booking request not found.");
var provider = providerResolver.Resolve(request.ProviderCode);
if (provider is null)
return OperationResult<InitiateBnplResult>.FailureResult("provider_code", "The BNPL provider is not available.");
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken);
if (gatewayId is null)
return OperationResult<InitiateBnplResult>.FailureResult("No active BNPL gateway is configured.");
// The whole money mutation runs under the lock; the DB uniques/state machine remain the authoritative
// backstop if the lock is lost. Keyed on the request (a b9 booking exists only after settle).
await using var _ = await distributedLock.AcquireAsync($"booking-request:{request.BookingRequestId}:payment", cancellationToken);
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
return OperationResult<InitiateBnplResult>.ConflictResult("This booking has already been paid.");
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return OperationResult<InitiateBnplResult>.ConflictResult("This request is not awaiting payment.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
if (ctx.PaymentDeadlineAt is { } deadline && deadline < now)
return OperationResult<InitiateBnplResult>.ConflictResult("The payment window for this request has lapsed.");
// Conversion happens ONLY here, at the provider boundary. The gross is already IRR, so this is a no-op
// today — but a real Toman-quoting provider is normalized through exactly this seam, never internally.
var orderAmountIrr = currencyNormalizer.ToIrr(ctx.GrossIrr, "IRR");
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
var bnpl = await BnplOrderInitializer.EnsureAsync(
unitOfWork, ctx, gatewayId.Value, request.ProviderCode, merchantOfRecord ?? "platform",
orderAmountIrr, "IRR", currentUser.IpAddress, cancellationToken);
// Only an eligible or already-tokenized order can be (re-)initiated; a settled/reverted/cancelled/failed
// one cannot. The provider token call is idempotent, so a replay returns the same token + redirect.
if (bnpl.Status is not (BnplStatus.Eligible or BnplStatus.TokenIssued))
return OperationResult<InitiateBnplResult>.ConflictResult("This BNPL order can no longer be started.");
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
? $"bnpl-br-{request.BookingRequestId}"
: request.IdempotencyKey!;
var token = await provider.CreatePaymentTokenAsync(ctx.CustomerMobile, orderAmountIrr, idempotencyKey, cancellationToken);
if (token.Status != PaymentProviderStatus.Succeeded)
{
if (bnpl.Status == BnplStatus.Eligible)
{
bnpl.MarkFailed();
await unitOfWork.CommitAsync();
}
return OperationResult<InitiateBnplResult>.FailureResult("The BNPL provider declined the order.");
}
// On the first initiate, bind the token to the pending payment_transaction so the callback can find it
// (and the filtered UNIQUE(gateway_reference_code) guards it), then walk eligible → token_issued.
if (bnpl.Status == BnplStatus.Eligible)
{
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
if (transaction is not null)
transaction.GatewayReferenceCode = token.ExternalPaymentToken;
bnpl.MarkTokenIssued(token.ExternalPaymentToken, token.ExternalTransactionId);
await unitOfWork.CommitAsync();
}
return OperationResult<InitiateBnplResult>.SuccessResult(new InitiateBnplResult(
bnpl.Id, bnpl.PaymentTransactionId, bnpl.Status, token.ExternalPaymentToken, token.RedirectUrl));
}
}
@@ -0,0 +1,16 @@
using Baya.Domain.Entities.Bnpl;
using FluentValidation;
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
public sealed class InitiateBnplOrderCommandValidator : AbstractValidator<InitiateBnplOrderCommand>
{
public InitiateBnplOrderCommandValidator()
{
RuleFor(x => x.BookingRequestId).GreaterThan(0);
RuleFor(x => x.ProviderCode)
.NotEmpty()
.Must(BnplProviderCodes.IsKnown)
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
/// <summary>
/// Starts a BNPL order for an <c>accepted_awaiting_payment</c> booking request owned by the caller: ensures the
/// 1:1 <c>bnpl_transactions</c> row (under the <c>UNIQUE(payment_transaction_id)</c> guard), normalizes the
/// order amount to IRR at the provider boundary, asks the provider for a payment token, transitions
/// <c>eligible → token_issued</c>, and returns the token + redirect. Runs under <c>lock(booking:{id}:payment)</c>
/// and carries an idempotency key so a retried start reuses the same token.
/// </summary>
public record InitiateBnplOrderCommand(long BookingRequestId, string ProviderCode, string? IdempotencyKey)
: IRequest<OperationResult<InitiateBnplResult>>;
@@ -0,0 +1,75 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
internal sealed class RevertBnplOrderCommandHandler(
ISender sender,
IUnitOfWork unitOfWork,
IDateTimeProvider dateTimeProvider)
: IRequestHandler<RevertBnplOrderCommand, OperationResult<RevertBnplResult>>
{
public async ValueTask<OperationResult<RevertBnplResult>> Handle(RevertBnplOrderCommand request, CancellationToken cancellationToken)
{
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
if (bnpl is null)
return OperationResult<RevertBnplResult>.NotFoundResult("BNPL order not found.");
// A replayed revert must not double-refund — an already-reverted order is a clean conflict.
if (bnpl.Status == BnplStatus.Reverted)
return OperationResult<RevertBnplResult>.ConflictResult("This BNPL order has already been reverted.");
// Only a settled order has a captured booking + ledger to reverse.
if (bnpl.Status != BnplStatus.Settled)
return OperationResult<RevertBnplResult>.ConflictResult("Only a settled BNPL order can be reverted.");
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
if (transaction?.BookingId is not { } bookingId)
return OperationResult<RevertBnplResult>.ConflictResult("The BNPL order has no captured booking to reverse.");
// Reuse the b11 refund path: it creates the refunds row (refund_channel='bnpl_revert'), executes the
// provider revert/update behind the seam, posts the balanced reversal ledger (+ clawback fork), and
// surfaces the async customer ETA. It owns lock(booking:{id}:refund), so we do not lock here.
var refund = await sender.Send(new CreateRefundCommand(
BookingId: bookingId,
TicketId: request.TicketId,
RefundPercentage: request.RefundPercentage ?? 1m,
PlatformFeeRefundedIrr: null,
NursePayoutRefundedIrr: null,
ReasonCategory: "bnpl_revert",
ReasonNotes: request.ReasonNotes,
AdminNotes: null,
ManualBankReference: null), cancellationToken);
if (!refund.IsSuccess)
return new OperationResult<RevertBnplResult>
{
IsSuccess = false,
IsNotFound = refund.IsNotFound,
IsConflict = refund.IsConflict,
IsForbidden = refund.IsForbidden,
IsUnauthorized = refund.IsUnauthorized,
IsException = refund.IsException,
ErrorMessages = refund.ErrorMessages
};
var refundResult = refund.Result;
var revertedAmount = long.TryParse(refundResult.Amount, out var amount) ? amount : 0;
var externalRef = await unitOfWork.RefundRepository.GetExternalRevertReferenceAsync(refundResult.RefundId, cancellationToken);
var now = dateTimeProvider.UtcNow.UtcDateTime;
// provider_commission_reversed_amount is reconciled from the provider response later — nullable here
// (some providers keep their fee on a refund; the b11 refund path does not persist it).
bnpl.MarkReverted(externalRef, revertedAmount, providerCommissionReversedAmount: null, now, request.CallbackPayloadJson);
await unitOfWork.CommitAsync();
return OperationResult<RevertBnplResult>.SuccessResult(new RevertBnplResult(
bnpl.Id, refundResult.RefundId, bnpl.Status, externalRef,
revertedAmount.ToString(), refundResult.ExpectedCustomerRefundEta));
}
}
@@ -0,0 +1,21 @@
using FluentValidation;
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
public sealed class RevertBnplOrderCommandValidator : AbstractValidator<RevertBnplOrderCommand>
{
public RevertBnplOrderCommandValidator()
{
RuleFor(x => x.BnplTransactionId).GreaterThan(0);
// A partial revert must be strictly lower than the full order (RefundPercentage < 1); 1 = full revert.
When(x => x.RefundPercentage.HasValue, () =>
{
RuleFor(x => x.RefundPercentage!.Value)
.GreaterThan(0m).LessThanOrEqualTo(1m)
.WithMessage("refund_percentage must be a fraction in (0, 1].");
});
RuleFor(x => x.ReasonNotes).MaximumLength(1000);
}
}
@@ -0,0 +1,24 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
/// <summary>
/// Reverses a <c>settled</c> BNPL order back through the provider (full via revert; partial/shortened-visit via
/// update to a strictly-lower amount) and records the reversal audit on the <c>bnpl_transactions</c> row. It
/// <b>reuses the b11 refund path</b> (<c>CreateRefundCommand</c>) — which creates the <c>refunds</c> row with
/// <c>refund_channel='bnpl_revert'</c>, executes the provider revert, posts the balanced reversal ledger (fee +
/// payout legs; a clawback if the nurse was already paid), and surfaces the async ~710-business-day customer
/// ETA. Money <b>always</b> flows customer ↔ provider ↔ Balinyaar. Admin-only.
/// </summary>
/// <param name="BnplTransactionId">The settled BNPL order to reverse.</param>
/// <param name="RefundPercentage">The reversed fraction (01]; 1 = full revert, &lt;1 = a strictly-lower partial.</param>
/// <param name="TicketId">Optional support-ticket link (config-gated "ticket required" rule, b15 FK).</param>
/// <param name="ReasonNotes">Free-text reason recorded on the refund.</param>
public record RevertBnplOrderCommand(
long BnplTransactionId,
decimal? RefundPercentage,
long? TicketId,
string? ReasonNotes,
string? CallbackPayloadJson = null) : IRequest<OperationResult<RevertBnplResult>>;
@@ -0,0 +1,141 @@
#nullable enable
using System.Text.Json;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bookings;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
/// <summary>
/// The BNPL money capture. Mirrors b10's <c>ConfirmPaymentAndPostLedger</c> — same shared booking conversion,
/// same idempotency shape — but posts the <b>net-of-fee</b> group (via <c>LedgerPosting.BnplSettle</c>) so escrow
/// reflects the real cash received, and verifies through the BNPL provider rather than the card PSP. The nurse's
/// <c>nurse_payable</c> accrual is <b>invariant to payment method</b>: it comes from the booking split, never
/// from <c>settled_amount_irr</c>.
/// </summary>
internal sealed class SettleBnplOrderCommandHandler(
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver,
IPlatformConfig platformConfig,
IVariantSnapshotSerializer variantSnapshotSerializer,
ISettlementSplitProvider settlementSplitProvider,
IDistributedLock distributedLock,
IDateTimeProvider dateTimeProvider,
INotificationDispatcher notifications)
: IRequestHandler<SettleBnplOrderCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SettleBnplOrderCommand request, CancellationToken cancellationToken)
{
// Key the lock on the BNPL order (1:1 with the booking) and load state under it, so a racing double
// settle can't both read 'verified'. The webhook dedup + settle-ledger probe are the DB backstops.
await using var _ = await distributedLock.AcquireAsync($"bnpl:{request.BnplTransactionId}:settle", cancellationToken);
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
if (bnpl is null)
return OperationResult<bool>.NotFoundResult("BNPL order not found.");
// Already settled — idempotent no-op (a replayed settle must not re-post the ledger).
if (bnpl.Status == BnplStatus.Settled)
return OperationResult<bool>.SuccessResult(true);
if (bnpl.Status != BnplStatus.Verified || string.IsNullOrEmpty(bnpl.ExternalPaymentToken))
return OperationResult<bool>.ConflictResult("This BNPL order is not ready to settle.");
var transaction = await unitOfWork.PaymentRepository.GetTrackedTransactionByIdAsync(bnpl.PaymentTransactionId, cancellationToken);
if (transaction is null)
return OperationResult<bool>.NotFoundResult("The BNPL payment transaction is missing.");
var provider = providerResolver.Resolve(bnpl.ProviderCode);
if (provider is null)
return OperationResult<bool>.FailureResult("The BNPL provider is not available.");
var idempotencyKey = string.IsNullOrWhiteSpace(request.IdempotencyKey)
? $"bnpl-settle-{bnpl.Id}"
: request.IdempotencyKey!;
var settle = await provider.SettleAsync(bnpl.ExternalPaymentToken!, bnpl.OrderAmountIrr, idempotencyKey, cancellationToken);
if (settle.Status != PaymentProviderStatus.Succeeded)
{
bnpl.MarkFailed();
await unitOfWork.CommitAsync();
return OperationResult<bool>.FailureResult("The BNPL provider declined the settlement.");
}
// Never trust the settlement blindly — the net + commission must reconcile to the stored order amount.
if (settle.SettledAmountIrr < 0 || settle.BnplCommissionIrr < 0
|| settle.SettledAmountIrr + settle.BnplCommissionIrr != bnpl.OrderAmountIrr)
return OperationResult<bool>.FailureResult("The BNPL settlement does not reconcile with the order amount.");
var now = dateTimeProvider.UtcNow.UtcDateTime;
// Create/confirm the booking through the shared conversion (same path as the card capture).
var conversion = await BookingConversion.EnsureBookingAsync(
unitOfWork, platformConfig, variantSnapshotSerializer, transaction.BookingRequestId, now, cancellationToken);
if (conversion is null)
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
transaction.MarkSucceeded(conversion.BookingId, BnplStatus.Settled, transaction.GatewayResponseJson);
// Idempotent net-of-fee ledger: the card-capture legs PLUS the bnpl_fee_expense leg, under one balanced
// group, so escrow_held reflects the NET cash. The nurse_payable leg equals the card-path amount.
if (!await unitOfWork.BnplRepository.SettleLedgerExistsAsync(bnpl.Id, cancellationToken))
{
var legs = LedgerPosting.BnplSettle(
conversion.BookingId, conversion.NurseId, conversion.GrossIrr, conversion.CommissionIrr,
conversion.PayoutIrr, settle.BnplCommissionIrr, bnpl.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
}
bnpl.MarkSettled(settle.SettledAmountIrr, settle.BnplCommissionIrr, settle.SettledAt, request.CallbackPayloadJson);
if (settle.ExternalTransactionId is not null)
transaction.GatewayTransactionId ??= settle.ExternalTransactionId;
try
{
await unitOfWork.CommitAsync();
}
catch (DbUpdateException)
{
// A concurrent confirm for the same booking hit the filtered UNIQUE(booking_id) WHERE
// status='succeeded' — the DB backstop. Treat as already-captured.
await unitOfWork.RollBackAsync();
return OperationResult<bool>.SuccessResult(true);
}
// The lawful تسهیم split — the nurse's payout is invariant to payment method (from the booking split,
// never from settled_amount). The provider commission is a platform expense, not the nurse's.
await settlementSplitProvider.RegisterSplitAsync(
conversion.BookingId,
[
new SettlementLeg("nurse-registered-sheba", conversion.PayoutIrr, "nurse"),
new SettlementLeg("platform-registered-sheba", conversion.CommissionIrr, "platform")
],
cancellationToken);
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
await NotifyConfirmedAsync(customerUserId, nurseUserId, conversion.BookingId, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
private async Task NotifyConfirmedAsync(int customerUserId, int nurseUserId, long bookingId, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
await notifications.DispatchAsync(
new Notification(customerUserId, "booking_confirmed", "Booking confirmed",
"Your installment plan was approved and your booking is confirmed.", payload),
cancellationToken);
await notifications.DispatchAsync(
new Notification(nurseUserId, "booking_confirmed_nurse", "New confirmed booking",
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
cancellationToken);
}
}
@@ -0,0 +1,15 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
/// <summary>
/// Settles a <c>verified</c> BNPL order: the provider pays the full order net of its commission, so this records
/// <c>settled_amount_irr</c>/<c>bnpl_commission_irr</c>/<c>settled_at</c> from the <b>actual settlement</b>,
/// posts the <b>net-of-fee</b> ledger group (card-capture legs PLUS the <c>bnpl_fee_expense</c> leg), confirms
/// the parent <c>payment_transaction</c> (which triggers the booking conversion), and transitions
/// <c>verified → settled</c>. Under <c>lock(bnpl:{id}:settle)</c>; carries an idempotency key. A replayed settle
/// is a no-op (state guard + webhook dedup + the settle-ledger idempotency probe).
/// </summary>
public record SettleBnplOrderCommand(long BnplTransactionId, string? IdempotencyKey = null, string? CallbackPayloadJson = null)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,45 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
internal sealed class VerifyBnplOrderCommandHandler(
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver)
: IRequestHandler<VerifyBnplOrderCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(VerifyBnplOrderCommand request, CancellationToken cancellationToken)
{
var bnpl = await unitOfWork.BnplRepository.GetTrackedByIdAsync(request.BnplTransactionId, cancellationToken);
if (bnpl is null)
return OperationResult<bool>.NotFoundResult("BNPL order not found.");
// Already verified/settled — idempotent no-op (a replayed callback must not re-drive the transition).
if (bnpl.Status is BnplStatus.Verified or BnplStatus.Settled)
return OperationResult<bool>.SuccessResult(true);
if (bnpl.Status != BnplStatus.TokenIssued || string.IsNullOrEmpty(bnpl.ExternalPaymentToken))
return OperationResult<bool>.ConflictResult("This BNPL order is not awaiting verification.");
var provider = providerResolver.Resolve(bnpl.ProviderCode);
if (provider is null)
return OperationResult<bool>.FailureResult("The BNPL provider is not available.");
var verify = await provider.VerifyAsync(bnpl.ExternalPaymentToken!, bnpl.OrderAmountIrr, cancellationToken);
// Never trust the callback alone — the provider must confirm and the amount must match the stored order.
if (verify.Status != PaymentProviderStatus.Succeeded || verify.OrderAmountIrr != bnpl.OrderAmountIrr)
{
bnpl.MarkFailed();
await unitOfWork.CommitAsync();
return OperationResult<bool>.FailureResult("The BNPL order could not be verified with the provider.");
}
bnpl.MarkVerified(verify.ExternalTransactionId, request.CallbackPayloadJson);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
}
}
@@ -0,0 +1,13 @@
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
/// <summary>
/// Confirms a <c>token_issued</c> BNPL order with the provider and re-checks the amount + reference server-side
/// (<b>never trust the callback alone</b>), then transitions <c>token_issued → verified</c>. Driven by the
/// provider callback and by the admin verify endpoint. Idempotent via the status state guard — an already
/// verified/settled order is a no-op success.
/// </summary>
public record VerifyBnplOrderCommand(long BnplTransactionId, string? CallbackPayloadJson = null)
: IRequest<OperationResult<bool>>;
@@ -0,0 +1,68 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
internal sealed class CheckBnplEligibilityQueryHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBnplProviderResolver providerResolver,
IPlatformConfig platformConfig)
: IRequestHandler<CheckBnplEligibilityQuery, OperationResult<BnplEligibilityDto>>
{
public async ValueTask<OperationResult<BnplEligibilityDto>> Handle(CheckBnplEligibilityQuery request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } userId)
return OperationResult<BnplEligibilityDto>.UnauthorizedResult("Not authenticated.");
var customerId = await unitOfWork.CustomerProfileRepository.GetProfileIdByUserIdAsync(userId, cancellationToken);
if (customerId is null)
return OperationResult<BnplEligibilityDto>.NotFoundResult("Booking request not found.");
// Tenancy: only the owning customer may finance; any other caller must not learn the request exists.
var ctx = await unitOfWork.BnplRepository.GetOrderContextAsync(request.BookingRequestId, cancellationToken);
if (ctx is null || ctx.CustomerId != customerId)
return OperationResult<BnplEligibilityDto>.NotFoundResult("Booking request not found.");
if (await unitOfWork.PaymentRepository.HasSucceededTransactionForRequestAsync(request.BookingRequestId, cancellationToken))
return OperationResult<BnplEligibilityDto>.ConflictResult("This booking has already been paid.");
if (ctx.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return OperationResult<BnplEligibilityDto>.ConflictResult("This request is not awaiting payment.");
var provider = providerResolver.Resolve(request.ProviderCode);
if (provider is null)
return OperationResult<BnplEligibilityDto>.FailureResult("provider_code", "The BNPL provider is not available.");
var gatewayId = await unitOfWork.PaymentRepository.GetActiveGatewayIdAsync(PaymentGatewayType.Bnpl, cancellationToken);
if (gatewayId is null)
return OperationResult<BnplEligibilityDto>.FailureResult("No active BNPL gateway is configured.");
var eligibility = await provider.CheckEligibilityAsync(ctx.CustomerMobile, ctx.GrossIrr, cancellationToken);
var merchantOfRecord = await platformConfig.GetConfig<string>("bnpl_merchant_of_record", cancellationToken);
var bnpl = await BnplOrderInitializer.EnsureAsync(
unitOfWork, ctx, gatewayId.Value, request.ProviderCode, merchantOfRecord ?? "platform",
ctx.GrossIrr, "IRR", currentUser.IpAddress, cancellationToken);
bnpl.EligibilityStatus = eligibility.EligibilityStatus;
bnpl.InstallmentCount = (byte)eligibility.InstallmentCount;
await unitOfWork.CommitAsync();
var isEligible = eligibility.EligibilityStatus == BnplEligibilityStatus.Eligible;
return OperationResult<BnplEligibilityDto>.SuccessResult(new BnplEligibilityDto(
eligibility.EligibilityStatus,
isEligible,
eligibility.InstallmentCount,
eligibility.PlanSummary,
eligibility.CreditCeilingIrr?.ToString()));
}
}
@@ -0,0 +1,16 @@
using Baya.Domain.Entities.Bnpl;
using FluentValidation;
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
public sealed class CheckBnplEligibilityQueryValidator : AbstractValidator<CheckBnplEligibilityQuery>
{
public CheckBnplEligibilityQueryValidator()
{
RuleFor(x => x.BookingRequestId).GreaterThan(0);
RuleFor(x => x.ProviderCode)
.NotEmpty()
.Must(BnplProviderCodes.IsKnown)
.WithMessage("provider_code must be one of snapppay, digipay, tara, torobpay.");
}
}
@@ -0,0 +1,16 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
/// <summary>
/// Checks whether a family can finance an <c>accepted_awaiting_payment</c> booking request with the chosen BNPL
/// provider, and records the outcome on a created/updated <c>bnpl_transactions</c> row (status <c>eligible</c>).
/// The order amount is the request's frozen gross (variant price × session count) — never client-supplied. The
/// client shows the plan summary on <c>eligible</c> or falls back to card. Owned by the requesting customer.
/// </summary>
/// <param name="BookingRequestId">The accepted request to finance (a b9 <c>bookings</c> row exists only on settle).</param>
/// <param name="ProviderCode">Which BNPL provider to check (<c>snapppay</c>/<c>digipay</c>/<c>tara</c>/<c>torobpay</c>).</param>
public record CheckBnplEligibilityQuery(long BookingRequestId, string ProviderCode)
: IRequest<OperationResult<BnplEligibilityDto>>;
@@ -0,0 +1,27 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
internal sealed class GetBnplOrderStatusQueryHandler(
IUnitOfWork unitOfWork,
ICurrentUser currentUser)
: IRequestHandler<GetBnplOrderStatusQuery, OperationResult<BnplOrderStatusDto>>
{
public async ValueTask<OperationResult<BnplOrderStatusDto>> Handle(GetBnplOrderStatusQuery request, CancellationToken cancellationToken)
{
var projection = await unitOfWork.BnplRepository.GetStatusAsync(request.BnplTransactionId, cancellationToken);
if (projection is null)
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
// Customer view: a cross-customer access is indistinguishable from "not found" — never confirm it exists.
if (!request.AdminView && projection.CustomerUserId != currentUser.UserId)
return OperationResult<BnplOrderStatusDto>.NotFoundResult("BNPL order not found.");
return OperationResult<BnplOrderStatusDto>.SuccessResult(projection.Order);
}
}
@@ -0,0 +1,14 @@
using Baya.Application.Models.Bnpl;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
/// <summary>
/// Surfaces a BNPL order: status, the money split (gross/net/commission), the <b>non-instant</b> settlement time,
/// and the revert audit + async customer ETA. <paramref name="AdminView"/> comes from the (policy-gated) admin
/// controller; the customer view is tenancy-scoped to <c>ICurrentUser</c> — a customer can never read another's
/// order (a cross-customer read is a clean not-found).
/// </summary>
public record GetBnplOrderStatusQuery(long BnplTransactionId, bool AdminView)
: IRequest<OperationResult<BnplOrderStatusDto>>;
@@ -0,0 +1,73 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Persistence;
using Baya.Domain.Entities.Booking;
namespace Baya.Application.Features.Bookings;
/// <summary>
/// The single place a captured payment turns an <c>accepted_awaiting_payment</c> request into a confirmed
/// <c>bookings</c> row (through <see cref="BookingFactory"/>), shared by <b>both</b> capture rails — b10's card
/// <c>ConfirmPaymentAndPostLedger</c> and b12's BNPL settle — so the conversion/idempotency logic lives once.
/// It re-checks the tracked request against a racing cancel/expiry (a clean conflict, never a double
/// conversion), creates the booking, and commits it; the caller owns the ledger posting for its rail (card
/// capture vs the BNPL net-of-fee group) and any notifications. Pure orchestration — no capture, no
/// current-user gate, no card/BNPL specifics.
/// </summary>
internal static class BookingConversion
{
/// <summary>Ensures the booking for <paramref name="bookingRequestId"/> exists (creating + committing it if
/// the request is still convertible) and returns its frozen ledger amounts + participants. Null when the
/// request can no longer be converted (already-terminal, racing cancel/expiry, or missing) — the caller
/// maps that to a conflict. Idempotent: a booking created by a prior confirm is loaded, not re-created.</summary>
public static async Task<BookingConversionResult?> EnsureBookingAsync(
IUnitOfWork unitOfWork,
IPlatformConfig platformConfig,
IVariantSnapshotSerializer variantSnapshotSerializer,
long bookingRequestId,
DateTime now,
CancellationToken cancellationToken)
{
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
if (existingId is { } eb)
{
var existingAmounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
return existingAmounts is null
? null
: new BookingConversionResult(eb, existingAmounts.NurseId, existingAmounts.GrossIrr, existingAmounts.CommissionIrr, existingAmounts.PayoutIrr, false, null, null);
}
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return null;
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer);
// Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion.
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
return null;
trackedRequest.MarkConverted();
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
await unitOfWork.CommitAsync();
return new BookingConversionResult(
booking.Id, booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount,
true, source.CustomerUserId, source.NurseUserId);
}
}
/// <summary>The confirmed booking's identity, frozen three-amount split, whether this call created it, and (only
/// on create) the participants to notify. Money is IRR <c>long</c>.</summary>
internal sealed record BookingConversionResult(
long BookingId,
long NurseId,
long GrossIrr,
long CommissionIrr,
long PayoutIrr,
bool Created,
int? CustomerUserId,
int? NurseUserId);
@@ -7,7 +7,6 @@ using Baya.Application.Contracts.Payments;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Features.Bookings;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Payments;
using Mediator;
using Microsoft.EntityFrameworkCore;
@@ -45,18 +44,20 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
return OperationResult<bool>.FailureResult("The payment could not be verified with the gateway.");
}
// Create/confirm the booking through the shared b9 conversion (idempotent on UNIQUE booking_request_id).
var (bookingId, amounts, created, participants) = await EnsureBookingAsync(transaction.BookingRequestId, now, cancellationToken);
if (bookingId is not { } booking)
// Create/confirm the booking through the shared conversion (idempotent on UNIQUE booking_request_id).
var conversion = await BookingConversion.EnsureBookingAsync(
unitOfWork, platformConfig, variantSnapshotSerializer, transaction.BookingRequestId, now, cancellationToken);
if (conversion is null)
return OperationResult<bool>.ConflictResult("This request can no longer be converted to a booking.");
var booking = conversion.BookingId;
transaction.MarkSucceeded(booking, verify.Status.ToString(), transaction.GatewayResponseJson);
// Idempotent ledger: don't post a second capture group if one already exists for this transaction.
if (!await unitOfWork.PaymentRepository.LedgerGroupExistsForTransactionAsync(transaction.Id, cancellationToken))
{
var legs = LedgerPosting.CardCapture(
booking, amounts!.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr, transaction.Id, now);
booking, conversion.NurseId, conversion.GrossIrr, conversion.CommissionIrr, conversion.PayoutIrr, transaction.Id, now);
await unitOfWork.PaymentRepository.AddLedgerEntriesAsync(legs, cancellationToken);
}
@@ -77,68 +78,29 @@ internal sealed class ConfirmPaymentAndPostLedgerCommandHandler(
await settlementSplitProvider.RegisterSplitAsync(
booking,
[
new SettlementLeg("nurse-registered-sheba", amounts.PayoutIrr, "nurse"),
new SettlementLeg("platform-registered-sheba", amounts.CommissionIrr, "platform")
new SettlementLeg("nurse-registered-sheba", conversion.PayoutIrr, "nurse"),
new SettlementLeg("platform-registered-sheba", conversion.CommissionIrr, "platform")
],
cancellationToken);
if (created && participants is { } p)
await NotifyConfirmedAsync(p, booking, cancellationToken);
if (conversion.Created && conversion.CustomerUserId is { } customerUserId && conversion.NurseUserId is { } nurseUserId)
await NotifyConfirmedAsync(customerUserId, nurseUserId, booking, cancellationToken);
return OperationResult<bool>.SuccessResult(true);
}
private async Task<(long? BookingId, BookingLedgerAmountsLocal? Amounts, bool Created, BookingParticipantsLocal? Participants)>
EnsureBookingAsync(long bookingRequestId, DateTime now, CancellationToken cancellationToken)
{
var existingId = await unitOfWork.BookingRepository.GetBookingIdByRequestIdAsync(bookingRequestId, cancellationToken);
if (existingId is { } eb)
{
var amounts = await unitOfWork.BookingRepository.GetLedgerAmountsAsync(eb, cancellationToken);
return amounts is null
? (null, null, false, null)
: (eb, new BookingLedgerAmountsLocal(amounts.NurseId, amounts.GrossIrr, amounts.CommissionIrr, amounts.PayoutIrr), false, null);
}
var source = await unitOfWork.BookingRequestRepository.GetConversionSourceAsync(bookingRequestId, cancellationToken);
if (source is null || source.Status != BookingRequestStatus.AcceptedAwaitingPayment)
return (null, null, false, null);
var rate = await platformConfig.GetConfig<decimal>("platform_fee_rate", cancellationToken);
var booking = BookingFactory.Create(source, rate, now, pspFeeAmount: null, variantSnapshotSerializer);
// Re-check the tracked request so a racing cancel/expiry is a clean conflict, not a double conversion.
var trackedRequest = await unitOfWork.BookingRequestRepository.GetTrackedByIdAsync(source.RequestId, cancellationToken);
if (trackedRequest is null || !trackedRequest.CanTransitionTo(BookingRequestStatus.Converted))
return (null, null, false, null);
trackedRequest.MarkConverted();
await unitOfWork.BookingRepository.AddAsync(booking, cancellationToken);
await unitOfWork.CommitAsync();
return (
booking.Id,
new BookingLedgerAmountsLocal(booking.NurseId, booking.GrossPriceIrr, booking.BalinyaarCommissionIrr, booking.NursePayoutAmount),
true,
new BookingParticipantsLocal(source.CustomerUserId, source.NurseUserId));
}
private async Task NotifyConfirmedAsync(BookingParticipantsLocal p, long bookingId, CancellationToken cancellationToken)
private async Task NotifyConfirmedAsync(int customerUserId, int nurseUserId, long bookingId, CancellationToken cancellationToken)
{
var payload = JsonSerializer.Serialize(new { booking_id = bookingId });
await notifications.DispatchAsync(
new Notification(p.CustomerUserId, "booking_confirmed", "Booking confirmed",
new Notification(customerUserId, "booking_confirmed", "Booking confirmed",
"Your payment was captured and your booking is confirmed.", payload),
cancellationToken);
await notifications.DispatchAsync(
new Notification(p.NurseUserId, "booking_confirmed_nurse", "New confirmed booking",
new Notification(nurseUserId, "booking_confirmed_nurse", "New confirmed booking",
"A booking has been confirmed and paid. The care instructions and schedule are now available.", payload),
cancellationToken);
}
private sealed record BookingLedgerAmountsLocal(long NurseId, long GrossIrr, long CommissionIrr, long PayoutIrr);
private sealed record BookingParticipantsLocal(int CustomerUserId, int NurseUserId);
}
@@ -0,0 +1,76 @@
#nullable enable
namespace Baya.Application.Models.Bnpl;
/// <summary>
/// The facts eligibility/initiate need for an <c>accepted_awaiting_payment</c> booking request: the owning
/// customer (tenancy) + their user id + mobile (for the provider eligibility/token call), the request status +
/// frozen payment window, and the gross to finance (variant price × session count — the same figure b9 freezes
/// onto the booking on capture). Money is IRR <c>long</c>.
/// </summary>
public record BnplOrderContext(
long RequestId,
long CustomerId,
int CustomerUserId,
string CustomerMobile,
string Status,
System.DateTime? PaymentDeadlineAt,
long GrossIrr);
/// <summary>The eligibility result the client shows (or falls back to card on): the outcome, the plan summary,
/// and whether the client should offer the "pay with installments" option.</summary>
public record BnplEligibilityDto(
string EligibilityStatus,
bool IsEligible,
int InstallmentCount,
string PlanSummary,
string? CreditCeilingIrr);
/// <summary>The result of starting a BNPL order: the token + provider redirect the client hands off to, plus the
/// row + status. No money crosses back here — it is the request's frozen gross.</summary>
public record InitiateBnplResult(
long BnplTransactionId,
long PaymentTransactionId,
string Status,
string ExternalPaymentToken,
string RedirectUrl);
/// <summary>The admin/customer BNPL order view: status, the money split (gross/net/commission), the non-instant
/// settlement time, and the revert audit. Money crosses the wire as digit strings; <c>settled_at</c> is nullable.</summary>
public record BnplOrderStatusDto(
long Id,
long PaymentTransactionId,
long? BookingId,
string ProviderCode,
string Status,
string? EligibilityStatus,
string OrderAmountIrr,
string? SettledAmountIrr,
string? BnplCommissionIrr,
string Currency,
int InstallmentCount,
System.DateTime? SettledAt,
string? RevertTransactionId,
string? RevertedAmountIrr,
System.DateTime? RevertedAt,
string? ProviderCommissionReversedAmount,
string? RefundChannel,
System.DateOnly? ExpectedCustomerRefundEta,
System.DateTimeOffset CreatedAt);
/// <summary>The tenancy envelope for the customer BNPL-order read: the owning customer's user id (compared to
/// <c>ICurrentUser</c>) plus the DTO. A cross-customer access is a clean not-found, never a leak.</summary>
public record BnplOrderStatusProjection(int CustomerUserId, BnplOrderStatusDto Order);
/// <summary>The outcome of ingesting a BNPL provider callback — the terminal processing status + whether it was a
/// deduplicated replay. The endpoint is always success (at-least-once tolerant).</summary>
public record BnplCallbackResult(string ProcessingStatus, bool Duplicate);
/// <summary>What <c>RevertBnplOrderCommand</c> returns — the reverted order, the created refund, the provider
/// revert reference and the async customer cash-back ETA (~710 business days).</summary>
public record RevertBnplResult(
long BnplTransactionId,
long RefundId,
string Status,
string? RevertTransactionId,
string RevertedAmountIrr,
System.DateOnly? ExpectedCustomerRefundEta);
@@ -0,0 +1,18 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The closed <c>bnpl_transactions.eligibility_status</c> code set recorded by the eligibility check — the
/// client shows the installment plan on <see cref="Eligible"/> and falls back to card otherwise. These are the
/// provider-agnostic outcomes; the concrete provider decides the code (credit ceiling, prior history, …).
/// </summary>
public static class BnplEligibilityStatus
{
/// <summary>The customer may use BNPL for this order amount.</summary>
public const string Eligible = "eligible";
/// <summary>The customer is not eligible (no line/blocked) — the client falls back to card.</summary>
public const string NotEligible = "not_eligible";
/// <summary>The order exceeds the customer's provider credit ceiling — the client falls back to card.</summary>
public const string CeilingExceeded = "ceiling_exceeded";
}
@@ -0,0 +1,19 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The closed set of supported <c>bnpl_transactions.provider_code</c> values — each selects one
/// <c>IBnplProvider</c> impl through the resolver. All four Iranian provider-financed BNPLs are uniformly
/// full-upfront / provider-bears-risk / interest-free-to-customer, so they share the mock behaviour; a real
/// system maps each to its concrete adapter. Persisted as these stable snake_case codes.
/// </summary>
public static class BnplProviderCodes
{
public const string SnappPay = "snapppay";
public const string Digipay = "digipay";
public const string Tara = "tara";
public const string TorobPay = "torobpay";
public static readonly IReadOnlyCollection<string> All = [SnappPay, Digipay, Tara, TorobPay];
public static bool IsKnown(string providerCode) => All.Contains(providerCode);
}
@@ -0,0 +1,31 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The closed <c>bnpl_transactions.status</c> code set — the <b>forward-only</b> state machine that is the
/// idempotency spine of the BNPL money path. A replayed <c>settle</c>/<c>revert</c> that would re-drive a
/// completed transition is an idempotent no-op (the handler treats an already-in-target state as done); the
/// allowed edges live in <see cref="BnplTransitions"/>. Persisted as these stable snake_case codes.
/// </summary>
public static class BnplStatus
{
/// <summary>Eligibility checked and approved — the row exists, no token yet.</summary>
public const string Eligible = "eligible";
/// <summary>A provider payment token was issued; the customer is redirected to complete the order.</summary>
public const string TokenIssued = "token_issued";
/// <summary>The provider confirmed the order (amount + reference re-checked server-side).</summary>
public const string Verified = "verified";
/// <summary>The provider settled the full order net-of-commission; the net-of-fee ledger group is posted.</summary>
public const string Settled = "settled";
/// <summary>The order was reversed back through the provider (a <c>bnpl_revert</c> refund).</summary>
public const string Reverted = "reverted";
/// <summary>The order was cancelled before settle. Terminal.</summary>
public const string Cancelled = "cancelled";
/// <summary>The provider refused (eligibility/token/verify/settle). Terminal.</summary>
public const string Failed = "failed";
}
@@ -0,0 +1,138 @@
#nullable enable
using Baya.Domain.Common;
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// One BNPL order, <b>1:1 with its <c>payment_transaction</c></b> (the <c>UNIQUE(payment_transaction_id)</c>
/// guard) — the single inbound settlement to reconcile plus the revert path. In Balinyaar's books a BNPL order
/// is a <b>card payment that lands net-of-fee</b>: there is nothing to amortize on our side (the provider owns
/// the customer's installments and 100% of default risk), so this is one row, not a plan+entries tree.
/// <para>
/// <see cref="Status"/> is a <b>forward-only</b> state machine (<see cref="BnplTransitions"/>) mutated only
/// through the cohesive mark-* methods; a replayed <c>settle</c>/<c>revert</c> that would re-drive a completed
/// transition throws (the handler treats an already-in-target state as an idempotent no-op), so the ledger is
/// never double-posted. Every amount is IRR <c>BIGINT</c> — currency is normalized to IRR at the provider
/// boundary, never here.
/// </para>
/// </summary>
public class BnplTransaction : BaseEntity<long>
{
/// <summary>The 1:1 parent payment attempt — <c>UNIQUE</c> so exactly one BNPL row exists per order.</summary>
public long PaymentTransactionId { get; set; }
/// <summary>Selects the provider impl — <c>snapppay</c> / <c>digipay</c> / <c>tara</c> / <c>torobpay</c>.</summary>
public string ProviderCode { get; set; } = null!;
/// <summary>Balinyaar entity or partner center that is merchant-of-record for the order.</summary>
public string MerchantOfRecord { get; set; } = null!;
/// <summary>The provider payment token, issued at initiate and used for verify/settle/revert.</summary>
public string? ExternalPaymentToken { get; private set; }
/// <summary>The provider's own order/txn id, when returned.</summary>
public string? ExternalTransactionId { get; private set; }
/// <summary>The recorded eligibility outcome — a <see cref="BnplEligibilityStatus"/> code.</summary>
public string? EligibilityStatus { get; set; }
/// <summary>Gross order (IRR) = the booking's <c>gross_price_irr</c>. No floats, ever.</summary>
public long OrderAmountIrr { get; set; }
/// <summary>Net of provider commission actually received (IRR) — set at settle from the real settlement.</summary>
public long? SettledAmountIrr { get; private set; }
/// <summary>The provider's merchant discount (IRR) = a <b>platform expense</b>, never the nurse's — set at settle.</summary>
public long? BnplCommissionIrr { get; private set; }
/// <summary><c>IRR</c>/<c>TOMAN</c> at the boundary; normalized to IRR on the way in.</summary>
public string Currency { get; set; } = "IRR";
/// <summary>Informational only (default 4) — the installment schedule is owned by the provider.</summary>
public byte InstallmentCount { get; set; } = 4;
/// <summary>Guarded — a <see cref="BnplStatus"/> code, mutated only through the mark-* methods.</summary>
public string Status { get; private set; } = BnplStatus.Eligible;
/// <summary><b>Per-transaction</b>, contract-defined (daily/T+13/weekly) — nullable, never assumed instant.</summary>
public DateTime? SettledAt { get; private set; }
// ---- reversal path ----
public string? RevertTransactionId { get; private set; }
public long? RevertedAmountIrr { get; private set; }
public DateTime? RevertedAt { get; private set; }
/// <summary>The provider's own commission reversal — <b>nullable</b>, reconciled from the provider response,
/// never hardcoded (some providers keep their fee on a refund).</summary>
public long? ProviderCommissionReversedAmount { get; private set; }
/// <summary><c>bnpl_revert</c> on a reversal.</summary>
public string? RefundChannel { get; private set; }
/// <summary>Raw verify/settle/revert payload for reconciliation and audit.</summary>
public string? CallbackPayloadJson { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
public bool CanTransitionTo(string target) => BnplTransitions.CanTransition(Status, target);
private void Transition(string target)
{
if (!BnplTransitions.CanTransition(Status, target))
throw new InvalidOperationException($"Illegal BNPL transition {Status} → {target}.");
Status = target;
}
/// <summary>Records the provider token and transitions <c>eligible → token_issued</c>.</summary>
public void MarkTokenIssued(string externalPaymentToken, string? externalTransactionId)
{
Transition(BnplStatus.TokenIssued);
ExternalPaymentToken = externalPaymentToken;
ExternalTransactionId ??= externalTransactionId;
}
/// <summary>Server-side amount + reference re-checked by the handler; transitions <c>token_issued → verified</c>.</summary>
public void MarkVerified(string? externalTransactionId, string? callbackPayloadJson)
{
Transition(BnplStatus.Verified);
ExternalTransactionId ??= externalTransactionId;
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
}
/// <summary>Records the actual settlement (net + commission + per-transaction <paramref name="settledAt"/>)
/// and transitions <c>verified → settled</c>. Enforces <c>settled = order commission</c> and non-negativity
/// so a settled row can never carry an unbalanced split.</summary>
public void MarkSettled(long settledAmountIrr, long commissionIrr, DateTime? settledAt, string? callbackPayloadJson)
{
if (commissionIrr < 0 || settledAmountIrr < 0)
throw new InvalidOperationException("BNPL settlement amounts must be non-negative.");
if (settledAmountIrr != OrderAmountIrr - commissionIrr)
throw new InvalidOperationException(
$"BNPL settlement would not reconcile: settled {settledAmountIrr} != order {OrderAmountIrr} commission {commissionIrr}.");
Transition(BnplStatus.Settled);
SettledAmountIrr = settledAmountIrr;
BnplCommissionIrr = commissionIrr;
SettledAt = settledAt;
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
}
/// <summary>Records the provider-mediated reversal audit and transitions to <c>reverted</c>. The customer
/// cash-back is async and owned by the provider — money flows customer ↔ provider ↔ Balinyaar only.</summary>
public void MarkReverted(
string? revertTransactionId, long revertedAmountIrr, long? providerCommissionReversedAmount,
DateTime revertedAt, string? callbackPayloadJson)
{
Transition(BnplStatus.Reverted);
RevertTransactionId = revertTransactionId;
RevertedAmountIrr = revertedAmountIrr;
ProviderCommissionReversedAmount = providerCommissionReversedAmount;
RevertedAt = revertedAt;
RefundChannel = Refunds.RefundChannel.BnplRevert;
CallbackPayloadJson = callbackPayloadJson ?? CallbackPayloadJson;
}
public void MarkCancelled() => Transition(BnplStatus.Cancelled);
public void MarkFailed() => Transition(BnplStatus.Failed);
}
@@ -0,0 +1,27 @@
namespace Baya.Domain.Entities.Bnpl;
/// <summary>
/// The forward-only allowed-edge table for the <see cref="BnplStatus"/> machine (mirrors
/// <c>BookingRequestTransitions</c>/<c>RefundTransitions</c>). Every write goes through
/// <see cref="BnplTransaction"/>'s cohesive mark-* methods, which assert the edge here — an illegal transition
/// throws, and a replayed callback that would re-drive a completed transition is rejected before it can
/// re-post the ledger. Terminal states have no outgoing edge.
/// </summary>
public static class BnplTransitions
{
private static readonly IReadOnlyDictionary<string, IReadOnlyCollection<string>> Allowed =
new Dictionary<string, IReadOnlyCollection<string>>
{
[BnplStatus.Eligible] = [BnplStatus.TokenIssued, BnplStatus.Failed, BnplStatus.Cancelled],
[BnplStatus.TokenIssued] = [BnplStatus.Verified, BnplStatus.Failed, BnplStatus.Cancelled],
[BnplStatus.Verified] = [BnplStatus.Settled, BnplStatus.Failed, BnplStatus.Reverted, BnplStatus.Cancelled],
[BnplStatus.Settled] = [BnplStatus.Reverted],
// Terminal states — no outgoing edges.
[BnplStatus.Reverted] = [],
[BnplStatus.Cancelled] = [],
[BnplStatus.Failed] = []
};
public static bool CanTransition(string from, string to)
=> Allowed.TryGetValue(from, out var targets) && targets.Contains(to);
}
@@ -36,6 +36,56 @@ public static class LedgerPosting
];
}
/// <summary>
/// The <b>BNPL settle group</b>: the card-capture legs <b>plus</b> the provider-fee leg, all under one fresh
/// <see cref="LedgerEntry.TransactionGroupId"/>, so <c>escrow_held</c> reflects the <b>net</b> cash actually
/// received (<c>order commission</c>), not the gross:
/// <code>
/// DEBIT escrow_held order (= gross)
/// CREDIT platform_revenue commission
/// CREDIT nurse_payable payout
/// DEBIT bnpl_fee_expense bnpl_commission
/// CREDIT escrow_held bnpl_commission
/// </code>
/// The three booking amounts come <b>frozen from the booking</b> (never recomputed) and must reconcile
/// (<c>gross = commission + payout</c>); the <b>nurse's payout is invariant to payment method</b> — the BNPL
/// commission is a platform expense and never touches it. Throws if the group would not balance, so an
/// unbalanced settle can never be persisted. Posted once (the state guard + webhook dedup make a replay a no-op).
/// </summary>
public static IReadOnlyList<LedgerEntry> BnplSettle(
long bookingId,
long nurseId,
long grossIrr,
long commissionIrr,
long payoutIrr,
long bnplCommissionIrr,
long bnplTransactionId,
DateTime createdAt)
{
if (grossIrr != commissionIrr + payoutIrr)
throw new InvalidOperationException(
$"BNPL settle group would not balance: gross {grossIrr} != commission {commissionIrr} + payout {payoutIrr}.");
if (bnplCommissionIrr < 0)
throw new InvalidOperationException("BNPL provider commission must be non-negative.");
var group = Guid.NewGuid();
var legs = new List<LedgerEntry>
{
Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Debit, grossIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt),
Leg(group, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit, commissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt),
Leg(group, LedgerAccountType.NursePayable, LedgerDirection.Credit, payoutIrr, nurseId, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt)
};
// The provider-fee leg: the merchant discount is a platform expense, so escrow reflects only the net cash.
if (bnplCommissionIrr > 0)
{
legs.Add(Leg(group, LedgerAccountType.BnplFeeExpense, LedgerDirection.Debit, bnplCommissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt));
legs.Add(Leg(group, LedgerAccountType.EscrowHeld, LedgerDirection.Credit, bnplCommissionIrr, null, bookingId, LedgerSourceRefType.BnplTransaction, bnplTransactionId, createdAt));
}
return legs;
}
/// <summary>
/// The <b>pre-payout refund reversal</b> (the nurse has not been paid, the common case): <c>DEBIT
/// platform_revenue fee + DEBIT nurse_payable payout / CREDIT refund_payable (sum)</c> under one group.