Orders and Subscriptions
Orders
The shape of the data
One row in orders is one movement of money. The key columns:
| Column | Description |
|---|---|
user_id | The payer. Goes NULL when the account is deleted, and the order survives (financial records do not follow the account) |
organization_id | The benefiting organization on a team purchase; NULL for personal orders |
plan_id | The plan slug from config/pricing.ts |
order_type | one_time / subscription_initial / subscription_renewal / subscription_upgrade |
status | pending / completed / partially_refunded / refunded / failed |
credits_granted | Credits granted by this order |
amount_total amount_subtotal amount_discount amount_tax amount_refunded | All integer cents |
provider + provider_order_id | Composite unique — this is the idempotency key |
provider_payment_id | The charge / capture / payment intent id a refund is issued against |
What provider_order_id actually holds per provider:
| Provider and case | Value |
|---|---|
| Stripe one-time | checkout session id |
| Stripe subscription | invoice id |
| Creem one-time | order / checkout id |
| Creem subscription | transaction id |
| PayPal one-time | capture id |
| PayPal subscription | sale 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 localeAdmin 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:
pendingandfailedorders have no settled money to refund (the provider would reject it anyway)- An order without a
provider_payment_idcannot 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
| Column | Description |
|---|---|
status | The canonical normalized status, see below |
interval | month / year |
current_period_start / current_period_end | The current period |
cancel_at_period_end | Whether cancellation at period end is scheduled |
canceled_at / ended_at | When it was canceled / when it actually ended |
monthly_credits / remaining_grants / next_grant_at | The yearly drip schedule (see Credit System) |
provider + provider_subscription_id | Composite unique |
organization_id | The 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 | pausedTwo 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.urlIt finds the live subscription for the active workspace, then dispatches on that subscription's provider:
| Provider | Destination |
|---|---|
| Stripe | Billing Portal (update card, view invoices, cancel, change plan) |
| Creem | Creem Customer Portal |
| PayPal | The 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 owndiscountCode, and PayPal does not support it.
Page reference
User side
| Route | Contents |
|---|---|
/dashboard/billing | Two 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/team | Four 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
| Route | Contents |
|---|---|
/dashboard/admin/overview | Operations dashboard: net revenue, MRR, new signups, credit burn, trend chart, needs-attention list, recent orders |
/dashboard/admin/orders | Order list (filter by user/status/type/provider, search email or plan) + refunds |
/dashboard/admin/credits | Global credit ledger audit + batch grants by email |
/dashboard/admin/coupons | The coupon console |
/dashboard/admin/users | User 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;
pendingandfaileddo 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_duesubscriptions 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.