feat(plans): three months free — trial length + copy consistency - #217
Merged
Conversation
Makes the "free for three months" offer true in code. TRIAL_DURATION_DAYS
30 → 90 (plans.ts), and every user-visible surface that states a length
now reads it from one place.
WHAT MOVED
- `TRIAL_DURATION_DAYS = 90` (workers/identity/src/plans.ts) — already the
single derivation of the trial clock; only `users.ts` createUser reads it
(`now + TRIAL_DURATION_DAYS * 86_400_000`). Nothing else in the tree
computes a trial expiry, so the constant is genuinely the one lever. Its
docstring now carries the campaign context (ratified 2026-07-25), the
new-signups-only scope, the revisit trigger, and Stripe's 730-day ceiling.
- `TRIAL_LENGTH_COPY = "3 months"` (new) — the phrase both length-claiming
surfaces render, so the number and the words can't drift:
- signup price pill (ui.ts renderSignup): "From $1/mo · 3 months free ·
no card to start"
- console no-card line (ui.ts renderPlanCards): "You're on your free
trial — 3 months free, no card needed. We'll ask before it ends."
- Stale "30-day" / "day-30" prose across plans/users/billing/console/
billing-lifecycle/vault-call + the smoke script, re-worded to be
duration-agnostic or to name the constant.
STRIPE — the one place a longer trial could break, and it doesn't
Checkout forwards `subscription_data.trial_end = plan_downgrade_at` when a
trial has ≥48h runway (STRIPE_MIN_TRIAL_END_MS). Stripe's documented window
is 48h minimum to 730 days (2 years) maximum — "The maximum number of trial
period days is 730 (2 years)". A 90-day trial sits comfortably inside it
with ~8x headroom, so the path is unchanged and a new test pins it end to
end (~90 days out, above the floor, under the ceiling, byte-equal to the
row's clock). billing.ts's comment now documents BOTH ends of the window.
TESTS
- plans.test.ts: TRIAL_DURATION_DAYS === 90 (literal, so editing the
constant can't silently move the offer); a fresh signup's clock is ~90d
and explicitly > 31d; TRIAL_LENGTH_COPY === "3 months"; the signup pill
and the console no-card line each assert the new string AND the absence
of the old one.
- trial-lifecycle.test.ts: createUser's clock pinned at ~90d.
- billing.test.ts: a default-clock trialist's checkout carries a ~90-day
trial_end inside Stripe's window.
SCOPE: NEW SIGNUPS ONLY. Accounts already on the clock are not re-stamped —
extending existing trials is a separate operator data-operation.
Version 0.0.8-rc.122 → rc.123.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
unforced
force-pushed
the
ag-trial-90-days
branch
from
July 26, 2026 00:00
eb89737 to
34ba707
Compare
Review nits on #217: - The Growth panel said pending_plan='expired' "IS the 90-day trial clock". The clock is stamped per row at signup (users.ts createUser), so until the existing trials are extended by hand, ~21 rows carry a 30-day stamp. On a page whose whole premise is honest measurement, that sentence was false. Now names the length only for NEW signups and says a mixed cohort is expected — the counts were always correct (they read the stored row), only the prose was wrong. - billing.test.ts seedPaidUser docstring said +30d; now names the constant. The derive-from-constant test property is preserved: it still asserts the rendered length comes from TRIAL_DURATION_DAYS, just on the narrower claim. admin-growth + billing: 88 passed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB
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.
Makes the "free for three months" offer true in code.
TRIAL_DURATION_DAYS30 → 90, and every user-visible surface that states a length now reads it from one place.Scope: NEW SIGNUPS ONLY. This constant does not reach back and re-stamp accounts already on the clock. Extending the existing trials (whose first expiries land 2026-08-05) is a separate operator data-operation.
The trial length
workers/identity/src/plans.ts—TRIAL_DURATION_DAYS = 90.It was already the single derivation: the only reader is
users.tscreateUser, which stampsplan_downgrade_at = now + TRIAL_DURATION_DAYS * 86_400_000. I swept for every other trial-expiry computation (* 86_400_000,24 * 60 * 60 * 1000,plan_downgrade_atwrites) and there is none — the promo path uses its owncomp_days, the Stripe path uses the paid-through date, and migration0018's+30 daysis a one-time historical backfill that already ran (deliberately untouched). Everything downstream is derived:trial_days_left(account-api), the console trial banner's day count, the Stripetrial_end.The constant's docstring now carries why it's 90, that it's new-signups-only, when to revisit (the first 90-day cohort lands ~late October 2026), and Stripe's ceiling.
Copy
New
TRIAL_LENGTH_COPY = "3 months"beside the constant, so the number and the words can't drift apart. Two surfaces state a length, both now render it:ui.tsrenderSignup)From $1/mo · 30 days free · no card to startFrom $1/mo · 3 months free · no card to startui.tsrenderPlanCards)You're on your 30-day free trial — no card needed. We'll ask before it ends.You're on your free trial — 3 months free, no card needed. We'll ask before it ends.Every other trial-facing string in the worker was already duration-free and needed no change —
planLine("Free trial — …"),upgradeTeaser,trialTierChosenMessage("for the rest of your trial"),trialBannerLine(renders livedaysLeft),vaultCapMessage, thereactivatecopy, andaccount-api'slabel: "Free trial". The/.well-known/parachute-accountdescriptor advertises the plan ladder but no trial length at all — see the follow-up note below.Stale
30-day/day-30prose inplans.ts,users.ts,billing.ts,console.ts,billing-lifecycle.ts,vault-call.tsandscripts/smoke-staging.tsis re-worded to be duration-agnostic or to name the constant, so the next reader isn't misled.Stripe — the one place a longer trial could genuinely break
checkoutCoresendssubscription_data.trial_end = plan_downgrade_atwhen a trial has ≥48h runway (STRIPE_MIN_TRIAL_END_MS). Stripe's window has two ends:trial_endmust be at least 48 hours out (already handled: less runway omits the field and bills immediately)billing_cycle_anchor"; the API error reads "The maximum number of trial period days is 730 (2 years)"90 days sits inside that with ~8x headroom, so the checkout path is correct unchanged — no 400 risk.
billing.ts's comment now documents both ends so a future trial-length change knows where the real wall is (730 days).Sources: Stripe API — create subscription, stripe-ruby#804
Tests
plans.test.ts—TRIAL_DURATION_DAYS === 90as a literal (so editing the constant can't silently move the offer without a test changing with it); a fresh/signuplands a ~90d clock and explicitly asserts> 31days, the exact regression this replaces;TRIAL_LENGTH_COPY === "3 months"; the signup pill and the console no-card line each assert the new string and the absence of the old one.trial-lifecycle.test.ts—createUser's clock pinned at ~90d.billing.test.ts— a default-clock trialist's checkout carries a ~90-daytrial_endthat is above Stripe's 48h floor, under its 730-day ceiling, and byte-equal to the row'splan_downgrade_at.Everything else changed in tests is comment-only (stale "day 30" prose); no existing assertion's semantics were altered.
Gates
billing.test.tsalone: 72 passed)workers/identitytypecheckbun test src test-bunVersion
0.0.8-rc.122→0.0.8-rc.123.Follow-up (NOT in this PR)
parachute-appstill says "Free for 30 days, no card." (src/app/routes/Landing.tsx:287, asserted inLanding.test.tsx:90/247/293). This is the primary claim a new visitor sees:GET /signupon the worker 302s to the front-door SPA, so the server-rendered signup pill this PR fixes is only reached on a signup error re-render. Cloud embeds that SPA from the commit pinned inscripts/spa-source.env, so the visible fix needs an app PR plus a pin bump here.Worth considering with it: the account descriptor publishes the plan ladder but no trial length, which is exactly why the app hardcodes one. Advertising
trial_daysin/.well-known/parachute-accountwould make this the last time the two repos have to be changed together.Also noticed, deliberately untouched (sibling repo, historical):
parachute.computer/blog/2026-07-07-parachute-cloud-is-open.mdsays "One month free, no card" in its subtitle, anddesign/2026-07-01-parachute-cloud-v1.mddescribes a 14-day card-on-file trial. Both are dated documents.🤖 Generated with Claude Code
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
https://claude.ai/code/session_01XLZtmuSs1RirWGMGyCB1QB