Skip to content

Subscriptions

┌──────────┐
│ pending │
└────┬─────┘
│ checkout completed
┌────────────────┐
┌────│ trial / active │────┐
│ └───────┬────────┘ │
│ │ │
│ payment failure │ cancel
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ past_due │ │ renewed │ │cancelled │
└────┬─────┘ │ (back to │ └────┬─────┘
│ │ active) │ │ expires
│ └──────────┘ ▼
▼ ┌──────────┐
┌──────────┐ │ expired │
│ expired │ └──────────┘
└──────────┘

All state transitions are driven by payment gateway webhooks.

ColumnTypeDescription
idtext PK
app_idtext FKApplication this subscription belongs to
owner_typetextuser or org (B2B readiness)
owner_idtextUser or org ID
product_idtext FK
skutextDenormalized SKU for query convenience
statustextpending, trial, active, past_due, cancelled, expired
gatewaytextlemonsqueezy or apple_iap
gateway_reftext?Gateway subscription ID
started_attimestamptz?
expires_attimestamptz?
quantityintegerSeat count (1 for individual)
lock_versionintegerOptimistic lock for concurrent webhook handling

The owner_type / owner_id pattern supports both individual and B2B subscriptions:

  • Individual: owner_type='user', owner_id=user.id
  • Organization: owner_type='org', owner_id=organization.id

This allows a zero-schema-change path from Phase 3 (personal subscriptions) to Phase 4 (B2B team subscriptions).

CREATE UNIQUE INDEX uniq_sub_active
ON subscription(app_id, owner_type, owner_id, sku)
WHERE status IN ('trial', 'active', 'pending');

This partial index ensures an owner can have only one active subscription per SKU per application. Expired and cancelled subscriptions do not count against this limit, allowing users to re-subscribe after cancellation.