Skip to content

Phase 5 — Public Free Trial

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.
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-user
plan_code = "starter" # default — non-trial signups land on Starter and pick their paid tier via PickPlanPage
if 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.

  1. SELECT … FOR UPDATE on the subscriptions row.
  2. used = subscription.lifetime_pages_used (or reports).
  3. quota = plan.pages_quota (100) or plan.reports_quota (1).
  4. used + count <= quota:
    • UPDATE subscriptions SET lifetime_pages_used = lifetime_pages_used + count (or reports).
    • Write quota_events row with period_start = subscription.created_at as a synthetic constant — keeps traceability via source_id and keeps the SUM … WHERE period_start = :p read consistent; UNIQUE(source_type, source_id) (which doesn’t depend on period_start) enforces idempotency the same way as paid plans.
  5. 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_id to the paid plan, fill Stripe IDs, set period bounds.
  • No fresh-ledger step — quota_events are append-only and scoped by period_start. The first paid charge stamps the new paid period’s bounds; old trial quota_events rows stay untouched as historical audit.
  • Leave lifetime_*_used in place (historical only once on a paid plan).

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.


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.


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.


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.

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 checked

The 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.


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).


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 PickPlanPage cards).
  • “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.


  • Unit: lifetime counter increments exactly once per fresh charge (idempotency replay does not re-increment); trial 429 shape includes upgrade_url; tampered signup_intent for a non-verified email → falls back to paid; race of two concurrent trial charges under SELECT FOR UPDATE on 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 to lifetime_pages_used.
  • Abuse guard: email-unverified signup → no trial subscription created.
  • Economics probe: staging trial pipeline, assert cost-per-trial stays within target.
  • 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.
  • Phase 3 (Stripe Checkout, /quota/charge paid-plan branches, upgrade flow).
  • Phase 4 (Portal for upgraded-from-trial users).
  • Growth / paid acquisition funnel. End of rollout.