You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/specs/2026-07-17-quote-to-close-f...

33 KiB

HQ Console — Growth Features Spec (Employee Foundation, Pipeline, Escalating Reminders, Module Roster, Convert/Supersede)

STATUS: DELIVERED 2026-07-17. All ten phases of PART B landed the same day (phases 16 + review fixes via the build swarm; 710 in the second pass), verified at 293 tests / 57 files with a live boot smoke. Delivery record: D16 in docs/06-DECISIONS.md; current capabilities: STATUS.md.

Final merged spec. Reconciles the combined WS1WS5 design against the simplicity, house-rules, and completeness critiques. Where critics conflicted I took the simplest option that breaks no locked decision and no hard rule. Every deviation is flagged inline.


PART A — THE SPEC

1. Overview & goals

Five capabilities, built on the existing HQ Console, in strict "reuse first / derive over store / least surface" spirit:

  1. Employee foundation (D-EMP, build step 1). Promote staff_user into a first-class Employee entity (management UI, owner|manager|staff roles, ownership + role-gated visibility). Everything else depends on it.
  2. Pipeline chase-list (D-PIPE, option B). One cross-client ranked list of enquiries/quotes — most-overdue on top, colour-coded, explicit Next action, All | Mine | Overdue | Owner filters. Stages derived, not stored.
  3. Escalating quote follow-up (D-REMIND). A sent quote that isn't accepted nudges the owning employee (dashboard) and emails the client (Gmail, share link) at dated intervals; stops on accept/lost/convert.
  4. Module → client roster (D-ROSTER). Pick a banking module → every client on it, what they pay, next renewal, plus Notify all / Export CSV.
  5. One-click convert + easy supersede (D-CONVERT). One-click Convert proforma → invoice & send; trivially easy proforma cancel/recreate; invoices also get escalating reminder mails. Invoice immutability preserved — corrections are credit notes only.

Non-negotiable throughout: integer paise; GST only via computeBill; dated config rows; immutable issued documents; portable repos; append-only audit in-txn; client.id never re-keyed; no silent caps; strict TS + green tests before landing.

2. Assumptions, defaults, non-goals

Defaulted decisions (user did not override — flagged):

  • A1 — Module roster emphasis = balanced. Support and revenue given equal weight in the roster panel (a client's pay + next renewal and a Notify-all/CSV impact action).
  • A2 — Employee screen = standard table with Add / Edit / Deactivate and a role dropdown. No org chart, no per-permission matrix.

Other flagged assumptions:

  • A3 — Document owner = created_by for v1 (simplicity critic #4, accepted). No document.owner_id column, no reassignment/handoff endpoint. A quote's owner is its creator; the pipeline "Mine/Owner" filter and quote-follow-up routing read created_by. This does not break D-EMP (every ownable thing still carries an owner). The reassignable document.owner_id + PATCH /documents/:id/owner are deferred until a handoff feature is actually requested.
  • A4 — client.owner_id is the one genuinely new owner column. client has no creator field, so an enquiry (lead with no quote) is otherwise unroutable. Additive, nullable.
  • A5 — Notify-all message is owner-typed ad-hoc text (subject + body entered in the panel), not dated config — it is a one-off human broadcast, not recurring automated config text, so rule 3 does not apply to it.
  • A6 — Roster is visible to any signed-in employee (support view); Notify-all is owner/manager only (bulk client email is privileged).
  • A7 — Invoice-overdue anchor = doc_date. There is no due_date column and payload.terms ("Net 30") is free text; v1 escalation counts days since issue, not since a parsed due date. Known limitation, surfaced in the reminder body wording.

Non-goals / explicitly deferred: support-desk/ticketing; HR (leave, payroll, attendance); upsell/expansion engine; WhatsApp or any non-email channel; a client self-service portal; kanban board (D-PIPE is a list, option B); reassignable document ownership/handoff (A3); parsed payment-term due dates (A7); true delete of a wrong unissued draft (pre-existing no-delete gap; superseded drafts are filtered via ref_doc_id).

3. Reused vs genuinely new

Reused (no change or minor extension): staff_user table (already carries id/email/display_name/role/active + scrypt hashes — it is the employee table); @sims/auth scrypt PIN + createStaff; document lifecycle (prepareDraft/createDraft/issueDocument/markStatus/convertDocument/raiseCreditNote/cancelDocument) and ref_doc_id; @sims/billing-engine computeBill; document_event (kind='sent' → age anchor); client.status='lead' + client.notes (= enquiry); the reminder engine (scheduler.ts, upsertReminder, UNIQUE(rule_kind,subject_id,due_period), listQueue, sendReminder, reminderContext, setReminderStatus, dismissReminder); Gmail send path + email_log + bounce handling; document_share (non-revoked/non-expired reuse, expires_at semantics); priceOn + client_module (kind, edition, next_renewal); @sims/ui DataTable; existing GET /documents, POST /documents/:id/status, POST /documents/:id/convert, POST /reminders/:id/send, GET /reminders/:id/preview.

Genuinely new: reminder_schedule table (dated cadence + dated quote-follow-up message text); client.owner_id column; quote_followup rule_kind; repos-employees.ts; repos-pipeline.ts + GET /pipeline; listClientsByModule reverse query + roster endpoints; convert-and-send + supersede endpoints; the Employees and Pipeline pages + Modules roster panel + doc-view buttons + ClientDetail owner dropdown; two auth fixes.

4. Data-model changes

Each earns its place; migrations are additive or run through one shared guarded rebuild helper (see the SQLite note).

Change Kind Justification (one line)
reminder_schedule (id TEXT PK, rule_kind TEXT NOT NULL, effective_from TEXT NOT NULL, effective_to TEXT, day_offsets TEXT NOT NULL, subject TEXT, body TEXT) New table The single dated home (rule 3 + locked D-REMIND) for reminder cadence and quote-follow-up message text; day_offsets = CSV like '3,7,14'; subject/body nullable (dated message text for quote_followup, code-constant fallback elsewhere). Serves both quote_followup and invoice_overdue.
client.owner_id TEXT (nullable → staff_user.id) New column, additive Account/enquiry owner so a lead with no quote (the derived "Enquiry" stage) is routable; client has no creator field, so it cannot be derived (A4).
staff_user.role — drop the CHECK (role IN ('owner','staff')) Rebuild-once Add manager; enum validated in the repo (Role = owner|manager|staff), matching how GSTIN/state are validated in code.
reminder.rule_kind — widen CHECK to add 'quote_followup' Rebuild-once New sendable reminder kind for quote follow-up.
Employee columns None Grounding confirms staff_user already has id/email/display_name/role/active + scrypt hashes — no additive employee columns are needed. The only employee DDL is the role CHECK relaxation above.
document.owner_id Not added (A3) Deferred; created_by serves as owner for v1.

SQLite CHECK-migration note (one shared helper, used for both staff_user and reminder). SQLite cannot ALTER/DROP a CHECK in place, and migrate() is additive-only, so each affected table is rebuilt once via the standard sequence, wrapped in db.transaction (crash-safe; F11) and guarded so it is idempotent and a no-op on fresh DBs (which are born correct from the SCHEMA constant):

rebuildTable(db, table, newDDL, guardToken):
  sql := SELECT sql FROM sqlite_master WHERE type='table' AND name=table
  if sql does NOT already satisfy guardToken:           // e.g. staff_user still has "CHECK (role IN ('owner','staff'))"
     db.transaction:                                      //      or reminder sql lacks 'quote_followup'
        CREATE TABLE <table>__new ( …newDDL… );
        INSERT INTO <table>__new (<explicit cols>) SELECT <explicit cols> FROM <table>;   // explicit column list, never SELECT *
        DROP TABLE <table>;
        ALTER TABLE <table>__new RENAME TO <table>;
  • staff_user__new omits the role CHECK (validate in repo); preserves PK + email UNIQUE.
  • reminder__new keeps the status/policy_applied CHECKs, widens rule_kind to include quote_followup, and preserves UNIQUE (rule_kind, subject_id, due_period) verbatim (the idempotency key).
  • No FK references either table, so no foreign_key_check dance.
  • Postgres (prod, D15): each is a one-line ALTER TABLE … DROP/ADD CONSTRAINT in the prod migration set; migrate() remains the SQLite-only path.

reminder_schedule and client.owner_id are plain additive (CREATE TABLE IF NOT EXISTS in SCHEMA; ALTER TABLE client ADD COLUMN owner_id TEXT guarded by PRAGMA table_info, matching the existing additive-column pattern).

5. API endpoints

[E] existing/reused · [N] new. All list endpoints paginate with returned total/page/pageSize (rule 8).

Method Path Gate Repo / notes
[N] GET /employees requireAuth listEmployees — any signed-in user needs the name/owner picker
[N] POST /employees requireOwner createEmployee
[N] PATCH /employees/:id requireOwner updateEmployee ({displayName?, role?})
[N] POST /employees/:id/deactivate requireOwner deactivateEmployee (+ purge sessions)
[N] POST /employees/:id/reactivate requireOwner reactivateEmployee
[N] POST /employees/:id/password requireOwner setEmployeePassword
[N] GET /pipeline requireAuth, role-gated in query listPipeline({filter,ownerId,viewerRole,viewerId,page,pageSize})
[N] PATCH /clients/:id/owner requireAuth; owner/manager only (inline check) set client.owner_id; audited
[N] GET /modules/:id/clients requireAuth listClientsByModule
[N] GET /modules/:id/clients.csv requireAuth streamed export, no silent cap
[N] POST /modules/:id/notify owner/manager (inline check) bulk notify (A5); audit + email_log per recipient
[N] POST /documents/:id/convert-and-send requireAuth orchestrates convert→issue→send
[N] POST /documents/:id/supersede requireAuth supersedeProforma
[E] POST /documents/:id/status, /convert; /reminders/:id/send, /preview; auth routes reused unchanged by pipeline action buttons and the queue

requireManager middleware is NOT added (simplicity critic #5 — it would be dead code; role gating lives inside queries and inline checks). Add it later if a route needs it.

6. Screens (ASCII sketches)

(a) Employees — /employees, owner-only nav item (beside Document Template):

Employees                                                 [ + Add employee ]
────────────────────────────────────────────────────────────────────────────
 Name              Email                    Role       Active   Actions
 Asha Rao          asha@tecnostac.com       owner        ●      Edit · Reset pw
 Vikram Shah       vikram@…                  manager      ●      Edit · Reset pw · Deactivate
 Priya Nair        priya@…                   staff        ○      Edit · Reset pw · Reactivate
────────────────────────────────────────────────────────────────────────────
 3 employees                        (guards: cannot deactivate/demote last owner or yourself)

Add/Edit form: email (immutable on edit — it is the login id), name, role dropdown owner|manager|staff, initial password (create only). Password reset is a separate action (never logs the hash).

(b) Pipeline chase-list — /pipeline, nav right after Dashboard (D-PIPE option B):

Pipeline                        [ All ] [ Mine ] [ Overdue ] [ Owner ▾ ]
──────────────────────────────────────────────────────────────────────────────────
 Client              Amount     Owner    Stage           Age   Next action
🔴 Sahakari Bank      ₹4,20,000  Vikram   Quoted/Waiting  22d   [Final nudge]  ⋯
🔴 Janata CCS         ₹1,80,000  Asha     Quoted/Waiting  15d   [Nudge]        ⋯
🟠 Adarsh Credit      ₹  95,000  Priya    Quoted/Waiting   5d   [Chase]        ⋯
🟢 Mahanagar Bank     ₹2,10,000  Asha     Quoted/Waiting   2d   Waiting        ⋯
⚪ Nagari Sah. (lead) —          Vikram   Enquiry          —    [Send quote]   ⋯
🟢 Pragati CCS        ₹  60,000  Priya    Won             —    [Convert→invoice] ⋯
──────────────────────────────────────────────────────────────────────────────────
 Showing 150 of 137         Prev  ·  Page 1/3  ·  Next      (Lost hidden — filter to show)

One DataTable; row tone green/amber/red by age band; Owner dropdown hidden for staff; action buttons fire existing lifecycle endpoints then reload; pager honours total. Staff see only their own rows (server-forced).

(c) Module → client roster panel (inside Modules page):

Modules ▸ Core Banking
──────────────────────────────────────────────────────────────────────
 Clients on this module                    [ Notify all ]  [ Export CSV ]
 Client            Edition     Pays / yr    Next renewal
 Sahakari Bank     enterprise  ₹1,20,000    2026-09-01
 Janata CCS        standard    ₹  60,000    2026-08-15
 Adarsh Credit     standard    ₹  60,000    2027-01-10
──────────────────────────────────────────────────────────────────────
 42 clients · total ₹34,80,000/yr         Prev · Page 1/1 · Next 

Notify all opens a subject+body composer (A5) → bulk send with per-recipient audit; owner/manager only. Export CSV streams all rows (no cap).

(d) Document view — convert+send & supersede (proforma only):

PROFORMA  PI/26-27-0007   status: sent           Sahakari Bank      ₹4,20,000
──────────────────────────────────────────────────────────────────────────────
 [ Convert to invoice & send ]   [ Supersede & recreate ]   [ Download PDF ]

INVOICE  INV/26-27-0031  status: sent           (immutable)
──────────────────────────────────────────────────────────────────────────────
 [ Raise credit note ]   [ Cancel ]  (only if unpaid & unallocated)   [ Download PDF ]

Invoices show no convert/supersede — corrections are credit notes or cancel-if-unpaid only.

(e) Where reminders surface — Dashboard queue (existing component, now paginated):

Reminders queue                                       [ Mine ] [ All ]
──────────────────────────────────────────────────────────────────────
 Quote follow-up — chase client   Sahakari Bank  PI review  [Send] [Dismiss]
 Invoice overdue (d15)            Janata CCS     INV/…-31   [Send] [Dismiss]
──────────────────────────────────────────────────────────────────────
 Showing 125 of 61      Prev · Next       (staff: only your queue)

ClientDetail gains one Account owner ▾ dropdown writing client.owner_id (critic B — else leads are unroutable).

7. Escalating quote follow-up reminder

Rule kind: new sendable quote_followup. Its queued row is the owner nudge; its send is the client email.

Config (rule 3 + locked D-REMIND — both dated): resolveSchedule(db,'quote_followup',today) reads the active reminder_schedule row (effective_from <= today AND (effective_to IS NULL OR effective_to > today)), yielding day_offsets (default 3,7,14) and the dated subject/body. Code-constant fallback body ships it working:

Subject: Following up on our quotation {ref} ({companyName}) Body: Dear {clientName}, we wanted to check whether you had a chance to review our quotation {ref}. You can view it any time here: {shareUrl}. We'd be glad to answer any questions. Warm regards, {companyName}. {ref} degrades gracefully when the quote has no docNo (quotes reach sent without issuance — F10): use client-facing date/reference, never render "quotation null". Send policy (auto|manual, default manual) is a flat operational setting (quote.followup.policy) — a toggle, not date-varying config.

Scan (new block in runDailyScan, injected today/now, deterministic):

  • Select quotes doc_type='QUOTATION' AND status='sent'; age anchor = MIN(document_event.at_wall WHERE kind='sent') (F15 — first-sent).
  • For each crossed offset d (loop breaks once age < d, ascending list → catch-up-safe): upsertReminder({ruleKind:'quote_followup', subjectId: quoteId, duePeriod: 'd'+d, docId: quoteId, clientId, policyApplied}).
  • Idempotency key = UNIQUE(rule_kind, subject_id='quote id', due_period='d3'|'d7'|'d14') → each interval fires at most once per quote, forever. Threshold-day bucket (not index) so reordering offsets never refires consumed buckets.
  • Catch-up burst guard (F7): on the first scan after deploy (or for any quote already past the last offset), enqueue only the single highest crossed milestone, not the whole ladder — prevents "×3 emails at once". Default policy manual keeps the burst review-gated regardless.

Owner nudge vs client email:

  • Owner nudge = the queued row, surfaced by listQueue with a quote_followup label. Routing to the owner is derived (A3): reminder.doc_id → document.created_by. No owner_id on reminder.
  • Client email = sendReminder(...) — same Gmail path as the manual Send button. Share-link handling (fixes rule-6 bug F5): reminderContext (shared with the preview route, which must write nothing) only resolves an existing live share (non-revoked, non-expired) — read-only. Minting happens only inside the real send path, never in the context builder. Confirm the public /share route renders a QUOTATION (critic A). Mint with an expiry covering the escalation window (~60 days), not never-expiring (F12), reuse an existing live share rather than duplicating, and it is audited by mintShare.
  • Auto mode drains its send queue strictly after the scan transaction commits, at-most-once via the unique key.

Stop conditions: the scan reads only status='sent', so accepted/lost (via markStatus), invoiced (via convertDocument), and cancelled all leave the set — no further reminders, no extra stop logic. STOP cleanup: when markStatus sets accepted/lost and when convertDocument produces the invoice, dismiss still-open (queued/failed) quote_followup rows for that quote in the same transaction — looping setReminderStatus per row so each dismissal is audited (F14), not a silent bulk UPDATE (rule 6).

8. Proforma → invoice convert+send; easy cancel/recreate

One-click POST /documents/:id/convert-and-send:

  1. Load source; guard docType==='PROFORMA', status !== 'cancelled', and no existing non-cancelled INVOICE child (F4 — see guard below); pre-check Gmail alive (fail before mutating).
  2. db.transaction(() => { d = convertDocument(db,uid,id,'INVOICE'); return issueDocument(db,uid,d.id) }) — convert carries the proforma's lines forward, links ref_doc_id, flips proforma → invoiced; issue assigns INV/FY-NNNN. better-sqlite3 savepoints make it all-or-nothing.
  3. After commit (never inside the txn): render PDF, send via Gmail, which marks the invoice draft→sent and logs email_log. A failed send never rolls back an issued invoice.

Rule 2 fix (F3) — recompute on convert. PROFORMA→INVOICE must build the invoice draft through the same computeBill path a normal draft save uses, recomputing the carried lines on the invoice's own doc_date — mirroring createCreditNote. This guarantees a tax invoice uses the rate that is law on its issue date even if the proforma sat across a dated tax_class boundary. (QT→PI may stay copy-only — both are non-legal.) The server trusts no client numbers.

Rule 4 fix (F4) — no duplicate legal invoices. convertDocument currently only rejects cancelled. Add: reject when a non-cancelled forward child of the target type already exists (SELECT 1 FROM document WHERE ref_doc_id=:src AND doc_type=:to AND status!='cancelled') and/or when src.status='invoiced'. convert-and-send re-checks the same condition before mutating so double-click/retry is idempotent — one sale, one invoice number, one client email.

Easy cancel / recreate — precise matrix (rule 4 firewall):

Document Cancel Recreate Correct after issue
PROFORMA (draft) n/a (no number) new PI draft carrying lines re-draft
PROFORMA (issued, unallocated) cancelDocument (number stays consumed) supersede = cancel + new PI draft, ref_doc_id-linked supersede
INVOICE (issued, unpaid, unallocated) cancelDocument (number consumed) never credit note
INVOICE (allocated/paid) credit note only

POST /documents/:id/supersede + supersedeProforma(db,uid,id): guard docType==='PROFORMA'reject INVOICE loudly ("Invoices are immutable — cancel if unpaid, or raise a credit note"). In one db.transaction: if issued, cancelDocument (its unpaid/unallocated guard always passes for proformas; number stays consumed), then insert a new PROFORMA draft carrying payload verbatim via insertDocRow with ref_doc_id → the superseded doc, plus a superseded event. convertDocument is not reused (its CHAIN guard is forward-only). This keeps D-CONVERT's "trivially easy" cancel/recreate while honouring immutability.

Invoice reminder mails (reuse invoice_overdue, no CHECK change): switch the scan's due_period from the monthly bucket to day-past-due milestones dN resolved from reminder_schedule('invoice_overdue') (default 7,15,30), upsertReminder firing each milestone once; populate ctx.daysOverdue = today doc_date (A7 limitation) so the existing "N days past due" template text renders; auto-stop is already handled (scan skips paid|cancelled|lost and outstanding<=0). Apply the same highest-milestone-only backfill (F7) on cutover so aged invoices already reminded under the month scheme don't double-fire; note the due_period scheme change in reminders.test.ts. A freshly issued invoice from convert-and-send enters this scan automatically.

9. Pipeline stage derivation (minimal storage)

Stage is never stored — computed per row from client.status + the client's latest QUOTATION status:

Stage Derivation
Enquiry client.status='lead' AND client has no QUOTATION
New Project latest quotation status='draft'
Quoted / Waiting latest quotation status='sent' (the chase target)
Won latest quotation status IN ('accepted','invoiced')
Lost latest quotation status='lost' OR client.status='lost'

Age = MIN(document_event.at_wall WHERE kind='sent') (F15, no sent_at column). Owner = document.created_by for quote rows (A3), client.owner_id for bare-lead enquiries (A4). Next action reads the same resolveSchedule(db,'quote_followup',today).day_offsets the reminder engine uses (single source — no hardcoded 3/7/14):

Row state Next action
Enquiry / draft quotation Send quote
sent, age < offset₁ Waiting
sent, age ≥ offset₁ / ₂ / ₃ Chase / Nudge / Final nudge
accepted Convert to invoice

Sort: descending age for actionable rows (oldest unanswered on top); Waiting/Won sink; Lost hidden by default (filterable). Colour band by age: green <offset₁, amber offset₁, red ≥offset₃. Server-side LIMIT/OFFSET + total (rule 8). New storage across §8§9: zero tables, zero columns beyond client.owner_id.

10. Module → client roster

Reverse query: listClientsByModule(db, moduleId, {page,pageSize})client_module JOIN client WHERE module_id=? ORDER BY client.name, paginated, total returned (rule 8). State whether active=0 links are included (default: exclude, and say so — no silent drop). Per-row derivation (A1 balanced): price via existing priceOn(db, moduleId, client_module.kind, client_module.edition, today); next renewal from client_module.next_renewal. Footer sums yearly revenue. GET /modules/:id/clients (roster panel) and GET /modules/:id/clients.csv (streamed export, no cap). POST /modules/:id/notify (owner/manager, A6): owner-typed subject+body (A5); resolves each client's primary contact email, sends via the existing Gmail path, writes email_log + one audit row per recipient (rule 6), internally paginated + rate-limited, returns sent/failed counts and warns rather than truncating (rule 8). No new table, no dated template (ad-hoc human broadcast).

11. Employee entity, roles, ownership routing

  • staff_user is the employee table — not renamed (churns session.staff_id, interaction.staff_id, audit entity='staff_user' for zero gain). "Employee" in UI/API/repo names only.
  • repos-employees.ts (plain functions, DB first arg, audited in-txn, entity='staff_user', never returns password columns): listEmployees, getEmployee, createEmployee (folds createStaff; validates role∈{owner,manager,staff} + password.length≥8), updateEmployee, setEmployeePassword (reuses hashPin, audits reset_password, never logs the hash), deactivateEmployee, reactivateEmployee. Guards: cannot deactivate/demote the last active owner; cannot deactivate yourself.
  • Roles: owner (full + employee management), manager (sees everyone in pipeline/queue/roster; may Notify-all), staff (server-forced to own rows).
  • Ownership routing (A3/A4): quote/enquiry owner = document.created_by (default) or client.owner_id (bare lead); reminder owner derived via doc_id → document.created_by. client.owner_id set on ClientDetail (owner/manager) — needed so the ~300 APEX-imported / unassigned leads (owner-null) can reach a staffer's queue; owner/manager unfiltered view is the safety net for created_by='system' recurring invoices and still-unassigned leads (F13).
  • Role gate (server-side, from res.locals.staff.{id,role}, never trusted from client): staff → query forces created_by = me (quotes) / client.owner_id = me (leads) and ignores any widening param; owner|manager → no filter by default, optional ?owner= narrows.
  • Auth fixes (headline capability of the employee screen): (1) add AND u.active = 1 to the verifySession join — a deactivated employee with an unexpired 14-day token is currently still authenticated; (2) deactivateEmployee does DELETE FROM session WHERE staff_id=? in the same txn. Deactivation takes effect immediately.
  • First boot: seedIfEmpty already creates the owner via createStaffcreateEmployee; that UUIDv7 row is the founding employee (appears in the table automatically, satisfies the last-owner guard, can create the first manager/staff). No promote migration.

PART B — THE ROADMAP

TDD-friendly, employee-foundation first, each phase small and independently shippable. npm run typecheck + npm test green before every landing. Money math changes require test changes (locked).

Phase 1 — Employee foundation (D-EMP core). staff_user.role CHECK relaxed via the shared rebuildTable helper (txn-wrapped, guarded); repos-employees.ts (7 fns, folds createStaff, last-owner + self-deactivate guards); auth fixes (verifySession active-check + session purge on deactivate). Accept: create manager/staff; role validated in repo (bad role rejected); deactivated user's session dies immediately (verifySession denies + rows purged); last active owner cannot be demoted/deactivated; rebuild is a no-op on a fresh DB and idempotent on re-run.

Phase 2 — Employee management surface. Six /employees routes (mutations owner-only); Employees page + owner-only nav + api.ts client. Accept: owner CRUD round-trips; non-owner gets 403 on mutations; GET /employees works for any signed-in user (picker); count shown, no silent truncate.

Phase 3 — Owner plumbing. client.owner_id additive column; PATCH /clients/:id/owner (owner/manager, audited); ClientDetail Account owner dropdown; shared server-side role-gate helper (staff → self). Accept: setting an owner writes an audit row; staff request cannot widen scope; a lead gains a routable owner.

Phase 4 — Dated reminder schedule (rule 3 / D-REMIND home). reminder_schedule table + resolveSchedule(db,ruleKind,today) resolver; seed defaults (quote_followup 3/7/14, invoice_overdue 7/15/30) in seed.ts with code fallback. Accept: resolver returns the row whose effective_from<=today<effective_to|null; a new dated row changes cadence without a code edit; missing-row falls back to the code default.

Phase 5 — Pipeline chase-list (D-PIPE option B). repos-pipeline.ts listPipeline (union of quote-bearing clients + bare leads, derived stage/age/next-action/tone, role-gated, paginated); GET /pipeline; Pipeline page + nav + api.ts. Next-action bands read Phase-4 resolver. Accept: a bare lead with no quote appears (Enquiry); stages derive correctly from status; staff see only their rows; oldest-overdue sorts top; pager honours total; action buttons hit existing lifecycle endpoints and reload.

Phase 6 — Escalating quote follow-up (D-REMIND). quote_followup rule_kind via shared rebuildTable; TS union extended (forces exhaustive switch); scan block (highest-milestone backfill guard); reminderContext resolves (never mints) share + shareUrl, dated subject/body with graceful null-docNo; mint only in send path with ~60-day expiry, reusing live shares; STOP cleanup dismissing open rows per-row (audited) in markStatus/convertDocument; listQueue paginated + owner-filtered (doc_id → created_by). Accept: each interval fires at most once per quote (unique key); catch-up fires only the highest crossed milestone; preview writes nothing (no share minted); accept/lost/convert dismisses open nudges (audited); auto-send drains after commit at-most-once; queue paginates.

Phase 7 — Convert + send (D-CONVERT). Re-convert guard in convertDocument (F4); PROFORMA→INVOICE recompute via computeBill on invoice date (F3); POST /documents/:id/convert-and-send (txn convert+issue, send after commit, Gmail pre-check, double-submit idempotent); doc-view button. Accept: double-click/retry yields exactly one INVOICE + one number + one email; a rate-boundary crossing produces date-correct GST (server recompute); a failed send leaves the issued invoice intact; QT→PI unaffected.

Phase 8 — Proforma supersede (D-CONVERT). supersedeProforma + POST /documents/:id/supersede; doc-view button (proforma only). Accept: issued proforma cancels (number stays consumed) and a new ref_doc_id-linked PI draft carries the lines; INVOICE supersede is hard-rejected → credit-note guidance; all steps audited in one txn.

Phase 9 — Invoice reminder escalation. Scan invoice_overdue due_perioddN milestones from Phase-4 resolver; ctx.daysOverdue populated; highest-milestone backfill on cutover; update reminders.test.ts. Accept: d7/d15/d30 fire once each; aged invoices don't double-fire across the month→dN cutover; template renders "N days past due"; auto-stops on paid/cancelled/settled.

Phase 10 — Module → client roster (D-ROSTER). listClientsByModule; GET /modules/:id/clients + .csv; POST /modules/:id/notify (owner/manager, audit+email_log per recipient); Modules roster panel. Accept: reverse query paginates with total and states active=0 handling; per-row price via priceOn + renewal from client_module; Notify-all writes one audit + email_log per recipient and never silently caps; CSV exports all rows.

Total: 10 phases — employee foundation (12), owner plumbing (3), dated config (4), then one phase per feature (5 pipeline, 6 quote reminders, 7 convert, 8 supersede, 9 invoice reminders, 10 roster), each independently shippable with typecheck + tests green.