Plans are hardcoded as a TypeScript union type and a static PLAN_METADATA object. Adding a new plan requires a code change and deploy. We need to support:
- Enterprise deals — custom plan per customer (custom credits, custom price, custom features)
- Employee accounts — free team plan with unlimited credits, no Stripe subscription
- Future — any one-off SKU without touching application code
plan-utils.ts:17
type PlanId = 'personal_standard' | 'personal_pro' | 'team_standard' | 'team_pro' | 'enterprise'Every function that touches plans uses this type. normalizePlanId() returns null for anything not in this list. The entire billing system is downstream of this type.
billing.config.ts:436
const PLAN_METADATA: Record<PlanId, Omit<BillingPlan, 'productId' | 'priceId'>> = { ... }Credits, pricing, limits, features — all baked into this object at build time. There's no way to say "workspace X gets 500 credits/month" without changing this file.
plan-features.ts:149
const PLAN_FEATURES: Record<PlanId, PlanFeatureId[]> = { ... }planHasFeature() does a lookup into this static map. A custom enterprise plan can't have a custom feature set.
stripe-webhooks.ts:902
function determineTierFromPriceId(priceId: string): string {
const plan = getBillingPlanByPriceId(priceId) // scans BILLING_PLANS
...
}Every webhook resolves the internal plan by matching a Stripe price ID against the 5 hardcoded plans. A custom Stripe product for an enterprise customer won't match.
stripe-webhooks.ts:183
const creditsPerSeat = plan.limits?.includedCredits || 0There's no per-workspace override. Credits come from the plan definition.
The cheapest path that unblocks enterprise deals and employee accounts without rearchitecting the entire billing system. The idea: keep the 5 base plans, but let individual workspaces override any plan property.
ALTER TABLE workspaces ADD COLUMN plan_overrides JSONB DEFAULT NULL;
ALTER TABLE workspaces ADD COLUMN is_custom_plan BOOLEAN DEFAULT FALSE;plan_overrides schema:
interface PlanOverrides {
// Override the base plan this workspace inherits from
// e.g., an enterprise deal might use 'team_pro' as the base
basePlanId?: PlanId
// Override credits per seat (or total for personal plans)
includedCredits?: number // null = use base plan, -1 = unlimited
// Override credit price
creditPrice?: number // null = use base plan, 0 = free
// Override monthly base price (informational — Stripe is source of truth)
monthlyBase?: number
// Additional features beyond the base plan
additionalFeatures?: PlanFeatureId[]
// Override seat limit
seatLimit?: number // null = use base plan, -1 = unlimited
// Human-readable label for this custom plan
planLabel?: string // e.g., "Acme Corp Enterprise", "Employee Plan"
// Disable credit expiration (purchased behavior for subscription credits)
creditsNeverExpire?: boolean
// Skip Stripe billing entirely (for gifted/internal plans)
skipBilling?: boolean
}Replace all direct getBillingPlan(workspace.subscriptionTier) calls with a resolver that merges overrides:
function getEffectivePlan(workspace: Workspace): EffectiveBillingPlan {
const basePlan = getBillingPlan(
workspace.planOverrides?.basePlanId ?? workspace.subscriptionTier
)
if (!workspace.planOverrides || !workspace.isCustomPlan) {
return basePlan // No overrides, return base plan as-is
}
const overrides = workspace.planOverrides
return {
...basePlan,
limits: {
...basePlan.limits,
includedCredits: overrides.includedCredits ?? basePlan.limits.includedCredits,
members: overrides.seatLimit ?? basePlan.limits.members,
},
pricing: {
...basePlan.pricing,
creditPrice: overrides.creditPrice ?? basePlan.pricing.creditPrice,
monthlyBase: overrides.monthlyBase ?? basePlan.pricing.monthlyBase,
},
name: overrides.planLabel ?? basePlan.name,
}
}function planHasFeature(workspace: Workspace, featureId: PlanFeatureId): boolean {
// Check base plan features
const basePlanId = workspace.planOverrides?.basePlanId ?? workspace.subscriptionTier
const baseHas = PLAN_FEATURES[basePlanId]?.includes(featureId) ?? false
// Check additional features from overrides
const additionalHas = workspace.planOverrides?.additionalFeatures?.includes(featureId) ?? false
return baseHas || additionalHas
}Breaking change: planHasFeature currently takes a planId string. It needs to take a workspace (or at least the override data) instead. Every call site needs updating.
In stripe-webhooks.ts and credit-balance.service.ts, replace:
const creditsPerSeat = plan.limits?.includedCredits || 0with:
const effectivePlan = getEffectivePlan(workspace)
const creditsPerSeat = effectivePlan.limits?.includedCredits || 0For unlimited credits (includedCredits: -1), skip credit deduction entirely in the consumption path.
The determineTierFromPriceId() function currently falls back to personal_standard for unknown price IDs. For custom enterprise Stripe products:
Option A (simpler): Store the mapping in plan_overrides:
interface PlanOverrides {
...
stripePriceIds?: string[] // Custom Stripe price IDs for this workspace
}Then in the webhook, when getBillingPlanByPriceId() returns null, look up the workspace by stripeCustomerId and use its plan_overrides.basePlanId.
Option B: Use Stripe product metadata to store the base plan ID. Add obvious_base_plan: team_pro to Stripe product metadata, read it in the webhook.
Recommendation: Option A. Keeps the source of truth in our DB, not split across Stripe metadata.
For skipBilling: true workspaces:
- Set
subscriptionTierto the base plan (e.g.,team_pro) - Set
subscriptionStatustoactive - Don't create a Stripe subscription
- Grant credits via a new admin endpoint or cron job (not via Stripe webhooks)
- For unlimited credits: set
includedCredits: -1and short-circuit the deduction path
New admin endpoint:
POST /admin/workspaces/:id/plan-overrides
Body: PlanOverrides
Auth: admin-only
- View all workspaces with custom plans
- Create/edit plan overrides for a workspace
- Grant one-time credit bonuses
- View credit usage for custom-plan workspaces
Automated flow:
- Employee signs up with @obvious.com email
- System auto-creates workspace with overrides:
{ "basePlanId": "team_pro", "includedCredits": -1, "creditPrice": 0, "skipBilling": true, "planLabel": "Employee Plan" } - No Stripe subscription needed
Sales workflow:
- Sales creates custom Stripe product/price for the customer
- Admin sets
plan_overrideson the workspace with deal terms - Customer subscribes via normal checkout (Stripe handles billing)
- Webhooks resolve the workspace via customer ID, use overrides for credit grants
Move PLAN_METADATA and PLAN_FEATURES into the database entirely. This is a much larger effort and isn't needed for the immediate use cases. Phase 1 handles enterprise and employee plans without this.
| File | Change | Effort |
|---|---|---|
apps/api/src/db/schema.ts |
Add plan_overrides, is_custom_plan columns |
S |
apps/api/src/config/plan-utils.ts |
No changes (base plan types stay) | — |
apps/api/src/config/billing.config.ts |
Add getEffectivePlan() resolver |
M |
apps/api/src/config/plan-features.ts |
Update planHasFeature() to accept overrides |
M |
apps/api/src/routes/stripe-webhooks.ts |
Use getEffectivePlan() for credit calcs, handle unknown price IDs |
M |
apps/api/src/services/credit-balance.service.ts |
Handle unlimited credits (-1), use effective plan |
M |
apps/api/src/services/seat-management.service.ts |
Respect seat limit overrides | S |
apps/api/src/routes/billing.ts |
Use getEffectivePlan() in status/checkout endpoints |
M |
apps/api/src/routes/admin.ts (new) |
Admin endpoint for plan overrides | M |
dashboard/ (billing UI) |
Show custom plan label, hide upgrade CTAs for custom plans | S |
{
"basePlanId": "team_pro",
"includedCredits": 500,
"creditPrice": 0.70,
"planLabel": "Acme Corp Enterprise",
"additionalFeatures": ["infra_dedicated", "sla_custom"],
"seatLimit": 50
}Acme gets team_pro features + enterprise features, 500 credits/seat, discounted credit price, 50 seat cap. Stripe handles billing with a custom price.
{
"basePlanId": "team_pro",
"includedCredits": -1,
"creditPrice": 0,
"skipBilling": true,
"planLabel": "Employee Plan",
"additionalFeatures": ["infra_dedicated"]
}Employee gets team_pro + unlimited credits + no billing. Provisioned automatically on signup with @obvious.com email.
{
"basePlanId": "personal_pro",
"includedCredits": 1000,
"creditPrice": 0,
"skipBilling": true,
"planLabel": "Advisor Plan"
}Personal pro with 1000 credits/month, no charge. Set manually by admin.
- Add
plan_overridesandis_custom_plancolumns (nullable, no breaking change) - Introduce
getEffectivePlan()— all existing workspaces return their base plan unchanged - Update call sites one-by-one (feature gating, credit grants, webhooks)
- Build admin endpoint for setting overrides
- Test with one enterprise customer
- Build employee auto-provisioning
No existing functionality breaks at any step. Workspaces without overrides behave identically.
- Credit tracking for unlimited plans — Do we still track usage for unlimited credit plans (for analytics), or skip the ledger entirely?
- Override inheritance — If an enterprise customer upgrades their base Stripe subscription (team_standard → team_pro), should overrides carry over automatically?
- Audit trail — Do we need a log of who changed plan overrides and when?
- Dashboard visibility — Should custom plan workspaces see a different billing page (no "change plan" button)?