---
title: Phase 5 — Public Free Trial
---

# Phase 5 — Public Free Trial

## 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

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

## Goal

- 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

| 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

**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

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

```python
# 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`

**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.
   - 200.
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

**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

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

---

## 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

**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

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

```python
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

**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

**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

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

---

## 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

**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`.

---

## Testing

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

## 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

- Phase 3 (Stripe Checkout, `/quota/charge` paid-plan branches, upgrade flow).
- Phase 4 (Portal for upgraded-from-trial users).

## Feeds

- Growth / paid acquisition funnel. End of rollout.
