Menu

Orders and Subscriptions

Orders

The shape of the data

One row in orders is one movement of money. The key columns:

ColumnDescription
user_idThe payer. Goes NULL when the account is deleted, and the order survives (financial records do not follow the account)
organization_idThe benefiting organization on a team purchase; NULL for personal orders
plan_idThe plan slug from config/pricing.ts
order_typeone_time / subscription_initial / subscription_renewal / subscription_upgrade
statuspending / completed / partially_refunded / refunded / failed
credits_grantedCredits granted by this order
amount_total amount_subtotal amount_discount amount_tax amount_refundedAll integer cents
provider + provider_order_idComposite unique — this is the idempotency key
provider_payment_idThe charge / capture / payment intent id a refund is issued against

What provider_order_id actually holds per provider:

Provider and caseValue
Stripe one-timecheckout session id
Stripe subscriptioninvoice id
Creem one-timeorder / checkout id
Creem subscriptiontransaction id
PayPal one-timecapture id
PayPal subscriptionsale id

Two design decisions: pending and refunds

pending has exactly one source: a PayPal eCheck-style capture that was accepted but has not settled. It is the durable local trace the cron chases — zero credits, excluded from revenue. On settlement it is promoted to completed in place (credits are granted exactly then); on denial it is marked failed (terminal, zero credits, audit trail only).

A refund rewrites the original order row instead of adding one. It accumulates into amount_refunded and flips status to partially_refunded or refunded. This keeps the "one payment, one row" invariant intact and keeps idempotency simple.

Queries

User side (actions/orders/user.ts):

import { getMyOrders } from '@/actions/orders/user'
 
const result = await getMyOrders({ pageIndex: 0, pageSize: 10 })
// Plan names are resolved from config/pricing by the request locale

Admin side (actions/orders/admin.ts):

import { getAdminOrders } from '@/actions/orders/admin'
 
const result = await getAdminOrders({
  pageIndex: 0,
  pageSize: 20,
  userId,        // Optional, restrict to one user (used by the user detail page)
  status,        // Optional
  orderType,     // Optional
  provider,      // Optional
  search,        // Optional, partial match on user email or plan id
})

Refunds

The refund dialog on /dashboard/admin/orders calls refundOrder:

import { refundOrder } from '@/actions/orders/admin'
 
await refundOrder({
  orderId,
  amountCents,   // Omit for a full refund of the remainder; pass a value for a partial refund
})

This action does exactly one thing: call the provider's refund API. The local order status and the credit clawback are all written when the refund webhook comes back.

The reason is that a refund started from the dashboard and one started directly in the provider's own dashboard then take the exact same code path — no second implementation, and no way for the two sides to drift apart.

Pre-checks:

  • pending and failed orders have no settled money to refund (the provider would reject it anyway)
  • An order without a provider_payment_id cannot be refunded
  • An already fully refunded order is rejected
  • A partial amount may not exceed the remaining refundable amount

Subscriptions

The shape of the data

ColumnDescription
statusThe canonical normalized status, see below
intervalmonth / year
current_period_start / current_period_endThe current period
cancel_at_period_endWhether cancellation at period end is scheduled
canceled_at / ended_atWhen it was canceled / when it actually ended
monthly_credits / remaining_grants / next_grant_atThe yearly drip schedule (see Credit System)
provider + provider_subscription_idComposite unique
organization_idThe organization on a team subscription. RESTRICT on organization deletion — an org that ever subscribed may not be hard-deleted

Statuses

Only this canonical set is ever stored:

active | trialing | past_due | unpaid | canceled | incomplete | expired | paused

Two important subsets:

// Counts as "holds a subscription"; entitlements continue
ENTITLED_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due', 'unpaid']
 
// Can receive a credit grant
GRANTABLE_STATUSES = ['active', 'trialing']

Dunning (past_due / unpaid) keeps entitlements but receives no new grants.

canceled and expired are terminal and never come back — re-subscribing produces a new subscription id and a new row.

Status sync

Subscription status is kept in sync by webhooks; no polling. syncSubscription handles status only and never touches credits or the drip counters (those belong to payment fulfillment). It also rejects terminal-state rewrites caused by out-of-order webhooks.

Lost webhooks are recovered by the reconcileOverdueRenewals cron.

The self-service portal

The user-side entry point is actions/billing/portal.ts:

import { createCustomerPortalSession } from '@/actions/billing/portal'
 
const result = await createCustomerPortalSession()
// Redirect to result.data.url

It finds the live subscription for the active workspace, then dispatches on that subscription's provider:

ProviderDestination
StripeBilling Portal (update card, view invoices, cancel, change plan)
CreemCreem Customer Portal
PayPalThe PayPal autopay page (PayPal has no hosted portal)

Without a live subscription, it falls back to the user's Stripe billing history (update card, view invoices).

The team portal restriction

A team subscription hangs off the purchaser's personal customer. That means any member holding a session could in principle open the portal, see the card on file, and even cancel the subscription.

So one restriction is enforced server side: in a team workspace, only the subscription's purchaser may open the portal. This check lives on the server, not in a hidden UI button.

Changing plans

Stripe plan changes happen inside the Billing Portal, which requires configuring the switchable products in the Stripe dashboard first. For the configuration and how credits are calculated, see Subscription Changes.

The coupon console

Admin route: /dashboard/admin/coupons.

Stripe is the only store — no local table, no migration. One coupon = a Stripe Coupon (discount, product scope, billing duration) + a Stripe Promotion Code (the customer-facing code, redemption cap, expiry, restrictions).

A code is immutable once created, and that constraint shapes the whole UI:

  • The only editable thing is the enable/disable switch
  • Changing anything else means "duplicate, then edit"
  • Creating one checks code availability against Stripe live

The create form, the status machine and the scan cap are covered in Coupon Console. The customer-side auto-apply logic lives in actions/billing/checkout.ts and reads the promotionCode configured in config/pricing.ts (see Pricing Configuration).

Note

Stripe promotion codes can only be restricted to Stripe products, so this feature applies to plans with provider: 'stripe' only. Creem uses its own discountCode, and PayPal does not support it.

Page reference

User side

RouteContents
/dashboard/billingTwo credit buckets, the current plan, purchase history and the credit ledger (each table paginated independently). In a team workspace a top banner points to the team page
/dashboard/teamFour cards: subscription, shared credit pool, members and seats, team credit ledger

/dashboard/subscription and /dashboard/credit-history were merged into /dashboard/billing in v4.0.0.

Admin side

RouteContents
/dashboard/admin/overviewOperations dashboard: net revenue, MRR, new signups, credit burn, trend chart, needs-attention list, recent orders
/dashboard/admin/ordersOrder list (filter by user/status/type/provider, search email or plan) + refunds
/dashboard/admin/creditsGlobal credit ledger audit + batch grants by email
/dashboard/admin/couponsThe coupon console
/dashboard/admin/usersUser list + the signup email blocklist
/dashboard/admin/users/[userId]A 360° view of one user: profile, balances, subscription, lifetime stats and history, with manual credit adjustment

How the Overview numbers are defined

Before reading the numbers, know how they are computed:

  • Amounts are integer cents throughout
  • Net revenue = total − refunded; pending and failed do not count as revenue
  • The reporting currency comes from the first active plan in the pricing config (off-currency orders show up in "needs attention")
  • New signups exclude anonymous accounts
  • Credit burn = usage − usage_refund
  • MRR = active + past_due subscriptions at the configured monthly rate

The time window is driven by ?range=7d|30d|90d, split on UTC calendar days. The five sections load in parallel under their own Suspense boundaries, so one failing section cannot take down the page.

FAQ

Why are amounts integers?

Always integer cents, to avoid floating-point error. Divide by 100 (or the currency's smallest unit) for display.

Do orders disappear when a user deletes their account?

No. user_id goes NULL and the row stays. The credit ledger works the same way — deletion de-identifies rather than erases, otherwise revenue and burn analytics would shrink retroactively.

Why weren't all the credits clawed back after a refund?

Clawback clamps at 0. Credits the customer already spent never push the balance negative.

Can I update a balance directly in code?

No. Balances change only through lib/credits/ledger.ts; anything else would desynchronize the ledger from the balance.