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.
Subscriptions — the credit hybrid
Besides one-time packs, subscriptionPlan in src/modules/billing/plans.tsdefines 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.