Payment System Overview
Good to know
This chapter describes the payment system as of v4.0.0. v4 was a rewrite: pricing moved from the database into code, and the fulfillment logic of all three providers collapsed into one layer. If you are upgrading from 3.x, read the Version 4.x Changelog first.
The NEXTY.DEV payment system supports Stripe, Creem and PayPal at the same time, covering subscriptions (monthly/yearly) and one-time purchases (credit packs), with a built-in credit ledger, team shared pools, refunds, reconciliation and fraud handling.
The whole path in one picture
config/pricing.ts Single source of truth for pricing (a plan is an object)
│ planId
▼
actions/billing/checkout.ts Unified checkout entry, dispatching on plan.provider
│
▼
Provider-hosted checkout Stripe Checkout / Creem Checkout / PayPal
│ the customer pays
▼
app/api/{stripe,creem,paypal}/webhook Routes only verify the signature and dispatch
│
▼
lib/billing/{stripe,creem,paypal}.ts Adapters: translate webhook payloads into core inputs
│
▼
lib/billing/fulfillment.ts Provider-agnostic fulfillment core, one transaction
│
├──▶ orders Order rows (idempotency key = provider + provider_order_id)
├──▶ subscriptions Subscription rows (including the yearly drip schedule)
└──▶ lib/credits/ Credit ledger (balances + append-only log)
app/api/cron/credits Cron: recovers lost webhooks, settles the yearly dripFive design rules
Once these five click, the rest of the chapter becomes obvious.
1. Pricing is code, not data. There is no pricing_plans table and no admin CRUD. config/pricing.ts is the single source of truth, so a price change goes through code review and a deploy. See Pricing Configuration.
2. Amounts are always integer cents. Every monetary column in the database is an integer in the provider's native cents. Never numeric, never a float.
3. Idempotency is structural, not defensive. orders carries a unique index on (provider, provider_order_id), writes go through onConflictDoNothing, and refunds accumulate a total instead of appending rows. A replayed webhook is harmless by construction — no scattered "have I seen this already?" checks.
4. Balances change only through the ledger. The balance tables are never UPDATEd directly. Every change goes through lib/credits/ledger.ts and appends one row with a snapshot to credit_transactions in the same transaction.
5. Provider SDK types never leave the adapter. The fulfillment core only knows the input shapes in lib/billing/types.ts; each provider's native payload is translated inside its own adapter. Adding a provider means adding an adapter, not touching the core.
Database tables
| Table | Purpose | Notes |
|---|---|---|
orders | One row per movement of money | Idempotency key (provider, provider_order_id); a refund rewrites the original row instead of adding one; on account deletion user_id goes NULL and the order survives |
subscriptions | Subscription state | One canonical status column (provider statuses are normalized by the adapters); the yearly drip schedule is first-class state: monthly_credits / remaining_grants / next_grant_at |
credit_balances | Personal credit balances | Two buckets: subscription_credits (reset on every grant) and purchased_credits (never expire) |
organization_credit_balances | Team shared pool | Same shape as the personal table, keyed by organization_id |
credit_transactions | Credit ledger | Append-only; every row carries a signed amount, per-bucket deltas, post-change snapshots and a monotonic seq |
Order types (order_type):
| Value | Meaning |
|---|---|
one_time | Credit pack purchase |
subscription_initial | First invoice of a subscription |
subscription_renewal | Recurring invoice |
subscription_upgrade | Mid-cycle plan change proration invoice |
Order statuses (order_status): pending (only reached by pending captures such as PayPal eCheck — zero credits, advanced by cron), completed, partially_refunded, refunded, failed.
Code map
config/
pricing.ts Plan definitions + lookup helpers (single source of truth)
credits.ts Non-payment credit rules (signup bonus)
actions/
billing/checkout.ts Unified checkout entry
billing/portal.ts Subscription self-service entry (dispatches per provider)
credits/ Credit reads and spending (user side), admin adjustment and batch grants
orders/ Order queries (user/admin), refund entry point
coupons/admin.ts Coupon management (Stripe is the only store)
lib/billing/
types.ts The type contract
fulfillment.ts Provider-agnostic fulfillment core
stripe.ts creem.ts paypal.ts The three adapters
cancel.ts Unified provider-side cancel
duplicate.ts Duplicate subscription settlement
reconcile.ts Cron reconciliation (recovers lost webhooks)
notify.ts Webhook-triggered notifications (Redis-deduplicated)
customer.ts The sole read/write channel for stripeCustomerId
lib/credits/
ledger.ts Write layer: the only channel for balance changes
index.ts Read layer + yearly drip settlement
app/api/
stripe/webhook creem/webhook paypal/webhook The three webhook routes
paypal/create-order paypal/capture-order PayPal one-time purchases
cron/credits Cron entry point
components/pricing/ PricingSection / PricingCard / CheckoutButtonBilling subjects: personal and team
The same code serves two kinds of billing subject:
- Personal:
organization_idis NULL, credits land incredit_balances - Team:
organization_idis set, credits land in theorganization_credit_balancesshared pool, anduser_iddegrades to "the payer / the acting member"
Both share the same bucket shape and idempotency mechanics; only the balance table differs. A few conventions:
- A team plan is marked with
audience: 'team', and itsseatssets the member cap; without a subscription the cap is 1 (the owner alone) - Credit packs are personal assets and cannot be bought inside a team workspace (checkout returns
TEAM_WORKSPACE_CREDIT_PACK, and the client prompts a switch back to the personal workspace) - The "has a live subscription" check is isolated between personal and team — neither occupies the other's slot
What each provider can do
| Capability | Stripe | Creem | PayPal |
|---|---|---|---|
| Subscriptions | ✅ Checkout Session | ✅ Checkout | ✅ Requires a pre-created Billing Plan |
| One-time purchases | ✅ Checkout Session | ✅ Checkout | ✅ Order created dynamically from price |
| Hosted self-service portal | ✅ Billing Portal | ✅ Customer Portal | ❌ Redirects to the PayPal autopay page |
| Auto-applied promotion code | ✅ | ✅ discountCode | ❌ |
| Coupon admin console | ✅ | ❌ | ❌ |
| Refund API | ✅ | ✅ | ✅ |
| Pending payments (eCheck) | — | — | ✅ Lands as a pending order, advanced by cron |
| Fraud signals | ✅ Radar | ✅ dispute | ✅ reversal |
Each plan declares its own provider, and all three can be enabled at once.
Read next
- Pricing Configuration — how to define plans and wire them to a provider
- Payment Flow — from the buy click to credits landing
- Webhook Handling — the events and fulfillment of all three providers
- Credit System — two buckets, the ledger, the yearly drip
- Orders and Subscriptions — queries, refunds, the self-service portal
- Subscription Changes — how a mid-cycle upgrade is calculated