Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 3 additions & 3 deletions scripts/smoke-staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion workers/identity/src/admin-growth-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}
</div>
<p class="how">How measured: a trial is "ending" when <code>plan_downgrade_at</code> 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 <em>and</em> carries a <code>stripe_subscription_id</code>; a comp is a paid plan with none. <strong>The plan half of that is load-bearing</strong>: the subscription id is write-once and never cleared, so a churned account keeps its old id forever on the expired floor &mdash; counting the id alone would report everyone who has EVER paid, a number that can only ever go up. <strong>Scheduled churn excludes trials on purpose</strong>: every new account is written with <code>pending_plan='expired'</code> 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.</p>
<p class="how">How measured: a trial is "ending" when <code>plan_downgrade_at</code> 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 <em>and</em> carries a <code>stripe_subscription_id</code>; a comp is a paid plan with none. <strong>The plan half of that is load-bearing</strong>: the subscription id is write-once and never cleared, so a churned account keeps its old id forever on the expired floor &mdash; counting the id alone would report everyone who has EVER paid, a number that can only ever go up. <strong>Scheduled churn excludes trials on purpose</strong>: every new account is written with <code>pending_plan='expired'</code> 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 &mdash; 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.</p>
</div>

<div class="card">
Expand Down
4 changes: 2 additions & 2 deletions workers/identity/src/billing-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
18 changes: 12 additions & 6 deletions workers/identity/src/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions workers/identity/src/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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=<tier>` (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.
*/
Expand All @@ -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
Expand Down
57 changes: 43 additions & 14 deletions workers/identity/src/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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). */
Expand Down Expand Up @@ -136,8 +164,9 @@ export const PLAN_SPECS: Record<PlanId, PlanSpec> = {
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",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.`;
Expand Down
5 changes: 3 additions & 2 deletions workers/identity/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
PAID_TIERS,
PLAN_SPECS,
TIER_PRICE_LABEL,
TRIAL_LENGTH_COPY,
type PaidTier,
type PlanId,
formatPlanBytes,
Expand Down Expand Up @@ -643,7 +644,7 @@ export function renderSignup(opts: { csrfToken: string; error?: string; email?:
`<h1>Create your account</h1>
<div class="lead" data-testid="signup-context">
<p style="margin:.2rem 0 .5rem">A private vault your AI can read and write — your notes, and everything you want it to remember, in one place you own.</p>
<p class="pricepill" data-testid="signup-pricing">From $1/mo · 30 days free · no card to start</p>
<p class="pricepill" data-testid="signup-pricing">From $1/mo · ${TRIAL_LENGTH_COPY} free · no card to start</p>
</div>
<div class="card">
${magicForm(csrfToken, email, "Email me a sign-in link", showPassword ? undefined : error)}
Expand Down Expand Up @@ -1491,7 +1492,7 @@ function renderPlanCards(opts: {
const noCardLine = checkoutAvailable
? ""
: isTrial
? `<p class="muted" data-testid="no-card-line" style="margin:.6rem 0 0">You're on your 30-day free trial — no card needed. We'll ask before it ends.</p>`
? `<p class="muted" data-testid="no-card-line" style="margin:.6rem 0 0">You're on your free trial — ${TRIAL_LENGTH_COPY} free, no card needed. We'll ask before it ends.</p>`
: `<p class="muted" data-testid="reactivate-line" style="margin:.6rem 0 0">Add a payment method to reactivate a plan — your notes stay readable and exportable anytime.</p>`;
const mockNote = mockBillingEnabled
? ` <span class="muted" data-testid="mock-billing-note">test mode &mdash; no real charge</span>`
Expand Down
14 changes: 8 additions & 6 deletions workers/identity/src/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion workers/identity/src/vault-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading