ShipAgent

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 (getOrCreateStripeCustomer in lib/billing/customer.ts), mode: 'payment' for one-time plans and 'subscription' otherwise. userId, planId and organizationId go 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 from pricingCampaign.promotionCode or the plan's promotionCode is applied automatically; without one, Stripe's own promo-code field is shown. One-time payments turn on invoice_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 appends checkout_id to 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:

  1. POST /api/paypal/create-order with { planId }: signed in, an active PayPal one-time plan, personal workspace only. The amount comes from config/pricing.ts, never from the browser.
  2. POST /api/paypal/capture-order with { orderId }: captures, checks the order belongs to the caller, then reads the capture-level status. COMPLETED fulfills 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:

  1. Syncs the subscription row.
  2. Takes a Postgres advisory lock per billing subject.
  3. 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_refunded becomes the new total; status becomes partially_refunded or refunded. 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:

  1. Settles due yearly-plan grants (up to 100 per tick).
  2. 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 in lib/billing/reconcile.ts.
  3. reconcilePendingPayPalCaptures (up to 20): pending PayPal orders are re-read; completed ones are promoted and granted, terminal ones are marked failed.

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