Menu

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 drip

Five 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

TablePurposeNotes
ordersOne row per movement of moneyIdempotency 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
subscriptionsSubscription stateOne 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_balancesPersonal credit balancesTwo buckets: subscription_credits (reset on every grant) and purchased_credits (never expire)
organization_credit_balancesTeam shared poolSame shape as the personal table, keyed by organization_id
credit_transactionsCredit ledgerAppend-only; every row carries a signed amount, per-bucket deltas, post-change snapshots and a monotonic seq

Order types (order_type):

ValueMeaning
one_timeCredit pack purchase
subscription_initialFirst invoice of a subscription
subscription_renewalRecurring invoice
subscription_upgradeMid-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 / CheckoutButton

Billing subjects: personal and team

The same code serves two kinds of billing subject:

  • Personal: organization_id is NULL, credits land in credit_balances
  • Team: organization_id is set, credits land in the organization_credit_balances shared pool, and user_id degrades 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 its seats sets 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

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