7.6 KiB
7.6 KiB
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 itspayment_transaction. GuardedStatusmutated only through cohesiveMarkTokenIssued/MarkVerified/MarkSettled/MarkReverted/MarkCancelled/MarkFailed;MarkSettledenforcessettled = order − commission(≥0). Money is IRRlong;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 plusDEBIT bnpl_fee_expense / CREDIT escrow_held), one balancedtransaction_group_id,SourceRefType = bnpl_transaction. Throws if the capture legs don't reconcile.
Application (Baya.Application/Features/Bnpl/)
- Queries:
CheckBnplEligibilityQuery(recordseligibility_statuson a created/updated row),GetBnplOrderStatusQuery(admin/customer, tenancy-scoped). - Commands:
InitiateBnplOrderCommand(token +eligible → token_issued, underlock(booking-request:{id}:payment), idempotency-keyed),VerifyBnplOrderCommand,SettleBnplOrderCommand(net-of-fee ledger + booking conversion, underlock(bnpl:{id}:settle)),RevertBnplOrderCommand(reuses b11CreateRefundCommand),HandleBnplCallbackCommand(webhook dedup + dispatch by event type). BnplOrderInitializer(shared find-or-create of the pendingpayment_transaction+ 1:1 BNPL row) and the extractedBookingConversionhelper (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;IBnplRepositoryonIUnitOfWork.
Infrastructure
Persistence:BnplTransactionConfig(payments.BnplTransactions,UNIQUE(payment_transaction_id),CK_BnplTransactions_SettleSplit, filtered token index),BnplRepository,UnitOfWorkwiring, one migrationBnplTransactions.IRefundRepository.GetExternalRevertReferenceAsyncadded for the revert audit.CrossCutting/Seams:MockBnplProvider(deterministic full state machine),MockBnplProviderResolver,MockCurrencyNormalizer;SeamOptionsextended (BnplOptions+CurrencyOptions); DI registration inAddCrossCuttingSeams.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%).
- Eligibility —
POST api/v1/checkout_bnpl/eligibility→eligible+ plan summary; abnpl_transactionsrow exists witheligibility_statusset andstatus='eligible'. (Covered:BnplHandlerTests.Eligibility_creates_row_eligible_and_returns_plan,BnplApiTestseligibility.) - Initiate —
POST api/v1/checkout_bnpl/initiate→status='token_issued', deterministic token + redirect; 1:1 with thepayment_transaction; a second initiate reuses the same row. (BnplHandlerTests.Initiate_issues_token_and_is_strictly_one_to_one.) - Verify → settle (the ledger) — drive
POST api/v1/webhooks_bnpl/snapppay(order.verifiedthenorder.settled) or the admin settle →verified → settled;settled_amount = order − commission, ledger shows the balanced net-of-fee group and netescrow_held = settled_amount. (BnplHandlerTests.Settle_posts_net_of_fee_group_…,BnplApiTests.Full_flow_…,BnplLedgerAndStateTests.) - Payout invariance —
nurse_payablecredited =gross − balinyaar_commission, identical to the card path and independent of the BNPL commission. (Asserted in both the handler + api full-flow tests, compared againstLedgerPosting.CardCapture.) - 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_….) - Revert —
POST api/v1/admin_bnpl/{id}/revert→status='reverted', revert audit set; arefundsrow withrefund_channel='bnpl_revert'+expected_customer_refund_eta; reversal ledger posts. (BnplApiTests.Admin_revert_reverses_a_settled_order_….) - Status —
GET api/v1/admin_bnpl/{id}(admin) /GET api/v1/checkout_bnpl/{id}(customer, own only). - 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(perprovider_codeviaIBnplProviderResolver) — deterministic mock, no network; settle returnsorder − round(order × Seams:Bnpl:CommissionRate)with the commission read from the response. Real: one adapter per code — SnappPay OAuthapi/online/v1/oauth/token+offer/v1/eligible+payment/v1/token|verify|settle|revert|cancel|update|status, or Digipay UPGtickets/business?type=13+purchases/verify+purchases/deliver?type=13+refunds/reverse; creds from the encryptedpayment_gateways.config_json; per-contract commission read from the settle response. Do not use the unrelated CanadianSnapPayInc/open-api-java-sdk.ICurrencyNormalizer— mock ×10 Toman→IRR (Seams:Currency:TomanToIrrMultiplier). Real: read the multiplier/unit from provider config at the boundary.settled_atis nullable / non-instant (Seams:Bnpl:SettlementInstant) — the mock models the daily/T+1–3/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.jsonrefreshed (386K → 411K, all BNPL paths present). - Consumed: b10
payment_transactions/ledger_entries/payment_webhook_events/IWebhookVerifier/IDistributedLock/LedgerPosting; b11refunds/CreateRefundCommand/refund_channel; b9 booking split +BookingFactory; b1 typed config accessor.
Follow-ups
- b13: the
settled_at-gates-payout coupling — addrequire_bnpl_settlement_for_payoutand gate a BNPL booking's payout onbnpl_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_amountreconciliation on revert (left null when the b11 refund path drives it). - The
BnplTransactionsmigration was not applied to a live DB this session (no reachable SQL Server in the agent env); apply withdotnet ef database updatebefore the next live run, and seed atype='bnpl'gateway.