Payment Flow
This page spells out everything a payment goes through, from the click to the ledger.
The flow at a glance
① Click on the pricing page components/pricing/CheckoutButton.tsx
│ planId
▼
② Unified checkout entry actions/billing/checkout.ts
│ once every guard passes, dispatch on plan.provider
▼
③ Provider-hosted checkout Stripe Checkout / Creem Checkout / PayPal approval
│ the customer pays
├────────────────────────────────┐
▼ ▼
④ Return to the success page ⑤ Provider webhook
/payment/success app/api/{provider}/webhook
│ │
└──────────┬─────────────────────┘
▼
lib/billing/fulfillment.ts Idempotent fulfillment, first writer wins
│
▼
orders + subscriptions + the credit ledger④ and ⑤ are belt and braces, not alternatives: both call the same idempotent fulfillment functions, whichever arrives first takes effect, and the other becomes a no-op. If the webhook is lost, refreshing the success page still delivers; if the customer closes the browser immediately, the webhook still delivers.
① The click on the pricing page
CheckoutButton calls createCheckoutSession({ planId, organizationId?, toltReferral? }) and redirects to the URL it returns.
PayPal credit packs are the exception: they do not go through this action but through the official PayPal button in PayPalCheckoutButton (see "The PayPal credit pack path" below).
② The unified checkout entry
The guards in actions/billing/checkout.ts run in order — every provider-agnostic check first, dispatch last.
Guard order
1. Authentication — not signed in returns UNAUTHORIZED.
2. The plan exists and is not retired — a plan getPlanById cannot find, or one with active === false, is refused outright.
3. Credit packs are forbidden in a team workspace — a credit pack is a personal asset and lands only in the buyer's personal purchased bucket. Letting it through in a team workspace would mean "paid, yet the current workspace balance never moved", so it is refused with TEAM_WORKSPACE_CREDIT_PACK and the client prompts a switch back to the personal workspace.
4. Organization resolution for team plans — a team plan bills an organization, and only its owner may subscribe. The resolution order:
An explicitly passed organizationId
↓ none
The active workspace's organization (when the caller owns it)
↓ none
The single organization this user owns
↓ still undecidable
Return ORGANIZATION_REQUIRED5. One live subscription per subject — a billing subject may hold only one live subscription at a time.
The reason is that subscription grants have reset semantics: two concurrent subscriptions would overwrite each other's credits. Changing plans goes through the provider's self-service portal, not through buying a second one.
"Live" here means the ENTITLED set (active / trialing / past_due / unpaid) — a subscription in dunning still occupies the slot, because the provider is still retrying and it may recover. A customer in dunning should fix their card in the portal rather than stack a second subscription.
Personal and team slots are isolated: a personal subscription and a team subscription never contend.
Failing this guard returns SUBSCRIPTION_EXISTS.
Dispatch per provider
| provider | Subscription | Credit pack |
|---|---|---|
stripe | Checkout Session (mode: 'subscription') | Checkout Session (mode: 'payment') |
creem | Creem Checkout | Creem Checkout |
paypal | Billing Subscription approval link | ❌ Not via this action; returns PAYPAL_CREDIT_PACK_BUTTON |
The Stripe duplicate-subscription defense
Before creating a Stripe subscription session, every status: 'open' subscription session belonging to that customer is expired first.
This is defense #1. A Stripe checkout session stays payable for roughly 24 hours, so a customer who opens A without paying, buys and pays B, then goes back and pays A ends up with two subscriptions. Closing that window at the source is the cheap fix. (Defense #2 lives on the fulfillment side: first writer wins plus an automatic refund — see Webhook Handling.)
A failure here does not block checkout — one extra refund beats a customer who cannot buy.
What goes into metadata
{
userId, // The buyer
planId, // Plan slug
organizationId, // Team subscriptions only; routes credits to the org pool
tolt_referral, // Only when an affiliate referral is present
}A team subscription hangs off the purchaser's personal customer (there is no organization-level customer), so metadata.organizationId is the only thing routing credits into the org pool. On the Stripe side it is pinned on all three copies: the session, the subscription and the payment intent.
Return URLs and locale
The success and cancel URLs both carry the current locale prefix. The project uses the as-needed prefix strategy with localeDetection off, which makes the URL prefix the sole carrier of the language — lose it, and the customer comes back from payment on the English site.
③ The provider-hosted checkout
The customer pays on the provider's page. With a promotion code configured, Stripe and Creem apply it automatically; without one, the provider's own manual input is shown (see Pricing Configuration).
④ Back on the success page
/payment/success is the shared success_url for all three providers, told apart by the query parameter:
| provider | Parameter |
|---|---|
| Stripe | session_id |
| Creem | checkout_id |
| PayPal | order_id (the capture id) |
Loading this page itself triggers a fulfillment attempt, through the same idempotent functions the webhook uses (fulfillCheckoutSessionById / fulfillCreemCheckoutById / fulfillPayPalCaptureById).
The page has three terminal states:
- Success — shows the plan name and the credits granted. On a team plan the second button is "invite members", going straight to
/dashboard/team - Processing — rendered for a PayPal pending capture (eCheck); refreshing re-verifies. This is the user-side self-healing channel when the COMPLETED webhook is lost
- Failed — a terminal failure (DECLINED / FAILED) renders the failed card, never a misleading "processing"
Signed-out visitors are redirected to /login with the locale prefix preserved.
⑤ Webhook fulfillment
This is the primary channel — see Webhook Handling. The essentials:
- Routes only verify the signature and dispatch; they hold no business logic
- Adapters translate the provider payload into core inputs, and the fulfillment core writes the order, the subscription and the credits in one transaction
- Retryable errors return 5xx so the provider retries; a
PermanentFulfillmentError(the buyer deleted their account, for instance) alerts and then acks instead of entering the retry loop
The PayPal credit pack path
A PayPal one-time purchase does not go through createCheckoutSession but through the official JS button:
PayPalCheckoutButton (client)
│
├─▶ POST /api/paypal/create-order Creates the order dynamically from plan.price
│
│ The customer approves inside the PayPal popup
│
└─▶ POST /api/paypal/capture-order Captures the funds
│
├─ COMPLETED → fulfill, redirect to the success page
├─ PENDING → write a pending order row, success page shows "processing"
└─ DENIED → mark it failedThat pending order row is the durable local trace the cron chases: /api/cron/credits keeps advancing it to completed (credits granted) or failed (zero credits, audit trail only).
Guard code reference
createCheckoutSession returns these through customCode on failure, and CheckoutButton maps each to a destination:
| customCode | Meaning | Client behavior |
|---|---|---|
UNAUTHORIZED | Not signed in | Redirect to login |
SUBSCRIPTION_EXISTS | The subject already holds a live subscription | Redirect to /dashboard/billing |
TEAM_WORKSPACE_CREDIT_PACK | Buying a credit pack in a team workspace | Prompt a switch back to the personal workspace |
ORGANIZATION_REQUIRED | A team plan with no decidable organization | Redirect to /dashboard/team |
PAYPAL_CREDIT_PACK_BUTTON | A PayPal credit pack took the wrong channel | Prompt to use the PayPal button |
Testing the payment flow
- Use test/sandbox keys for all three providers, and fill the
testcolumn ofconfig/pricing.tswith test-environment price ids - Forward webhooks locally (for Stripe:
stripe listen --forward-to localhost:3000/api/stripe/webhook) - Stripe test card:
4242 4242 4242 4242, any future date, any CVC - After paying, check three places: the
/payment/successrendering, the balance and history on/dashboard/billing, and the order row in/dashboard/admin/orders