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 carries POST /change-plan plus Lemon Squeezy checkout, portal, and webhook routes (apps/firebase-functions/src/subscription/). End-to-end paid checkout is blocked on Lemon Squeezy product/variant setup + Firebase secrets — see NEXT-STEPS §3 and TBD-features/lemon-squeezy-subscriptions.md. Until secrets exist, checkout/portal return 503 and the client shows a friendly “not available yet” toast.

The tier model — single source of truth​

Everything lives in libs/stretched-types/src/subscription/subscription.ts, imported by both the Angular app and 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).
SELF_SERVE_PAID_TIERShours, days — the only tiers that hit Lemon Squeezy checkout in v1.
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.99—All 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 cards — PUBLIC_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.

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).

Returning from Lemon Squeezy with ?checkout=success&plan= names the plan that was bought, then force-refreshes claims and toasts success once that plan is on the token. ?checkout=cancelled is informational only.

Checkout flow (client-side)​

apps/stretched/src/app/core/services/checkout.service.ts — CheckoutService:

  • startCheckout(tier, billingCycle) → POST environment.subscriptionCheckoutUrl with a Firebase ID token → redirects the browser to the returned Lemon Squeezy Checkout URL.
  • openBillingPortal() → POST environment.subscriptionPortalUrl → redirects to the Lemon Squeezy customer portal.
  • Errors map to CheckoutError codes; 404 / 503 / network-0 map to 'unavailable' until secrets are configured — 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 } // new checkout, or an in-place plan change when a subscription already exists
POST /subscription/portal {} → { url } // customer portal
POST /subscription/change-plan { tier: TierId } → { status: 'changed'|'payment-required', tier? }

The Lemon Squeezy API key stays server-side; the client only ever sees redirect URLs. Checkout refuses to open a second subscription when the account already has an entitled subscription on the requested variant and Lemon Squeezy did not return a portal URL. Checkout nonces stay until the one-hour payment link has expired and an extra hour has passed for the webhook, rather than dropping the oldest after 20. The webhook is responsible for updating the tier custom claim and User.subscription on the user document. A subscription webhook that omits custom_data is applied to the uid stored for that subscription, or to the billing account already on that subscription id. An unknown id is left unresolved instead of being assigned to whichever account shares the Lemon customer, and that subscription is refunded and cancelled so it does not keep renewing. A metadata-free payment refunds against that same owner, including an open deletion, and does not use another account that shares the Lemon customer. A payment that names a subscription before that owner is stored is retried instead of refunded. A paying member who picks Minutes is sent to the customer portal to cancel; the expiry webhook moves them back to the free tier. The portal button follows the hasLemonBilling claim, which only a Lemon Squeezy webhook sets, so a comped plan does not offer a portal that would 400. Webhook writes are ordered by the subscription's updated_at, so a retried older event cannot restore a plan that a later event already replaced. Two subscriptions created at the same timestamp keep the higher subscription id. A later fraction of that second wins even when its id is lower. An expiry for a subscription that is not the one on file is remembered, so a delayed grant for that same subscription cannot restore it. A later grant that is accepted deletes that subscription's expiry mark with a field update, so other remembered expiries stay. An expiry, unpaid status, or void pause that arrives without updated_at still removes paid access and records that mark from the time it was received, even when an earlier webhook already stored a timestamp. That mark is compared at microsecond precision, so a resume later in the same millisecond still restores access. An unmapped cancellation removes paid access as well. An unpaid status removes paid access the same way; a later event with a newer updated_at can grant it again. A pause with mode: void removes paid access; a free pause keeps it. An entitled webhook for a deleted account cancels that subscription even when its variant is no longer in the map. An update that moves the subscription on file onto a variant that is not in the map removes paid access and cancels that subscription. One that is already cancelled still removes paid access and does not cancel it again. A staff override on that subscription is cleared too, rather than kept through the expiry. An unmapped cancellation still refunds an invoice created after deletion started, even before that deletion is finalized. A second subscription for the same account is cancelled, so only the one on file keeps billing. A plan change on a void-paused subscription clears that pause. Choosing the variant a cancelled subscription already uses resumes it. A void-paused PayPal subscription is sent to the customer portal instead, because that API cannot patch it. A subscription that loses the lock to a newer one is cancelled only after the replacement claim is minted and a later webhook has taken the lock and confirmed that replacement is still on file, including the id the locked write itself replaced. That cleanup is journaled before Lemon Squeezy is called, and its latest paid invoice is refunded while that subscription is still an active or trialing checkout. That refund decision is stored with the pending cancellation, comparing the displaced subscription with the incoming one's creation time, so a retry more than a day later still refunds the original checkout. Checkout and the billing portal take the account lock and compare the Auth user's creation time with the token's sign-in time before they call Lemon Squeezy, so they cannot bill an account that is being deleted or a uid that was registered again. Deletion uses that same sign-in time after it takes the lock. An account created in that same second needs a nonce on the token, and deletion refreshes that token once so the minted nonce can be used. A recorded refund still applies after the subscription is cancelled, paused, or past due, including each paid invoice after a renewal. A later deletion of the same uid keeps every earlier generation's refund boundary. A paid claim that Firebase rejects rolls the billing write back, including when checkout adopts the variant already on file. A rejected downgrade rolls that expiry write back the same way. The portal reads the billing account again after that lock, and a missing subscription falls back to the customer portal. A new checkout or plan change is refunded only when its invoice was created after the account deletion started. A renewal from before that is not refunded. The cancellation event deletion sends still refunds an invoice created after that boundary, including while the deletion record is not finalized yet. A staff override is dropped only while that locked read still shows a live subscription. A refund error counts only when the invoice or order is already marked refunded. An admin who switches their own plan while a Lemon Squeezy subscription is on file keeps that tier across renewals until the customer buys a different subscription or variant. Minutes opens the billing portal for any tier that already has Lemon billing. Deleting the account records that intent, blocks client writes, and records the time deletion started, before it cancels the Lemon Squeezy subscription on file and refunds then cancels any displaced subscription still waiting to be cancelled, then removes the Auth user. A subscription Lemon Squeezy has already expired or cancelled counts as stopped. A cancellation Lemon Squeezy rejects drops that start time so the next attempt records a new one. A uid that was already deleted starts a new boundary instead of keeping the previous account's. A subscription created before that deletion does not attach to a recreated account, even when a later portal change updates it. A checkout records the account generation, and a webhook from a generation that was already deleted is not applied, including when the deletion finishes while that webhook is waiting for the account lock. A webhook that names a later generation is applied even when its creation time is not after that deletion. A webhook with no generation uses the full creation timestamp, so one created a fraction of a millisecond after that deletion is not treated as the previous account, but a new paid grant still requires the checkout nonce stored before redirect, so a guessed generation cannot mint claims, a retired generation is refunded on expiry, unpaid, or payment success including the order when no invoice is open and that subscription was created after the deletion boundary, a customer id kept beside the billing document still refunds a later event after that document is removed, a finished deletion does not refund a later account from the old start time, including a payment that claims the current generation, and a payment with no custom data that resolves only through the retained customer mapping refunds from the generation stored for that subscription together with the uid that owned it, so a later purchase by the same Lemon customer still refunds the old subscription against the account that was deleted, and a later deletion of the same uid still refunds the earlier account's charge, and a paid webhook that lowers the tier revokes refresh tokens and keeps the subscription journal until that revocation is recorded, a stale unmapped webhook does not cancel the subscription already on file, and an entitled event for that subscription that omits updated_at is ordered when it arrives so a resume can restore access; a later event that omits custom data applies only for the subscription or customer already on file, and a user id alone does not mint claims, including when it names the subscription already on file, and that rejected checkout is refunded before it is cancelled even when this account has no deletion boundary. A checkout whose variant was removed from the map after the link was issued is refunded the same way, including when the event carries only a user id. The return page does not treat the paid plan already on the token as the purchase that just completed when it could not store which plan was bought. That comparison uses the cached token from before the page force-refreshes, so a webhook that finished first is still a change from the plan the browser had cached. An admin plan change refreshes a rejected token once and retries, the same way checkout does. A payment for a checkout that was already rejected is acknowledged, because that rejection is recorded and a later invoice cannot discover an owner that will never be stored. A later event for the subscription already on file still updates that plan after the checkout nonce expires. A subscription this account already owned is not refunded from the beginning of time when that nonce has expired. A charge created after that deletion started is refunded before the old subscription is cancelled, including when it is later only past that millisecond, using that generation's own start time when the uid is deleted again, including when the Auth user is already gone. A webhook that does not name a generation uses the earliest kept deletion start that is still after the subscription was created. That scan continues through later invoice pages until the invoices are older than the deletion that refunds them. The page Lemon Squeezy sends the customer back to waits until the token shows the plan they just bought before it says the subscription is active, and it keeps that return until the plan is confirmed, the wait ends, or checkout is cancelled. Remembering that plan is best-effort, so a browser that blocks session storage still opens the checkout URL. An expiry resume, and a later event for the same subscription, are ordered by the full provider timestamp, including fractions of a millisecond. Client reads and writes of the documents account deletion removes are refused while that deletion is in progress. Auth refuses to create that same uid until the deletion finishes. A recorded duplicate refunds every open invoice, including invoices past the first page and a partial refund that still has a balance. A first-seen duplicate that overlaps the subscription on file refunds its renewals too. Recovery of a cancellation uses the Auth creation time stored on the journal, not the user who holds the uid now. A rejected later deletion puts that completed fence back instead of erasing it. Recovery of a cancellation Lemon Squeezy rejects releases the account. If recording that completed deletion fails, the journal stays so recovery can clear the deleting marker. Recovery of a deletion that already recorded its completion, or that finds an Auth user created after the deletion started, releases the journal without deleting a recreated account. A uid registered while billing cancellation is still running is blocked from writing, then the former documents are removed, and only that Auth user is left in place. The creation time is taken from the authenticated request. The deletion fence is journaled before it is written, so a lost write can still be cleared without erasing a deletion that already finished, and deletion stops before it cancels billing when that user has already been replaced. A variant id listed for more than one plan is rejected. If one of those cancellations succeeds and a later one fails, the deletion journal stays so recovery can finish both. A checkout that fails after that billed variant has already replaced a staff override releases the account lock. A crash after cancellation is recovered by finishing the deletion. An entitled webhook that arrives after that deletion cancels the Lemon Squeezy subscription instead of recreating the account. A subscription created for an account that is already gone is refunded before that cancellation. A duplicate is refunded only while it is active or trialing, was created within a day of now or of the subscription kept on file, and its newest paid invoice is still the original checkout. Hosted checkout links expire after an hour. A missing Lemon Squeezy subscription opens a new checkout. If releasing the displaced subscription fails after the claim is minted, the account lock is released so the next webhook can retry. If the process stops after that claim and before the release, recovery cancels the displaced subscription from the journal, using the winning subscription's creation time stored there so a late retry still refunds the duplicate. A grant that reaches a completed journal before that release is recorded still cancels the subscription it replaced. Choosing the variant Lemon Squeezy already bills replaces a staff override with that subscription's cycle and dates, and revokes refresh tokens when that paid tier is lower than the comp. A cancelled grace period keeps that override until the resume is applied, so leaving the confirmation page does not drop it. A PayPal resume that the customer confirms clears it when subscription_resumed arrives, even on the same variant. A void unpause of that same variant clears it on subscription_unpaused and revokes refresh tokens when the paid tier is lower than the comp. An ordinary renewal of the same variant keeps the override. A staff tier change on an account that already has a Lemon Squeezy subscription is stored as compTier. Later renewals keep that claim and the comped subscription record; expiries and unpaid events keep the claim too.

Manage billing (profile modal)​

The settings dialog Account section shows the current plan name (from the tier claim) and, for paid tiers, a Manage billing button that calls CheckoutService.openBillingPortal().

Admin plan switching (server-side)​

apps/firebase-functions/src/subscription/subscription.ts — 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 both admin: true and roles: ['admin'] count). The doc's subscription.* is display state. Portal URLs and plan changes read Lemon Squeezy ids from billing_accounts/{uid}, which clients cannot write.
  • 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 }). isFounder, isOfferedTrial and the Lemon Squeezy ids are deliberately left untouched.

Admins can also comp another account's tier from /admin/users (PATCH /users/:uid/claims with { tier }).

  • 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 Lemon Squeezy checkout instead; it is not an error.

Admin plan switching (client-side)​

CheckoutService.changePlan(tier) → POST environment.subscriptionChangePlanUrl, returning a discriminated PlanChangeResult — { status: 'changed', tier } or { status: 'payment-required' }.

On /account, a session whose claims carry the admin role gets "View this plan" on every tier card; the plan it is already on stays the marked "You're in" state. Non-admins see the funnel exactly as before.

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, lemonSqueezyCustomerId?, lemonSqueezySubscriptionId?, …) in libs/stretched-types/src/user/user.ts — see Data Model.

Remaining work​

  • Human: Lemon Squeezy products/variants + secrets + webhook URL (see NEXT-STEPS §3).
  • Months seat/application backend — today "Apply for a seat" is a prefilled mailto:.