Skip to main content

⚠️ Legacy keys post-2026-04-18 PewSearch decouple. ps_pro_website and pro_website are now legacy — no new writes use them. They persist on 2 demo rows + 1 founder-test row only. The canonical key for all new Pro Website signups is cwa_pro_website. See knowledge/decisions/pro-website-decouple-2026-04-18.md. Follow-ups FA-047 (demo migration) + FA-048 (CHECK constraint tighten) will drop them from the allowlist.

premium_churches.plan — canonical Stripe tier key contract

The Bug Flow (2026-04-17)

The rule

The premium_churches.plan column stores the canonical Stripe tier key. Never a normalized/collapsed tier.

Canonical tier keys are the values in VALID_PLAN_KEYS (see src/lib/tier-config.ts). Examples: cwa_pro_website, cwa_pro_chat, cwa_starter_voice, cwa_suite_both, ps_pro_website. Each one maps 1:1 to a Stripe price ID + product SKU.

The normalized tier is the three-value enum returned by normalizePlanTier(): starter | pro | suite. It exists for feature-gating (e.g. "does this plan include analytics?"). It is lossy — multiple canonical keys collapse to the same tier:

Canonical keyNormalized tier
cwa_pro_chat, cwa_pro_voice, cwa_pro_bothpro
cwa_starter_chat, cwa_starter_voice, cwa_starter_bothstarter
cwa_suite_chat, cwa_suite_bothsuite
cwa_pro_website, ps_pro_website, pro_website, ps_premiumstarter

Writing the normalized tier back into the plan column destroys identity and breaks every downstream query that pattern-matches canonical keys — including tier-gated UI (Website tab for Pro Website, Voice tab for voice plans), /s/[slug] Pro Website lookup, PRO_WEBSITE_PLANS filter, and isProWebsitePlan(plan).

The bug that forced this doc (2026-04-17, P0 launch blocker)

The founder's first real Pro Website signup landed in a broken admin dashboard — no Website tab.

Sequence:

  1. checkout.session.completed fires → provisionNewChurch() wrote plan = 'cwa_pro_website' correctly (via a hard-coded shortcut on isProWebsiteStandalone).
  2. Seconds later, customer.subscription.updated fires (Stripe emits this on trial activation, invoice attachment, etc.).
  3. Handler at route.ts:377 wrote plan: normalized.plan. For cwa_pro_website, that's 'starter'.
  4. Result: plan flipped from cwa_pro_websitestarter. Website-tab gate (isProWebsitePlan(plan)) failed; customer saw no Website tab.

The bug wasn't Pro Website-specific. Every CWA tier with a compound canonical key (cwa_pro_chat, cwa_starter_voice, etc.) had the same vulnerability — just less visible because downstream code mostly uses the normalized tier for those, not the canonical key.

Fix (PR #49, commit c0309a7c): three call sites in src/app/api/stripe/webhook/route.ts now write the raw Stripe metadata tier / newTier to plan, and use normalized.channel only for channel (which IS an enum of real values chat | voice | both).

Two columns, two purposes

ColumnWhat it holdsSource of truth
planCanonical Stripe tier keysession.metadata.tier or subscription.metadata.tier
channelDelivery channel enum (chat/voice/both)normalizePlanChannel(plan) — this IS a real enum

Feature gating code (canAccess(), planIncludesVoice()) calls normalizePlanTier(plan) at read time. The collapse happens in computation, never in persistence.

Rules for code that writes to premium_churches.plan

  1. Always write the canonical key, never the normalized tier. The canonical key comes from Stripe metadata (session.metadata.tier, subscription.metadata.tier). Preserve it end-to-end.
  2. Never write normalized.plan. If a variable is called normalized, it's lossy — only its .channel is safe to persist.
  3. Validate before writing. Use isValidPlanKey(value) before a write to catch typos. An invalid key in metadata should raise an error, not silently default to 'starter'.
  4. Never default to 'starter'. The fallback || 'starter' pattern in webhook handlers masks metadata-propagation bugs. If tier metadata is missing, log + alert + skip the write — don't silently lie.

Invariant check (post-provision assertion)

After provisioning any new church, the following must hold:

-- Returns 0 rows when the invariant holds
SELECT pc.id, pc.plan, s.metadata->>'tier' AS stripe_tier
FROM premium_churches pc
-- Replace with actual subscription cross-reference
WHERE pc.stripe_subscription_id IS NOT NULL
AND pc.plan <> (s.metadata->>'tier')
AND pc.status = 'active';

See knowledge/tests/scripts/check-plan-canonical-invariant.sql and the regression spec in churchwiseai-web/src/lib/__tests__/tier-config.contract.test.ts.

When adding a new product / plan key

  1. Add the canonical key to VALID_PLAN_KEYS in src/lib/tier-config.ts.
  2. Add a case branch in normalizePlanTier() mapping it to starter | pro | suite.
  3. Add a case branch in normalizePlanChannel() mapping it to chat | voice | both.
  4. If the product is distinct (like Pro Website), add it to PRO_WEBSITE_PLANS in premium-shared.ts and wire an isXxxPlan() helper.
  5. Verify: webhook → DB → feature-gate round-trip preserves the canonical key.