Add demo cases (HarvestLink v1/v2), subscription technical contract, portal + ops-portal HTML

- demo-case-harvestlink.md + v2: fictional staged proposals for product-marketing video (no PII)
- CONTRACT.md: subscription model / contact form / receipt fixes technical contract (read-only analysis 2026-08-19)
- portal/index.html + ops-portal/: customer portal + ops portal single-file builds
- .gitignore: demo-creds*.txt excluded
This commit is contained in:
root
2026-08-26 02:26:50 -04:00
parent 3c07727f5c
commit 6ac8a185ef
7 changed files with 2725 additions and 1 deletions
+537
View File
@@ -0,0 +1,537 @@
# 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$<iters>$<salt_hex>$<hash_hex>`. 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 <token>` 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** — `<div class="value">{submission["submitted_at"][:19].replace("T", " ")}</div>` —
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** — `<div class="value">{label} — {price}</div>` — em dash between tier label and
price. **Line 908** — `VerdictTank — a product of <strong>IT Pro Partner</strong>` — 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.