# 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 1–6 + review fixes via the build swarm; 7–10 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 WS1–WS5 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
__new ( …newDDL… );
INSERT INTO __new () SELECT FROM ; // explicit column list, never SELECT *
DROP TABLE ;
ALTER TABLE __new RENAME TO ;
```
- `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 1–50 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 1–25 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 `