Phase 5 — Public Free Trial
Phase 5 — Public Free Trial
Section titled “Phase 5 — Public Free Trial”Problem → Solution
Section titled “Problem → Solution”Problem: requiring a credit card on signup kills top-of-funnel conversion — prospects can’t try the product before committing. Solution: public “Start Free Trial” (100 pages + 1 report, lifetime). On cap-hit, upgrade modal. Abuse guards (email verification, IP rate limit, 1 concurrent parse, daily LLM-spend alert) bound the economic exposure.
What this phase adds to the user experience
Section titled “What this phase adds to the user experience”- Landing page “Start Free Trial” CTA — verify email, no card, straight into the product.
- Upload up to 100 pages (lifetime) and generate 1 report — same upload → review → extract flow as paid plans.
- Persistent dashboard banner showing remaining trial pages + reports and an upgrade CTA.
- Hit-the-cap upgrade modal drops directly into the Phase 3 Stripe Checkout flow; on success, existing trial data is preserved.
- Free reprocessing on failed records / reports (no cost to lifetime counters).
- Data export stays available after cap (read-only access per spec §7).
- Public landing offers “Start Free Trial” (no card) alongside “Choose a plan.”
- Trial users get 100 pages + 1 report lifetime (never resets).
- On cap: upload/report blocked with “Upgrade to continue” modal → Stripe Checkout.
- Trial has no overage option.
- Abuse guards limit exposure to LLM spend blowout.
Repos touched
Section titled “Repos touched”| Repo | Changes |
|---|---|
| landing site (n1.care) | “Start Free Trial” CTA |
| authentication-service | Trial signup path policy check |
| billing-service | Lifetime-cap branch in /quota/charge; lifetime_*_used columns |
| react-frontend | Trial signup flow, trial banner, upgrade modal on cap |
| api-backend | One concurrent-parse check for trial users |
Step 1 — subscriptions.lifetime_*_used columns
Section titled “Step 1 — subscriptions.lifetime_*_used columns”Problem: trial quota is lifetime, not per-period — quota_events is scoped by period_start for paid plans’ monthly windows, and there’s no place on the subscription itself to track a number that never resets.
Solution: two integer columns on subscriptions, written only when plan.is_lifetime_cap=true, alongside the quota_events row that already records every charge for audit.
| Column | Type | Notes |
|---|---|---|
lifetime_pages_used |
int default 0 | Written only for is_lifetime_cap=true plans |
lifetime_reports_used |
int default 0 | |
| Paid plans: always 0 (unused). Trial users: incremented on commit. |
Step 2 — Server-authoritative trial assignment
Section titled “Step 2 — Server-authoritative trial assignment”Problem: authentication-service currently calls billing-service with a fixed plan code. With the trial landing page, the request carries a signup_intent hint, but the client is untrusted — the server decides whether to honour it. Separately, trial signup is the one flow where T&C acceptance happens before the subscription row exists (paid signups accept later on PickPlanPage), so the acceptance has to ride along on the register-user call.
Solution: authentication-service consults policy gates before passing plan_code="free_trial" to billing-service’s register-user endpoint, and forwards the client-submitted T&C version for billing-service to validate and stamp.
# authentication-service → billing-service /subscriptions/register-userplan_code = "starter" # default — non-trial signups land on Starter and pick their paid tier via PickPlanPageif body.signup_intent == "free_trial": if email_verified(user) and not ip_rate_limited(request.client.ip): plan_code = "free_trial"
# T&C version is passed through unchanged — billing-service validates it against# its own TC_CURRENT_VERSION env and silently null-stamps on mismatch (Phase 2 Step 5).await billing_client.register_subscription( n1_user_id=user.n1_user_id, email=user.email, plan_code=plan_code, tc_accepted_version=body.tc_accepted_version, # from the trial signup form)Client can claim anything; the server decides. Tampered signup_intent never grants trial access; tampered tc_accepted_version fails the exact-match check in billing-service and falls through to null — captured later at Checkout (Contract D) if the user upgrades, or stays null if they never leave the trial. No in-app re-prompt surface in this rollout.
Step 3 — Lifetime-cap branch in /quota/charge
Section titled “Step 3 — Lifetime-cap branch in /quota/charge”Problem: Phase 3’s /quota/charge transaction reads current-period usage as SUM(quota_events.count) scoped to period_start. For a trial plan there is no period — the cap is lifetime. A call for a free_trial user today would fall through to paid-plan logic and compute usage against a period that doesn’t exist.
Solution: add a branch for plan.is_lifetime_cap=true that reads/writes lifetime_*_used on the subscription row and still writes a quota_events row for audit.
SELECT … FOR UPDATEon thesubscriptionsrow.used = subscription.lifetime_pages_used(or reports).quota = plan.pages_quota(100) orplan.reports_quota(1).used + count <= quota:UPDATE subscriptions SET lifetime_pages_used = lifetime_pages_used + count(or reports).- Write
quota_eventsrow withperiod_start = subscription.created_atas a synthetic constant — keeps traceability viasource_idand keeps theSUM … WHERE period_start = :pread consistent;UNIQUE(source_type, source_id)(which doesn’t depend onperiod_start) enforces idempotency the same way as paid plans. -
- Else: 429
{error: "trial_limit_reached", upgrade_url, remaining}. No mutation.
Idempotency replay works the same — duplicate (source_type, source_id) returns the existing event and does not re-increment lifetime_pages_used (the UPDATE only fires on fresh inserts).
Overage branch never fires for lifetime-cap plans (overage rates are null). Reprocess (Phase 3 Step 9) replays the same (source_type, source_id) → no new charge, no lifetime_*_used increment — free reprocessing works identically for trial users.
Step 4 — Contract B response for trial users
Section titled “Step 4 — Contract B response for trial users”Problem: Contract B today computes remaining from period ledgers and always returns a period object — the trial shape (period: null, remaining from lifetime columns) isn’t produced.
Solution: when plan.is_lifetime_cap=true, compute remaining from lifetime_*_used vs plan.*_quota and return period: null.
Frontend branch for this shape is already implemented in Phase 2’s QuotaStatusCard.
Step 5 — Trial → paid upgrade webhook handling
Section titled “Step 5 — Trial → paid upgrade webhook handling”Problem: when a trial user upgrades, Stripe fires customer.subscription.created for what is actually an existing (trial) user — the Phase 3 handler would try to create a duplicate subscription row.
Solution: handler looks up the existing subscription for the user and updates it in place; lifetime counters stay as historical values (unread on paid plans).
- Find existing subscription for the user.
- Update
plan_idto the paid plan, fill Stripe IDs, set period bounds. - No fresh-ledger step —
quota_eventsare append-only and scoped byperiod_start. The first paid charge stamps the new paid period’s bounds; old trialquota_eventsrows stay untouched as historical audit. - Leave
lifetime_*_usedin place (historical only once on a paid plan).
Step 6 — Email verification guard
Section titled “Step 6 — Email verification guard”Problem: without a verification requirement, disposable email addresses grant unlimited trial signups and unlimited LLM spend.
Solution: Step 2’s policy gate requires email_verified=true before the trial plan is assigned.
Unverified users fall through to paid signup flow. Kratos already implements email verification — no new code.
Step 7 — Per-IP signup rate limit
Section titled “Step 7 — Per-IP signup rate limit”Problem: one-verified-email-per-account doesn’t stop batch signups from a single IP using many addresses. Solution: Cloudflare rule — 5 signups/hour/IP. Infra config, documented in runbook. No code.
Step 8 — One concurrent parse per trial user
Section titled “Step 8 — One concurrent parse per trial user”Problem: a trial user can fire many uploads in parallel before the 100-page cap triggers, maximizing LLM spend for a free account. Solution: api-backend upload handler blocks a trial user’s new upload while any of their existing records is still in PARSING state.
if plan.code == "free_trial": if has_record_in_parsing_state(user_id): raise HTTPException(429, "trial_parse_busy")Executed before page counting. No other trial-specific branches — everything else runs through the same /quota/charge path with the lifetime-cap branch from Step 3. Paid plans unchanged.
Step 9 — Daily LLM spend alert
Section titled “Step 9 — Daily LLM spend alert”Problem: aggregate trial LLM spend has no alert — a spike goes unnoticed until the invoice arrives. Solution: observability dashboard gains a threshold: total LLM cost attributable to trial subscriptions > $500/day → page on-call.
Alert config, not code.
Step 10 — Landing site trial CTA
Section titled “Step 10 — Landing site trial CTA”Problem: the marketing landing page directs visitors to paid pricing only — there’s no entry to the trial. Solution: two CTAs above the fold.
- “Start Free Trial” (no card, 100 pages + 1 report, lifetime).
- “Choose a plan” → existing pricing page.
Step 11 — Frontend signup flow
Section titled “Step 11 — Frontend signup flow”Problem: the signup page currently has one path (paid) — there’s no UI for selecting the trial intent, no request shape carrying signup_intent, and no T&C acceptance capture for the trial path (the paid path captures on PickPlanPage downstream, but trial users never see that page).
Solution: two primary CTAs; trial path posts signup_intent: "free_trial" plus tc_accepted_version pulled from a small public endpoint that exposes the current version string.
Paid path existing.
Trial path T&C capture. The trial signup form carries the same checkbox pattern as PickPlanPage:
[ ] I have read and agree to the Terms & Conditions (version 2026-05-01) and the Privacy Policy. [Start Free Trial] ← disabled until box is checkedThe current version string is fetched from a small public endpoint (GET /billing/tc/current on api-backend — unauthenticated, cacheable, returns { "current_version": "2026-05-01" }) since pre-signup users don’t yet have a JWT to hit Contract B. api-backend serves this by proxying to a matching unauthenticated endpoint on billing-service (GET /tc/current, same shape) — billing-service’s TC_CURRENT_VERSION env remains the single source of truth (Phase 2 Step 2); no duplicated config. Submission payload includes tc_accepted_version which authentication-service forwards to billing-service’s register-user (Step 2). Tampered values fail the exact-match check server-side and null-stamp.
Step 12 — Trial banner on dashboard
Section titled “Step 12 — Trial banner on dashboard”Problem: trial users have no persistent reminder of remaining quota or upgrade path — usage is invisible until they hit the cap.
Solution: non-dismissible dashboard card rendered when plan.code == "free_trial".
Free Trial — 77 of 100 pages remaining · 1 of 1 reports remaining[ Upgrade to a paid plan ]Reads Contract B (already trial-aware after Step 4).
Step 13 — Upgrade-on-cap modal
Section titled “Step 13 — Upgrade-on-cap modal”Problem: when /quota/charge returns 429 with trial_limit_reached, frontend has no UI — the user sees a generic error with no path forward.
Solution: modal triggered on that specific error code.
- Headline: “You’ve used your free trial.”
- Plan grid (reuses
PickPlanPagecards). - “Download your data” link (existing export flow).
- “Contact support” mailto.
No overage toggle for trial users — OverageToggleCard already hides when plan.pages_overage_cents == null.
Testing
Section titled “Testing”- Unit: lifetime counter increments exactly once per fresh charge (idempotency replay does not re-increment); trial 429 shape includes
upgrade_url; tamperedsignup_intentfor a non-verified email → falls back to paid; race of two concurrent trial charges underSELECT FOR UPDATEon the subscription row → one wins + one 429s. - Integration: public signup as trial → consume 100 pages over multiple extract clicks → 101st 429s → upgrade via Checkout → next extract succeeds. Trial user tries concurrent uploads → second 429s with
trial_parse_busy. Reprocess a failed trial record →(source_type, source_id)replays the existing row, no new event row, no increment tolifetime_pages_used. - Abuse guard: email-unverified signup → no trial subscription created.
- Economics probe: staging trial pipeline, assert cost-per-trial stays within target.
Done when
Section titled “Done when”- Public trial signup path end-to-end in staging: CTA → verify email → trial subscription → upload → extract → lifetime counter increments.
- Cap-hit path: 101st page extract → 429 → upgrade modal → Checkout → upgrade succeeds → trial data preserved.
- Abuse guards active: unverified email rejected, Cloudflare IP cap enforced, concurrent-parse cap rejects second upload, LLM spend alert configured.
Depends on
Section titled “Depends on”- Phase 3 (Stripe Checkout,
/quota/chargepaid-plan branches, upgrade flow). - Phase 4 (Portal for upgraded-from-trial users).
- Growth / paid acquisition funnel. End of rollout.
