# VerdictTank — Subscription Model, Contact Form, Receipt Fixes: Technical Contract Status: DRAFT for review. Read-only analysis of the live codebase on Core (152.53.192.33) as of 2026-08-19. No code was modified, no service restarted, no schema migration applied. Surfaces inspected: - Backend API: `/opt/verdicttank/api.py` (989 lines, FastAPI, systemd `verdicttank-api.service`, port 8201 behind Caddy) - Database: `/opt/verdicttank/users.db` (SQLite) - Ops admin portal: `/var/www/verdicttank-ops/index.html` (1060 lines, static SPA, served at ops.verdicttank.com, calls `/api/verdicttank/admin/*`) - Marketing site: `/var/www/verdicttank/index.html` (1602 lines, served at verdicttank.com) - Client portal source: `/root/projects/verdicttank-mockups/portal-index.html` (1068 lines) — source of the LIVE file on app3 at `/home/myverdicttank/htdocs/my.verdicttank.com/index.html`, served at my.verdicttank.com Canonical tier quotas used throughout this contract (authoritative, not invented): | Tier | Included / term | Term | Overage price/each | |---|---|---|---| | Free | 1 | lifetime | none (hard stop) | | One-Shot | 1 | one-time | none (hard stop) | | Pro | 8 | monthly | $18 | | Enterprise | 50 | monthly | $16 | | White-Label | 100 | monthly | $28 | --- ## 1. CURRENT API INVENTORY All routes are under `PREFIX = "/api/verdicttank"` unless noted. FastAPI app `verdicttank-api`, `version="0.2.0"`, CORS allows `https://verdicttank.com`, `https://proposals.iamgmb.com`, `https://core.itpropartner.com`, `https://my.verdicttank.com`; methods GET/POST/OPTIONS only. ### Public / health - `GET /api/verdicttank/health` → `{status:"ok", service:"verdicttank-api", version:"0.2.0"}` ### Customer auth (embedded JWT, no social, no Stack Auth) - `POST /api/verdicttank/auth/register` — body `{email, password, name?}` → `{token, user:{id,email,name}}`. 400 invalid email, 400 password<8 chars, 409 duplicate email. - `POST /api/verdicttank/auth/login` — body `{email, password}` → `{token, user:{id,email,name}}`. 401 bad creds, 429 after 5 failures/15min (in-memory, resets on restart). - `GET /api/verdicttank/auth/me` — Bearer → `{user:{id,email,name,created_at}}`. 401 if none. - `POST /api/verdicttank/auth/forgot-password` — body `{email}` → `{message}` (always 200, no account enumeration). Sends reset email via `smtplib` if account exists. - `POST /api/verdicttank/auth/reset-password` — body `{token, new_password}` → `{message}`. 400 invalid/expired token, 400 password<8. - `POST /api/verdicttank/auth/change-password` — Bearer, body `{current_password, new_password}` → `{message}`. 401 not authed, 400 wrong current password / short new password. ### Submission / results - `POST /api/verdicttank/submit` — multipart form: `name`(str,""), `email`(str,""), `proposal_name`(required), `proposal_text`(str,""), `proposal_file`(UploadFile, optional), `tier`(str,"free"), optional Bearer. Invalid tier silently coerces to `"free"` — **no rejection, no quota check today**. 422 if name/email missing or neither text nor file. Response (`SubmissionResponse`): `{review_id, status:"queued", message, submitted_at}`. - `GET /api/verdicttank/status/{review_id}` → `{review_id, status, verdict, proposal_strength, investor_readiness, composite, progress, report_url, submitted_at, completed_at}`. 404 unknown id. - `GET /api/verdicttank/results/{review_id}` → full structured result: `review_id, status, proposal_name, name, tier, tier_label, submitted_at, completed_at, verdict, verdict_rationale, executive_summary, proposal_strength, investor_readiness, composite, divergence, dimensions[], fatal_flaws[], action_plan[], kill_criteria[], consensus[], report_url, error`. - `GET /api/verdicttank/reviews` — Bearer → `{reviews:[{review_id, proposal_name, tier, tier_label, status, verdict, proposal_strength, investor_readiness, composite, submitted_at, completed_at}]}` (only rows where `user_id` matches). 401 if no Bearer. - `GET /api/verdicttank/report/{review_id}` → PDF file or 404 until `status=="complete"`. - `GET /api/receipt/{review_id}` (note: **no `/verdicttank` prefix**, pre-existing quirk) → receipt PDF, no auth required, 404 if not generated. ### Staff/admin (separate auth track, see section 3) - `POST /api/verdicttank/admin/login` — body `{email, password}` → `{token, user:{email,name}}`. Delegates to auth2/Hexclave password sign-in, then a staff-team or owner-email check; 401 bad creds, 403 not staff. - `GET /api/verdicttank/admin/submissions?status=&q=&limit=&offset=` — Bearer staff → `{submissions:[...], total}` (all users, filterable, `limit` clamped 1-500). - `GET /api/verdicttank/admin/submissions/{review_id}` — Bearer staff → full detail incl. `user_id`, `panel_audit`, `receipt_url`. - `GET /api/verdicttank/admin/stats` — Bearer staff → `{total, completed, failed, queued, processing, by_status:{}, by_tier:{}}`. ### Misc - `POST /api/verdicttank/assist` — body `{message, section_id, section_title, current_content, full_draft}` → `{reply}`. Calls internal LLM (`admin-ai.itpropartner.com`, DeepSeek Flash) for proposal-builder brainstorming. No auth. Unrelated to subscription work but shares the router. **No Stripe or any payment-provider code exists anywhere in api.py.** `tier` on `/submit` is a free-text form field with zero enforcement — a client can submit `enterprise` with no account and no charge. There is no per-user submission counter, no quota table, no renewal/expiration logic. --- ## 2. CURRENT DATA MODEL `/opt/verdicttank/users.db`, SQLite, confirmed via `sqlite3 ... ".schema"`: ```sql CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE sqlite_sequence(name, seq); -- auto-managed by SQLite for AUTOINCREMENT ``` That is the **entire** schema. No `tier`, `plan_status`, `term`, `quota`, `renewal`, `expiration`, `cancelled_at`, `stripe_customer_id`, or any subscription-related column exists on `users`, and there is no second table for subscriptions, invites, quota grants, or notifications. Submissions themselves are **not** in SQLite — each submission is a flat JSON file at `/opt/verdicttank/data/{review_id}.json` (fields: review_id, name, email, user_id, proposal_name, proposal_text, proposal_file, tier, status, submitted_at, completed_at, verdict, report_path, receipt_path, plus fields the worker fills in later: proposal_strength, investor_readiness, composite, divergence, dimensions, fatal_flaws, action_plan, kill_criteria, consensus, panel_audit, executive_summary, verdict_rationale, error, progress). There is no SQL table indexing submissions by user_id/tier/date — the API scans `DATA_DIR.glob("*.json")` for `/reviews`, `/admin/submissions`, and `/admin/stats`. Any subscription/quota logic that needs to count "submissions this term" must either scan this JSON directory (works today, gets slower as volume grows) or maintain a running counter in SQLite (recommended — see Section 6). --- ## 3. AUTH MODEL (as actually implemented — confirmed, not assumed) There is **no Stack Auth / Hexclave auth2 for customers**. Two entirely separate, non-interoperable auth tracks exist in the same file: ### 3a. Customer auth — self-contained embedded JWT - Identity source: local SQLite `users` table only. - Password hashing: stdlib `hashlib.pbkdf2_hmac("sha256", ..., 200_000 iterations)`, stored as `pbkdf2_sha256$$$`. bcrypt is NOT installed/used. - Token: PyJWT (`import jwt as pyjwt`), algorithm HS256, 30-day TTL (`TOKEN_TTL_SECONDS = 60*60*24*30`). Payload: `{sub: str(user_id), email, exp}`. - Transport: `Authorization: Bearer ` header only. No cookies, no session store. - Secret: `_load_jwt_secret()` reads env `VERDICTTANK_JWT_SECRET` first; else reads/creates `/opt/verdicttank/.jwt_secret` (0600, `secrets.token_urlsafe(48)`). **Confirmed on disk**: `.jwt_secret` EXISTS (64 bytes, 0600, root-owned, last modified 2026-08-18 21:38), alongside `.admin_jwt_secret` (64 bytes, 2026-08-18 23:15). `/etc/verdicttank.env` does NOT define `VERDICTTANK_JWT_SECRET`, so the on-disk file is the live secret source and persists across restarts. No secret-persistence concern. - Password reset: separate short-lived JWT (`purpose:"password_reset"`, 1h TTL), same secret, emailed via direct `smtplib` to `mail.itpropartner.com:2525` (creds in `/etc/verdicttank.env`). ### 3b. Staff/admin auth — auth2 (Hexclave) delegated, JWT re-mint - `POST admin/login` receives `{email,password}`, calls auth2 password sign-in (`https://auth2-api.itpropartner.com/api/v1/auth/password/sign-in`) server-side using `HEXCLAVE_PUBLISHABLE_KEY`/`HEXCLAVE_PROJECT_ID` from env, then `GET users/me`. - Staff gate: hardcoded `OWNER_EMAILS = {g@germainebrown.com, info@itpropartner.com, shonuff@germainebrown.com}` bypasses the team check; otherwise queries auth2 team `verdicttank-it-staff` via `GET /api/latest/teams?user_id=me&query=verdicttank-it-staff`. - On success, api.py mints ITS OWN JWT (not an auth2 token) with `{sub: email, role:"staff", name, iat, exp}`, HS256, 12h TTL, signed with a **separate** secret: env `VERDICTTANK_ADMIN_JWT_SECRET` else `/opt/verdicttank/.admin_jwt_secret` (0600). **Confirmed on disk**: this file exists, 64 bytes, root-owned, `-rw-------`, last modified 2026-08-18. - `require_staff()` dependency decodes with the admin secret and requires `role=="staff"`. Because the two JWT secrets are different, a customer token can never be replayed against an admin route. - No customer identity is used anywhere in the admin path — admin/staff users are NOT rows in `users.db`. This means admin actions in a1/a2 below (invite, cancel) must look up the *target customer* by email in `users.db`, not by staff identity. ### Implication for the subscription build Any new admin endpoint (invite, cancel-subscription, quota grant) is protected by `require_staff` exactly like the existing four admin endpoints — no new auth mechanism is needed. Any new client-facing endpoint (subscription-state, contact, quota display) is protected by the existing `current_user(authorization)` helper exactly like `/reviews`. No Stack Auth exists for customers and none should be introduced — do not add Hexclave dependencies to the customer path. --- ## 4. PROPOSED SCHEMA MIGRATION Two changes: (1) extend `users` with subscription/quota columns, (2) add three new tables for invites, extra-submission grants, and ops notifications. All statements are additive (`ALTER TABLE ... ADD COLUMN` / `CREATE TABLE IF NOT EXISTS`) so they are safe to run against the live `users.db` without data loss; SQLite ALTER TABLE ADD COLUMN never rewrites existing rows and existing users get the column default. ```sql -- 4.1: extend users with tier/subscription state ALTER TABLE users ADD COLUMN tier TEXT NOT NULL DEFAULT 'free'; -- one of: free | oneshot | pro | enterprise | whitelabel ALTER TABLE users ADD COLUMN subscription_status TEXT NOT NULL DEFAULT 'active'; -- one of: active | pending_cancel | expired | demo ALTER TABLE users ADD COLUMN term_start TEXT; -- ISO8601 UTC; start of current billing/quota term. NULL for free/oneshot (no recurring term). ALTER TABLE users ADD COLUMN term_end TEXT; -- ISO8601 UTC; renewal date for monthly tiers (pro/enterprise/whitelabel). -- For demo accounts, this doubles as the auto-expiration timestamp (see a1). ALTER TABLE users ADD COLUMN cancel_at_term_end INTEGER NOT NULL DEFAULT 0; -- 0/1 boolean. Set to 1 by a2 (cancel). Subscription stays 'active' with full quota until -- term_end, then a scheduled job (see 6.4) flips tier to 'free' and status to 'expired'. ALTER TABLE users ADD COLUMN submissions_used_this_term INTEGER NOT NULL DEFAULT 0; -- Running counter of submissions consumed against the included quota in the CURRENT term. -- Reset to 0 whenever term_start advances (monthly rollover) — see 6.3. ALTER TABLE users ADD COLUMN extra_submissions_granted INTEGER NOT NULL DEFAULT 0; -- Cumulative count of manually-admin-granted overage submissions available beyond the -- included quota for the CURRENT term. Since there is no payment provider, this is set only -- by an admin action (b3 "paid additional submissions" are recorded manually). ALTER TABLE users ADD COLUMN extra_submissions_used INTEGER NOT NULL DEFAULT 0; -- Of extra_submissions_granted, how many have been consumed this term. ALTER TABLE users ADD COLUMN is_demo INTEGER NOT NULL DEFAULT 0; -- 1 if this account was created via the ops "create demo account" flow (a1). ALTER TABLE users ADD COLUMN demo_expires_at TEXT; -- ISO8601 UTC; set only for demo accounts. Same auto-expire mechanism as term_end but kept -- as a distinct column so a real subscriber's term_end is never confused with a demo cutoff. -- 4.2: invite-to-register tokens (a1) CREATE TABLE IF NOT EXISTS invites ( id INTEGER PRIMARY KEY AUTOINCREMENT, token TEXT UNIQUE NOT NULL, email TEXT NOT NULL, tier TEXT NOT NULL DEFAULT 'free', is_demo INTEGER NOT NULL DEFAULT 0, expires_in_days INTEGER NOT NULL DEFAULT 14, -- the X in "X-day expiration" created_by TEXT NOT NULL, -- staff email from admin JWT created_at TEXT NOT NULL, redeemed_at TEXT, -- NULL until the invite is used redeemed_user_id INTEGER, invite_expires_at TEXT NOT NULL -- the invite LINK's own expiry (separate from -- the resulting demo account's expiry) ); -- 4.3: extra-submission grant ledger (b3 — audit trail for manually-added overage) CREATE TABLE IF NOT EXISTS submission_grants ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), quantity INTEGER NOT NULL, -- positive = granted, negative = consumed (optional; or -- keep consumption as a simple counter on users and use -- this table purely as a grant-only audit log) reason TEXT, -- free text, e.g. "manual overage payment received 8/19" granted_by TEXT NOT NULL, -- staff email granted_at TEXT NOT NULL ); -- 4.4: ops notifications (b1 contact form) CREATE TABLE IF NOT EXISTS ops_notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL DEFAULT 'contact', -- extensible: contact | cancellation | demo_expiring payload TEXT NOT NULL, -- JSON blob: {name, email, subject, message, user_id} created_at TEXT NOT NULL, read_at TEXT -- NULL until a staff member marks it read in ops portal ); ``` Notes: - `submissions_used_this_term` + `extra_submissions_used` together define "remaining" (Section 6). - Free and One-Shot are lifetime/one-time, so `term_start`/`term_end` stay NULL for them; quota enforcement instead checks `submissions_used_this_term >= 1` for the life of the account (Free) or the single submission ever made (One-Shot) — see 6.1/6.2. - No `stripe_customer_id` or any payment column is added, matching the "no Stripe exists" constraint. `submission_grants` is the entire "billing" record for overage — a manual ledger. --- ## 5. PROPOSED NEW ENDPOINTS All new endpoints live under the existing `PREFIX = "/api/verdicttank"` router in api.py. Admin endpoints reuse `require_staff()`; client endpoints reuse `current_user()`. ### a1. Ops — invite-to-register + create demo account **`POST /api/verdicttank/admin/invite`** (staff Bearer) ```json // Request { "email": "prospect@example.com", "tier": "pro", "is_demo": true, "expires_in_days": 14 } // Response 200 { "invite_token": "3f9c1a...e2", "invite_url": "https://my.verdicttank.com/?invite=3f9c1a...e2", "email": "prospect@example.com", "tier": "pro", "is_demo": true, "expires_in_days": 14, "invite_expires_at": "2026-09-02T00:00:00+00:00" } ``` Behavior: inserts a row into `invites` (`invite_expires_at = now + expires_in_days` for the LINK itself, default 7 days for the link unless staff overrides — the "X-day expiration" in the task refers to the resulting **demo account's** lifetime, not necessarily the invite link's; if the same X should govern both, set link expiry = `expires_in_days` too). Does NOT create the user yet. 409 if an unredeemed invite already exists for that email+tier combo (optional; else always allow multiple). Sends an email to the invitee with `invite_url` (reuses the existing `send_email()` / SMTP helper). **`GET /api/verdicttank/invite/{token}`** (public, no auth) ```json // Response 200 {"email": "prospect@example.com", "tier": "pro", "is_demo": true, "valid": true} // Response 410 (expired or already redeemed) {"detail": "This invite link is no longer valid."} ``` Used by the client portal to pre-fill/lock the registration form when `?invite=` is present. **`POST /api/verdicttank/auth/register`** (existing endpoint, extended) ```json // Request adds optional field {"email": "...", "password": "...", "name": "...", "invite_token": "3f9c1a...e2"} ``` If `invite_token` is present and valid: on success, set the new user's `tier`, `is_demo=1`, `demo_expires_at = now + expires_in_days`, `term_start/term_end` per tier rules, mark the invite row `redeemed_at`/`redeemed_user_id`. If invalid/expired, 410 (do not silently fall back to free — surface the error so the client can prompt for a fresh invite). **Auto-expiration mechanism**: no code path runs on a timer inside api.py today (no scheduler). Two implementation options, pick one explicitly before building: 1. **Lazy expiry** (recommended, zero new infra): every place that reads `tier`/quota (`current_user`, `/submit`, `/subscription-state`) checks `is_demo=1 AND demo_expires_at < now` and, if true, downgrades the row to `tier='free', subscription_status='expired', is_demo=0` in that same request before proceeding. Self-healing, no new process, but a demo that no one touches after expiry stays "expired-but-not-yet-flagged" in the DB until next access — cosmetic only, does not affect enforcement since /submit checks it too. 2. **Cron sweep**: a new script (`/opt/verdicttank/expire_demos.py`) run via systemd timer/cron that scans `WHERE is_demo=1 AND demo_expires_at < now` and downgrades in bulk. More visible in `admin/stats`/ops list at all times, but is new infrastructure this contract does not currently assume exists — would need its own service unit, outside api.py's process model. ### a2. Ops — cancel subscription, effective at end of term **`POST /api/verdicttank/admin/cancel-subscription`** (staff Bearer) ```json // Request {"user_id": 42} // Response 200 { "user_id": 42, "tier": "pro", "subscription_status": "pending_cancel", "cancel_at_term_end": true, "term_end": "2026-09-19T00:00:00+00:00", "message": "Subscription will remain active with full quota through 2026-09-19, then move to Free." } ``` Behavior: sets `cancel_at_term_end=1`. Does **not** change `tier`, `subscription_status` (stays `active` until term boundary, per the "not immediate lock" constraint — this endpoint sets `subscription_status='pending_cancel'` purely as a display flag, tier/quota enforcement is unaffected until term_end passes), does not touch `submissions_used_this_term`. The customer keeps full remaining quota for the rest of the paid term. At `term_end`, the same lazy-expiry or cron-sweep mechanism from a1 flips `tier='free'`, `subscription_status='expired'`, `cancel_at_term_end=0`. **`POST /api/verdicttank/admin/reactivate-subscription`** (staff Bearer, companion undo) ```json // Request {"user_id": 42} // Response 200 {"user_id": 42, "subscription_status": "active", "cancel_at_term_end": false} ``` Lets staff undo a pending cancellation before term_end (e.g. customer called back). Not explicitly requested but a near-zero-cost companion to a2 that avoids a support dead-end; flag as optional. ### b1. Client — Contact VerdictTank form **`POST /api/verdicttank/contact`** (public; Bearer optional to attach `user_id`/pre-fill) ```json // Request { "name": "Jane Prospect", "email": "jane@example.com", "subject": "Question about Enterprise seats", "message": "..." } // Response 200 {"message": "Thanks — we've received your message and will reply within one business day."} // Response 422 (missing/invalid fields) {"detail": "A valid email address is required."} ``` Behavior: (1) sends an email via the existing `send_email()`/`smtplib` helper — sender `noreply@verdicttank.com`, recipient `support@verdicttank.com`, subject prefixed `[VerdictTank Contact] {subject}`, body includes name/email/message/user_id-if-known; (2) inserts a row into `ops_notifications` (`type='contact'`, `payload` = the request JSON + resolved `user_id`) so it also surfaces in the ops portal without depending on email deliverability. Note: current SMTP config (`VERDICTTANK_SMTP_FROM`) defaults to `noreply@itpropartner.com`, not `noreply@verdicttank.com` — confirm the `verdicttank.com` domain has SPF/DKIM/relay authorization for that From address before wiring, or the send will fail/land in spam (see Constraints note in Section 6). **`GET /api/verdicttank/admin/notifications`** (staff Bearer, ops portal display for b1) ```json // Response 200 {"notifications": [{"id": 1, "type": "contact", "payload": {...}, "created_at": "...", "read_at": null}], "total": 1} ``` **`POST /api/verdicttank/admin/notifications/{id}/read`** (staff Bearer) ```json // Response 200 {"id": 1, "read_at": "2026-08-19T14:02:00+00:00"} ``` ### b2 / b3. Client — subscription/quota state (remaining submissions, renewal date, paid extras) **`GET /api/verdicttank/subscription`** (Bearer required) ```json // Response 200 — Pro example { "tier": "pro", "tier_label": "Pro Review", "subscription_status": "active", "cancel_at_term_end": false, "term_start": "2026-08-01T00:00:00+00:00", "term_end": "2026-09-01T00:00:00+00:00", "included_per_term": 8, "submissions_used_this_term": 5, "included_remaining": 3, "extra_submissions_granted": 2, "extra_submissions_used": 1, "extra_remaining": 1, "total_remaining": 4, "overage_price_each": 18, "is_demo": false, "demo_expires_at": null } // Response 200 — Free example { "tier": "free", "tier_label": "Free Assessment", "subscription_status": "active", "cancel_at_term_end": false, "term_start": null, "term_end": null, "included_per_term": 1, "submissions_used_this_term": 1, "included_remaining": 0, "extra_submissions_granted": 0, "extra_submissions_used": 0, "extra_remaining": 0, "total_remaining": 0, "overage_price_each": null, "is_demo": false, "demo_expires_at": null } ``` This single endpoint backs both b2 (remaining + renewal date = `term_end`) and b3 (paid additional submissions = `extra_submissions_granted`/`extra_submissions_used`/`extra_remaining`). The client portal's dashboard view calls this alongside the existing `/reviews` call and renders a quota card. ### Quota-blocking on submit (implicit, not a new endpoint — extends existing `/submit`) `POST /api/verdicttank/submit` gains a pre-check: if the caller is authenticated and `total_remaining <= 0`, return `403` with: ```json {"detail": "You've used all included and extra submissions for this term. Contact us to add more."} ``` Anonymous (no-Bearer) submissions are unaffected by quota (today's behavior — tier is self-reported and unenforced for guests; enforcing quota requires an account, so this contract does not change the current guest-checkout path, only the authenticated path). --- ## 6. QUOTA ENFORCEMENT RULES ### 6.1 Included quota by tier (authoritative table, restated) | Tier | Included/term | Term length | Overage/each | |---|---|---|---| | free | 1 | lifetime (no reset, ever) | not allowed (hard stop) | | oneshot | 1 | one-time (no reset, ever) | not allowed (hard stop) | | pro | 8 | 1 month | $18 | | enterprise | 50 | 1 month | $16 | | whitelabel | 100 | 1 month | $28 | ### 6.2 Remaining-submissions formula ``` included_remaining = max(0, included_per_term(tier) - submissions_used_this_term) extra_remaining = max(0, extra_submissions_granted - extra_submissions_used) total_remaining = included_remaining + extra_remaining ``` For `free`/`oneshot`, `included_per_term` is a fixed lifetime cap of 1 and `term_end` is always NULL — there is no rollover, so `included_remaining` only ever goes from 1 to 0 and never resets. ### 6.3 Consumption order and counter updates (on a successful `/submit` by an authenticated user) 1. Compute `total_remaining` as above. If `<= 0`, reject with 403 (see Section 5). 2. If `included_remaining > 0`: increment `submissions_used_this_term` by 1 (consume included quota first). 3. Else (included exhausted, extra available): increment `extra_submissions_used` by 1 (consume paid/manual extras second). 4. Anonymous/guest submissions (no Bearer) are NOT counted against any user's quota — they have no `user_id` to attribute to, matching current behavior where `tier` is self-reported and unenforced for guests. ### 6.4 Term rollover (monthly tiers: pro/enterprise/whitelabel) On any authenticated request that reads or mutates quota (submit, `/subscription`), first check: `if now >= term_end: ` then roll the term forward: ``` new_term_start = term_end new_term_end = term_end + 1 month submissions_used_this_term = 0 extra_submissions_granted = 0 -- extras do NOT carry over; each term's manual grants are scoped extra_submissions_used = 0 -- to that term, consistent with "no billing exists to track a running balance" ``` Then, if `cancel_at_term_end == 1` at the moment of rollover: instead of rolling forward, downgrade — `tier='free'`, `subscription_status='expired'`, `cancel_at_term_end=0`, `term_start=NULL`, `term_end=NULL`. This is the actual mechanical trigger for a2's "effective at end of term": the cancellation flag is inert until the term boundary is crossed, at which point the same rollover check that would normally renew the term instead terminates it. This makes rollover/expiry a **pure function of "did a request happen after term_end", not a background job** — matching the lazy-expiry approach recommended in Section 5/a1 and requiring no new systemd timer. ### 6.5 Demo accounts (a1) Independent of tier quota — a demo account still has a real `tier` (e.g. "pro") and consumes quota normally per 6.2/6.3, but additionally has `demo_expires_at`. On any request, if `is_demo==1 AND now >= demo_expires_at`: downgrade identically to the cancellation end-of-term path (`tier='free'`, `subscription_status='expired'`, `is_demo=0`, `demo_expires_at=NULL`) — a demo's expiration is immediate/hard (not "end of term" like a real cancellation), since a demo was never a paid commitment. ### 6.6 Marketing site consistency (c1) The `#pricing` section text must not contradict the enforced numbers above — in particular, the current live copy calls Pro "Unlimited submissions and revisions for one active proposal at a time," which directly conflicts with the 8/month quota. Section c1's job is to replace that claim with the actual per-tier count (Free: 1 review; One-Shot: 1 review; Pro: 8/month; Enterprise: 50/month; White-Label: 100/month) so the enforced backend and the marketing promise match. --- ## 7. KNOWN RECEIPT DEFECTS (confirmed exact lines, `/opt/verdicttank/api.py`, `generate_receipt()`) 1. **Line 892** — `
{submission["submitted_at"][:19].replace("T", " ")}
` — prints raw UTC with no timezone label or conversion (e.g. "2026-08-19 14:02:00" with no "UTC" suffix), so an Eastern-time submitter sees what looks like their own local time but is 4-5 hours off. Fix: append explicit `" UTC"` suffix (safest, no timezone-conversion risk) or convert to a configured business timezone with a labeled abbreviation. 2. **Lines 897-905** — the "What Happens Next" card hardcodes the full panel description ("Multi-Seat Panel — Independent reasoning seats from separate providers review your proposal in a single pass, scoring ten dimensions") on every receipt regardless of tier. Per `references/subscription-and-receipt.md` and `api-and-auth.md`, Free tier actually runs a reduced 4-seat panel (research + primary + legal + financial), not the 9-seat/10-dimension panel. Fix: branch this block on `submission["tier"] == "free"` and use tier-accurate seat/ dimension counts in both branches. 3. **Line 891** — `
{label} — {price}
` — em dash between tier label and price. **Line 908** — `VerdictTank — a product of IT Pro Partner` — em dash in footer. Both violate the house style rule (no em/en dashes on VerdictTank surfaces). Fix: replace `—` with a plain separator (e.g. `,` or `|`) in both lines; grep the whole `generate_receipt()` function body for any other `—`/`–` before considering the fix complete (a byte-level codepoint scan is safer than eyeballing, per the platform skill's pitfall notes). No other defects in `generate_receipt()` were found on this pass; the QUEUED badge and 4-step copy layout are otherwise accurate to the current submit-flow behavior. --- ## Open items requiring a decision before implementation (not assumptions made in this contract) - Whether the invite LINK's own expiry should equal the demo account's expiry X, or be a shorter fixed window (e.g. 7 days to redeem) independent of the demo lifetime once redeemed (Section 5/a1). - Whether `noreply@verdicttank.com` has outbound send authorization (SPF/DKIM/relay credentials) distinct from the currently configured `noreply@itpropartner.com` — b1 assumes it does per the task's stated sender, but the live `VERDICTTANK_SMTP_FROM` env default is the itpropartner.com address; this needs its own SMTP credential/domain verification, not a code change. - Whether extra-submission grants (b3) should be a simple counter (as modeled here) or a full ledger table (`submission_grants`, also proposed in Section 4) for audit purposes — the schema proposes both so either can be adopted without a second migration.