diff --git a/package.json b/package.json
index 9829c52..ec7b482 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@openparachute/cloud",
- "version": "0.0.8-rc.124",
+ "version": "0.0.8-rc.125",
"private": true,
"description": "Open Parachute PBC's Vault Cloud \u2014 one Durable Object per vault on Cloudflare, OAuth issuer + self-serve console (accounts + vault ownership).",
"license": "AGPL-3.0",
diff --git a/scripts/smoke-staging.ts b/scripts/smoke-staging.ts
index 723866d..4efea41 100644
--- a/scripts/smoke-staging.ts
+++ b/scripts/smoke-staging.ts
@@ -374,7 +374,7 @@ async function main() {
assert(cvRes.status === 303 && (cvRes.headers.get("location") ?? "").includes(encodeURIComponent(`/vault/${newVault}`)), "console create vault → lands in Notes (303)", `status ${cvRes.status} loc ${cvRes.headers.get("location")}`);
// The console page shows the connect card with the reachable URL shape,
- // plus the plan line (fresh signup = the 30-day no-card TRIAL, which mirrors
+ // plus the plan line (fresh signup = the no-card TRIAL, which mirrors
// PLUS entitlements) rendered from PLAN_SPECS.
const conPage = await fetch(`${IDENTITY}/console`, { headers: { cookie: `parachute_id_session=${newSession}` } });
const conHtml = await conPage.text();
@@ -1182,7 +1182,7 @@ async function main() {
}
const conHtml = await (await fetch(`${IDENTITY}/console`, { headers: { cookie: arrivalCookie } })).text();
assert(
- // The arrival user is on the 30-day trial (mirrors Plus): the card cap
+ // The arrival user is on the no-card trial (mirrors Plus): the card cap
// renders "of 8.5 GiB" (500 MB notes + 8 GiB attachments, summed).
conHtml.includes('data-testid="vault-usage"') && /Using \d+(\.\d+)? MB of 8\.5 GiB/.test(conHtml),
"usage: the vault card shows 'Using X of Y' from the rollup row",
@@ -1366,7 +1366,7 @@ async function main() {
fail("tier-change: live section threw (non-fatal — sections continue)", String(err));
}
- // 17. GFS snapshots + restore (Wave 4e). The arrival user is on the 30-day
+ // 17. GFS snapshots + restore (Wave 4e). The arrival user is on the no-card
// trial, which mirrors PLUS entitlements — so restore is ENABLED (the
// new no-restore floor is `expired`, exercised in §20). Flow: drive one
// snapshot sweep via the staging-only trigger (POST /__test/snapshot-run,
diff --git a/workers/identity/src/admin-growth-ui.ts b/workers/identity/src/admin-growth-ui.ts
index 6a88823..8c60731 100644
--- a/workers/identity/src/admin-growth-ui.ts
+++ b/workers/identity/src/admin-growth-ui.ts
@@ -216,7 +216,7 @@ export function renderAdminGrowth(props: GrowthProps): string {
${stat("Comped", trials.comps, "paid plan, no subscription")}
${stat("Scheduled churn", trials.scheduledChurn, "downgrade already booked")}
-
How measured: a trial is "ending" when plan_downgrade_at falls within ${TRIAL_HORIZON_DAYS} days (this includes any already past due and waiting on the hourly sweep). A subscriber is on a PAID PLAN and carries a stripe_subscription_id; a comp is a paid plan with none. The plan half of that is load-bearing: the subscription id is write-once and never cleared, so a churned account keeps its old id forever on the expired floor — counting the id alone would report everyone who has EVER paid, a number that can only ever go up. Scheduled churn excludes trials on purpose: every new account is written with pending_plan='expired' because that flag IS the ${TRIAL_DURATION_DAYS}-day trial clock, so counting it unqualified would just re-report the trial cohort. Here it means a paid or comped account with a downgrade on the books.
+ How measured: a trial is "ending" when plan_downgrade_at falls within ${TRIAL_HORIZON_DAYS} days (this includes any already past due and waiting on the hourly sweep). A subscriber is on a PAID PLAN and carries a stripe_subscription_id; a comp is a paid plan with none. The plan half of that is load-bearing: the subscription id is write-once and never cleared, so a churned account keeps its old id forever on the expired floor — counting the id alone would report everyone who has EVER paid, a number that can only ever go up. Scheduled churn excludes trials on purpose: every new account is written with pending_plan='expired' because that flag IS the trial clock, so counting it unqualified would just re-report the trial cohort. (The clock length is stamped per row at signup — new signups get ${TRIAL_DURATION_DAYS} days, but rows written under an earlier setting keep whatever they were stamped with, so a mixed cohort is expected and counted correctly either way.) Here it means a paid or comped account with a downgrade on the books.
diff --git a/workers/identity/src/billing-lifecycle.ts b/workers/identity/src/billing-lifecycle.ts
index 290c737..13a52f9 100644
--- a/workers/identity/src/billing-lifecycle.ts
+++ b/workers/identity/src/billing-lifecycle.ts
@@ -521,7 +521,7 @@ export async function runBillingSweep(db: D1Database, deps: OAuthDeps, now: Date
// paid tier there for entitlement mirroring (plans.ts
// entitlementPlanFor); a real conversion clears the pair via
// checkout.session.completed long before the sweep sees it, so applying
- // a paid pending tier here would be a FREE upgrade at day 30 — floor it.
+ // a paid pending tier here would be a FREE upgrade at trial's end — floor it.
// Folded into the `plan` bound into the CONDITIONAL write below, never a
// separate read-then-write.
const plan: PlanId = coercePlanId(row.plan) === "trial" && isPaidTier(pending) ? "expired" : pending;
@@ -531,7 +531,7 @@ export async function runBillingSweep(db: D1Database, deps: OAuthDeps, now: Date
// atomically (billing-lifecycle handleCheckoutSessionCompleted). If we
// wrote `plan` unconditionally here we'd overwrite the just-paid plan
// with the 'expired' floor and push frozen caps over the ones the webhook
- // already pushed — flooring a person who paid at the day-30 deadline. So
+ // already pushed — flooring a person who paid at the trial deadline. So
// the sweep's OWN write is the guard: it applies ONLY while the pending
// pair is still set and still due (same `now` the SELECT used). A
// conversion that won the race leaves this UPDATE matching 0 rows.
diff --git a/workers/identity/src/billing.ts b/workers/identity/src/billing.ts
index 872b492..b457115 100644
--- a/workers/identity/src/billing.ts
+++ b/workers/identity/src/billing.ts
@@ -206,16 +206,22 @@ async function checkoutCore(
if (!price) return { ok: false, reason: "invalid" };
// TRIAL-AWARE CHECKOUT (card-on-file conversion): a user still inside their
- // 30-day trial who picks a plan enters card details TODAY, and the Stripe
- // subscription starts billing when the free 30 days end —
+ // trial who picks a plan enters card details TODAY, and the Stripe
+ // subscription starts billing when the free months end —
// `subscription_data.trial_end` = the trial clock (plan_downgrade_at). The
// webhook conversion path runs UNCHANGED at session completion
// (checkout.session.completed → plan flips + pending pair clears
// immediately — entitlements from the picked tier while the Stripe-trial
- // runs is correct: they chose a plan, the subscription exists). Stripe
- // refuses trial_end closer than 48h out — less runway than that omits the
- // field and bills immediately (STRIPE_MIN_TRIAL_END_MS). Expired users
- // (canStartCheckout's other half) have no runway — always bill now.
+ // runs is correct: they chose a plan, the subscription exists).
+ //
+ // STRIPE'S TRIAL WINDOW, both ends: it refuses a trial_end closer than 48h
+ // out — less runway than that omits the field and bills immediately
+ // (STRIPE_MIN_TRIAL_END_MS) — and it refuses one more than 730 days (2 years)
+ // out. plan_downgrade_at is always now + TRIAL_DURATION_DAYS (90, the "three
+ // months free" campaign), so the ceiling has ~8x headroom and only the 48h
+ // floor is ever reachable here; a future trial length would have to pass 730
+ // days before this call could 400. Expired users (canStartCheckout's other
+ // half) have no runway — always bill now.
const now = deps.now?.() ?? new Date();
let trialEnd: number | null = null;
if (user.plan === "trial" && user.planDowngradeAt) {
diff --git a/workers/identity/src/console.ts b/workers/identity/src/console.ts
index ca207dc..ec39006 100644
--- a/workers/identity/src/console.ts
+++ b/workers/identity/src/console.ts
@@ -371,7 +371,7 @@ async function renderConsoleFor(
firstRun: opts.firstRun,
plan: user.plan,
// Honest paid-until surface: a scheduled downgrade to the expired floor
- // (the 30-day trial clock, a promo comp's expiry, or a real subscription's
+ // (the trial clock, a promo comp's expiry, or a real subscription's
// scheduled cancel) shows its date on the plan line — never a silent cliff.
planUntil: user.pendingPlan === "expired" && user.planDowngradeAt ? user.planDowngradeAt : null,
totalUsedBytes,
@@ -528,9 +528,9 @@ export async function handleConsoleGet(db: D1Database, req: Request, deps: OAuth
* churned/lapsed account without payment. They're pointed at checkout
* (`plan_err=reactivate`) instead; pending_plan/plan stay untouched (still
* frozen). Effect for a trial: set `pending_plan=
` (KEEP plan_downgrade_at
- * — changing tier mid-trial doesn't reset or extend the 30-day clock), then
+ * — changing tier mid-trial doesn't reset or extend the trial clock), then
* re-apply the entitlement so the two-meter caps + voice update immediately
- * across every owned vault. The day-30 sweep still floors this trial to expired
+ * across every owned vault. The expiry sweep still floors this trial to expired
* (billing-lifecycle.ts #84 guard) — picking a paid tier is a preview, not a
* free upgrade.
*/
@@ -556,7 +556,7 @@ export async function handleChoosePlanPost(db: D1Database, req: Request, deps: O
}
// Set the chosen tier as the pending plan — KEEP the plan_downgrade_at clock.
// CONDITIONAL WRITE AS GUARD (the runBillingSweep #84 pattern): the `plan !=
- // 'trial'` check above was read from the session snapshot, so the day-30 sweep
+ // 'trial'` check above was read from the session snapshot, so the expiry sweep
// could FLOOR this user to `expired` in the read→write window. Pin the write to
// `plan = 'trial'` so a raced-to-expired row can't be re-mirrored to a paid
// tier — changes=0 means the sweep won reading, we must NOT push the chosen
diff --git a/workers/identity/src/plans.ts b/workers/identity/src/plans.ts
index b7d33e9..bea6222 100644
--- a/workers/identity/src/plans.ts
+++ b/workers/identity/src/plans.ts
@@ -15,8 +15,10 @@
* already ~25k+ typical notes. Start small + increase later beats start big +
* claw back; the same price points will carry more FEATURES over time, so we
* hold higher margins here. Attachments/voice/vault-count unchanged.
- * trial mirrors PLUS entitlements — the 30-day no-card trial every new
- * account starts on (full paid experience → stickiness)
+ * trial mirrors PLUS entitlements — the no-card trial every new account
+ * starts on (full paid experience → stickiness). Length lives in
+ * ONE place: TRIAL_DURATION_DAYS (90 — "three months free",
+ * 2026-07-25).
* expired the post-trial FLOOR: 0 new vaults, notes/attach writes FROZEN
* (reads + export UNTOUCHED — "your notes are safe"), voice off,
* free-tier disaster-recovery snapshot only
@@ -28,11 +30,12 @@
* turns a 0 attachment budget into a distinct 403 `attachments_not_included`.
*
* THE TRIAL / EXPIRED STATE MACHINE:
- * signup → plan='trial', pending_plan='expired', plan_downgrade_at=now+30d
- * (users.ts createUser). The hourly billing sweep flips a due trial → expired
- * and pushes `frozen: true` into the owner's vault DOs (billing-lifecycle.ts).
- * A checkout / promo / admin comp before day 30 clears the pair and lifts the
- * paid caps — the trial converts, never a data cliff.
+ * signup → plan='trial', pending_plan='expired',
+ * plan_downgrade_at = now + TRIAL_DURATION_DAYS (users.ts createUser). The
+ * hourly billing sweep flips a due trial → expired and pushes `frozen: true`
+ * into the owner's vault DOs (billing-lifecycle.ts). A checkout / promo /
+ * admin comp before the clock strikes clears the pair and lifts the paid caps
+ * — the trial converts, never a data cliff.
*
* Everything that speaks about a plan reads THIS module: the console, vault-count
* enforcement (console.ts), the cap+entitlement push (vault-call.ts), billing
@@ -49,8 +52,33 @@ export type PlanId = "entry" | "standard" | "plus" | "power" | "trial" | "expire
export type PaidTier = "entry" | "standard" | "plus" | "power";
export const PAID_TIERS: readonly PaidTier[] = ["entry", "standard", "plus", "power"] as const;
-/** The 30-day no-card trial length. Signup stamps plan_downgrade_at = now+this. */
-export const TRIAL_DURATION_DAYS = 30;
+/**
+ * The no-card trial length in days — the ONE derivation of a new account's trial
+ * clock. Signup stamps plan_downgrade_at = now + this (users.ts createUser), and
+ * nothing else expresses the length, so changing it here moves the whole product:
+ * the clock, `trial_days_left` (account-api.ts), the console trial banner, and
+ * the Stripe `trial_end` a converting trialist's subscription carries.
+ *
+ * WHY 90 — the "three months free" campaign, ratified 2026-07-25. Thirty days is
+ * not long enough for a second brain to become someone's second brain: the habit
+ * forms over months, and a trialist who never got there had nothing to convert
+ * on. Three months is the honest length of the experiment we're asking people to
+ * run. NEW SIGNUPS ONLY — this constant does not reach back and re-stamp accounts
+ * already on the clock; extending those is a separate operator data-operation.
+ *
+ * REVISIT once the first 90-day cohort lands (earliest new-signup expiries are
+ * ~late October 2026) — that's the first real read on whether the longer runway
+ * converts. Headroom is ample: Stripe caps a subscription trial at 730 days (2
+ * years), and the floor is STRIPE_MIN_TRIAL_END_MS (48h), under which checkout
+ * drops trial_end and bills immediately (billing.ts).
+ */
+export const TRIAL_DURATION_DAYS = 90;
+
+/** {@link TRIAL_DURATION_DAYS} as user-facing copy — the ONE phrase every
+ * "how long is the trial?" surface renders, so the number and the words can
+ * never drift apart. "3 months", not "90 days": months are how people hold a
+ * span this long. */
+export const TRIAL_LENGTH_COPY = "3 months";
/** GFS snapshot retention per rank (the vault worker's snapshots.ts owns the
* rotation algorithm; THIS is the per-plan policy fed into it). */
@@ -136,8 +164,9 @@ export const PLAN_SPECS: Record = {
voice_enabled: true,
transcribe_minutes: 1200,
},
- // The 30-day no-card trial mirrors PLUS entitlements exactly (best taste →
- // best conversion) — the only difference is the clock (pending_plan='expired').
+ // The no-card trial (TRIAL_DURATION_DAYS long) mirrors PLUS entitlements
+ // exactly (best taste → best conversion) — the only difference is the clock
+ // (pending_plan='expired').
trial: {
id: "trial",
label: "Trial",
@@ -357,11 +386,11 @@ export interface VaultEntitlement {
/**
* The plan whose SPEC drives a user's live entitlements — "try any plan free
- * for 30 days": a TRIAL user's entitlement mirrors the tier they CHOSE
+ * for three months": a TRIAL user's entitlement mirrors the tier they CHOSE
* (`pending_plan` when it names a purchasable tier — an Entry trialist
* experiences Entry (no attachments: an honest preview, no data-loss trap at
* conversion), a Power trialist experiences Power). When no tier is chosen —
- * signup stamps pending_plan='expired', the day-30 floor, which is NOT a
+ * signup stamps pending_plan='expired', the end-of-trial floor, which is NOT a
* chosen tier — the trial keeps its plus-mirroring spec (PLAN_SPECS.trial).
* Every non-trial plan is its own spec. Callers: applyPlanToVaults
* (vault-call.ts) + the console's vault-creation cap pushes.
@@ -470,7 +499,7 @@ export function tierCapSummary(tier: PaidTier): string {
/**
* The confirmation copy after a TRIAL user picks/changes their tier with no
* Stripe (POST /console/plan) — the trial now mirrors this tier's caps for the
- * rest of the 30 days (the clock is unchanged; the day-30 sweep still floors).
+ * rest of the trial (the clock is unchanged; the expiry sweep still floors).
*/
export function trialTierChosenMessage(tier: PaidTier): string {
return `You're now trying ${PLAN_SPECS[tier].label} — ${tierCapSummary(tier)} — for the rest of your trial.`;
diff --git a/workers/identity/src/ui.ts b/workers/identity/src/ui.ts
index f406956..66ab557 100644
--- a/workers/identity/src/ui.ts
+++ b/workers/identity/src/ui.ts
@@ -10,6 +10,7 @@ import {
PAID_TIERS,
PLAN_SPECS,
TIER_PRICE_LABEL,
+ TRIAL_LENGTH_COPY,
type PaidTier,
type PlanId,
formatPlanBytes,
@@ -643,7 +644,7 @@ export function renderSignup(opts: { csrfToken: string; error?: string; email?:
`Create your account
A private vault your AI can read and write — your notes, and everything you want it to remember, in one place you own.
-
From $1/mo · 30 days free · no card to start
+
From $1/mo · ${TRIAL_LENGTH_COPY} free · no card to start
${magicForm(csrfToken, email, "Email me a sign-in link", showPassword ? undefined : error)}
@@ -1491,7 +1492,7 @@ function renderPlanCards(opts: {
const noCardLine = checkoutAvailable
? ""
: isTrial
- ? `
You're on your 30-day free trial — no card needed. We'll ask before it ends.
`
+ ? `
You're on your free trial — ${TRIAL_LENGTH_COPY} free, no card needed. We'll ask before it ends.
`
: `
Add a payment method to reactivate a plan — your notes stay readable and exportable anytime.
`;
const mockNote = mockBillingEnabled
? `
test mode — no real charge`
diff --git a/workers/identity/src/users.ts b/workers/identity/src/users.ts
index 23b867f..bd9bb98 100644
--- a/workers/identity/src/users.ts
+++ b/workers/identity/src/users.ts
@@ -212,13 +212,15 @@ function base64urlToBytes(s: string): Uint8Array {
* login fails until one is set via {@link setPassword}. `emailVerified` starts
* true for a magic-link signup (the link proves the address).
*
- * EVERY new account STARTS THE 30-DAY NO-CARD TRIAL (the pricing model — there
- * is no perpetual free tier; self-host is the free-forever option). We write
- * the full trial state machine right here so BOTH signup paths (password
- * /signup and the first magic-link, auth-handlers.ts) land on it identically:
+ * EVERY new account STARTS THE NO-CARD TRIAL (the pricing model — there is no
+ * perpetual free tier; self-host is the free-forever option). Its LENGTH is
+ * plans.ts `TRIAL_DURATION_DAYS` — 90 days, the "three months free" campaign
+ * (2026-07-25) — and this is the only place it's stamped. We write the full
+ * trial state machine right here so BOTH signup paths (password /signup and the
+ * first magic-link, auth-handlers.ts) land on it identically:
* - plan = 'trial' (mirrors PLUS entitlements — full experience)
- * - pending_plan = 'expired' (the floor the hourly sweep flips to at day 30)
- * - plan_downgrade_at = now + 30d (when the sweep applies it)
+ * - pending_plan = 'expired' (the floor the hourly sweep flips to when due)
+ * - plan_downgrade_at = now + TRIAL_DURATION_DAYS (when the sweep applies it)
* A fresh account owns no vaults yet, so there is nothing to push caps into
* here — the vault-creation path (console.ts) pushes the trial entitlement as
* each vault is made; the sweep + any checkout/comp re-push on a plan change.
diff --git a/workers/identity/src/vault-call.ts b/workers/identity/src/vault-call.ts
index 97cf76d..94734ad 100644
--- a/workers/identity/src/vault-call.ts
+++ b/workers/identity/src/vault-call.ts
@@ -264,7 +264,7 @@ export async function applyPlanToVaults(
// One entitlement for all the owner's vaults: the two-meter caps, the voice
// entitlement, and frozen — a plan change flips them together. A TRIAL
// mirrors the CHOSEN tier when pending_plan names one (plans.ts
- // entitlementPlanFor — "try any plan free for 30 days").
+ // entitlementPlanFor — "try any plan free for three months").
const entitlement = planEntitlement(entitlementPlanFor(user.plan, user.pendingPlan));
const vaults = await listVaultsForOwner(db, userId);
const results: CapPushResult[] = [];
diff --git a/workers/identity/test/admin-growth.test.ts b/workers/identity/test/admin-growth.test.ts
index 313d913..a39c0d9 100644
--- a/workers/identity/test/admin-growth.test.ts
+++ b/workers/identity/test/admin-growth.test.ts
@@ -362,7 +362,10 @@ describe("Plan mix + the trial pipeline", () => {
const html = await growthHtml(cookie);
// The trial length is in flight (30 → 90). A literal would go stale on the
// one page whose entire premise is honest measurement.
- expect(html).toContain(`IS the ${TRIAL_DURATION_DAYS}-day trial clock`);
+ // The clock is stamped per row at signup, so the page must NOT claim one
+ // length for the whole cohort — it names the length only for NEW signups.
+ expect(html).toContain("IS the trial clock");
+ expect(html).toContain(`new signups get ${TRIAL_DURATION_DAYS} days`);
expect(html).toContain(`Session cookies last ${Math.round(SESSION_TTL_MS / DAY_MS)} days`);
expect(html).toContain(`its ${Math.round(REFRESH_TOKEN_TTL_MS / DAY_MS)}-day refresh cycle`);
});
diff --git a/workers/identity/test/admin.test.ts b/workers/identity/test/admin.test.ts
index 6cef5dd..9c304b7 100644
--- a/workers/identity/test/admin.test.ts
+++ b/workers/identity/test/admin.test.ts
@@ -164,7 +164,7 @@ describe("GET /admin/users — the accounts table", () => {
const html = await (await app.fetch(get("/admin/users", cookie), env)).text();
expect(html).toContain("table-user@example.com");
- expect(html).toContain(">Trial<"); // seedUser → the 30-day trial default
+ expect(html).toContain(">Trial<"); // seedUser → the no-card trial default
expect(html).toContain(">2"); // vault count
expect(html).toContain(">2/5"); // checklist progress, hidden excluded
expect(html).toContain("drip-unsub");
@@ -274,12 +274,12 @@ describe("POST /admin/users/plan — the comp lever", () => {
expect(JSON.parse(body!)).toEqual(planEntitlement("standard"));
});
- test("the comp CLEARS the trial clock (pending_plan + plan_downgrade_at) — the day-30 revert can't re-enter", async () => {
+ test("the comp CLEARS the trial clock (pending_plan + plan_downgrade_at) — the expiry revert can't re-enter", async () => {
const { cookie } = await seedOperator("comp-clock-op@example.com");
const { id } = await seedUser("comp-clock-user@example.com");
await seedVault("comp-clock-box", id);
- // A fresh signup is a trial with the 30-day clock armed (createUser).
+ // A fresh signup is a trial with the clock armed (createUser).
const before = (await getUserById(env.DB, id))!;
expect(before.pendingPlan).toBe("expired");
expect(before.planDowngradeAt).not.toBeNull();
@@ -295,7 +295,7 @@ describe("POST /admin/users/plan — the comp lever", () => {
const after = (await getUserById(env.DB, id))!;
expect(after.plan).toBe("plus");
// Without the clock-clear (admin.ts) the hourly sweep would revert this
- // comped user to expired at day 30.
+ // comped user to expired when the trial clock struck.
expect(after.pendingPlan).toBeNull();
expect(after.planDowngradeAt).toBeNull();
});
diff --git a/workers/identity/test/billing.test.ts b/workers/identity/test/billing.test.ts
index caf0c1b..c14368e 100644
--- a/workers/identity/test/billing.test.ts
+++ b/workers/identity/test/billing.test.ts
@@ -304,8 +304,8 @@ function readBarrierDb(real: D1Database, barrier: { arrive: () => Promise
/** Seed a paid user wired to Stripe (the post-checkout shape) — lands on the
* `standard` tier, the anchor purchasable tier the monthly/yearly Prices back.
* Clears pending_plan/plan_downgrade_at too: seedUser's createUser stamps
- * EVERY fresh signup with the 30-day trial pair (pending_plan='expired',
- * plan_downgrade_at=+30d) — a real checkout webhook's CAS write clears both
+ * EVERY fresh signup with the trial pair (pending_plan='expired',
+ * plan_downgrade_at=+TRIAL_DURATION_DAYS) — a real checkout webhook's CAS write clears both
* (billing-lifecycle.ts), so this fixture mirrors that "settled, no pending
* change" post-checkout shape rather than leaking the pre-purchase trial clock. */
async function seedPaidUser(
@@ -549,7 +549,7 @@ describe("POST /console/plan — pick/change your trial tier (no Stripe)", () =>
.reply(200, { ok: true }, { headers: { "content-type": "application/json" } });
}
- test("a TRIAL user picks a tier: pending_plan set, the CHOSEN tier's caps push to every vault, the 30-day clock is KEPT, 302 plan_chosen", async () => {
+ test("a TRIAL user picks a tier: pending_plan set, the CHOSEN tier's caps push to every vault, the trial clock is KEPT, 302 plan_chosen", async () => {
const { id } = await seedUser("plan-pick@example.com"); // trial, clock armed
await seedVault("plan-pick-box", id);
const before = (await getUserById(env.DB, id))!;
@@ -568,7 +568,7 @@ describe("POST /console/plan — pick/change your trial tier (no Stripe)", () =>
const after = (await getUserById(env.DB, id))!;
expect(after.plan).toBe("trial"); // still a trial — a free preview, not a purchase
expect(after.pendingPlan).toBe("power"); // now mirrors Power
- expect(after.planDowngradeAt).toBe(before.planDowngradeAt); // the 30-day clock is UNTOUCHED
+ expect(after.planDowngradeAt).toBe(before.planDowngradeAt); // the trial clock is UNTOUCHED
// The re-push carried the CHOSEN tier's two-meter caps + voice (Power: 1 GiB
// notes / 50 GiB attach / 1200 min), not the plus-mirroring trial default.
expect(JSON.parse(pushed)).toEqual(planEntitlement("power"));
@@ -634,7 +634,7 @@ describe("POST /console/plan — pick/change your trial tier (no Stripe)", () =>
expect(after.pendingPlan).toBeNull(); // untouched
});
- test("SECURITY (F1 TOCTOU): the day-30 sweep floors the trial to expired INSIDE the read→write window → the conditional write matches 0 rows → reactivate + NO cap push (never un-freeze at paid caps)", async () => {
+ test("SECURITY (F1 TOCTOU): the expiry sweep floors the trial to expired INSIDE the read→write window → the conditional write matches 0 rows → reactivate + NO cap push (never un-freeze at paid caps)", async () => {
const { id } = await seedUser("plan-toctou@example.com"); // trial, clock armed
await seedVault("plan-toctou-box", id);
expect((await getUserById(env.DB, id))!.plan).toBe("trial"); // trial when the handler reads it
@@ -799,12 +799,43 @@ describe("POST /billing/checkout — hosted Checkout session", () => {
expect(params.get("subscription_data[metadata][user_id]")).toBe(id);
expect(params.get("subscription_data[metadata][plan]")).toBe("standard"); // the updated-webhook fallback tag
expect(params.get("metadata[plan]")).toBe("standard");
- // A fresh trial (clock ~30d out) converts card-on-file: trial_end rides
- // the subscription so billing starts when the free 30 days end.
+ // A fresh trial (clock ~90d out) converts card-on-file: trial_end rides
+ // the subscription so billing starts when the free three months end.
const user = (await getUserById(env.DB, id))!;
expect(params.get("subscription_data[trial_end]")).toBe(String(Math.floor(Date.parse(user.planDowngradeAt!) / 1000)));
});
+ test("THREE-MONTH TRIAL through checkout: a DEFAULT-clock trialist's trial_end is ~90 days out and inside Stripe's window", async () => {
+ // The one place a longer trial could genuinely break: Stripe rejects a
+ // subscription trial_end under 48h out (STRIPE_MIN_TRIAL_END_MS) or more
+ // than 730 days (2 years) out. The campaign's 90 days sits between them
+ // with ~8x headroom above, so the untouched createUser clock must forward
+ // cleanly — no clamping, no omission.
+ const { id } = await seedUser("trial-90d-checkout@example.com"); // untouched clock
+ const sessionId = await seedSession(id);
+ let raw = "";
+ interceptCheckoutCreate((b) => (raw = b));
+ const before = Date.now();
+ const res = await app.fetch(
+ post("/billing/checkout", { __csrf: CSRF, interval: "monthly", plan: "plus" }, sessionCookie(sessionId)),
+ BILLING_ENV,
+ );
+ expect(res.status).toBe(200);
+ const trialEnd = new URLSearchParams(raw).get("subscription_data[trial_end]");
+ expect(trialEnd).not.toBeNull();
+ const trialEndMs = Number(trialEnd) * 1000;
+ const daysOut = (trialEndMs - before) / 86_400_000;
+ expect(daysOut).toBeGreaterThan(89);
+ expect(daysOut).toBeLessThan(91);
+ // Stripe's window, both ends — the assertion that would fail if the trial
+ // length ever grew past what Stripe accepts.
+ expect(trialEndMs - before).toBeGreaterThanOrEqual(STRIPE_MIN_TRIAL_END_MS);
+ expect(daysOut).toBeLessThan(730);
+ // It is exactly the row's clock, forwarded unmodified.
+ const user = (await getUserById(env.DB, id))!;
+ expect(trialEnd).toBe(String(Math.floor(Date.parse(user.planDowngradeAt!) / 1000)));
+ });
+
test("yearly button → the yearly env price", async () => {
const { id } = await seedUser("checkout2@example.com");
const sessionId = await seedSession(id);
@@ -894,7 +925,7 @@ describe("POST /billing/checkout — hosted Checkout session", () => {
// --- trial-aware checkout (card-on-file conversion) -----------------------
test("TRIAL_END forwarding: a trial with ≥48h runway checks out with subscription_data[trial_end] = its plan_downgrade_at", async () => {
- const { id } = await seedUser("trial-end-fwd@example.com"); // trial, clock ~30d out
+ const { id } = await seedUser("trial-end-fwd@example.com"); // trial, clock ~90d out
const downgradeAt = new Date(Date.now() + 10 * 86_400_000); // 10 days runway
await env.DB.prepare("UPDATE users SET plan_downgrade_at = ? WHERE id = ?")
.bind(downgradeAt.toISOString(), id)
@@ -1221,7 +1252,7 @@ describe("checkout.session.completed — plan flips, ids persist, caps lift", ()
test("TRIAL CONVERSION lands IMMEDIATELY at session completion: metadata.plan routes the tier, the pair clears, the tier's caps push (while the Stripe-trial still runs)", async () => {
// The card-on-file flow: checkout.session.completed fires when the card is
- // entered — the Stripe subscription is still TRIALING (trial_end = day 30)
+ // entered — the Stripe subscription is still TRIALING (trial_end = the trial clock)
// but the plan + entitlements flip NOW. They picked a plan; the sub exists.
const { id } = await seedUser("trial-convert@example.com"); // trial, clock armed
await seedVault("trial-convert-box", id);
@@ -1239,7 +1270,7 @@ describe("checkout.session.completed — plan flips, ids persist, caps lift", ()
const user = (await getUserById(env.DB, id))!;
expect(user.plan).toBe("entry"); // immediately — not at trial_end
- expect(user.pendingPlan).toBeNull(); // the day-30 sweep can't revert the conversion
+ expect(user.pendingPlan).toBeNull(); // the expiry sweep can't revert the conversion
expect(user.planDowngradeAt).toBeNull();
expect(JSON.parse(pushed)).toEqual(planEntitlement("entry")); // the PICKED tier's entitlement
});
diff --git a/workers/identity/test/mock-billing.test.ts b/workers/identity/test/mock-billing.test.ts
index 9b1065b..d1e698e 100644
--- a/workers/identity/test/mock-billing.test.ts
+++ b/workers/identity/test/mock-billing.test.ts
@@ -220,12 +220,12 @@ describe("mock upgrade applies the plan + caps + voice entitlement (real seam)",
expect(JSON.parse(pushed)).toEqual(planEntitlement("entry"));
});
- test("the conversion CLEARS the trial clock (pending_plan + plan_downgrade_at) — the day-30 revert can't re-enter", async () => {
+ test("the conversion CLEARS the trial clock (pending_plan + plan_downgrade_at) — the expiry revert can't re-enter", async () => {
const { id } = await seedUser("mock-clock@example.com");
await seedVault("mock-clock-box", id);
interceptCapPush("mock-clock-box");
- // A fresh signup is a trial with the 30-day clock armed (createUser).
+ // A fresh signup is a trial with the clock armed (createUser).
const before = (await getUserById(env.DB, id))!;
expect(before.pendingPlan).toBe("expired");
expect(before.planDowngradeAt).not.toBeNull();
@@ -237,7 +237,7 @@ describe("mock upgrade applies the plan + caps + voice entitlement (real seam)",
const after = (await getUserById(env.DB, id))!;
expect(after.plan).toBe("plus");
// Without the clock-clear (billing.ts handleMockCheckoutPost) the hourly
- // sweep would revert this converted user to expired at day 30.
+ // sweep would revert this converted user to expired at the trial deadline.
expect(after.pendingPlan).toBeNull();
expect(after.planDowngradeAt).toBeNull();
});
diff --git a/workers/identity/test/plans.test.ts b/workers/identity/test/plans.test.ts
index b5146b1..9705eac 100644
--- a/workers/identity/test/plans.test.ts
+++ b/workers/identity/test/plans.test.ts
@@ -1,12 +1,13 @@
/**
* Plans — the pricing-model entitlement layer (the new ladder + two-meter caps
- * + the 30-day trial state machine; payments are billing.test.ts).
+ * + the trial state machine; payments are billing.test.ts).
*
* Pins:
* - PLAN_SPECS carries the ratified ladder numbers (entry|standard|plus|power)
* and is the SINGLE SOURCE the console renders from,
- * - EVERY new signup starts on the 30-day no-card TRIAL (plan='trial',
- * pending_plan='expired', plan_downgrade_at≈now+30d); a legacy/garbage plan
+ * - EVERY new signup starts on the no-card TRIAL, TRIAL_DURATION_DAYS long
+ * (90 — "three months free", 2026-07-25): plan='trial',
+ * pending_plan='expired', plan_downgrade_at ≈ now+90d; a legacy/garbage plan
* coerces to the 'expired' FLOOR (never grants unknown entitlements),
* - vault-count enforcement at create with the friendly per-plan message —
* including the GRANDFATHER contract,
@@ -22,6 +23,8 @@ import app from "../src/index.ts";
import { validateAccessToken } from "../src/tokens.ts";
import {
PLAN_SPECS,
+ TRIAL_DURATION_DAYS,
+ TRIAL_LENGTH_COPY,
canStartCheckout,
cheapestInterval,
coercePlanId,
@@ -242,8 +245,15 @@ describe("PLAN_SPECS — the ratified ladder", () => {
// --- signup → trial; coercion → the expired floor ----------------------------
-describe("plan defaults — signup starts the 30-day trial", () => {
- test("a fresh signup lands on plan 'trial' with pending_plan='expired' + a ~30d clock", async () => {
+describe("plan defaults — signup starts the three-months-free trial", () => {
+ // THE CAMPAIGN PIN (2026-07-25): the trial is THREE MONTHS, not thirty days.
+ // A literal, not `TRIAL_DURATION_DAYS`, so silently editing the constant can
+ // never silently move the offer — the number and this test change together.
+ test("TRIAL_DURATION_DAYS is 90 — the one derivation of every trial clock", () => {
+ expect(TRIAL_DURATION_DAYS).toBe(90);
+ });
+
+ test("a fresh signup lands on plan 'trial' with pending_plan='expired' + a ~90d clock (NOT 30)", async () => {
const res = await app.fetch(
post("/signup", { __csrf: CSRF, email: "trialstart@example.com", password: "longenough1" }, `parachute_id_csrf=${CSRF}`),
env,
@@ -255,12 +265,45 @@ describe("plan defaults — signup starts the 30-day trial", () => {
expect(row!.plan).toBe("trial");
expect(row!.pending_plan).toBe("expired");
const daysOut = (Date.parse(row!.plan_downgrade_at!) - Date.now()) / 86_400_000;
- expect(daysOut).toBeGreaterThan(29);
- expect(daysOut).toBeLessThan(31);
+ expect(daysOut).toBeGreaterThan(89);
+ expect(daysOut).toBeLessThan(91);
+ // The regression this replaces: a 30-day clock would land here.
+ expect(daysOut).toBeGreaterThan(31);
const user = await getUserByEmail(env.DB, "trialstart@example.com");
expect(user!.plan).toBe("trial");
});
+ // --- the OFFER COPY, pinned at every surface that states a length ----------
+
+ test("TRIAL_LENGTH_COPY is the '3 months' phrase every length-claiming surface renders", () => {
+ expect(TRIAL_LENGTH_COPY).toBe("3 months");
+ });
+
+ test("the signup page's price pill says '3 months free', never '30 days free'", async () => {
+ // GET /signup 302s to the front-door SPA (console.ts handleSignupGet), so
+ // the server-rendered signup page reaches a human on the ERROR re-render —
+ // still a real, user-visible surface, and the one that carries the pill.
+ const res = await app.fetch(
+ post("/signup", { __csrf: CSRF, email: "not-an-email", password: "longenough1" }, `parachute_id_csrf=${CSRF}`),
+ env,
+ );
+ expect(res.status).toBe(200);
+ const html = await res.text();
+ expect(html).toContain('data-testid="signup-pricing"');
+ expect(html).toContain("From $1/mo · 3 months free · no card to start");
+ expect(html).not.toContain("30 days free");
+ });
+
+ test("the console's no-card line states 3 months, never '30-day'", async () => {
+ const { id } = await seedUser("nocardcopy@example.com"); // seedUser → trial
+ await seedVault("nocardcopy-box", id);
+ // PROD_ENV carries no Stripe keys → the calm no-card stand-in renders.
+ const html = await consoleHtml(await seedSession(id), PROD_ENV);
+ expect(html).toContain('data-testid="no-card-line"');
+ expect(html).toContain("You're on your free trial — 3 months free, no card needed.");
+ expect(html).not.toContain("30-day free trial");
+ });
+
test("a legacy-shaped INSERT (no plan named) reads back the 'expired' floor (coercion; DEFAULT is 'free')", async () => {
await env.DB.prepare(
"INSERT INTO users (id, email, password_hash, created_at) VALUES ('legacy-1', 'legacy@example.com', '', ?)",
diff --git a/workers/identity/test/promo.test.ts b/workers/identity/test/promo.test.ts
index 5656033..768d204 100644
--- a/workers/identity/test/promo.test.ts
+++ b/workers/identity/test/promo.test.ts
@@ -61,7 +61,7 @@ function redeemReq(code: string, sessionId: string, opts: { csrf?: string; origi
}
/** A fresh trial user with a live session (the redemption prerequisites —
- * every new account starts the 30-day no-card trial, which IS redeem-eligible). */
+ * every new account starts the no-card trial, which IS redeem-eligible). */
async function seedTrialUser(email: string): Promise<{ id: string; sessionId: string }> {
const { id } = await seedUser(email);
const sessionId = await seedSession(id);
diff --git a/workers/identity/test/trial-lifecycle.test.ts b/workers/identity/test/trial-lifecycle.test.ts
index 4b4910f..50bf01b 100644
--- a/workers/identity/test/trial-lifecycle.test.ts
+++ b/workers/identity/test/trial-lifecycle.test.ts
@@ -1,6 +1,7 @@
/**
* The trial → expired state machine (the pricing model's buffer):
- * signup → plan='trial', pending_plan='expired', plan_downgrade_at=now+30d
+ * signup → plan='trial', pending_plan='expired',
+ * plan_downgrade_at = now + TRIAL_DURATION_DAYS (90 — three months)
* the hourly billing sweep flips a DUE trial → expired and pushes frozen:true
* into the owner's vault DOs (writes freeze; reads/export stay).
*
@@ -10,6 +11,7 @@
import { env } from "cloudflare:test";
import { describe, expect, test } from "vitest";
import { runBillingSweep } from "../src/billing-lifecycle.ts";
+import { TRIAL_DURATION_DAYS } from "../src/plans.ts";
import { getUserById } from "../src/users.ts";
import { deps, seedSession, seedUser, seedVault } from "./helpers.ts";
@@ -96,7 +98,7 @@ describe("trial → expired sweep", () => {
// The tier-picker path stamps the CHOSEN tier into pending_plan so the
// trial's entitlements mirror it (plans.ts entitlementPlanFor). A real
// conversion clears the pair via checkout.session.completed; a trial that
- // reaches day 30 UNCONVERTED must floor, not be granted the tier free.
+ // runs out UNCONVERTED must floor, not be granted the tier free.
const { id } = await seedUser("sweepguard@example.com");
await seedVault("sweepguard-box", id);
await env.DB.prepare("UPDATE users SET pending_plan = 'power', plan_downgrade_at = ? WHERE id = ?")
@@ -156,7 +158,7 @@ describe("trial → expired sweep", () => {
test("a trial whose clock hasn't struck is left ALONE (still trial, still ticking)", async () => {
const { id } = await seedUser("sweepfuture@example.com");
await seedVault("sweepfuture-box", id);
- // Default createUser clock is ~30 days out → not due.
+ // Default createUser clock is TRIAL_DURATION_DAYS (~90 days) out → not due.
const { d, pushes } = recordingDeps();
await runBillingSweep(env.DB, d, new Date());
const user = await getUserById(env.DB, id);
@@ -165,14 +167,16 @@ describe("trial → expired sweep", () => {
expect(pushes.length).toBe(0); // nothing pushed
});
- test("createUser establishes the full trial state machine", async () => {
+ test("createUser establishes the full trial state machine — a THREE-MONTH clock", async () => {
const { id } = await seedUser("machinestate@example.com");
const user = await getUserById(env.DB, id);
expect(user!.plan).toBe("trial");
expect(user!.pendingPlan).toBe("expired");
const daysOut = (Date.parse(user!.planDowngradeAt!) - Date.now()) / 86_400_000;
- expect(daysOut).toBeGreaterThan(29);
- expect(daysOut).toBeLessThan(31);
+ // The campaign length (plans.ts TRIAL_DURATION_DAYS = 90), not the old 30.
+ expect(daysOut).toBeGreaterThan(89);
+ expect(daysOut).toBeLessThan(91);
+ expect(TRIAL_DURATION_DAYS).toBe(90);
// seedSession keeps the helper import exercised (a live trial can sign in).
expect(await seedSession(id)).toBeTruthy();
});