Files
verdicttank/CONTRACT.md
T
root 6ac8a185ef 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
2026-08-26 02:26:50 -04:00

30 KiB
Raw Blame History

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":

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.

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

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

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

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

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

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

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

// Response 200
{"notifications": [{"id": 1, "type": "contact", "payload": {...}, "created_at": "...", "read_at": null}], "total": 1}

POST /api/verdicttank/admin/notifications/{id}/read (staff Bearer)

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

// 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:

{"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 908VerdictTank — 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.