Files
baya-monorepo/archive/build-chain/working-context/reports/backend-phase-12-report.md
T
2026-08-02 18:48:32 +03:30

7.6 KiB
Raw Blame History

Backend phase 12 — BNPL: provider-financed installments (mocked) — report

Mission: let a family pay for a booking with a provider-financed BNPL plan, and record it correctly — a BNPL order is, in Balinyaar's books, a card payment that lands net-of-fee.

What was built

Domain (Baya.Domain/Entities/Bnpl/)

  • BnplTransaction — one row per order, 1:1 with its payment_transaction. Guarded Status mutated only through cohesive MarkTokenIssued/MarkVerified/MarkSettled/MarkReverted/MarkCancelled/MarkFailed; MarkSettled enforces settled = order commission (≥0). Money is IRR long; settled_at, provider_commission_reversed_amount, and all settle/revert amounts are nullable.
  • BnplStatus + BnplTransitions — the forward-only state machine (eligible → token_issued → verified → settled → reverted/cancelled/failed), the idempotency spine.
  • BnplEligibilityStatus (eligible/not_eligible/ceiling_exceeded), BnplProviderCodes (snapppay/digipay/tara/torobpay).
  • LedgerPosting.BnplSettle — the net-of-fee group (card-capture legs plus DEBIT bnpl_fee_expense / CREDIT escrow_held), one balanced transaction_group_id, SourceRefType = bnpl_transaction. Throws if the capture legs don't reconcile.

Application (Baya.Application/Features/Bnpl/)

  • Queries: CheckBnplEligibilityQuery (records eligibility_status on a created/updated row), GetBnplOrderStatusQuery (admin/customer, tenancy-scoped).
  • Commands: InitiateBnplOrderCommand (token + eligible → token_issued, under lock(booking-request:{id}:payment), idempotency-keyed), VerifyBnplOrderCommand, SettleBnplOrderCommand (net-of-fee ledger + booking conversion, under lock(bnpl:{id}:settle)), RevertBnplOrderCommand (reuses b11 CreateRefundCommand), HandleBnplCallbackCommand (webhook dedup + dispatch by event type).
  • BnplOrderInitializer (shared find-or-create of the pending payment_transaction + 1:1 BNPL row) and the extracted BookingConversion helper (shared by b10 card capture + b12 settle — b10 was refactored to use it).
  • New seams in Contracts/Payments/: IBnplProvider (full verb set, supersedes the b11 revert-only stub), IBnplProviderResolver, ICurrencyNormalizer; IBnplRepository on IUnitOfWork.

Infrastructure

  • Persistence: BnplTransactionConfig (payments.BnplTransactions, UNIQUE(payment_transaction_id), CK_BnplTransactions_SettleSplit, filtered token index), BnplRepository, UnitOfWork wiring, one migration BnplTransactions. IRefundRepository.GetExternalRevertReferenceAsync added for the revert audit.
  • CrossCutting/Seams: MockBnplProvider (deterministic full state machine), MockBnplProviderResolver, MockCurrencyNormalizer; SeamOptions extended (BnplOptions + CurrencyOptions); DI registration in AddCrossCuttingSeams.
  • API: CheckoutBnplController, WebhooksBnplController, AdminBnplController.

What is now testable and exactly how (per phase §7)

Seed a pending_payment/accepted booking request with a known three-amount split and a payment_gateways row type='bnpl', provider_code='snapppay'; mock commission % via Seams:Bnpl:CommissionRate (default 10%).

  1. EligibilityPOST api/v1/checkout_bnpl/eligibilityeligible + plan summary; a bnpl_transactions row exists with eligibility_status set and status='eligible'. (Covered: BnplHandlerTests.Eligibility_creates_row_eligible_and_returns_plan, BnplApiTests eligibility.)
  2. InitiatePOST api/v1/checkout_bnpl/initiatestatus='token_issued', deterministic token + redirect; 1:1 with the payment_transaction; a second initiate reuses the same row. (BnplHandlerTests.Initiate_issues_token_and_is_strictly_one_to_one.)
  3. Verify → settle (the ledger) — drive POST api/v1/webhooks_bnpl/snapppay (order.verified then order.settled) or the admin settle → verified → settled; settled_amount = order commission, ledger shows the balanced net-of-fee group and net escrow_held = settled_amount. (BnplHandlerTests.Settle_posts_net_of_fee_group_…, BnplApiTests.Full_flow_…, BnplLedgerAndStateTests.)
  4. Payout invariancenurse_payable credited = gross balinyaar_commission, identical to the card path and independent of the BNPL commission. (Asserted in both the handler + api full-flow tests, compared against LedgerPosting.CardCapture.)
  5. Replayed settle is a no-op — re-deliver the same settle callback → webhook dedup + state guard reject it; no second ledger group. (BnplApiTests.Replayed_settle_callback_is_idempotent_no_second_ledger, BnplHandlerTests.Replayed_settle_is_a_noop_….)
  6. RevertPOST api/v1/admin_bnpl/{id}/revertstatus='reverted', revert audit set; a refunds row with refund_channel='bnpl_revert' + expected_customer_refund_eta; reversal ledger posts. (BnplApiTests.Admin_revert_reverses_a_settled_order_….)
  7. StatusGET api/v1/admin_bnpl/{id} (admin) / GET api/v1/checkout_bnpl/{id} (customer, own only).
  8. Guards — settle-before-verify is a 409; the state machine rejects illegal edges. (BnplHandlerTests.Settle_before_verify_…, BnplLedgerAndStateTests.State_machine_….)

Full suite: 314 pass (4 identity + 214 foundation + 96 api). Build clean, 0 new code warnings.

What is mocked + how to make it real

  • IBnplProvider (per provider_code via IBnplProviderResolver) — deterministic mock, no network; settle returns order round(order × Seams:Bnpl:CommissionRate) with the commission read from the response. Real: one adapter per code — SnappPay OAuth api/online/v1/oauth/token + offer/v1/eligible + payment/v1/token|verify|settle|revert|cancel|update|status, or Digipay UPG tickets/business?type=13 + purchases/verify + purchases/deliver?type=13 + refunds/reverse; creds from the encrypted payment_gateways.config_json; per-contract commission read from the settle response. Do not use the unrelated Canadian SnapPayInc/open-api-java-sdk.
  • ICurrencyNormalizer — mock ×10 Toman→IRR (Seams:Currency:TomanToIrrMultiplier). Real: read the multiplier/unit from provider config at the boundary.
  • settled_at is nullable / non-instant (Seams:Bnpl:SettlementInstant) — the mock models the daily/T+13/weekly reality. b13 must not assume BNPL cash funds a payout.

Contracts produced / consumed

  • Produced: dev/contracts/domains/bnpl.md; dev/contracts/openapi/swagger.v1.json refreshed (386K → 411K, all BNPL paths present).
  • Consumed: b10 payment_transactions/ledger_entries/payment_webhook_events/IWebhookVerifier/ IDistributedLock/LedgerPosting; b11 refunds/CreateRefundCommand/refund_channel; b9 booking split + BookingFactory; b1 typed config accessor.

Follow-ups

  • b13: the settled_at-gates-payout coupling — add require_bnpl_settlement_for_payout and gate a BNPL booking's payout on bnpl_transactions.settled_at.
  • DEFERRED: bnpl_settlement_entries (tranched settlement — modeled-but-not-built, additive migration later); multi-provider routing/failover (one active route today); provider_commission_reversed_amount reconciliation on revert (left null when the b11 refund path drives it).
  • The BnplTransactions migration was not applied to a live DB this session (no reachable SQL Server in the agent env); apply with dotnet ef database update before the next live run, and seed a type='bnpl' gateway.