Menu

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_REQUIRED

5. 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

providerSubscriptionCredit pack
stripeCheckout Session (mode: 'subscription')Checkout Session (mode: 'payment')
creemCreem CheckoutCreem Checkout
paypalBilling 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:

providerParameter
Stripesession_id
Creemcheckout_id
PayPalorder_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 failed

That 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:

customCodeMeaningClient behavior
UNAUTHORIZEDNot signed inRedirect to login
SUBSCRIPTION_EXISTSThe subject already holds a live subscriptionRedirect to /dashboard/billing
TEAM_WORKSPACE_CREDIT_PACKBuying a credit pack in a team workspacePrompt a switch back to the personal workspace
ORGANIZATION_REQUIREDA team plan with no decidable organizationRedirect to /dashboard/team
PAYPAL_CREDIT_PACK_BUTTONA PayPal credit pack took the wrong channelPrompt to use the PayPal button

Testing the payment flow

  1. Use test/sandbox keys for all three providers, and fill the test column of config/pricing.ts with test-environment price ids
  2. Forward webhooks locally (for Stripe: stripe listen --forward-to localhost:3000/api/stripe/webhook)
  3. Stripe test card: 4242 4242 4242 4242, any future date, any CVC
  4. After paying, check three places: the /payment/success rendering, the balance and history on /dashboard/billing, and the order row in /dashboard/admin/orders