Skip to main content

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 subscription Cloud Function now exists (apps/firebase-functions/src/subscription/subscription.ts) but carries only POST /change-plan (see Admin plan switching). The Stripe routes — checkout, portal, webhook — are still spec'd-not-built: the contract exists in libs/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.

ExportWhat it is
TIER_IDSOrdered 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.
TierInfoDisplay metadata per tier: name, tagline, blurb, priceMonthly, rank, flags, adds, support. Marked @erd-ignore (not a stored entity).
SUBSCRIPTION_TIERSRecord<TierId, TierInfo> — the actual tier data.
TIER_LIST / PUBLIC_TIERS / SECRET_TIERSDisplay order / openly-sold / teased-but-locked subsets.
FEATURED_TIER'days' — the recommended default the funnel highlights.
ANNUAL_MONTHS10 — a year bills as 10 months (two months free).
tierRank() / isPaidTier() / annualPrice()Rank comparison and pricing helpers.

The six tiers

TierRankPrice/moFlagsSells
Minutes0FreeisFreeYour value-of-time number; translate any price into hours
Hours1$6.99All income stories; earnings in context by category
Days2$17.99featuredWealth stories; multi-year trajectory; advanced analytics
Months3From $80 (floor)isPayWhatYouWant, isApplication, isLimited, 12-month term, seats {perMonth: 20, cap: 100}Custom stories; priority human support; early access; fund the mission
Decades4$1,000isSecret (invitation only)Concierge-level guidance
Generations5null (contact-only)isSecret, isContactOnlyFamily & 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:

  1. Billing toggle — monthly / annual (annual shows "2 months free" via annualPrice()).
  2. Self-serve tier cardsPUBLIC_TIERS minus the application tier (Minutes / Hours / Days), with the FEATURED_TIER accent-ringed and badged "Most popular".
  3. Patron band — the Months tier spotlighted separately: pay-what-you-want from priceFloor, apply-for-a-seat CTA.
  4. Story types explainer — Income / Wealth / Custom stories, colour-matched to the tier that unlocks each (see Story Engine).
  5. 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.tsCheckoutService:

  • startCheckout(tier, billingCycle)POST environment.subscriptionCheckoutUrl with 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 CheckoutError codes; 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-onlyrolesOf(claimsFromToken(token)) (so both admin: true and roles: ['admin'] count). The doc's subscription.* 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 calls applyAccountClaims(uid, { tier }), which re-mints the tier claim and bumps claimsVersion so the client force-refreshes its ID token. isFounder, isOfferedTrial and 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-required is 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 bumped claimsVersion), re-reads the typed claims, then bumps the claimsRevision signal.
  • Every component holding an AccountClaims signal reads claimsRevision() inside its claims effect alongside isLoggedIn() — the shell (app.ts, so the sidebar's secret tier entries rebuild), the tier-feature pages (audience) and /account itself. Route components mounted later re-read fresh claims on construction anyway.
  • The cached users/{uid} doc is dropped via UserStoryEntriesService.invalidate() so subscription displays re-read the new subscription block.

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: tier custom claim — already consumed by the feedback function's feature-request gate (FEATURE_REQUEST_MIN_TIER = 'months' in apps/firebase-functions/src/feedback/feedback.ts, see Feedback System).
  • User document: User.subscription (type: TierId, billingCycle, stripeCustomerId?, stripeSubscriptionId?, …) in libs/stretched-types/src/user/user.ts — see Data Model.

Remaining work

  • Add the Stripe routes to the subscription function (Checkout session, portal session, webhook → claim + user doc). Blocked on the user's Stripe account setup. POST /change-plan already 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 shared SubscriptionApi namespace (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 on months.