Payments
How a click on a pricing card becomes an order, a subscription row and credits, for Stripe, Creem and PayPal.
Money reaches the database along one path: a provider sends an event, a thin webhook route checks its signature, the provider's adapter translates it, and the fulfillment core writes the order, the subscription row and the credits in one transaction. Checkout, the success page and the reconcile cron all end in the same core, so an event that arrives twice changes nothing the second time.
| Layer | Files |
|---|---|
| Plans | config/pricing.ts (the only source; see Pricing plans) |
| Checkout and portal | actions/billing/checkout.ts, actions/billing/portal.ts, components/pricing/ |
| Webhook routes | app/api/stripe/webhook/route.ts, app/api/creem/webhook/route.ts, app/api/paypal/webhook/route.ts |
| PayPal button flow | app/api/paypal/create-order/route.ts, app/api/paypal/capture-order/route.ts |
| Adapters | lib/billing/stripe.ts, lib/billing/creem.ts, lib/billing/paypal.ts |
| Core | lib/billing/fulfillment.ts, lib/billing/types.ts |
| Helpers | cancel.ts (provider-side cancel), duplicate.ts (double subscription), customer.ts (Stripe customer), reconcile.ts (cron recovery), notify.ts (alerts) in lib/billing/ |
| Provider SDK layers | lib/stripe/, lib/creem/, lib/paypal/ (transport only, no business logic) |
The deeper agent-facing notes are in lib/billing/CLAUDE.md and skills/reference/payments.md.
Plan kinds and providers
| Kind | Charged | Grants | Order type |
|---|---|---|---|
license |
once | nothing; the order row is the purchase | one_time |
credit_pack |
once | credits into the purchased bucket |
one_time |
subscription |
monthly or yearly | monthlyCredits into the subscription bucket each month |
subscription_initial, subscription_renewal, subscription_upgrade |
| Provider | Price reference in the plan | Test or live |
|---|---|---|
| Stripe | stripePriceId (a Price id) |
live when STRIPE_SECRET_KEY starts with sk_live_ or rk_live_ (lib/stripe/index.ts) |
| Creem | creemProductId (a Product id) |
test when CREEM_API_KEY starts with creem_test_ (lib/creem/client.ts) |
| PayPal | paypalPlanId (a Billing Plan id), subscriptions only; a PayPal one-time order is built from price |
live when NEXT_PUBLIC_PAYPAL_ENVIRONMENT=live |
Checkout
Every purchase except a PayPal one-time payment starts with one Server Action:
import { createCheckoutSession } from '@/actions/billing/checkout'
const result = await createCheckoutSession({
planId: 'license-lifetime',
organizationId, // optional: the team a team plan bills
toltReferral, // optional: Tolt affiliate id, copied into metadata
})
if (result.success) window.location.href = result.data.url
components/pricing/CheckoutButton.tsx calls it and maps the failure codes to the right screen. The guards run in this order before any provider is called:
| Guard | Failure customCode |
|---|---|
| The user is signed in | UNAUTHORIZED (the button opens the login dialog and comes back to the same page) |
The plan exists and is not active: false |
none (400 Unknown plan) |
| A one-time plan is not bought from a team workspace | TEAM_WORKSPACE_CREDIT_PACK |
A team plan resolves to an organization the buyer owns: the explicit organizationId, else the active workspace, else the only owned organization |
ORGANIZATION_REQUIRED |
The billing subject (the user, or the organization for a team plan) has no live subscription; live means active, trialing, past_due or unpaid |
SUBSCRIPTION_EXISTS |
| PayPal one-time plans go through the PayPal button, not this action | PAYPAL_CREDIT_PACK_BUTTON |
Then it dispatches on plan.provider:
- Stripe. Creates a Checkout Session on the user's Stripe customer (
getOrCreateStripeCustomerinlib/billing/customer.ts),mode: 'payment'for one-time plans and'subscription'otherwise.userId,planIdandorganizationIdgo into the metadata of the session, the subscription and the payment intent. Before a subscription checkout it expires the customer's other open subscription sessions. A promotion code frompricingCampaign.promotionCodeor the plan'spromotionCodeis applied automatically; without one, Stripe's own promo-code field is shown. One-time payments turn oninvoice_creation. The success URL is/payment/success?session_id=…with the current locale prefix. - Creem. Creates a hosted Creem checkout for the plan's product, with the same metadata and the promotion code as
discountCode. Creem appendscheckout_idto the success URL. - PayPal subscription. Creates a PayPal subscription and returns its approval link. The user, plan and organization travel in PayPal's
custom_id.
PayPal one-time payments use components/pricing/PayPalCheckoutButton.tsx, which calls two routes:
POST /api/paypal/create-orderwith{ planId }: signed in, an active PayPal one-time plan, personal workspace only. The amount comes fromconfig/pricing.ts, never from the browser.POST /api/paypal/capture-orderwith{ orderId }: captures, checks the order belongs to the caller, then reads the capture-level status.COMPLETEDfulfills at once;PENDING(an eCheck) writes a pending order and a Discord alert.
The success page
app/[locale]/(basic-layout)/payment/success/page.tsx handles the return from all three providers. Loading it runs the same idempotent fulfillment the webhook runs (fulfillCheckoutSessionById, fulfillCreemCheckoutById, fulfillPayPalCaptureById), but only when the payment belongs to the signed-in user. The user sees their purchase even when the webhook is late. A PayPal capture that is still pending shows a processing state and checks again on refresh.
The customer portal
createCustomerPortalSession() in actions/billing/portal.ts finds the live subscription of the current workspace and opens the provider's page: the Stripe Billing Portal, the Creem Customer Portal, or PayPal's autopay page. In a team workspace only the member who bought the subscription may open it. PayPal has no hosted cancel page, so cancelPayPalSubscriptionAction in actions/paypal/ cancels through the API.
Webhook routes
Each route verifies the signature, dispatches on the event type, and holds no business logic. Event types not listed are answered 200 and ignored.
| Route | Signature | Not configured |
|---|---|---|
/api/stripe/webhook |
stripe-signature header against STRIPE_WEBHOOK_SECRET |
400 |
/api/creem/webhook |
HMAC-SHA256 with CREEM_WEBHOOK_SECRET (Standard Webhooks headers or the older creem-signature header) |
404 |
/api/paypal/webhook |
PayPal's verify API with PAYPAL_WEBHOOK_ID; skipped when NODE_ENV=development |
404 (no client id and secret) |
Stripe events
| Event | Handler in lib/billing/stripe.ts |
Effect |
|---|---|---|
checkout.session.completed |
fulfillCheckoutSession |
One-time plans only (mode: 'payment', paid or no payment required): order plus purchased credits. Subscription sessions are skipped; their money arrives as invoice.paid. |
invoice.paid |
fulfillInvoicePaid |
Routed by billing_reason: subscription_create and subscription_cycle are a new period (full fulfillment); subscription_update is a mid-cycle plan change (incremental fulfillment); anything else only syncs the subscription |
customer.subscription.created, customer.subscription.updated |
syncStripeSubscription |
Upserts the subscription row |
customer.subscription.deleted |
handleSubscriptionDeleted |
Ends the subscription, stops the drip, clears the subscription bucket |
charge.refunded |
applyRefundForCharge |
Rewrites the order and claws back credits; a full refund of a period also cancels the subscription |
invoice.payment_failed |
handleInvoicePaymentFailed |
Syncs the subscription and mails the user (emails/invoice-payment-failed.tsx) |
radar.early_fraud_warning.created |
handleEarlyFraudWarning |
Refunds and/or mails the admin, as set in STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE |
Creem events
| Event | Handler in lib/billing/creem.ts |
|---|---|
checkout.completed |
fulfillCreemCheckout |
subscription.paid |
fulfillCreemSubscriptionPaid |
subscription.active, .update, .trialing, .paused, .scheduled_cancel, .past_due, .unpaid |
syncCreemSubscription |
subscription.canceled, subscription.expired |
handleCreemSubscriptionEnded |
refund.created |
applyCreemRefund |
dispute.created |
handleCreemDispute (Discord alert) |
Handled events are parsed strictly with the Creem SDK; a payload that no longer matches the SDK's schema throws and answers 5xx so Creem retries.
PayPal events
| Event | Handler in lib/billing/paypal.ts |
|---|---|
PAYMENT.CAPTURE.COMPLETED |
handlePayPalCaptureCompleted (fallback for the capture-order route; promotes a pending order) |
PAYMENT.CAPTURE.PENDING |
recordPendingPayPalCapture (fallback trace when the capture-order route was down) |
PAYMENT.CAPTURE.DENIED, PAYMENT.CAPTURE.DECLINED |
handlePayPalCaptureDenied (marks the pending order failed, alerts) |
PAYMENT.CAPTURE.REFUNDED, PAYMENT.CAPTURE.REVERSED |
handlePayPalCaptureRefunded |
PAYMENT.SALE.COMPLETED |
handlePayPalSaleCompleted (subscription payments; decides initial or renewal) |
PAYMENT.SALE.REFUNDED, PAYMENT.SALE.REVERSED |
handlePayPalSaleRefunded |
BILLING.SUBSCRIPTION.ACTIVATED, BILLING.SUBSCRIPTION.UPDATED |
handlePayPalSubscriptionActivated |
BILLING.SUBSCRIPTION.SUSPENDED |
handlePayPalSubscriptionSuspended |
BILLING.SUBSCRIPTION.PAYMENT.FAILED |
handlePayPalSubscriptionPaymentFailed |
BILLING.SUBSCRIPTION.CANCELLED, BILLING.SUBSCRIPTION.EXPIRED |
handlePayPalSubscriptionEnded |
PayPal sends amounts as decimal strings; the adapter converts them to cents. Stripe and Creem already send integer cents.
A Stripe one-time purchase
sequenceDiagram
participant U as Browser
participant A as createCheckoutSession
participant S as Stripe
participant P as /payment/success
participant W as /api/stripe/webhook
participant F as fulfillOneTimePurchase
participant DB as Postgres
U->>A: click the plan's button
A->>A: guards (signed in, plan active, personal workspace)
A->>S: create Checkout Session (mode payment, metadata userId + planId)
S-->>A: session url
A-->>U: redirect to Stripe
U->>S: pay
S-->>U: redirect to /payment/success?session_id=...
U->>P: load the page
P->>S: retrieve the session
P->>F: fulfill (only if the session belongs to this user)
F->>DB: insert order ON CONFLICT (provider, provider_order_id) DO NOTHING
F->>DB: grant purchased credits (credit packs only), same transaction
S->>W: checkout.session.completed
W->>F: fulfill the same session
F->>DB: insert conflicts, nothing granted
W-->>S: 200
Whichever of the two arrives first writes the order. The other finds the row and returns created: false. A license plan writes the order and grants nothing.
Fulfillment paths
All of these are in lib/billing/fulfillment.ts. Amounts are integer cents; credits move only through lib/credits (see Credits).
One-time purchase
fulfillOneTimePurchase inserts a one_time order with status='completed' and, when the plan grants credits, adds them to the purchased bucket in the same transaction. If the insert conflicts with an existing pending row (an eCheck that has now cleared), it promotes that row to completed in place and grants then. A conflict with a completed row is a replay and does nothing.
Pending payment
recordPendingOneTimePurchase writes an order with status='pending' and 0 credits, so money in flight is visible to the user, to the admin and to the cron. Credits are granted only when the payment settles. A denial marks the row failed. Pending rows count toward no revenue figure.
Subscription payment
fulfillSubscriptionPayment handles subscription_initial and subscription_renewal:
- Syncs the subscription row.
- Takes a Postgres advisory lock per billing subject.
- Writes the order, resets the subscription bucket to
monthlyCredits, and for a yearly plan schedules the 11 remaining monthly grants (remainingGrants,nextGrantAt).
If the same subject already has another live subscription with an order, this payment is a duplicate: the core writes a 0-credit order, marks this subscription canceled, and returns the winner. The adapter then cancels the duplicate at the provider, refunds it and alerts (lib/billing/duplicate.ts). A failed refund throws, so the provider retries until it succeeds.
Mid-cycle plan change
fulfillSubscriptionUpgrade handles proration invoices (Stripe billing_reason=subscription_update). It never resets the bucket. It tops it up by:
delta = round( max(0, new monthlyCredits − credits already granted in this window)
× fraction of the window still remaining )
The "already granted" figure comes from the ledger, so switching plans back and forth cannot farm credits, and a cheap upgrade near the end of the period earns only the remaining fraction. A downgrade grants and takes nothing; the next period's reset applies the new amount. Switching between monthly and yearly re-anchors or clears the yearly drip.
Subscription sync and end
syncSubscription upserts the row by (provider, provider_subscription_id) with the status mapped to one of active, trialing, past_due, unpaid, canceled, incomplete, expired, paused. Webhooks can arrive out of order, so once a row is canceled or expired a later event with a live status is ignored.
handleSubscriptionEnded writes the terminal status, stops the drip, and clears the subscription bucket, unless the subject still has another live subscription.
Refund
applyRefund finds the order by provider_order_id, else by provider_payment_id, locks it, and applies the provider's cumulative refunded total:
amount_refundedbecomes the new total;statusbecomespartially_refundedorrefunded. No new order row.- Credits are clawed back in proportion (
credits_granted × refunded delta ÷ amount_total), from the purchased bucket for one-time orders and the subscription bucket for subscription orders, never below zero. - A full refund of an initial or renewal order stops the drip and returns the subscription id; the adapter then cancels the subscription at the provider (
lib/billing/cancel.ts). A refund of an upgrade order only claws back the increment. - An event with a total not above the stored one is a replay and does nothing.
Admins start refunds with refundOrder in actions/orders/admin.ts. It only calls the provider's refund API; the local order changes when the refund webhook comes back. Creem's API refunds the full remaining amount only; partial Creem refunds are made in the Creem dashboard.
Idempotency
The unique index uq_orders_provider_order_id on orders(provider, provider_order_id) is the guard. Every insert uses ON CONFLICT DO NOTHING. The key per provider:
| Payment | provider_order_id |
|---|---|
| Stripe one-time | Checkout Session id |
| Stripe subscription | Invoice id |
| Creem one-time | Creem order id |
| Creem subscription | Creem transaction id |
| PayPal one-time | Capture id |
| PayPal subscription | Sale id |
provider_payment_id holds the handle refunds are issued against (payment intent, capture, charge). Refunds are idempotent because they carry a cumulative total. Credit spends are refunded at most once because of a unique column on the ledger (see Credits).
Errors and retries
| Situation | What the route does | Why |
|---|---|---|
| Plan not found, database down, any unexpected error | answers 5xx | The provider retries with backoff (Stripe for up to 3 days). The transaction rolled back, so a retry after a fix fulfills cleanly. |
| A refund for an order that does not exist locally | throws, answers 5xx | The order may still be on its way; acknowledging would lose the refund |
PermanentFulfillmentError (the buyer deleted their account and there is no organization to credit) |
sends the alert, answers 200 with permanentError: true |
A retry can never succeed |
A money event fails (checkout.session.completed, invoice.paid, Creem checkout.completed / subscription.paid, PayPal PAYMENT.CAPTURE.COMPLETED / PAYMENT.SALE.COMPLETED) |
mails the admin "Credit grant failed" (notifyCreditGrantFailed), then answers per the rows above |
A buyer is waiting. The mail is sent once per provider object per 24 hours (Redis SET NX); without Redis it is sent every time. |
The Stripe route also records a webhook_fail event for the optional event tracker on every non-2xx answer (lib/traffic/webhook.ts).
The reconcile cron
/api/cron/credits (app/api/cron/credits/route.ts) runs one tick per call, when the request carries Authorization: Bearer $CRON_SECRET:
- Settles due yearly-plan grants (up to 100 per tick).
reconcileOverdueRenewals(up to 20): subscriptions whose period ended without a renewal are re-read from the provider and the latest invoice or transaction is replayed through the same fulfillment. Stripe and Creem only; PayPal is a TODO inlib/billing/reconcile.ts.reconcilePendingPayPalCaptures(up to 20): pending PayPal orders are re-read; completed ones are promoted and granted, terminal ones are markedfailed.
POST needs the secret. GET with the secret runs the same tick (Cloudflare's scheduled handler and Vercel Cron send GET); GET without it is a health check. The repo schedules nothing by default. The deploy skills add the cron once the product sells subscriptions or uses PayPal (Cloudflare, Vercel). Manual run:
curl -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/credits
Coupons
There is no coupon table. A coupon is a Stripe Coupon plus a Stripe Promotion Code, managed from the admin coupons page through actions/coupons/admin.ts. To apply a code automatically at checkout, set pricingCampaign.promotionCode (site-wide) or a plan's promotionCode in config/pricing.ts.
When it fails
| Symptom | Likely cause | Where to look |
|---|---|---|
| Paid, but no order and no credits | The webhook never arrived or answered 5xx | The provider's webhook log; the "Credit grant failed" mail; the server log line Webhook handler failed for … |
| Every renewal answers 5xx after a plan change | A sold plan was deleted from config/pricing.ts |
Put it back with active: false |
SUBSCRIPTION_EXISTS for a user who cancelled |
The subscription is still past_due or unpaid, which counts as live |
Let it end at the provider, or fix the payment in the portal |
Stripe webhook answers 400 Webhook Error |
STRIPE_WEBHOOK_SECRET belongs to another endpoint or mode (a stripe listen secret in production, say) |
Copy the signing secret of this endpoint |
A PayPal order stays pending |
An eCheck that has not cleared, or the cron is not scheduled | Admin orders page; schedule /api/cron/credits |