Family Wall Plus — launch runbook
How to turn on the Family Wall Plus freemium subscription. The gate + Stripe checkout + cost-point gates all shipped dark (PR #1411); this runbook is the three ops steps that flip it live. Mirrors the ABOUT_THAT_PAID_LAUNCH.md / EMBERKILN_PAID_LAUNCH.md flag-gated pattern.
The model
- Free tier = one genuinely-useful kitchen wall — the acquisition wedge ("no hardware to buy" vs Skylight/Hearth). Plus (~$4.99/mo · $39/yr) unlocks the cost-bearing + power features: Ask JQ + voice actions, cloned-voice wall messages, intercom, extra (bedroom) walls, sleep stories, voice alarms, kids' chat.
- Dark by default: the whole gate is behind
FAMILY_WALL_PLUS_ENFORCED. While unset/false,requireFamilyWallPlus()is a no-op — nothing is paywalled and the wall behaves exactly as it does today. Flip totrueonly after the Stripe prices exist and early users are grandfathered. - Gates degrade, they don't hard-break. Extra walls / Ask JQ / intercom / sleep
stories / alarms / kids'-chat-enable return 402 with an upgrade CTA. Cloned-voice
wall messages degrade gracefully — the message still shows and reads aloud in free
browser TTS; only the paid ElevenLabs clone render is skipped (
plus_requiredwarning, no 402, no spend).
One-time setup
1. Create two recurring Stripe prices
In the Stripe dashboard, one product ("Family Wall Plus") with two recurring prices:
- Monthly — $4.99/month
- Annual — $39/year
2. Set the price env vars (Railway backend)
STRIPE_PRICE_FAMILY_WALL_PLUS=price_...
STRIPE_PRICE_FAMILY_WALL_PLUS_ANNUAL=price_...
Until these are set:
POST /api/payment/family-wall-plus/checkoutreturns 503 (same as every other tier).GET /api/payment/family-wall-plus/configreturns{ available: false }, so the settings Upgrade button stays hidden — no user-visible breakage.
The webhook already activates purchases via priceToPlanKey (maps both prices →
family_wall_plus); no new code needed. STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET
are already configured (shared with the other tiers).
3. Grandfather early users (do this BEFORE flipping the flag)
The free tier during the dark period let people use everything. Be generous — comp every pre-launch active family so the flag flip never yanks a feature someone was already relying on. No Stripe charge needed — insert active subscription rows directly.
Grandfather a single user:
insert into subscriptions (user_id, plan_key, status, current_period_end, cancel_at_period_end)
values ('<user-uuid>', 'family_wall_plus', 'active', now() + interval '10 years', false);
Grandfather everyone who has built a wall (the natural "already using it" cohort):
insert into subscriptions (user_id, plan_key, status, current_period_end, cancel_at_period_end)
select distinct d.parent_user_id, 'family_wall_plus', 'active', now() + interval '10 years', false
from family_wall_displays d
where not exists (
select 1 from subscriptions s
where s.user_id = d.parent_user_id and s.plan_key = 'family_wall_plus'
);
current_period_end far in the future + cancel_at_period_end = false = a comp that
doesn't lapse. (Use a shorter interval + cancel_at_period_end = true for a time-boxed
trial instead.) These rows have no stripe_subscription_id, which is fine — they're
never touched by the webhook and isFamilyWallPlus() only checks status + period.
Run this against prod (Supabase MCP ≠ prod — see CLAUDE.md). Verify the row count matches the distinct wall-owner count before flipping the flag.
4. Flip the master switch
FAMILY_WALL_PLUS_ENFORCED=true
Redeploy the backend. Enforcement is now live: the settings page shows the upsell banner
- ✨Plus badges for non-Plus users, the gated features return 402, and cloned-voice wall messages fall back to browser TTS for free users.
How activation works (no new code)
Checkout (POST /api/payment/family-wall-plus/checkout { interval: 'monthly'|'annual' })
→ Stripe Checkout → the existing POST /api/payment/webhook upserts a subscriptions
row with plan_key='family_wall_plus' on checkout.session.completed (mapped by
priceToPlanKey). isFamilyWallPlus(userId) reads that row (active, keeps access through
a cancelled-but-not-ended period). Cancel/update ride the existing customer.subscription.*
webhook handlers.
Cancellation
Self-serve cancel is plan-aware (PR #1411): the CancellationModal should be mounted
with planKey="family_wall_plus" on any Family Wall Plus manage screen so it acts on the
right subscription for users who also hold another tier (e.g. Graphene+). The
/api/payment/cancellation/* routes accept plan_key; omitting it when a user has >1
active sub returns 409 {plan_keys}. Stripe customer portal
(/api/payment/customer-portal) also works.
Rollback
Set FAMILY_WALL_PLUS_ENFORCED=false (or unset) and redeploy — instantly un-paywalls
everything, no data change. Grandfather rows are harmless while off.
Code map
- Gate + flag + subscriber check:
services/family-wall-plus.ts(requireFamilyWallPlus,isFamilyWallPlus,familyPlusEnforced). - Checkout + config + plan-key map:
routes/payment.ts(/family-wall-plus/checkout,/family-wall-plus/config,priceToPlanKey). - Cost-point gates:
services/family-wall.ts(applyMessageAudio— graceful clone-voice skip) +services/family-kid-chat.ts(updateKidChat— 402 on enable). - Status endpoint:
GET /api/family/wall/plus-status(drives the upsell banner + badges). - Settings CTA + thank-you:
dashboard/family/wall/page.tsx(startPlusCheckout,?plus=1). - Plan-aware cancel:
services/subscription-cancellation.ts. - Design + free/paid split rationale: docs/product/FAMILY_WALL_MONETIZATION.md; pick-up point: docs/product/FAMILY_WALL_HANDOFF.md.