Skip to content

fix(web): keep lapsed subscribers out of new-user onboarding - #547

Merged
Makisuo merged 3 commits into
mainfrom
fix/lapsed-subscription-onboarding
Aug 20, 2026
Merged

fix(web): keep lapsed subscribers out of new-user onboarding#547
Makisuo merged 3 commits into
mainfrom
fix/lapsed-subscription-onboarding

Conversation

@Makisuo

@Makisuo Makisuo commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

The bug

Cancel a subscription, wait for it to expire, and you land back in the
quick-start wizard — a returning customer being asked "what's your role?" and
offered demo seed data, with no obvious route to resubscribing.

The root gate only ever read live billing state:

if (!selectedPlan && !quotaPreview && !ALLOWED_WITHOUT_PLAN.includes(pathname)) {
  return <Navigate to="/quick-start" ... />
}

selectedPlan bottoms out at isActivePlanSubscription (status === "active"),
so a never-subscribed org and an expired one fail it identically. Nothing else
told them apart — wizard progress is localStorage-only, and
org_onboarding_state.onboardingCompletedAt is dead code that nothing writes.

The fix

Autumn keeps lapsed subscription rows on the customer, so that history is the
signal we were missing.

  • packages/domain/src/billing.ts — factored a status-blind
    isPlanSubscription (a real plan, not an add-on / auto-enable / legacy-free
    row) out of isActivePlanSubscription, which now composes it. Behaviour of
    the latter is unchanged: the API's customer-cache TTL shares that gate and the
    two must not drift.
  • plan-gating.tsgetLapsedPlan / hasLapsedPlan: non-null only when
    there's no active plan but plan history remains, picking the subscription that
    ended last. Built on isUsableCustomer, so an error-shaped Autumn payload is
    never read as "lapsed".
  • __root.tsx — the gate is now !(selectedPlan || lapsedPlan), so only an
    org with no plan history at all reaches onboarding.
  • quick-start.tsx — waits for the customer query before rendering a step
    (otherwise a returning customer flashes "what's your role?"), then redirects
    lapsed orgs to /.
  • subscription-ended-banner.tsx (new) — non-dismissible banner naming the
    lapsed plan, mounted with the other billing banners in dashboard-layout.tsx.
  • select-plan.tsx — drops the trial badge and pitch for a lapsed org in
    favour of reactivation copy.

Notes for review

  • No read-only mode, deliberately. Ingestion is already refused for these
    orgs at the gateway (402, apps/ingest/src/main.rs), so their data goes stale
    on its own. An exemption list over mutating API routes is easy to get wrong in
    the direction that locks someone out of resubscribing.
  • Detection is payload-only. No server-side flag is revived; if Autumn ever
    stopped returning lapsed rows for some plan type, those orgs would quietly
    revert to today's behaviour.
  • The anti-flash localStorage flag now records "may render the app" rather than
    "holds a plan", so unsubscribing no longer costs a boot splash on every load.

Verification

bun typecheck clean; domain (542) and web (1733) suites pass, with new cases
for isPlanSubscription and getLapsedPlan/hasLapsedPlan — including
expired-plus-active, add-on-only, free-only, and most-recently-ended tie-breaks.

Browser-checked via the dev-only ?subscription_ended_preview=1 flag: the
banner renders non-dismissibly and reaches /select-plan, which shows the
reactivation framing rather than the trial pitch.

Not verified end-to-end against a genuinely expired Autumn customer — that
needs a real cancelled subscription. The decision logic underneath is unit
tested.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

An org whose subscription was cancelled and then expired got bounced into
the quick-start wizard — returning customers asked "what's your role?" and
offered demo seed data, with no obvious way to resubscribe.

The root gate only ever looked at live billing state: `hasSelectedPlan`
resolves to `isActivePlanSubscription`, so a never-subscribed org and an
expired one failed it identically and both landed on /quick-start. Nothing
else distinguished them — wizard progress is localStorage-only and
`org_onboarding_state.onboardingCompletedAt` is dead code nothing writes.

Autumn keeps lapsed subscription rows on the customer, so that history is
the missing signal. Split `isPlanSubscription` (status-blind) out of
`isActivePlanSubscription`, which now composes it and is unchanged in
behaviour — the API's cache TTL shares that gate and must not drift. On top
of it, `getLapsedPlan`/`hasLapsedPlan` identify an org that held a plan and
holds none now, picking the subscription that ended last.

The gate becomes "has a plan OR had one", so only an org with no plan
history reaches onboarding. A lapsed org keeps the app behind a
non-dismissible reactivation banner; ingestion is already refused for them
by the gateway (402, no active subscription), so no write-blocking is added
here. /quick-start bounces lapsed orgs out, and /select-plan drops the trial
pitch for reactivation copy.

The localStorage anti-flash flag now records the broader "may render the
app" condition, so unsubscribing no longer costs a boot splash every load.
…ests

`as unknown as Customer` tripped the anti-slop chained-assertion gate. The
malformed-payload case it was covering already has a home: the shared
`errorPayload` fixture and the "gating helpers never throw" test. Assert
`hasLapsedPlan` there instead, and keep the local test to the no-customer case.
"No active plan" covered two states with opposite cache economics, and both
got the 5s unsettled TTL: an org seconds from its first checkout, and one
that lapsed weeks ago. The short TTL exists for the first — it stops a
just-subscribed user being stranded on the gate while the Stripe→Autumn sync
lands — but it sends every page load of the second straight upstream for an
answer that will not change for weeks.

That is a new hot path as of the previous commit: lapsed orgs now browse the
app instead of being parked on /quick-start, and the customer read is on
every page load. Prod traces put `autumn.request` at p50 94ms / p95 603ms,
and the cached route at p50 6ms / p95 14ms, so each avoidable miss is a
~100ms first-paint tax and a ~600ms tail one.

Split the tiers on plan history, which is the same signal the web gate now
reads: active -> 300s, lapsed -> 60s, never-subscribed -> 5s. A resubscribe
stays visible promptly (attach invalidates the entry outright, and 60s bounds
the worst case), while the upstream call rate for a lapsed org drops ~12x.
@Makisuo
Makisuo merged commit 5475262 into main Aug 20, 2026
32 checks passed
@Makisuo
Makisuo deleted the fix/lapsed-subscription-onboarding branch August 20, 2026 09:56
@Makisuo
Makisuo deployed to pr-preview August 20, 2026 09:56 — with GitHub Actions Active
@github-actions

Copy link
Copy Markdown

🍁 Maple PR preview

Note

Preview resources were removed when this pull request closed.

Final commit c93e36f · View workflow run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant