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:
+537
@@ -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.
|
||||
Reference in New Issue
Block a user