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
+32 -3
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices), appsettings*.json
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -363,6 +363,35 @@ per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; co
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
seam definition**.
**BNPL — provider-financed installments (backend-phase-12).** The `payments` schema gains one table —
`BnplTransactions` (entity in `Domain/Entities/Bnpl/`, config in `Persistence/Configuration/BnplConfig/`, one
migration `BnplTransactions`) — **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
A BNPL order is, in our books, **a card payment that lands net-of-fee**: there is no customer-installment
tracking (the provider owns the schedule + 100% default risk). Features under
`Baya.Application/Features/Bnpl/{Commands|Queries}/` (eligibility/initiate/verify/settle/revert/callback/status);
per-domain repo `IBnplRepository` on `IUnitOfWork`; controllers `CheckoutBnplController` (customer, rate-limited)
/ `WebhooksBnplController` (anonymous, signature-verified, rate-limited) / `AdminBnplController` (admin,
rate-limited). The b10 booking-conversion path was extracted to the shared **`Features/Bookings/BookingConversion`**
helper (used by both the card `ConfirmPaymentAndPostLedger` and the BNPL settle). Load-bearing rules:
- **Forward-only `BnplStatus` state machine** (`eligible → token_issued → verified → settled →
reverted/cancelled/failed`, `BnplTransitions`), mutated only through the entity's mark-* methods — the
idempotency spine. A replayed settle/revert that would re-drive a completed transition is an idempotent no-op.
- **Settle posts the net-of-fee group via `LedgerPosting.BnplSettle`** — the card-capture legs **plus** `DEBIT
bnpl_fee_expense / CREDIT escrow_held` for the provider commission, one balanced `transaction_group_id`, so
escrow reflects the **net** cash (`settled_amount_irr = order commission`). Settle confirms the parent
`payment_transaction` (which triggers the booking conversion) exactly like the card capture.
- **The nurse's payout is invariant to payment method** — `nurse_payable` comes from the booking split
(`gross commission`), **never** from `settled_amount_irr`; the BNPL commission is a **platform expense**.
- **`settled_at` is per-transaction and nullable** — never assumed instant; the commission is read from the
actual settlement, never hardcoded. **Currency is normalized to IRR at the provider boundary only**.
- **Revert reuses the b11 refund path** (`CreateRefundCommand` with `refund_channel='bnpl_revert'`) — money
flows customer ↔ provider ↔ Balinyaar only; the async ~710-business-day customer ETA is surfaced.
- **Two new seams** in `Application/Contracts/Payments/`: **`IBnplProvider`** (the full SnappPay-superset verb
set, superseding b11's revert-only stub; the b11 refund path still injects it) selected per `provider_code`
by **`IBnplProviderResolver`**, and **`ICurrencyNormalizer`** (Toman↔IRR at the boundary). Mocks
(`MockBnplProvider`/`MockBnplProviderResolver`/`MockCurrencyNormalizer`) in `CrossCutting/Seams/`, registered by
`AddCrossCuttingSeams`. `bnpl_settlement_entries` (tranched settlement) is **DEFERRED — modeled-but-not-built**.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.RevertBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Commands.VerifyBnplOrder;
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
using Baya.Application.Models.Bnpl;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin-only BNPL operations console: manually drive verify/settle (also driven by the provider callback) and
/// the payout-/refund-sensitive full revert, plus read an order. The revert reverses through the provider only
/// (customer ↔ provider ↔ Balinyaar) and surfaces the async ~710-business-day customer ETA. Rate-limited as
/// money endpoints.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/admin_bnpl")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin BNPL verify/settle/revert + order status")]
public sealed class AdminBnplController(ISender sender) : BaseController
{
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Verify(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new VerifyBnplOrderCommand(id), cancellationToken));
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Settle(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new SettleBnplOrderCommand(id), cancellationToken));
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RevertBnplResult>]
public async Task<IActionResult> Revert(long id, RevertBnplBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(
new RevertBnplOrderCommand(id, body.RefundPercentage, body.TicketId, body.ReasonNotes), cancellationToken));
[HttpGet("{id}")]
[ProducesOkApiResponseType<BnplOrderStatusDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: true), cancellationToken));
/// <summary>The revert body (the order id comes from the route). Omit <c>refund_percentage</c> for a full revert.</summary>
public record RevertBnplBody(decimal? RefundPercentage, long? TicketId, string? ReasonNotes);
}
@@ -0,0 +1,52 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
using Baya.Application.Models.Bnpl;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The customer-facing BNPL checkout — the "pay with installments" alternative to the card path. Eligibility
/// records the plan on a <c>bnpl_transactions</c> row; initiate issues the provider token + redirect. Both are
/// tenancy-scoped to the caller and rate-limited as money endpoints. The order amount is always the request's
/// frozen gross — never client-supplied.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/checkout_bnpl")]
[Authorize]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Customer BNPL checkout (eligibility + initiate)")]
public sealed class CheckoutBnplController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<BnplEligibilityDto>]
public async Task<IActionResult> Eligibility(CheckBnplEligibilityQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<InitiateBnplResult>]
public async Task<IActionResult> Initiate(InitiateBnplBody body, CancellationToken cancellationToken)
{
var idempotencyKey = Request.Headers["Idempotency-Key"].FirstOrDefault();
return OperationResult(await sender.Send(
new InitiateBnplOrderCommand(body.BookingRequestId, body.ProviderCode, idempotencyKey), cancellationToken));
}
[HttpGet("{id}")]
[ProducesOkApiResponseType<BnplOrderStatusDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken));
/// <summary>The initiate body (the idempotency key comes from the <c>Idempotency-Key</c> header).</summary>
public record InitiateBnplBody(long BookingRequestId, string ProviderCode);
}
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Text;
using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.HandleBnplCallback;
using Baya.Application.Models.Bnpl;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The inbound BNPL provider-callback surface. Authenticated by <b>signature</b>, not a user session, so it is
/// anonymous to the auth pipeline; at-least-once tolerant and idempotency-deduplicated on
/// <c>(provider_code, external_event_id)</c> before any money moves. Rate-limited (per-IP). The raw body is read
/// verbatim and stored in <c>payload_json</c>.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/webhooks_bnpl")]
[AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksBnplController(ISender sender) : BaseController
{
[HttpPost("{provider}")]
[ProducesOkApiResponseType<BnplCallbackResult>]
public async Task<IActionResult> Callback(string provider, CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync(cancellationToken);
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
return OperationResult(await sender.Send(new HandleBnplCallbackCommand(provider, headers, rawBody), cancellationToken));
}
}
@@ -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.
@@ -1,27 +1,78 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// A thin, deterministic mock <see cref="IBnplProvider"/> so b11's <c>bnpl_revert</c> refund path is exercised
/// before b12 merges — <b>b12 owns the real seam definition and its full adapter</b> (SnappPay/Tara). Revert
/// and update both succeed, echo a deterministic <c>external_revert_reference</c> derived from the order +
/// idempotency key, and report a nullable provider commission reversal (null by default — some providers keep
/// their fee on a refund; the amount is reconciled from the response, never hardcoded).
/// A deterministic, network-free mock <see cref="IBnplProvider"/> that drives the full SnappPay-superset verb
/// set — <c>eligible → token_issued → verified → settled → reverted/cancelled</c> — with no external call. One
/// instance stands in for every <c>provider_code</c> (all Iranian provider-financed BNPLs behave the same);
/// a real system maps each code to its concrete adapter (SnappPay OAuth/eligible/token/verify/settle/revert,
/// Digipay UPG ticket/verify/deliver/refund). The mock commission % is <see cref="BnplOptions.CommissionRate"/>,
/// so the settle returns <c>order commission</c> and the handler reads the commission from the response, never
/// hardcoded. A real adapter reads the actual deducted amount from each settlement.
/// </summary>
public sealed class MockBnplProvider(IOptions<SeamOptions> options) : IBnplProvider
{
private readonly BnplOptions _options = options.Value.Bnpl;
public ValueTask<BnplEligibilityResult> CheckEligibilityAsync(string customerMobile, long orderAmountIrr, CancellationToken cancellationToken = default)
{
var status = string.Equals(customerMobile, _options.NotEligibleMobile, StringComparison.Ordinal)
? BnplEligibilityStatus.NotEligible
: orderAmountIrr > _options.CreditCeilingIrr
? BnplEligibilityStatus.CeilingExceeded
: BnplEligibilityStatus.Eligible;
return ValueTask.FromResult(new BnplEligibilityResult(
status, InstallmentCount: 4, CreditCeilingIrr: _options.CreditCeilingIrr,
PlanSummary: "4 interest-free installments, 0% interest, provider-financed."));
}
public ValueTask<BnplTokenResult> CreatePaymentTokenAsync(string customerMobile, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplTokenResult(PaymentProviderStatus.Failed, string.Empty, string.Empty, null));
var token = $"mock-bnpl-token-{orderAmountIrr}-{idempotencyKey}";
return ValueTask.FromResult(new BnplTokenResult(
PaymentProviderStatus.Succeeded, token,
RedirectUrl: $"https://mock-bnpl.local/checkout/{token}",
ExternalTransactionId: $"mock-bnpl-order-{idempotencyKey}"));
}
public ValueTask<BnplVerifyResult> VerifyAsync(string externalPaymentToken, long expectedOrderAmountIrr, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplVerifyResult(
PaymentProviderStatus.Succeeded, expectedOrderAmountIrr, $"mock-bnpl-verify-{externalPaymentToken}"));
public ValueTask<BnplSettleResult> SettleAsync(string externalPaymentToken, long orderAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
{
// The merchant discount is read from the (mock) settlement — commission %, never hardcoded in a handler.
var commission = (long)Math.Round(orderAmountIrr * _options.CommissionRate, MidpointRounding.AwayFromZero);
var settled = orderAmountIrr - commission;
// Settlement timing is contract-defined and not instant — the mock can model it null (deferred) or now.
DateTime? settledAt = _options.SettlementInstant ? DateTime.UtcNow : null;
return ValueTask.FromResult(new BnplSettleResult(
PaymentProviderStatus.Succeeded, settled, commission, settledAt, $"mock-bnpl-settle-{idempotencyKey}"));
}
public ValueTask<BnplStatusResult> GetStatusAsync(string externalPaymentToken, CancellationToken cancellationToken = default)
=> ValueTask.FromResult(new BnplStatusResult("settled"));
public ValueTask<BnplRevertResult> CancelAsync(string externalPaymentToken, string idempotencyKey, CancellationToken cancellationToken = default)
=> Reversal(externalPaymentToken, idempotencyKey);
public ValueTask<BnplRevertResult> RevertAsync(string providerOrderReference, long amountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
=> Reversal(providerOrderReference, idempotencyKey);
public ValueTask<BnplRevertResult> UpdateAsync(string providerOrderReference, long newAmountIrr, string idempotencyKey, CancellationToken cancellationToken = default)
=> Result(providerOrderReference, idempotencyKey);
=> Reversal(providerOrderReference, idempotencyKey);
private ValueTask<BnplRevertResult> Result(string providerOrderReference, string idempotencyKey)
private ValueTask<BnplRevertResult> Reversal(string providerOrderReference, string idempotencyKey)
{
if (_options.ForceFailure)
return ValueTask.FromResult(new BnplRevertResult(PaymentProviderStatus.Failed, null, null));
@@ -0,0 +1,18 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Bnpl;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Config-driven <see cref="IBnplProviderResolver"/> — maps every known <c>provider_code</c> to the deterministic
/// <see cref="MockBnplProvider"/> (all four Iranian provider-financed BNPLs share the mock behaviour). A real
/// system registers one concrete adapter per code (<c>SnappPayBnplProvider</c>, <c>DigipayBnplProvider</c>, …)
/// and this resolver returns the right one; swapping is a registration change, <b>never</b> an <c>if (mock)</c>
/// branch in a handler. An unknown code resolves to <c>null</c> so the handler rejects it cleanly.
/// </summary>
public sealed class MockBnplProviderResolver(MockBnplProvider provider) : IBnplProviderResolver
{
public IBnplProvider? Resolve(string providerCode)
=> BnplProviderCodes.IsKnown(providerCode) ? provider : null;
}
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Contracts.Payments;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Deterministic mock <see cref="ICurrencyNormalizer"/> — Toman ↔ IRR at the provider boundary only. Toman is
/// multiplied by <see cref="CurrencyOptions.TomanToIrrMultiplier"/> (10) to get IRR (and divided back for
/// display); IRR passes through unchanged. A real adapter reads the multiplier from provider config; the
/// conversion never happens internally, only here.
/// </summary>
public sealed class MockCurrencyNormalizer(IOptions<SeamOptions> options) : ICurrencyNormalizer
{
private readonly CurrencyOptions _options = options.Value.Currency;
public long ToIrr(long amount, string currency)
=> string.Equals(currency, "TOMAN", StringComparison.OrdinalIgnoreCase)
? amount * _options.TomanToIrrMultiplier
: amount;
public long ToDisplayToman(long amountIrr)
=> _options.TomanToIrrMultiplier == 0 ? amountIrr : amountIrr / _options.TomanToIrrMultiplier;
}
@@ -18,6 +18,18 @@ public sealed class SeamOptions
public PaymentsOptions Payments { get; set; } = new();
public MoadianOptions Moadian { get; set; } = new();
public BnplOptions Bnpl { get; set; } = new();
public CurrencyOptions Currency { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>ICurrencyNormalizer</c> (b12). Toman → IRR multiplies by <see cref="TomanToIrrMultiplier"/>
/// (10); IRR passes through. The real adapter reads the multiplier from provider config. Conversion happens only
/// at the provider boundary, never internally.
/// </summary>
public sealed class CurrencyOptions
{
/// <summary>How many Rial one Toman is (10). A currency redenomination is a config change, not a code change.</summary>
public long TomanToIrrMultiplier { get; set; } = 10;
}
/// <summary>
@@ -32,18 +44,32 @@ public sealed class MoadianOptions
}
/// <summary>
/// Tunes the thin local mock <c>IBnplProvider</c> b11 registers until b12 ships the real seam. By default a
/// revert/update succeeds and the provider keeps its commission (null reversal). The real b12 adapter ignores
/// these.
/// Tunes the deterministic mock <c>IBnplProvider</c> (b12, superset of the b11 revert-only stub). The mock
/// commission % drives the settle (net = order commission); the real adapter reads the actual deducted amount
/// from each settlement and ignores these knobs.
/// </summary>
public sealed class BnplOptions
{
/// <summary>When true, every revert/update fails so the refund-channel-refused path is testable.</summary>
/// <summary>When true, token/revert/update/cancel all fail so the provider-declined paths are testable.</summary>
public bool ForceFailure { get; set; }
/// <summary>When true, the mock reports the provider returned its commission (a non-null, zero reversal
/// placeholder) so the <c>provider_commission_reversed_amount</c> reconciliation is exercised.</summary>
public bool ReverseProviderCommission { get; set; }
/// <summary>The mock provider merchant commission rate (fraction) the settle deducts. Read from the response
/// by the handler, never hardcoded there; the real adapter reads the true per-contract deducted amount.</summary>
public decimal CommissionRate { get; set; } = 0.10m;
/// <summary>When true the mock settle stamps <c>settled_at = now</c>; false models the non-instant
/// (deferred/T+13/weekly) settlement with a null <c>settled_at</c>.</summary>
public bool SettlementInstant { get; set; } = true;
/// <summary>The provider credit ceiling (IRR); an order above it returns <c>ceiling_exceeded</c>.</summary>
public long CreditCeilingIrr { get; set; } = 2_000_000_000;
/// <summary>A designated test mobile that returns <c>not_eligible</c> so the fall-back-to-card path is testable.</summary>
public string NotEligibleMobile { get; set; } = "09120000099";
}
/// <summary>
@@ -60,10 +60,18 @@ public static class ServiceCollectionExtension
services.AddSingleton<IDistributedLock, InProcessDistributedLock>();
// Refunds/invoices seams (backend-phase-11). سامانه مودیان e-invoicing is mocked (pending/no-ref by
// default; config can force registered). IBnplProvider is a thin local stub so the bnpl_revert refund
// path runs before b12 merges — b12 owns the real seam. Both swap in by a registration change only.
// default; config can force registered).
services.AddSingleton<IMoadianClient, MockMoadianClient>();
services.AddSingleton<IBnplProvider, MockBnplProvider>();
// BNPL seams (backend-phase-12). The deterministic MockBnplProvider drives the full eligible → settled →
// reverted state machine with no network; the resolver selects one impl per provider_code (config-driven,
// never an if(mock) in a handler); ICurrencyNormalizer does Toman↔IRR at the boundary only. A real
// SnappPay/Digipay adapter + real Redis normalizer swap in by a registration change only. IBnplProvider
// is still registered directly for the b11 refund path's bnpl_revert channel.
services.AddSingleton<MockBnplProvider>();
services.AddSingleton<IBnplProvider>(sp => sp.GetRequiredService<MockBnplProvider>());
services.AddSingleton<IBnplProviderResolver, MockBnplProviderResolver>();
services.AddSingleton<ICurrencyNormalizer, MockCurrencyNormalizer>();
return services;
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.BnplConfig;
/// <summary>
/// <c>bnpl_transactions</c> — one row per BNPL order, <b>1:1 with its <c>payment_transaction</c></b>. The
/// <c>UNIQUE(payment_transaction_id)</c> is the structural one-BNPL-row-per-order guard; the settle invariant
/// <c>settled = order commission</c> (with both net + commission set together) is a DB CHECK mirroring b9/b11.
/// The state-machine guard on <c>status</c> lives in the entity; there is no <c>bnpl_settlement_entries</c> child
/// table (tranched settlement is DEFERRED — adding it later is a purely additive migration).
/// </summary>
internal sealed class BnplTransactionConfig : IEntityTypeConfiguration<BnplTransaction>
{
public void Configure(EntityTypeBuilder<BnplTransaction> builder)
{
builder.ToTable("BnplTransactions", "payments", t => t.HasCheckConstraint(
"CK_BnplTransactions_SettleSplit",
"([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) " +
"OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] " +
"AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)"));
builder.Property(t => t.ProviderCode).HasMaxLength(50).IsRequired();
builder.Property(t => t.MerchantOfRecord).HasMaxLength(40).IsRequired();
builder.Property(t => t.ExternalPaymentToken).HasMaxLength(200);
builder.Property(t => t.ExternalTransactionId).HasMaxLength(200);
builder.Property(t => t.EligibilityStatus).HasMaxLength(30);
builder.Property(t => t.Currency).HasMaxLength(5).IsRequired();
builder.Property(t => t.Status).HasMaxLength(30).IsRequired();
builder.Property(t => t.RevertTransactionId).HasMaxLength(200);
builder.Property(t => t.RefundChannel).HasMaxLength(20);
// Strict 1:1 — exactly one BNPL row per order. The structural guard, not just a handler pre-check.
builder.HasIndex(t => t.PaymentTransactionId).IsUnique();
// The callback dispatch resolves the order from the provider token in the payload.
builder.HasIndex(t => t.ExternalPaymentToken).HasFilter("[ExternalPaymentToken] IS NOT NULL");
builder.HasIndex(t => t.Status);
builder.HasOne<PaymentTransaction>().WithMany().HasForeignKey(t => t.PaymentTransactionId).IsRequired();
builder.HasQueryFilter(t => t.DeletedAt == null);
}
}
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class BnplTransactions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BnplTransactions",
schema: "payments",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
PaymentTransactionId = table.Column<long>(type: "bigint", nullable: false),
ProviderCode = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: false),
MerchantOfRecord = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
ExternalPaymentToken = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
ExternalTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
EligibilityStatus = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: true),
OrderAmountIrr = table.Column<long>(type: "bigint", nullable: false),
SettledAmountIrr = table.Column<long>(type: "bigint", nullable: true),
BnplCommissionIrr = table.Column<long>(type: "bigint", nullable: true),
Currency = table.Column<string>(type: "nvarchar(5)", maxLength: 5, nullable: false),
InstallmentCount = table.Column<byte>(type: "tinyint", nullable: false),
Status = table.Column<string>(type: "nvarchar(30)", maxLength: 30, nullable: false),
SettledAt = table.Column<DateTime>(type: "datetime2", nullable: true),
RevertTransactionId = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
RevertedAmountIrr = table.Column<long>(type: "bigint", nullable: true),
RevertedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ProviderCommissionReversedAmount = table.Column<long>(type: "bigint", nullable: true),
RefundChannel = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
CallbackPayloadJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_BnplTransactions", x => x.Id);
table.CheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)");
table.ForeignKey(
name: "FK_BnplTransactions_PaymentTransactions_PaymentTransactionId",
column: x => x.PaymentTransactionId,
principalSchema: "payments",
principalTable: "PaymentTransactions",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_ExternalPaymentToken",
schema: "payments",
table: "BnplTransactions",
column: "ExternalPaymentToken",
filter: "[ExternalPaymentToken] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_PaymentTransactionId",
schema: "payments",
table: "BnplTransactions",
column: "PaymentTransactionId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_BnplTransactions_Status",
schema: "payments",
table: "BnplTransactions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BnplTransactions",
schema: "payments");
}
}
}
@@ -98,6 +98,115 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<long?>("BnplCommissionIrr")
.HasColumnType("bigint");
b.Property<string>("CallbackPayloadJson")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(5)
.HasColumnType("nvarchar(5)");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EligibilityStatus")
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.Property<string>("ExternalPaymentToken")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("ExternalTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<byte>("InstallmentCount")
.HasColumnType("tinyint");
b.Property<string>("MerchantOfRecord")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("OrderAmountIrr")
.HasColumnType("bigint");
b.Property<long>("PaymentTransactionId")
.HasColumnType("bigint");
b.Property<string>("ProviderCode")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<long?>("ProviderCommissionReversedAmount")
.HasColumnType("bigint");
b.Property<string>("RefundChannel")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("RevertTransactionId")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<long?>("RevertedAmountIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("RevertedAt")
.HasColumnType("datetime2");
b.Property<long?>("SettledAmountIrr")
.HasColumnType("bigint");
b.Property<DateTime?>("SettledAt")
.HasColumnType("datetime2");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("nvarchar(30)");
b.HasKey("Id");
b.HasIndex("ExternalPaymentToken")
.HasFilter("[ExternalPaymentToken] IS NOT NULL");
b.HasIndex("PaymentTransactionId")
.IsUnique();
b.HasIndex("Status");
b.ToTable("BnplTransactions", "payments", t =>
{
t.HasCheckConstraint("CK_BnplTransactions_SettleSplit", "([SettledAmountIrr] IS NULL AND [BnplCommissionIrr] IS NULL) OR ([SettledAmountIrr] = [OrderAmountIrr] - [BnplCommissionIrr] AND [SettledAmountIrr] >= 0 AND [BnplCommissionIrr] >= 0)");
});
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.Property<long>("Id")
@@ -4203,6 +4312,15 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Bnpl.BnplTransaction", b =>
{
b.HasOne("Baya.Domain.Entities.Payments.PaymentTransaction", null)
.WithMany()
.HasForeignKey("PaymentTransactionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Booking.Booking", b =>
{
b.HasOne("Baya.Domain.Entities.Booking.BookingRequest", null)
@@ -0,0 +1,111 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Bnpl;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.Refunds;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class BnplRepository : BaseAsyncRepository<BnplTransaction>, IBnplRepository
{
public BnplRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<BnplOrderContext?> GetOrderContextAsync(long bookingRequestId, CancellationToken cancellationToken)
=> (from r in DbContext.Set<BookingRequest>().AsNoTracking()
where r.Id == bookingRequestId
join c in DbContext.Set<CustomerProfile>() on r.CustomerId equals c.Id
join u in DbContext.Set<User>() on c.UserId equals u.Id
select new BnplOrderContext(
r.Id,
r.CustomerId,
u.Id,
u.PhoneNumber,
r.Status,
r.PaymentDeadlineAt,
r.Variant.Price * (r.Variant.SessionCount != null ? r.Variant.SessionCount.Value : 1)))
.FirstOrDefaultAsync(cancellationToken);
public Task AddAsync(BnplTransaction transaction, CancellationToken cancellationToken)
=> base.AddAsync(transaction);
public Task<BnplTransaction?> GetTrackedByPaymentTransactionIdAsync(long paymentTransactionId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.PaymentTransactionId == paymentTransactionId, cancellationToken);
public Task<BnplTransaction?> GetTrackedByIdAsync(long id, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
public Task<BnplTransaction?> GetTrackedByBookingRequestIdAsync(long bookingRequestId, CancellationToken cancellationToken)
// A correlated subquery keeps the root Table query tracking (a join to an AsNoTracking set would detach
// the returned entity, so a later mutation would never persist).
=> Table.FirstOrDefaultAsync(
t => DbContext.Set<PaymentTransaction>().Any(pt => pt.Id == t.PaymentTransactionId && pt.BookingRequestId == bookingRequestId),
cancellationToken);
public Task<BnplTransaction?> GetTrackedByTokenAsync(string externalPaymentToken, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(t => t.ExternalPaymentToken == externalPaymentToken, cancellationToken);
public Task<bool> SettleLedgerExistsAsync(long bnplTransactionId, CancellationToken cancellationToken)
=> DbContext.Set<LedgerEntry>().AsNoTracking().AnyAsync(
e => e.SourceRefType == LedgerSourceRefType.BnplTransaction && e.SourceRefId == bnplTransactionId,
cancellationToken);
public async Task<BnplOrderStatusProjection?> GetStatusAsync(long id, CancellationToken cancellationToken)
{
var row = await (from t in TableNoTracking
where t.Id == id
join pt in DbContext.Set<PaymentTransaction>() on t.PaymentTransactionId equals pt.Id
join c in DbContext.Set<CustomerProfile>() on pt.CustomerId equals c.Id
select new
{
c.UserId,
t.Id,
t.PaymentTransactionId,
pt.BookingId,
t.ProviderCode,
t.Status,
t.EligibilityStatus,
t.OrderAmountIrr,
t.SettledAmountIrr,
t.BnplCommissionIrr,
t.Currency,
t.InstallmentCount,
t.SettledAt,
t.RevertTransactionId,
t.RevertedAmountIrr,
t.RevertedAt,
t.ProviderCommissionReversedAmount,
t.RefundChannel,
t.CreatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (row is null)
return null;
// The async customer cash-back ETA lives on the linked bnpl_revert refund (b11), surfaced for the UI.
DateOnly? eta = null;
if (row.BookingId is { } bookingId && row.Status == BnplStatus.Reverted)
eta = await DbContext.Set<Refund>().AsNoTracking()
.Where(refund => refund.BookingId == bookingId && refund.RefundChannel == RefundChannel.BnplRevert)
.OrderByDescending(refund => refund.Id)
.Select(refund => refund.ExpectedCustomerRefundEta)
.FirstOrDefaultAsync(cancellationToken);
var dto = new BnplOrderStatusDto(
row.Id, row.PaymentTransactionId, row.BookingId, row.ProviderCode, row.Status, row.EligibilityStatus,
row.OrderAmountIrr.ToString(), row.SettledAmountIrr?.ToString(), row.BnplCommissionIrr?.ToString(),
row.Currency, row.InstallmentCount, row.SettledAt,
row.RevertTransactionId, row.RevertedAmountIrr?.ToString(), row.RevertedAt,
row.ProviderCommissionReversedAmount?.ToString(), row.RefundChannel, eta, row.CreatedAt);
return new BnplOrderStatusProjection(row.UserId, dto);
}
}
@@ -25,6 +25,7 @@ public class UnitOfWork : IUnitOfWork
public IPaymentRepository PaymentRepository { get; }
public IRefundRepository RefundRepository { get; }
public IInvoiceRepository InvoiceRepository { get; }
public IBnplRepository BnplRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -48,6 +49,7 @@ public class UnitOfWork : IUnitOfWork
PaymentRepository = new PaymentRepository(_db);
RefundRepository = new RefundRepository(_db);
InvoiceRepository = new InvoiceRepository(_db);
BnplRepository = new BnplRepository(_db);
}
public Task CommitAsync()
@@ -132,6 +132,12 @@ internal sealed class RefundRepository : BaseAsyncRepository<Refund>, IRefundRep
return new RefundStatusProjection(row.UserId, dto);
}
public Task<string?> GetExternalRevertReferenceAsync(long refundId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(r => r.Id == refundId)
.Select(r => r.ExternalRevertReference)
.FirstOrDefaultAsync(cancellationToken);
// Show only the last 4 characters of an external reference to the customer — never the full PSP/BNPL id.
private static string? Mask(string? reference)
{
@@ -0,0 +1,256 @@
using System.Net;
using System.Net.Http.Json;
using System.Text;
using Baya.Application.Contracts.Identity;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Api;
public class BnplApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
[Fact]
public async Task Eligibility_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = 1L, providerCode = "snapppay" });
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task Eligibility_InvalidProvider_Returns400()
{
var client = factory.CreateClient();
await ProfileTestClient.AuthenticateAsync(factory, client, "09131902101", "customer");
var response = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = 1L, providerCode = "not_a_provider" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Full_flow_eligibility_initiate_settle_posts_net_of_fee_ledger_and_invariant_payout()
{
var client = factory.CreateClient();
const string phone = "09131902102";
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
var (requestId, nurseId) = await SeedAcceptedBnplRequestAsync(phone, price: 10_000_000);
// 1. Eligibility → eligible + a bnpl_transactions row in status 'eligible'.
var eligibility = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility",
new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
Assert.Equal(HttpStatusCode.OK, eligibility.StatusCode);
Assert.True((await AuthTestClient.ReadDataAsync(eligibility)).GetProperty("isEligible").GetBoolean());
// 2. Initiate → token_issued + a token/redirect.
var initiate = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/initiate",
new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
Assert.Equal(HttpStatusCode.OK, initiate.StatusCode);
var initData = await AuthTestClient.ReadDataAsync(initiate);
var token = initData.GetProperty("externalPaymentToken").GetString()!;
Assert.Equal("token_issued", initData.GetProperty("status").GetString());
// 3. Provider callbacks verify then settle (anonymous, signature-authenticated).
Assert.Equal(HttpStatusCode.OK, (await PostCallbackAsync(client, CallbackBody("evt-verify-1", "order.verified", token))).StatusCode);
var settle = await PostCallbackAsync(client, CallbackBody("evt-settle-1", "order.settled", token));
Assert.Equal(HttpStatusCode.OK, settle.StatusCode);
Assert.Equal(WebhookProcessingStatus.Processed, (await AuthTestClient.ReadDataAsync(settle)).GetProperty("processingStatus").GetString());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var booking = db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId);
Assert.Equal(BookingStatus.Confirmed, booking.Status);
var legs = db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == booking.Id).ToList();
Assert.Equal(5, legs.Count); // capture (3) + provider-fee (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// Net escrow = settled amount (gross provider commission), and the bnpl_fee_expense leg exists.
Assert.Equal(1_000_000, legs.Where(l => l.AccountType == LedgerAccountType.BnplFeeExpense).Sum(l => l.AmountIrr)); // 10%
var escrowNet = legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr)
- legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr);
Assert.Equal(9_000_000, escrowNet);
// Payout invariant to method — identical to the card path (gross 15% commission).
var payable = legs.Single(l => l.AccountType == LedgerAccountType.NursePayable);
Assert.Equal(nurseId, payable.NurseId);
Assert.Equal(8_500_000, payable.AmountIrr);
var settledTxn = db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.BookingRequestId == requestId && t.Status == PaymentTransactionStatus.Succeeded);
var bnpl = db.Set<BnplTransaction>().AsNoTracking().Single(b => b.PaymentTransactionId == settledTxn.Id);
Assert.Equal(BnplStatus.Settled, bnpl.Status);
Assert.Equal(9_000_000, bnpl.SettledAmountIrr);
Assert.Equal(1_000_000, bnpl.BnplCommissionIrr);
}
[Fact]
public async Task Replayed_settle_callback_is_idempotent_no_second_ledger()
{
var client = factory.CreateClient();
const string phone = "09131902103";
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
var (requestId, _) = await SeedAcceptedBnplRequestAsync(phone, price: 5_000_000);
await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var initiate = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/initiate", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var token = (await AuthTestClient.ReadDataAsync(initiate)).GetProperty("externalPaymentToken").GetString()!;
await PostCallbackAsync(client, CallbackBody("evt-verify-2", "order.verified", token));
var body = CallbackBody("evt-settle-2", "order.settled", token);
var first = await PostCallbackAsync(client, body);
var replay = await PostCallbackAsync(client, body);
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.OK, replay.StatusCode);
Assert.True((await AuthTestClient.ReadDataAsync(replay)).GetProperty("duplicate").GetBoolean());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var booking = db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId);
Assert.Equal(5, db.Set<LedgerEntry>().AsNoTracking().Count(l => l.BookingId == booking.Id));
Assert.Equal(1, db.Set<PaymentWebhookEvent>().AsNoTracking().Count(e => e.ExternalEventId == "evt-settle-2"));
}
[Fact]
public async Task Admin_revert_reverses_a_settled_order_and_opens_a_bnpl_revert_refund()
{
var client = factory.CreateClient();
const string phone = "09131902104";
await ProfileTestClient.AuthenticateAsync(factory, client, phone, "customer");
var (requestId, _) = await SeedAcceptedBnplRequestAsync(phone, price: 8_000_000);
await client.PostAsJsonAsync("/api/v1/checkout_bnpl/eligibility", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var initiate = await client.PostAsJsonAsync("/api/v1/checkout_bnpl/initiate", new { bookingRequestId = requestId, providerCode = BnplProviderCodes.SnappPay });
var token = (await AuthTestClient.ReadDataAsync(initiate)).GetProperty("externalPaymentToken").GetString()!;
await PostCallbackAsync(client, CallbackBody("evt-verify-3", "order.verified", token));
await PostCallbackAsync(client, CallbackBody("evt-settle-3", "order.settled", token));
long bnplId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var pt = db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.GatewayReferenceCode == token);
bnplId = db.Set<BnplTransaction>().AsNoTracking().Single(b => b.PaymentTransactionId == pt.Id).Id;
}
// A customer may not revert (admin-only) — the dynamic-permission policy forbids it.
var forbidden = await client.PostAsJsonAsync($"/api/v1/admin_bnpl/{bnplId}/revert", new { });
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
var admin = factory.CreateClient();
await AdminTestClient.AuthenticateAsync(factory, admin, "09131902199");
var revert = await admin.PostAsJsonAsync($"/api/v1/admin_bnpl/{bnplId}/revert", new { });
Assert.Equal(HttpStatusCode.OK, revert.StatusCode);
var revertData = await AuthTestClient.ReadDataAsync(revert);
Assert.Equal(BnplStatus.Reverted, revertData.GetProperty("status").GetString());
Assert.Equal("8000000", revertData.GetProperty("revertedAmountIrr").GetString());
var status = await admin.GetAsync($"/api/v1/admin_bnpl/{bnplId}");
Assert.Equal(HttpStatusCode.OK, status.StatusCode);
var statusData = await AuthTestClient.ReadDataAsync(status);
Assert.Equal(BnplStatus.Reverted, statusData.GetProperty("status").GetString());
Assert.False(statusData.GetProperty("refundChannel").GetString() is null);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var pt = db.Set<PaymentTransaction>().AsNoTracking().Single(t => t.GatewayReferenceCode == token);
var refund = db.Set<Baya.Domain.Entities.Refunds.Refund>().AsNoTracking().Single(r => r.PaymentTransactionId == pt.Id);
Assert.Equal(Baya.Domain.Entities.Refunds.RefundChannel.BnplRevert, refund.RefundChannel);
Assert.NotNull(refund.ExpectedCustomerRefundEta);
}
}
private static string CallbackBody(string eventId, string eventType, string token)
=> $"{{\"external_event_id\":\"{eventId}\",\"event_type\":\"{eventType}\",\"gateway_reference_code\":\"{token}\"}}";
private static Task<HttpResponseMessage> PostCallbackAsync(HttpClient client, string body)
=> client.PostAsync("/api/v1/webhooks_bnpl/snapppay", new StringContent(body, Encoding.UTF8, "application/json"));
/// <summary>Seeds reference data + a bookable nurse + an accepted request owned by the authenticated customer,
/// plus an active BNPL gateway. Returns the request id and nurse profile id.</summary>
private async Task<(long RequestId, long NurseId)> SeedAcceptedBnplRequestAsync(string customerPhone, long price)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<IAppUserManager>();
var customerUser = await userManager.GetUserByPhoneNumber(customerPhone);
var customer = db.Set<CustomerProfile>().FirstOrDefault(c => c.UserId == customerUser!.Id);
if (customer is null)
{
customer = new CustomerProfile { UserId = customerUser!.Id };
db.Set<CustomerProfile>().Add(customer);
db.SaveChanges();
}
if (!db.Set<PaymentGateway>().Any(g => g.Type == PaymentGatewayType.Bnpl && g.IsActive))
{
db.Set<PaymentGateway>().Add(new PaymentGateway
{
ProviderCode = BnplProviderCodes.SnappPay, Type = PaymentGatewayType.Bnpl, DisplayName = "SnappPay",
ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0
});
db.SaveChanges();
}
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
db.Set<Province>().Add(province);
db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
db.Set<ServiceCategory>().Add(category);
db.SaveChanges();
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = customerPhone,
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
};
db.Set<CustomerAddress>().Add(address);
var nurseUser = new User { UserName = $"nurse_{Guid.NewGuid():N}", PhoneNumber = $"0912{Random.Shared.Next(1000000, 9999999)}", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
db.Users.Add(nurseUser);
db.SaveChanges();
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
db.Set<NurseProfile>().Add(nurse);
db.SaveChanges();
var variant = new NurseServiceVariant
{
NurseId = nurse.Id, ServiceCategoryId = category.Id, Price = price, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
db.Set<NurseServiceVariant>().Add(variant);
db.SaveChanges();
var request = new BookingRequest
{
CustomerId = customer.Id, NurseId = nurse.Id, PatientId = patient.Id, VariantId = variant.Id,
CustomerAddressId = address.Id, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
db.Set<BookingRequest>().Add(request);
db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
db.SaveChanges();
return (request.Id, nurse.Id);
}
}
@@ -0,0 +1,136 @@
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
using Baya.Application.Features.Bnpl.Commands.SettleBnplOrder;
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
using Microsoft.EntityFrameworkCore;
using BookingEntity = Baya.Domain.Entities.Booking.Booking;
namespace Baya.Test.Foundation.Bnpl;
public class BnplHandlerTests
{
private static readonly DateTimeOffset Now = new(2026, 8, 10, 10, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Eligibility_creates_row_eligible_and_returns_plan()
{
using var host = new BnplTestHost();
var requestId = host.AddAcceptedRequest(10_000_000);
var handler = new CheckBnplEligibilityQueryHandler(host.AsCustomer(), host.UnitOfWork, host.Resolver(), host.Config());
var result = await handler.Handle(new CheckBnplEligibilityQuery(requestId, BnplProviderCodes.SnappPay), CancellationToken.None);
Assert.True(result.IsSuccess);
Assert.True(result.Result.IsEligible);
Assert.Equal(BnplEligibilityStatus.Eligible, result.Result.EligibilityStatus);
Assert.Equal(4, result.Result.InstallmentCount);
var bnpl = host.Db.Set<BnplTransaction>().AsNoTracking().Single();
Assert.Equal(BnplStatus.Eligible, bnpl.Status);
Assert.Equal(BnplEligibilityStatus.Eligible, bnpl.EligibilityStatus);
Assert.Equal(10_000_000, bnpl.OrderAmountIrr);
}
[Fact]
public async Task Initiate_issues_token_and_is_strictly_one_to_one()
{
using var host = new BnplTestHost();
var requestId = host.AddAcceptedRequest(10_000_000);
var handler = new InitiateBnplOrderCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Resolver(), host.Currency, host.Config(), host.Lock, host.Clock(Now));
var first = await handler.Handle(new InitiateBnplOrderCommand(requestId, BnplProviderCodes.SnappPay, "idem-1"), CancellationToken.None);
Assert.True(first.IsSuccess);
Assert.Equal(BnplStatus.TokenIssued, first.Result.Status);
Assert.False(string.IsNullOrEmpty(first.Result.ExternalPaymentToken));
// A second initiate reuses the same 1:1 row (the UNIQUE(payment_transaction_id) guard) — no second order.
var second = await handler.Handle(new InitiateBnplOrderCommand(requestId, BnplProviderCodes.SnappPay, "idem-1"), CancellationToken.None);
Assert.True(second.IsSuccess);
Assert.Equal(first.Result.BnplTransactionId, second.Result.BnplTransactionId);
Assert.Equal(1, host.Db.Set<BnplTransaction>().AsNoTracking().Count());
}
[Fact]
public async Task Settle_posts_net_of_fee_group_and_nurse_payout_is_invariant_to_method()
{
using var host = new BnplTestHost();
var (bnplId, requestId, _) = host.SeedVerifiedOrder(10_000_000);
var handler = SettleHandler(host, commissionRate: 0.10m);
var result = await handler.Handle(new SettleBnplOrderCommand(bnplId), CancellationToken.None);
Assert.True(result.IsSuccess);
var bnpl = host.ReloadBnpl(bnplId);
Assert.Equal(BnplStatus.Settled, bnpl.Status);
Assert.Equal(1_000_000, bnpl.BnplCommissionIrr); // 10% of 10M
Assert.Equal(9_000_000, bnpl.SettledAmountIrr); // net = order commission
var bookingId = host.Db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId).Id;
var legs = host.LedgerFor(bookingId);
Assert.Equal(5, legs.Count); // capture (3) + provider-fee (2)
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// The bnpl_fee_expense leg, and escrow reflecting NET cash (10M debit 1M credit = 9M = settled).
Assert.Equal(1_000_000, Leg(legs, LedgerAccountType.BnplFeeExpense, LedgerDirection.Debit));
Assert.Equal(10_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Debit));
Assert.Equal(1_000_000, Leg(legs, LedgerAccountType.EscrowHeld, LedgerDirection.Credit));
// Payout invariant to method: nurse_payable = gross commission(0.15) = 8.5M, independent of the BNPL fee.
Assert.Equal(8_500_000, Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Credit));
Assert.Equal(1_500_000, Leg(legs, LedgerAccountType.PlatformRevenue, LedgerDirection.Credit));
// Identical to the card-capture path (the DoD payout-invariance assertion).
var card = LedgerPosting.CardCapture(bookingId, host.NurseId, 10_000_000, 1_500_000, 8_500_000, 1, Now.UtcDateTime);
Assert.Equal(
card.Single(l => l.AccountType == LedgerAccountType.NursePayable).AmountIrr,
Leg(legs, LedgerAccountType.NursePayable, LedgerDirection.Credit));
}
[Fact]
public async Task Replayed_settle_is_a_noop_no_second_ledger_group()
{
using var host = new BnplTestHost();
var (bnplId, requestId, _) = host.SeedVerifiedOrder(10_000_000);
var handler = SettleHandler(host, commissionRate: 0.10m);
await handler.Handle(new SettleBnplOrderCommand(bnplId), CancellationToken.None);
var bookingId = host.Db.Set<BookingEntity>().AsNoTracking().Single(b => b.BookingRequestId == requestId).Id;
var before = host.LedgerFor(bookingId).Count;
var replay = await handler.Handle(new SettleBnplOrderCommand(bnplId), CancellationToken.None);
Assert.True(replay.IsSuccess); // state guard → idempotent no-op
Assert.Equal(before, host.LedgerFor(bookingId).Count);
}
[Fact]
public async Task Settle_before_verify_is_rejected_by_the_state_guard()
{
using var host = new BnplTestHost();
var requestId = host.AddAcceptedRequest(10_000_000);
// Drive only to token_issued (skip verify) via the real initiate handler.
var initiate = new InitiateBnplOrderCommandHandler(
host.AsCustomer(), host.UnitOfWork, host.Resolver(), host.Currency, host.Config(), host.Lock, host.Clock(Now));
var order = await initiate.Handle(new InitiateBnplOrderCommand(requestId, BnplProviderCodes.SnappPay, "idem-1"), CancellationToken.None);
var settle = SettleHandler(host, commissionRate: 0.10m);
var result = await settle.Handle(new SettleBnplOrderCommand(order.Result.BnplTransactionId), CancellationToken.None);
Assert.False(result.IsSuccess);
Assert.True(result.IsConflict);
Assert.Empty(host.Db.Set<LedgerEntry>().AsNoTracking().ToList());
}
private static SettleBnplOrderCommandHandler SettleHandler(BnplTestHost host, decimal commissionRate)
=> new(host.UnitOfWork, host.Resolver(commissionRate), host.Config(), host.Serializer,
host.Settlement, host.Lock, host.Clock(Now), host.Notifications());
private static long Leg(IReadOnlyList<LedgerEntry> legs, string account, string direction)
=> legs.Where(l => l.AccountType == account && l.Direction == direction).Sum(l => l.AmountIrr);
}
@@ -0,0 +1,56 @@
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Payments;
namespace Baya.Test.Foundation.Bnpl;
public class BnplLedgerAndStateTests
{
[Fact]
public void BnplSettle_group_is_balanced_and_reflects_net_escrow()
{
var legs = LedgerPosting.BnplSettle(
bookingId: 1, nurseId: 2, grossIrr: 10_000_000, commissionIrr: 1_500_000,
payoutIrr: 8_500_000, bnplCommissionIrr: 1_000_000, bnplTransactionId: 3, createdAt: DateTime.UtcNow);
Assert.Equal(5, legs.Count);
Assert.Equal(
legs.Where(l => l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr),
legs.Where(l => l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr));
// Net escrow = 10M debit 1M credit = 9M (the settled amount), not the gross.
var escrowNet = legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Debit).Sum(l => l.AmountIrr)
- legs.Where(l => l.AccountType == LedgerAccountType.EscrowHeld && l.Direction == LedgerDirection.Credit).Sum(l => l.AmountIrr);
Assert.Equal(9_000_000, escrowNet);
Assert.All(legs, l => Assert.Equal(LedgerSourceRefType.BnplTransaction, l.SourceRefType));
}
[Fact]
public void BnplSettle_throws_when_the_capture_legs_do_not_reconcile()
=> Assert.Throws<InvalidOperationException>(() => LedgerPosting.BnplSettle(1, 2, 10_000_000, 1_500_000, 8_000_000, 1_000_000, 3, DateTime.UtcNow));
[Fact]
public void State_machine_allows_forward_edges_and_rejects_illegal_ones()
{
Assert.True(BnplTransitions.CanTransition(BnplStatus.Eligible, BnplStatus.TokenIssued));
Assert.True(BnplTransitions.CanTransition(BnplStatus.TokenIssued, BnplStatus.Verified));
Assert.True(BnplTransitions.CanTransition(BnplStatus.Verified, BnplStatus.Settled));
Assert.True(BnplTransitions.CanTransition(BnplStatus.Settled, BnplStatus.Reverted));
Assert.False(BnplTransitions.CanTransition(BnplStatus.TokenIssued, BnplStatus.Settled)); // must verify first
Assert.False(BnplTransitions.CanTransition(BnplStatus.Settled, BnplStatus.Settled)); // no self-loop
Assert.False(BnplTransitions.CanTransition(BnplStatus.Reverted, BnplStatus.Settled)); // terminal
}
[Fact]
public void MarkSettled_enforces_the_net_equals_order_minus_commission_invariant()
{
var bnpl = new BnplTransaction { OrderAmountIrr = 10_000_000 };
bnpl.MarkTokenIssued("t", null);
bnpl.MarkVerified(null, null);
Assert.Throws<InvalidOperationException>(() => bnpl.MarkSettled(settledAmountIrr: 9_500_000, commissionIrr: 1_000_000, settledAt: null, null));
bnpl.MarkSettled(settledAmountIrr: 9_000_000, commissionIrr: 1_000_000, settledAt: null, null);
Assert.Equal(BnplStatus.Settled, bnpl.Status);
}
}
@@ -0,0 +1,208 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Payments;
using Baya.Domain.Entities.Booking;
using Baya.Domain.Entities.Bnpl;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Payments;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.CrossCutting.Seams;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Baya.Test.Foundation.Bnpl;
/// <summary>
/// A self-contained SQLite host exercising the real EF model (the strict 1:1 <c>UNIQUE(payment_transaction_id)</c>,
/// the settle-split CHECK, the append-only ledger) for the b12 BNPL engine. Seeds one bookable nurse + one
/// customer + an active BNPL gateway, and can seed a verified BNPL order so a test can drive the real settle
/// handler against the real <see cref="UnitOfWork"/> with a deterministic provider mock.
/// </summary>
public sealed class BnplTestHost : IDisposable
{
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public UnitOfWork UnitOfWork { get; }
public long CustomerId { get; }
public int CustomerUserId { get; }
public long NurseId { get; }
public int NurseUserId { get; }
public long PatientId { get; }
public long AddressId { get; }
public long CategoryId { get; }
public long GatewayId { get; }
public BnplTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>().UseSqlite(_connection).Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
UnitOfWork = new UnitOfWork(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
Db.Set<ServiceCategory>().Add(category);
Db.SaveChanges();
CategoryId = category.Id;
var customerUser = new User { UserName = "cust1", PhoneNumber = "09120000001", Gender = "male", Name = "علی", FamilyName = "رضایی", IsActive = true };
Db.Users.Add(customerUser);
Db.SaveChanges();
CustomerUserId = customerUser.Id;
var customer = new CustomerProfile { UserId = customerUser.Id };
Db.Set<CustomerProfile>().Add(customer);
Db.SaveChanges();
CustomerId = customer.Id;
var patient = new Patient { CustomerId = customer.Id, DisplayName = "پدر", FirstName = "حسن", LastName = "رضایی", Gender = "male", IsActive = true };
Db.Set<Patient>().Add(patient);
var address = new CustomerAddress
{
CustomerId = customer.Id, CityId = city.Id, Title = "خانه", AddressLine = "خیابان اول",
PostalCode = "1111111111", RecipientName = "علی", RecipientPhone = "09120000001",
Latitude = 35.6892m, Longitude = 51.3890m, IsPrimary = true
};
Db.Set<CustomerAddress>().Add(address);
Db.SaveChanges();
PatientId = patient.Id;
AddressId = address.Id;
var nurseUser = new User { UserName = "nurse1", PhoneNumber = "09120000002", Gender = "female", Name = "زهرا", FamilyName = "احمدی", IsActive = true };
Db.Users.Add(nurseUser);
Db.SaveChanges();
NurseUserId = nurseUser.Id;
var nurse = new NurseProfile { UserId = nurseUser.Id };
nurse.MarkVerified();
nurse.SetAcceptingBookings(true);
Db.Set<NurseProfile>().Add(nurse);
Db.SaveChanges();
NurseId = nurse.Id;
var gateway = new PaymentGateway
{
ProviderCode = BnplProviderCodes.SnappPay, Type = PaymentGatewayType.Bnpl, DisplayName = "SnappPay",
ConfigJson = "{\"merchantId\":\"test\",\"sandbox\":true}", IsActive = true, Priority = 0
};
Db.Set<PaymentGateway>().Add(gateway);
Db.SaveChanges();
GatewayId = gateway.Id;
}
public long AddAcceptedRequest(long price)
{
var variant = new NurseServiceVariant
{
NurseId = NurseId, ServiceCategoryId = CategoryId, Price = price, PriceUnit = "per_day",
SessionCount = null, DisplayName = "مراقبت روزانه", OptionSetHash = $"hash-{Guid.NewGuid():N}", IsActive = true
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
var request = new BookingRequest
{
CustomerId = CustomerId, NurseId = NurseId, PatientId = PatientId, VariantId = variant.Id,
CustomerAddressId = AddressId, RequiredCaregiverGender = CaregiverGender.Any,
RequestedDate = new DateOnly(2026, 8, 1), RequestedTimeStart = new TimeOnly(9, 0), RequestedTimeEnd = new TimeOnly(13, 0),
CustomerNotes = "note", NurseResponseDeadlineAt = new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc)
};
Db.Set<BookingRequest>().Add(request);
Db.SaveChanges();
request.Accept(new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc));
Db.SaveChanges();
return request.Id;
}
/// <summary>Seeds a verified BNPL order (its pending payment_transaction + the 1:1 bnpl_transactions row walked
/// eligible → token_issued → verified) against a fresh accepted request. Returns (bnplId, requestId, token).</summary>
public (long BnplId, long RequestId, string Token) SeedVerifiedOrder(long gross)
{
var requestId = AddAcceptedRequest(gross);
var token = $"tok-{Guid.NewGuid():N}";
var transaction = new PaymentTransaction
{
BookingRequestId = requestId, CustomerId = CustomerId, GatewayId = GatewayId,
Amount = gross, Currency = "IRR", IsInstallment = true, GatewayReferenceCode = token
};
Db.Set<PaymentTransaction>().Add(transaction);
Db.SaveChanges();
var bnpl = new BnplTransaction
{
PaymentTransactionId = transaction.Id, ProviderCode = BnplProviderCodes.SnappPay,
MerchantOfRecord = "platform", OrderAmountIrr = gross, Currency = "IRR"
};
bnpl.EligibilityStatus = BnplEligibilityStatus.Eligible;
bnpl.MarkTokenIssued(token, "order-1");
bnpl.MarkVerified("verify-1", null);
Db.Set<BnplTransaction>().Add(bnpl);
Db.SaveChanges();
return (bnpl.Id, requestId, token);
}
// ---- seams ----
public IVariantSnapshotSerializer Serializer { get; } = new VariantSnapshotSerializer();
public ISettlementSplitProvider Settlement { get; } = new MockSettlementSplitProvider();
public IDistributedLock Lock { get; } = new InProcessDistributedLock();
public ICurrencyNormalizer Currency { get; } = new MockCurrencyNormalizer(Options.Create(new SeamOptions()));
public IBnplProviderResolver Resolver(decimal commissionRate = 0.10m, bool forceFailure = false)
{
var opts = Options.Create(new SeamOptions { Bnpl = new BnplOptions { CommissionRate = commissionRate, ForceFailure = forceFailure } });
return new MockBnplProviderResolver(new MockBnplProvider(opts));
}
public ICurrentUser AsCustomer()
{
var u = Substitute.For<ICurrentUser>();
u.UserId.Returns(CustomerUserId);
u.Roles.Returns(new[] { RoleNames.Customer });
return u;
}
public IDateTimeProvider Clock(DateTimeOffset now)
{
var c = Substitute.For<IDateTimeProvider>();
c.UtcNow.Returns(now);
return c;
}
public IPlatformConfig Config(decimal feeRate = 0.15m)
{
var cfg = Substitute.For<IPlatformConfig>();
cfg.GetConfig<decimal>("platform_fee_rate", Arg.Any<CancellationToken>()).Returns(feeRate);
cfg.GetConfig<string>("bnpl_merchant_of_record", Arg.Any<CancellationToken>()).Returns("platform");
return cfg;
}
public INotificationDispatcher Notifications() => Substitute.For<INotificationDispatcher>();
public IReadOnlyList<LedgerEntry> LedgerFor(long bookingId)
=> Db.Set<LedgerEntry>().AsNoTracking().Where(l => l.BookingId == bookingId).OrderBy(l => l.Id).ToList();
public BnplTransaction ReloadBnpl(long id)
=> Db.Set<BnplTransaction>().AsNoTracking().Single(b => b.Id == id);
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}