Billing
Checkout creates an order from the plan row, then pays (or immediately fulfills zero-price plans). Fulfillment is idempotent.
Flow
text
Plans → Checkout → Create/reuse Order
↓
amount = plan.priceAmount (never from the client)
↓
If amount is 0 → fulfillPaidOrder → billing result
Else → payment provider (ZarinPal or mock)
↓
Callback / verify
↓
fulfillPaidOrder (idempotent) → assign planOrders
An order snapshots plan name, amount, and currency. Status: pending, paid, failed, cancelled. Idempotency keys prevent duplicate pending checkouts for the same attempt.
Billing history is under /billing. There is no admin “mark as paid” shortcut — paid state comes from verified payment or zero-price fulfill.
Providers
- ZarinPal — when
ZARINPAL_MERCHANT_IDis set. Sandbox defaults outside production unlessZARINPAL_SANDBOXoverrides. - Mock — in-app simulated gateway at
/billing/pay/[orderId]when merchant is unset in non-production, or whenPAYMENT_PROVIDER=mock.
Browser callback GET /api/payment/callback is untrusted until server-to-server verify succeeds.