Skip to content

feat(plans): three months free — trial length + copy consistency - #217

Merged
unforced merged 2 commits into
mainfrom
ag-trial-90-days
Jul 26, 2026
Merged

feat(plans): three months free — trial length + copy consistency#217
unforced merged 2 commits into
mainfrom
ag-trial-90-days

Conversation

@unforced

Copy link
Copy Markdown
Contributor

Makes the "free for three months" offer true in code. TRIAL_DURATION_DAYS 30 → 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.tsTRIAL_DURATION_DAYS = 90.

It was already the single derivation: the only reader is users.ts createUser, which stamps plan_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_at writes) and there is none — the promo path uses its own comp_days, the Stripe path uses the paid-through date, and migration 0018's +30 days is 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 Stripe trial_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:

Surface Before After
Signup price pill (ui.ts renderSignup) From $1/mo · 30 days free · no card to start From $1/mo · 3 months free · no card to start
Console no-card line (ui.ts renderPlanCards) 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 live daysLeft), vaultCapMessage, the reactivate copy, and account-api's label: "Free trial". The /.well-known/parachute-account descriptor advertises the plan ladder but no trial length at all — see the follow-up note below.

Stale 30-day / day-30 prose in plans.ts, users.ts, billing.ts, console.ts, billing-lifecycle.ts, vault-call.ts and scripts/smoke-staging.ts is 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

checkoutCore sends subscription_data.trial_end = plan_downgrade_at when a trial has ≥48h runway (STRIPE_MIN_TRIAL_END_MS). Stripe's window has two ends:

  • minimumtrial_end must be at least 48 hours out (already handled: less runway omits the field and bills immediately)
  • maximum"Can be at most two years from 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.tsTRIAL_DURATION_DAYS === 90 as a literal (so editing the constant can't silently move the offer without a test changing with it); a fresh /signup lands a ~90d clock and explicitly asserts > 31 days, 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.tscreateUser's clock pinned at ~90d.
  • billing.test.ts — a default-clock trialist's checkout carries a ~90-day trial_end that is above Stripe's 48h floor, under its 730-day ceiling, and byte-equal to the row's plan_downgrade_at.

Everything else changed in tests is comment-only (stale "day 30" prose); no existing assertion's semantics were altered.

Gates

Gate Verdict
identity vitest 1029 passed, 0 failed across all 37 files (run per-file — the full parallel run can't start workerd on this box; billing.test.ts alone: 72 passed)
workers/identity typecheck clean
root typecheck clean
root bun test src test-bun 167 pass, 0 fail, 533 expect() calls, 18 files

Version 0.0.8-rc.1220.0.8-rc.123.

Follow-up (NOT in this PR)

parachute-app still says "Free for 30 days, no card." (src/app/routes/Landing.tsx:287, asserted in Landing.test.tsx:90/247/293). This is the primary claim a new visitor sees: GET /signup on 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 in scripts/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_days in /.well-known/parachute-account would 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.md says "One month free, no card" in its subtitle, and design/2026-07-01-parachute-cloud-v1.md describes 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

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
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
@unforced
unforced merged commit cee08a6 into main Jul 26, 2026
3 checks passed
@unforced
unforced deleted the ag-trial-90-days branch July 26, 2026 00:11
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