Pricing and Subscriptions
See also: Data Model · Auth and Users · Firestore and Security Rules
Stretched sells horizon and depth of insight plus support, not hard feature walls. Tiers are named after units of time — the brand translates money into hours of your life, so the plans are literally time horizons.
Status: the frontend funnel is DONE. The
subscriptionCloud Function now exists (apps/firebase-functions/src/subscription/subscription.ts) but carries onlyPOST /change-plan(see Admin plan switching). The Stripe routes — checkout, portal, webhook — are still spec'd-not-built: the contract exists inlibs/stretched-types/src/subscription/subscription.ts(SubscriptionApi), the routes do not. The client degrades gracefully until they land (see Checkout flow).
The tier model — single source of truth
Everything lives in libs/stretched-types/src/subscription/subscription.ts, imported by both the Angular app and (eventually) the Firebase function, so names, prices, and copy can never drift between client and server.
| Export | What it is |
|---|---|
TIER_IDS | Ordered tier ids, free → highest: minutes, hours, days, months, decades, generations. Persisted strings (the tier custom claim + subscription.type on the user doc) — never rename after first use. |
TierInfo | Display metadata per tier: name, tagline, blurb, priceMonthly, rank, flags, adds, support. Marked @erd-ignore (not a stored entity). |
SUBSCRIPTION_TIERS | Record<TierId, TierInfo> — the actual tier data. |
TIER_LIST / PUBLIC_TIERS / SECRET_TIERS | Display order / openly-sold / teased-but-locked subsets. |
FEATURED_TIER | 'days' — the recommended default the funnel highlights. |
ANNUAL_MONTHS | 10 — a year bills as 10 months (two months free). |
tierRank() / isPaidTier() / annualPrice() | Rank comparison and pricing helpers. |
The six tiers
| Tier | Rank | Price/mo | Flags | Sells |
|---|---|---|---|---|
| Minutes | 0 | Free | isFree | Your value-of-time number; translate any price into hours |
| Hours | 1 | $6.99 | — | All income stories; earnings in context by category |
| Days | 2 | $17.99 | featured | Wealth stories; multi-year trajectory; advanced analytics |
| Months | 3 | From $80 (floor) | isPayWhatYouWant, isApplication, isLimited, 12-month term, seats {perMonth: 20, cap: 100} | Custom stories; priority human support; early access; fund the mission |
| Decades | 4 | $1,000 | isSecret (invitation only) | Concierge-level guidance |
| Generations | 5 | null (contact-only) | isSecret, isContactOnly | Family & legacy scope |
Perks (adds) are cumulative: each tier's adds lists only what it introduces over the tier below. The funnel renders lower tiers' adds with a ✓ ("you have this") and the current tier's with a + ("new here"), so the ladder visibly grows.
The /account funnel page
apps/stretched/src/app/pages/account/account.component.ts (route account, with pricing redirecting to it — see apps/stretched/src/app/app.routes.ts). Layout, top to bottom:
- Billing toggle — monthly / annual (annual shows "2 months free" via
annualPrice()). - Self-serve tier cards —
PUBLIC_TIERSminus the application tier (Minutes / Hours / Days), with theFEATURED_TIERaccent-ringed and badged "Most popular". - Patron band — the Months tier spotlighted separately: pay-what-you-want from
priceFloor, apply-for-a-seat CTA. - Story types explainer — Income / Wealth / Custom stories, colour-matched to the tier that unlocks each (see Story Engine).
- Secret tiers — Decades and Generations, dimmed/locked cards; CTAs open a
mailto:hello@stretched.app.
Per-tier colour/icon identity lives in the page (TIER_THEME), not the shared model — presentation stays out of stretched-types.
A logged-out user who picks a paid tier gets the shared sc-user-login organism in an overlay; the picked tier+cycle is stashed in a pending signal and checkout resumes after sign-in (see Auth and Users).
Checkout flow (client-side)
apps/stretched/src/app/core/services/checkout.service.ts — CheckoutService:
startCheckout(tier, billingCycle)→POST environment.subscriptionCheckoutUrlwith a Firebase ID token → redirects the browser to the returned Stripe Checkout URL.openBillingPortal()→POST environment.subscriptionPortalUrl→ redirects to the Stripe billing portal.- Errors map to
CheckoutErrorcodes; 404 / network-0 map to'unavailable'because the function isn't deployed yet — the funnel shows "Checkout isn't available just yet" instead of an error.
API contract (SubscriptionApi namespace)
POST /subscription/checkout { tier: TierId, billingCycle?: BillingCycle } → { url } // Stripe Checkout
POST /subscription/portal {} → { url } // billing portal
The Stripe secret key stays server-side; the client only ever sees redirect URLs. The (future) webhook is responsible for updating the tier custom claim and User.subscription on the user document.
Admin plan switching (server-side)
apps/firebase-functions/src/subscription/subscription.ts — the one route that exists today. It lets developer (admin) accounts switch their own plan freely and steers everyone else into paying:
POST /subscription/change-plan { tier: TierId }
→ 200 { status: 'changed', tier } // caller's VERIFIED token has the admin claim
→ 200 { status: 'payment-required' } // any other signed-in caller — NO writes happen
→ 400 { error } // admin sent an id outside TIER_IDS
→ 401 { error } // missing / invalid / revoked token
- Emulator URL:
http://localhost:5001/stretched-dev/us-central1/subscription/change-plan. - Authorization is claims-only —
rolesOf(claimsFromToken(token))(so bothadmin: trueandroles: ['admin']count). The doc'ssubscription.*is a spoofable display copy and is never read for authorization. - On the admin path it writes
users/{uid}.subscription(type,isPaid,billingCycle,activatedAt,expiresAt— a one-year comp term) with{ merge: true }, then callsapplyAccountClaims(uid, { tier }), which re-mints thetierclaim and bumpsclaimsVersionso the client force-refreshes its ID token.isFounder,isOfferedTrialand the Stripe ids are deliberately left untouched. - The free tier (
minutes) is a valid target — that is how a dev views the free experience. payment-requiredis the client's cue to open Stripe checkout instead; it is not an error.
Admin plan switching (client-side)
CheckoutService.changePlan(tier) → POST environment.subscriptionChangePlanUrl (defined per environment beside the checkout/portal URLs), returning a discriminated PlanChangeResult — { status: 'changed', tier } or { status: 'payment-required' }. Errors map to CheckoutError exactly like checkout's, plus 400 → 'invalid-tier'; toPlanChangeMessage(err) renders them. The request/response DTOs are app-local for now — promote them into SubscriptionApi when the Stripe routes land and the function group's contracts are versioned together.
On /account, a session whose claims carry the admin role gets "View this plan" on every tier card, on the patron band and on the secret cards; the plan it is already on stays the marked "You're in" state (isCurrentTier reads the tier CLAIM, so it moves with the switch). Non-admins see the funnel exactly as before. payment-required — which an admin should never get — falls through to startCheckout(), so a stale client-side isAdmin() can never hand out a free plan: the endpoint is the authority.
The refresh seam is what makes the switch visible without a reload:
AuthService.refreshClaims()—getIdToken(true)(the server bumpedclaimsVersion), re-reads the typed claims, then bumps theclaimsRevisionsignal.- Every component holding an
AccountClaimssignal readsclaimsRevision()inside its claims effect alongsideisLoggedIn()— the shell (app.ts, so the sidebar's secret tier entries rebuild), the tier-feature pages (audience) and/accountitself. Route components mounted later re-read fresh claims on construction anyway. - The cached
users/{uid}doc is dropped viaUserStoryEntriesService.invalidate()so subscription displays re-read the newsubscriptionblock.
Covered end to end by apps/playwright-e2e/src/web/account-plan-switch.e2e.ts (admin-claim session vs plain member).
Where tier state lives once purchased
- Auth token:
tiercustom claim — already consumed by the feedback function's feature-request gate (FEATURE_REQUEST_MIN_TIER = 'months'inapps/firebase-functions/src/feedback/feedback.ts, see Feedback System). - User document:
User.subscription(type: TierId,billingCycle,stripeCustomerId?,stripeSubscriptionId?, …) inlibs/stretched-types/src/user/user.ts— see Data Model.
Remaining work
- Add the Stripe routes to the
subscriptionfunction (Checkout session, portal session, webhook → claim + user doc). Blocked on the user's Stripe account setup.POST /change-planalready ships in that function group and shows the claim-writing shape the webhook wants (applyAccountClaims+ a{ merge: true }doc write). - Promote
/change-plan's request/response types out of the function and the app into the sharedSubscriptionApinamespace (both sides duplicate the shape today). The client itself is wired — see Admin plan switching (client-side). - Months seat/application backend — today "Apply for a seat" is a prefilled
mailto:. - Update the feedback function's stale doc-comment tier reference (
tier: 'pro') — the code already gates onmonths.