⚠️ Legacy keys post-2026-04-18 PewSearch decouple.
ps_pro_websiteandpro_websiteare 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 iscwa_pro_website. Seeknowledge/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 key | Normalized tier |
|---|---|
cwa_pro_chat, cwa_pro_voice, cwa_pro_both | pro |
cwa_starter_chat, cwa_starter_voice, cwa_starter_both | starter |
cwa_suite_chat, cwa_suite_both | suite |
cwa_pro_website, ps_pro_website, pro_website, ps_premium | starter |
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:
checkout.session.completedfires →provisionNewChurch()wroteplan = 'cwa_pro_website'correctly (via a hard-coded shortcut onisProWebsiteStandalone).- Seconds later,
customer.subscription.updatedfires (Stripe emits this on trial activation, invoice attachment, etc.). - Handler at
route.ts:377wroteplan: normalized.plan. Forcwa_pro_website, that's'starter'. - Result:
planflipped fromcwa_pro_website→starter. 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
| Column | What it holds | Source of truth |
|---|---|---|
plan | Canonical Stripe tier key | session.metadata.tier or subscription.metadata.tier |
channel | Delivery 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
- 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. - Never write
normalized.plan. If a variable is callednormalized, it's lossy — only its.channelis safe to persist. - 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'. - 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
- Add the canonical key to
VALID_PLAN_KEYSinsrc/lib/tier-config.ts. - Add a case branch in
normalizePlanTier()mapping it tostarter | pro | suite. - Add a case branch in
normalizePlanChannel()mapping it tochat | voice | both. - If the product is distinct (like Pro Website), add it to
PRO_WEBSITE_PLANSinpremium-shared.tsand wire anisXxxPlan()helper. - Verify: webhook → DB → feature-gate round-trip preserves the canonical key.