frontend phase 5 & backend phase 12
This commit is contained in:
+32
-3
@@ -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 ~7–10-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 ~7–10-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;
|
||||
}
|
||||
}
|
||||
+140
@@ -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));
|
||||
}
|
||||
+16
@@ -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>>;
|
||||
+103
@@ -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));
|
||||
}
|
||||
}
|
||||
+16
@@ -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.");
|
||||
}
|
||||
}
|
||||
+15
@@ -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>>;
|
||||
+75
@@ -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));
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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 ~7–10-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 (0–1]; 1 = full revert, <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>>;
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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>>;
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+13
@@ -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>>;
|
||||
+68
@@ -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()));
|
||||
}
|
||||
}
|
||||
+16
@@ -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.");
|
||||
}
|
||||
}
|
||||
+16
@@ -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>>;
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+14
@@ -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);
|
||||
+13
-51
@@ -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 (~7–10 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+1–3/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.
|
||||
|
||||
+59
-8
@@ -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));
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
+24
@@ -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+1–3/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>
|
||||
|
||||
+11
-3
@@ -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;
|
||||
}
|
||||
|
||||
+45
@@ -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);
|
||||
}
|
||||
}
|
||||
+4987
File diff suppressed because it is too large
Load Diff
+88
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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)
|
||||
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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()
|
||||
|
||||
+6
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user