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(); });