Users buy credit packs (one-time payments), defined in src/modules/billing/plans.ts. Payments are provider-agnostic behind the PaymentProvider interface — Stripe (global) and iyzico (Turkey) ship in the box, selected with one env var:
PAYMENT_PROVIDER="stripe" # or "iyzico"Billing stays disabled (UI shows a notice, API returns 501) until the chosen provider’s keys are set — the app never breaks for lack of a payment account.
Stripe
- Set
STRIPE_SECRET_KEY(test keys work). - Create a webhook endpoint for
checkout.session.completedpointing at/api/stripe/webhook, and setSTRIPE_WEBHOOK_SECRET. Locally,stripe listen --forward-to localhost:3000/api/stripe/webhookprints one.
Flow: checkout → hosted payment page → webhook verifies the signature and grants credits.
iyzico
- Set
IYZICO_API_KEYandIYZICO_SECRET_KEY(sandbox keys from sandbox-merchant.iyzipay.com;IYZICO_URIdefaults to the sandbox API). - No webhook setup needed — iyzico’s Checkout Form POSTs a token back to
/api/iyzico/callback, which retrieves and verifies the payment.
Before going live: the shipped provider charges the pack’s USD number as TRY (₺20 for the $20 pack) — placeholder pricing that’s fine in the sandbox and wrong with real money. It deliberately refuses to run when IYZICO_URI points at the production API; implement real TRY pricing in src/modules/billing/providers/iyzico.ts first.
Subscriptions — the credit hybrid
Besides one-time packs, subscriptionPlan in src/modules/billing/plans.ts defines a monthly plan that auto-grants credits every billing cycle. Stripe-only: checkout uses subscription mode with a dynamically created recurring price (no dashboard products needed), the plan’s metadata is snapshotted onto every invoice, and the webhook grants monthlyCredits on invoice.paid — add that event to your webhook endpoint. The invoice id is the idempotency reference, so Stripe’s retries can’t double-grant.
Why double-grants can’t happen
Both providers grant credits through grantCredits() with the provider’s payment reference. That reference has a unique constraint in the ledger, so retried webhooks and refreshed callbacks are database-level no-ops.
Adding another provider
Implement the PaymentProvider interface in src/modules/billing/providers/ (one method: createCheckoutUrl), add its completion route, and register it in providers/index.ts. Nothing else in the app changes.