fix(web): keep lapsed subscribers out of new-user onboarding - #547
Merged
Conversation
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.
🍁 Maple PR previewNote Preview resources were removed when this pull request closed. Final commit |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
selectedPlanbottoms out atisActivePlanSubscription(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.onboardingCompletedAtis 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-blindisPlanSubscription(a real plan, not an add-on / auto-enable / legacy-freerow) out of
isActivePlanSubscription, which now composes it. Behaviour ofthe latter is unchanged: the API's customer-cache TTL shares that gate and the
two must not drift.
plan-gating.ts—getLapsedPlan/hasLapsedPlan: non-null only whenthere's no active plan but plan history remains, picking the subscription that
ended last. Built on
isUsableCustomer, so an error-shaped Autumn payload isnever read as "lapsed".
__root.tsx— the gate is now!(selectedPlan || lapsedPlan), so only anorg 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 thelapsed plan, mounted with the other billing banners in
dashboard-layout.tsx.select-plan.tsx— drops the trial badge and pitch for a lapsed org infavour of reactivation copy.
Notes for review
orgs at the gateway (402,
apps/ingest/src/main.rs), so their data goes staleon its own. An exemption list over mutating API routes is easy to get wrong in
the direction that locks someone out of resubscribing.
stopped returning lapsed rows for some plan type, those orgs would quietly
revert to today's behaviour.
"holds a plan", so unsubscribing no longer costs a boot splash on every load.
Verification
bun typecheckclean; domain (542) and web (1733) suites pass, with new casesfor
isPlanSubscriptionandgetLapsedPlan/hasLapsedPlan— includingexpired-plus-active, add-on-only, free-only, and most-recently-ended tie-breaks.
Browser-checked via the dev-only
?subscription_ended_preview=1flag: thebanner renders non-dismissibly and reaches
/select-plan, which shows thereactivation 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.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.