From 8267ef92575bc8e7184d62af5aa79679da37ce06 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 21:05:22 +0530 Subject: [PATCH] =?UTF-8?q?docs:=20HQ-3a=20implementation=20plan=20(10=20T?= =?UTF-8?q?DD=20tasks=20=E2=80=94=20reports,=20AWS=20costs,=20receipts,=20?= =?UTF-8?q?company=20profile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../plans/2026-07-10-hq3a-reports-costs.md | 1804 +++++++++++++++++ 1 file changed, 1804 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-10-hq3a-reports-costs.md diff --git a/docs/superpowers/plans/2026-07-10-hq3a-reports-costs.md b/docs/superpowers/plans/2026-07-10-hq3a-reports-costs.md new file mode 100644 index 0000000..254baf2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-hq3a-reports-costs.md @@ -0,0 +1,1804 @@ +# HQ-3a — AWS Cost Attribution, Reports, Receipts & Company Profile Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes. + +**Goal:** Ship the first slice of HQ-3 from [docs/14-SPEC-HQ-CONSOLE.md](../../14-SPEC-HQ-CONSOLE.md) §5–§6 on top of the completed HQ-1 + HQ-2 `apps/hq` + `apps/hq-web` codebase: per-client **AWS usage & cost** captured monthly (automated pull from Cost Explorer grouped by a cost-allocation tag, plus a manual owner fallback), the founder's **cross-client cost ranking** (rank + share of total), the three **reports** (dues aging, module-wise revenue, client profitability with margin), optional **payment receipts** as their own document series, and an owner-only **Company Profile** editor for the `company.*` settings the letterhead PDFs read. Everything grounds in the real HQ interfaces (`openDb`/`SCHEMA`/`migrate`, `writeAudit`, `uuidv7`, `outstandingPaise`, `splitProRata`/`modulePaidView`, `createDraft`/`issueDocument`/`insertDocRow`, `nextDocNo`, `getSetting`/`setSetting`, `documentHtml`/`renderPdf`, the `Fetcher` pattern from `gmail.ts`/`bounces.ts`, `dashboardView`, and the `@sims/ui` + `useData` page style). + +**Architecture:** Extend the store-server-shaped HQ pattern exactly as HQ-1/HQ-2 established it — express + better-sqlite3 behind plain-function repositories (`repos-*.ts`), pure logic reused from `@sims/domain` (`formatINR`, `fromRupees`, `uuidv7`, `fyOf`, `validateGstin`) and the HQ document engine. The **one new table** (`aws_usage`) is added to the `SCHEMA` string in `db.ts` via `CREATE TABLE IF NOT EXISTS` — it appears on next `openDb`, no migration entry needed. AWS ingestion is a pure module `aws-costs.ts` with an **injected `Fetcher`** (same shape as `gmail.ts`): `signAwsRequest` implements SigV4 with `node:crypto` (no AWS SDK), `pullMonthlyCosts(db, deps, month)` maps cost-allocation tag values to client codes and upserts rows, and `maybePullAwsCosts(db, deps, today)` gates it to once per calendar month. **Credentials are read only in the server/route wiring** (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`), never inside the pure functions; the scheduler tick calls the pull only when creds exist. Reports reuse the exact settlement primitives HQ-1/HQ-2 already ship: `outstandingPaise` for dues, and the largest-remainder `splitProRata` (promoted to an export) for module-wise revenue, so a paisa split here matches a paisa split in `modulePaidView`. Receipts flow through the existing `insertDocRow` → `issueDocument` path with their own `RCT/` series and carry **no GST lines** (a payment-acknowledgment layout in `templates.ts`). + +**Tech Stack:** unchanged from HQ-1/HQ-2 — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF; injected into deps so tests never launch Chromium), vitest, React 19 + Vite 6 + react-router 7, `@sims/ui`. **No new dependencies** — SigV4 uses `node:crypto` (`createHash`, `createHmac`), the AWS call is `fetch`-based exactly like `gmail.ts`/`bounces.ts`. + +## Global Constraints + +Everything from the HQ-1/HQ-2 plans still holds; the load-bearing ones for HQ-3a, plus the extensions: + +- All money is **integer paise** (`Paise` from `@sims/domain`); never floats. Amounts render with `formatINR`. AWS Cost Explorer returns a **decimal currency string** — convert once, at the boundary, via `Math.round(Number(amount) * 100)`. +- **AWS billing currency is INR.** The Mumbai payer account bills in INR, so `UnblendedCost.Amount` is rupees. `pullMonthlyCosts` records the reported `Unit`; a whole-response non-INR unit is surfaced loudly (thrown, caught by the scheduler/route wrapper) rather than silently mis-converted. USD→INR FX is out of scope for HQ-3a. +- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain` (ids sort by creation time — reused for `ORDER BY id`). +- **Every mutation writes an `audit_log` row** via `writeAudit(db, userId, action, entity, entityId, before?, after?)`. Scheduler-originated mutations and seed-style writes use `userId = 'system'`. +- SQL stays **portable** (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (`INSERT … ON CONFLICT`) is commented as a portability quirk, matching `series.ts`/`seed.ts`/`repos-reminders.ts`. +- **New table → `SCHEMA` const in `db.ts` with `CREATE TABLE IF NOT EXISTS`.** No migration entry. HQ-3a adds **no new column** to an existing table — `document.payload` (JSON) carries the receipt block; `DocPayload` gains an optional `receipt?` field, no DDL. +- **Env is read only in wiring.** `pullMonthlyCosts` / `signAwsRequest` take `creds` as a parameter. `server.ts` (scheduler) and the `POST /api/aws/pull` route read `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` and pass them in. Never log credentials or the SigV4 signing key material. +- **AWS pull cadence & idempotency.** `aws_usage` is keyed `UNIQUE (client_id, month)`; re-pulling a month overwrites rather than duplicates. `maybePullAwsCosts` additionally gates to once per calendar month via the `aws.last_pull_month` setting, so the 6-hourly tick does not hit AWS every run. Costs settle after month-end, so the target is **the previous complete month** of `today`. +- Reports are **read-only aggregations** — they compute from issued (`doc_no IS NOT NULL`), non-cancelled invoices and never mutate. Settlement per invoice is `payablePaise − outstandingPaise(db, id)` (= allocations + credit-notes, clamped to `[0, payable]`), identical to `modulePaidView`. +- Issued documents (incl. **receipts**, once numbered) are **never edited or deleted** — a receipt is an issued acknowledgment; its `RCT/` number is consumed permanently. +- HQ prices/documents remain **GST-exclusive** (`priceIncludesTax: false`). Receipts are the one document type that carries **zero tax** (they acknowledge money, they do not levy it). +- Server port **5182**. Secrets: `HQ_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (HQ-1/2), plus `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (HQ-3a, optional — absence disables the auto pull and makes `POST /api/aws/pull` return `409`). +- Tests: vitest, colocated under `apps/hq/test/` (already in the vitest `include`). The AWS `Fetcher` is injected and puppeteer is injected as `deps.renderPdf`, so every HQ-3a test runs offline and Chromium-free. No test performs a live AWS or Gmail call. +- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit. +- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form: + ```bash + git commit -m "feat(hq): " -m "Co-Authored-By: Claude Fable 5 " + ``` + +## Scope (locked — do not reopen) + +**In:** `aws_usage` table; `aws-costs.ts` (SigV4 signer + `pullMonthlyCosts` + `maybePullAwsCosts`, injectable `Fetcher`); `repos-aws.ts` (upsert/get/list + `costRanking` + `awsCostForClient`); `repos-reports.ts` (`duesAging`, `moduleRevenue`, `clientProfitability`); receipt documents (`generateReceipt` + `RCT/` series + zero-GST acknowledgment template + optional generation on `recordPayment`); Company Profile editor (`GET`/`PUT /api/settings/company`, owner-gated, audited); scheduler wiring for the monthly pull; the routes and hq-web pages for all of it (Reports page, Company Profile page, Client 360 AWS-usage trend section). + +**Out (do not plan):** SMS pack-balance tracking (founder decision pending); **live AWS credentials / a real Cost Explorer call** (env-gated — all tests use a stubbed fetcher); USD→INR FX conversion; the Postgres engine swap (a separate sequenced task); CloudWatch storage/bandwidth *metric* collection beyond what the manual upsert and the cost pull populate (the `aws_usage.storage_gb`/`transfer_gb` columns exist and are editable via the manual owner route; automated metric harvesting is deferred); APEX historical-cost backfill. State these exclusions where relevant, don't re-litigate them. + +## File Structure (HQ-3a additions) + +``` +apps/hq/ + src/db.ts — MODIFY: 1 new CREATE TABLE IF NOT EXISTS (aws_usage) + src/repos-aws.ts — NEW: upsertAwsUsage, getAwsUsage, listAwsUsage, costRanking, awsCostForClient, previousMonth + src/aws-costs.ts — NEW: signAwsRequest (SigV4), pullMonthlyCosts, maybePullAwsCosts + src/repos-reports.ts — NEW: duesAging, moduleRevenue, clientProfitability + src/repos-payments.ts — MODIFY: export splitProRata; add issueReceipt option + receiptForPayment + src/repos-documents.ts — MODIFY: DocPayload.receipt; generateReceipt (RECEIPT via insertDocRow+issueDocument) + src/templates.ts — MODIFY: RECEIPT acknowledgment layout (zero-GST) + src/api.ts — MODIFY: reports / aws / settings-company / payment-receipt / client-aws-usage routes + src/server.ts — MODIFY: schedulerDeps gains AWS creds from env; tick calls maybePullAwsCosts when present + test/*.test.ts — NEW: one file per task +apps/hq-web/ + src/api.ts — MODIFY: types + typed calls for reports/aws/company-profile + src/Layout.tsx — MODIFY: nav gains Reports (+ Company for owner) + src/main.tsx — MODIFY: routes /reports and /settings/company + src/pages/Reports.tsx — NEW: dues aging + module revenue + profitability + AWS ranking + src/pages/CompanyProfile.tsx — NEW: owner-only company.* editor + src/pages/ClientDetail.tsx — MODIFY: AWS usage trend section (last 12 months) +``` + +--- + +### Task 1: HQ-3a schema — the `aws_usage` table + +**Files:** +- Modify: `apps/hq/src/db.ts` (append one table to `SCHEMA`) +- Test: `apps/hq/test/hq3-schema.test.ts` + +**Interfaces:** +- Produces (schema only — no new TS exports): table `aws_usage` with `UNIQUE (client_id, month)`. No `migrate()` change — a brand-new table needs no additive column path. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/hq3-schema.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' + +describe('hq3 schema', () => { + it('creates the aws_usage table', () => { + const db = openDb(':memory:') + const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name) + expect(names).toContain('aws_usage') + }) + it('has the expected columns', () => { + const db = openDb(':memory:') + const cols = (db.prepare(`PRAGMA table_info(aws_usage)`).all() as { name: string }[]).map((c) => c.name) + for (const c of ['id', 'client_id', 'month', 'storage_gb', 'transfer_gb', 'cost_paise', 'source', 'updated_at']) + expect(cols, `missing column ${c}`).toContain(c) + }) + it('enforces one row per (client, month)', () => { + const db = openDb(':memory:') + const ins = db.prepare( + `INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at) + VALUES (?, 'c1', '2026-06', 0, 0, 0, 'auto', '2026-07-01T00:00:00Z')`, + ) + expect(ins.run('a1').changes).toBe(1) + expect(() => ins.run('a2')).toThrow() // UNIQUE (client_id, month) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/hq3-schema.test.ts` → FAIL (table absent). + +- [ ] **Step 3: Implement.** Append to the `SCHEMA` template string in `apps/hq/src/db.ts` (before the closing backtick): + +```sql +CREATE TABLE IF NOT EXISTS aws_usage ( + id TEXT PRIMARY KEY, client_id TEXT NOT NULL, + month TEXT NOT NULL, -- 'YYYY-MM' + storage_gb REAL NOT NULL DEFAULT 0, -- GB stored, month-end snapshot (manual or CloudWatch later) + transfer_gb REAL NOT NULL DEFAULT 0, -- GB transferred over the month + cost_paise INTEGER NOT NULL DEFAULT 0, -- Cost Explorer UnblendedCost for the 'client' tag, in paise (INR) + source TEXT NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','manual')), + updated_at TEXT NOT NULL, + UNIQUE (client_id, month) -- one row per client per month; the pull upserts on this key +); +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/hq3-schema.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/db.ts apps/hq/test/hq3-schema.test.ts +git commit -m "feat(hq): hq-3a schema — aws_usage table (per client per month)" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 2: `repos-aws.ts` — usage upsert/read + cross-client cost ranking + routes + +**Files:** +- Create: `apps/hq/src/repos-aws.ts` +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/aws-repo.test.ts` + +Built before `aws-costs.ts` (Task 3) so the ingestion module imports a finished `upsertAwsUsage` — the AWS module dependency runs one way only (`aws-costs → repos-aws`), no cycle. The month helper `previousMonth` also lives here so the ranking route (and later the pull route/scheduler) share one implementation. + +**Interfaces:** +- Produces: + - `AwsUsage = { id: string; clientId: string; month: string; storageGb: number; transferGb: number; costPaise: number; source: 'auto'|'manual'; updatedAt: string }` + - `upsertAwsUsage(db, userId, input: { clientId; month; storageGb?; transferGb?; costPaise?; source?: 'auto'|'manual' }): AwsUsage` — validates the client and `month` (`'YYYY-MM'`); merges onto any existing `(client, month)` row (a manual storage edit does not clobber an auto cost, and vice versa); audits. + - `getAwsUsage(db, clientId, month): AwsUsage | null`; `listAwsUsage(db, clientId, limit = 12): AwsUsage[]` (newest month first — the Client 360 trend). + - `CostRankRow = { clientId; clientName; costPaise; sharePctBp }` and `costRanking(db, month): { total: number; rows: CostRankRow[] }` — clients ranked by `cost_paise` desc with share of total in **basis points** (10000 = 100%; the founder's "where do they sit in the AWS cost chart" view). + - `awsCostForClient(db, clientId, from?, to?): number` — Σ `cost_paise` over months in `[from-month, to-month]` (used by profitability). + - `previousMonth(today: string): string` — the previous complete month (what has settled); exported here so `aws-costs.ts` and the pull route import it from `repos-aws`, keeping the AWS module dependency one-directional. +- Routes: `GET /api/aws/ranking?month=` (auth), `GET /api/clients/:id/aws-usage` (auth), `POST /api/aws/usage` (**owner** — the manual fallback; forces `source='manual'`). + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/aws-repo.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { upsertAwsUsage, getAwsUsage, listAwsUsage, costRanking, awsCostForClient, previousMonth } from '../src/repos-aws' + +function setup() { + const db = openDb(':memory:') + const a = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) + const b = createClient(db, 'u1', { name: 'Bolt', code: 'BOLT', stateCode: '29' }) + return { db, a, b } +} + +describe('aws usage repo', () => { + it('upserts and merges a (client, month) row without clobbering the other source', () => { + const { db, a } = setup() + upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 5_000_00, source: 'auto' }) + upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', storageGb: 42.5, source: 'manual' }) // merges + const row = getAwsUsage(db, a.id, '2026-06')! + expect(row.costPaise).toBe(5_000_00) // auto cost preserved + expect(row.storageGb).toBe(42.5) // manual storage applied + expect(db.prepare(`SELECT COUNT(*) AS n FROM aws_usage WHERE client_id=?`).get(a.id)).toMatchObject({ n: 1 }) + const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='aws_usage'`).all() + expect(audits.length).toBe(2) // create + update + }) + it('lists newest month first, capped', () => { + const { db, a } = setup() + for (const m of ['2026-04', '2026-05', '2026-06']) upsertAwsUsage(db, 'u1', { clientId: a.id, month: m, costPaise: 100 }) + expect(listAwsUsage(db, a.id, 2).map((r) => r.month)).toEqual(['2026-06', '2026-05']) + }) + it('rejects a bad month', () => { + const { db, a } = setup() + expect(() => upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026/06', costPaise: 1 })).toThrow(/month/i) + }) + it('ranks clients by cost with share in basis points', () => { + const { db, a, b } = setup() + upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 7_500_00 }) + upsertAwsUsage(db, 'u1', { clientId: b.id, month: '2026-06', costPaise: 2_500_00 }) + const r = costRanking(db, '2026-06') + expect(r.total).toBe(10_000_00) + expect(r.rows.map((x) => x.clientId)).toEqual([a.id, b.id]) // Acme first (higher cost) + expect(r.rows[0]!.sharePctBp).toBe(7500) // 75.00% + expect(r.rows[1]!.sharePctBp).toBe(2500) + }) + it('sums a client cost over a month range and derives the previous month', () => { + const { db, a } = setup() + upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-05', costPaise: 100 }) + upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 200 }) + upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-07', costPaise: 400 }) + expect(awsCostForClient(db, a.id, '2026-05-01', '2026-06-30')).toBe(300) + expect(awsCostForClient(db, a.id)).toBe(700) + expect(previousMonth('2026-07-10')).toBe('2026-06') + expect(previousMonth('2026-01-05')).toBe('2025-12') // year rollover + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/aws-repo.test.ts` → FAIL. + +- [ ] **Step 3: Implement `apps/hq/src/repos-aws.ts`** + +```ts +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' +import { getClient } from './repos-clients' + +/** Per-client per-month AWS usage & cost (D12 portable-repo pattern). The auto + * pull writes cost_paise; the owner manual fallback writes storage/transfer/cost. + * One row per (client, month) — upsert merges so the two sources never clobber. */ + +export interface AwsUsage { + id: string; clientId: string; month: string + storageGb: number; transferGb: number; costPaise: number + source: 'auto' | 'manual'; updatedAt: string +} + +interface AwsUsageRow { + id: string; client_id: string; month: string + storage_gb: number; transfer_gb: number; cost_paise: number + source: string; updated_at: string +} + +function toUsage(r: AwsUsageRow): AwsUsage { + return { + id: r.id, clientId: r.client_id, month: r.month, + storageGb: r.storage_gb, transferGb: r.transfer_gb, costPaise: r.cost_paise, + source: r.source as 'auto' | 'manual', updatedAt: r.updated_at, + } +} + +export function getAwsUsage(db: DB, clientId: string, month: string): AwsUsage | null { + const row = db.prepare(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`).get(clientId, month) as AwsUsageRow | undefined + return row === undefined ? null : toUsage(row) +} + +export function listAwsUsage(db: DB, clientId: string, limit = 12): AwsUsage[] { + const rows = db.prepare( + `SELECT * FROM aws_usage WHERE client_id=? ORDER BY month DESC LIMIT ?`, + ).all(clientId, limit) as AwsUsageRow[] + return rows.map(toUsage) +} + +export interface UpsertAwsUsageInput { + clientId: string; month: string; storageGb?: number; transferGb?: number + costPaise?: number; source?: 'auto' | 'manual' +} + +export function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): AwsUsage { + if (getClient(db, input.clientId) === null) throw new Error('Client not found') + if (!/^\d{4}-\d{2}$/.test(input.month)) throw new Error("month must be 'YYYY-MM'") + if (input.costPaise !== undefined && (!Number.isInteger(input.costPaise) || input.costPaise < 0)) { + throw new Error('costPaise must be a non-negative integer (paise)') + } + for (const [k, v] of [['storageGb', input.storageGb], ['transferGb', input.transferGb]] as const) { + if (v !== undefined && (!Number.isFinite(v) || v < 0)) throw new Error(`${k} must be a non-negative number`) + } + const before = getAwsUsage(db, input.clientId, input.month) + const id = before?.id ?? uuidv7() + const storageGb = input.storageGb ?? before?.storageGb ?? 0 + const transferGb = input.transferGb ?? before?.transferGb ?? 0 + const costPaise = input.costPaise ?? before?.costPaise ?? 0 + const source = input.source ?? before?.source ?? 'manual' + db.prepare( + // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). + `INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (client_id, month) DO UPDATE SET + storage_gb=excluded.storage_gb, transfer_gb=excluded.transfer_gb, + cost_paise=excluded.cost_paise, source=excluded.source, updated_at=excluded.updated_at`, + ).run(id, input.clientId, input.month, storageGb, transferGb, costPaise, source, new Date().toISOString()) + const after = getAwsUsage(db, input.clientId, input.month)! + writeAudit(db, userId, before === null ? 'create' : 'update', 'aws_usage', after.id, before ?? undefined, after) + return after +} + +export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number } + +/** Cross-client ranking for one month: cost desc, share of total in basis points. */ +export function costRanking(db: DB, month: string): { total: number; rows: CostRankRow[] } { + const rows = db.prepare( + `SELECT a.client_id, c.name AS client_name, a.cost_paise + FROM aws_usage a JOIN client c ON c.id = a.client_id + WHERE a.month=? ORDER BY a.cost_paise DESC, c.name`, + ).all(month) as { client_id: string; client_name: string; cost_paise: number }[] + const total = rows.reduce((s, r) => s + r.cost_paise, 0) + return { + total, + rows: rows.map((r) => ({ + clientId: r.client_id, clientName: r.client_name, costPaise: r.cost_paise, + sharePctBp: total > 0 ? Math.round((r.cost_paise * 10000) / total) : 0, + })), + } +} + +/** Σ cost over months in [from-month, to-month]; unbounded when a bound is omitted. */ +export function awsCostForClient(db: DB, clientId: string, from?: string, to?: string): number { + let sql = `SELECT COALESCE(SUM(cost_paise), 0) AS total FROM aws_usage WHERE client_id=?` + const args: unknown[] = [clientId] + if (from !== undefined) { sql += ` AND month >= ?`; args.push(from.slice(0, 7)) } + if (to !== undefined) { sql += ` AND month <= ?`; args.push(to.slice(0, 7)) } + return (db.prepare(sql).get(...args) as { total: number }).total +} + +/** '2026-07-10' → '2026-06' (the previous complete month, which is what has settled). */ +export function previousMonth(today: string): string { + const [y, m] = today.split('-').map(Number) + return new Date(Date.UTC(y!, m! - 2, 1)).toISOString().slice(0, 7) +} +``` + +Wire routes in `apps/hq/src/api.ts` — add the import and the block (`GET`s auth-only; the manual upsert is owner-gated): + +```ts +import { + costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, +} from './repos-aws' +``` + +```ts + // ---------- aws usage & cost ---------- + r.get('/aws/ranking', requireAuth, (req, res) => { + const today = new Date().toISOString().slice(0, 10) + const month = typeof req.query['month'] === 'string' ? req.query['month'] : previousMonth(today) + res.json({ ok: true, month, ...costRanking(db, month) }) + }) + r.get('/clients/:id/aws-usage', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, usage: listAwsUsage(db, id, 12) }) + }) + r.post('/aws/usage', requireAuth, requireOwner, (req, res) => { + try { + // Manual owner entry — always 'manual' provenance regardless of the body. + const usage = upsertAwsUsage(db, staffId(res), { ...(req.body as UpsertAwsUsageInput), source: 'manual' }) + res.json({ ok: true, usage }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/aws-repo.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-aws.ts apps/hq/src/api.ts apps/hq/test/aws-repo.test.ts +git commit -m "feat(hq): aws usage repo — upsert/list, cross-client cost ranking, routes" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 3: AWS ingestion — SigV4 signer + `pullMonthlyCosts` (injectable Fetcher) + +**Files:** +- Create: `apps/hq/src/aws-costs.ts` +- Test: `apps/hq/test/aws-costs.test.ts` + +Imports `upsertAwsUsage` and `previousMonth` from the Task-2 `repos-aws.ts` (one-directional). Pure module — no env, no SDK; credentials arrive as a parameter and `fetch` is injected so tests stub the network. + +**Interfaces:** +- Produces: + - `AwsCreds = { accessKeyId: string; secretAccessKey: string }` + - `SignedRequest = { url: string; method: 'POST'; headers: Record; body: string }` + - `signAwsRequest(args: { creds: AwsCreds; region: string; service: string; target: string; body: string; now: Date }): SignedRequest` — pure SigV4-signed POST (`node:crypto`), deterministic for a fixed `now`. + - `AwsCostDeps = { f: Fetcher; creds: AwsCreds; now?: () => string }` (`Fetcher` = `typeof fetch`, reused from `gmail.ts`). + - `PullResult = { upserted: number; unknownTags: { tag: string; costPaise: number }[]; totalPaise: number }`. + - `pullMonthlyCosts(db, deps, month: string): Promise` — signs and POSTs a Cost Explorer `GetCostAndUsage` grouped by the `client` tag for `month` (`'YYYY-MM'`), maps each tag value to a client by `code` (case-insensitive), upserts matched clients' `cost_paise` with `source='auto'` via `upsertAwsUsage`, and returns unmatched/untagged groups in `unknownTags` (never dropped silently). + - `maybePullAwsCosts(db, deps, today: string): Promise` — pulls `previousMonth(today)` once per calendar month (guarded by the `aws.last_pull_month` setting); returns `null` when already done. +- Consumes: `upsertAwsUsage`, `previousMonth` (from `repos-aws`); `getSetting`/`setSetting` (from `repos-reminders`); `Fetcher` (from `gmail`). + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/aws-costs.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { getSetting } from '../src/repos-reminders' +import { previousMonth } from '../src/repos-aws' +import { signAwsRequest, pullMonthlyCosts, maybePullAwsCosts, type AwsCostDeps } from '../src/aws-costs' + +const CREDS = { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' } + +describe('signAwsRequest (SigV4)', () => { + it('produces a deterministic, well-formed authorization header', () => { + const now = new Date('2026-02-01T00:00:00.000Z') + const a = signAwsRequest({ creds: CREDS, region: 'us-east-1', service: 'ce', target: 'AWSInsightsIndexService.GetCostAndUsage', body: '{}', now }) + const b = signAwsRequest({ creds: CREDS, region: 'us-east-1', service: 'ce', target: 'AWSInsightsIndexService.GetCostAndUsage', body: '{}', now }) + expect(a.headers['authorization']).toBe(b.headers['authorization']) // deterministic for a fixed clock + expect(a.headers['authorization']).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/20260201\/us-east-1\/ce\/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-target, Signature=[0-9a-f]{64}$/, + ) + expect(a.headers['x-amz-date']).toBe('20260201T000000Z') + expect(a.url).toBe('https://ce.us-east-1.amazonaws.com/') + }) + it('changes the signature when the secret changes', () => { + const now = new Date('2026-02-01T00:00:00.000Z') + const a = signAwsRequest({ creds: CREDS, region: 'us-east-1', service: 'ce', target: 't', body: '{}', now }) + const b = signAwsRequest({ creds: { ...CREDS, secretAccessKey: 'other' }, region: 'us-east-1', service: 'ce', target: 't', body: '{}', now }) + expect(a.headers['authorization']).not.toBe(b.headers['authorization']) + }) +}) + +const CE_RESPONSE = { + ResultsByTime: [{ + Groups: [ + { Keys: ['client$ACME'], Metrics: { UnblendedCost: { Amount: '1234.56', Unit: 'INR' } } }, + { Keys: ['client$GHOST'], Metrics: { UnblendedCost: { Amount: '10.00', Unit: 'INR' } } }, + { Keys: ['client$'], Metrics: { UnblendedCost: { Amount: '5.00', Unit: 'INR' } } }, + ], + }], +} + +function deps(response: unknown, capture?: (url: string, init?: RequestInit) => void): AwsCostDeps { + const f = (async (url: string, init?: RequestInit) => { + capture?.(String(url), init) + return new Response(JSON.stringify(response), { status: 200 }) + }) as unknown as typeof fetch + return { f, creds: CREDS, now: () => '2026-07-01T00:00:00Z' } +} + +describe('pullMonthlyCosts', () => { + it('maps client tags to clients, upserts cost rows, and collects unknown tags', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const acme = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) + let sentUrl = '' + const out = await pullMonthlyCosts(db, deps(CE_RESPONSE, (u) => { sentUrl = u }), '2026-06') + expect(sentUrl).toBe('https://ce.us-east-1.amazonaws.com/') + expect(out.upserted).toBe(1) // only ACME matched + expect(out.unknownTags.map((u) => u.tag).sort()).toEqual(['', 'GHOST']) // ghost client + untagged + expect(out.unknownTags.find((u) => u.tag === 'GHOST')?.costPaise).toBe(10_00) + const row = db.prepare(`SELECT cost_paise, source FROM aws_usage WHERE client_id=?`).get(acme.id) + expect(row).toMatchObject({ cost_paise: 123456, source: 'auto' }) + }) + it('throws on a non-INR billing currency rather than mis-converting', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + const usd = { ResultsByTime: [{ Groups: [{ Keys: ['client$ACME'], Metrics: { UnblendedCost: { Amount: '10.00', Unit: 'USD' } } }] }] } + await expect(pullMonthlyCosts(db, deps(usd), '2026-06')).rejects.toThrow(/INR/i) + }) +}) + +describe('maybePullAwsCosts', () => { + it('pulls the previous month once per calendar month', async () => { + const db = openDb(':memory:'); seedIfEmpty(db) + createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) + expect(previousMonth('2026-07-10')).toBe('2026-06') + const first = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-10') + expect(first).not.toBeNull() + expect(getSetting(db, 'aws.last_pull_month')).toBe('2026-06') + const second = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-20') + expect(second).toBeNull() // already pulled 2026-06 this run + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/aws-costs.test.ts` → FAIL (module not found). + +- [ ] **Step 3: Implement `apps/hq/src/aws-costs.ts`** + +```ts +import { createHash, createHmac } from 'node:crypto' +import type { DB } from './db' +import type { Fetcher } from './gmail' +import { getSetting, setSetting } from './repos-reminders' +import { previousMonth, upsertAwsUsage } from './repos-aws' + +/** + * AWS Cost Explorer ingestion — SigV4 signed with node:crypto (no SDK), fetch + * injected (Fetcher) so tests stub the network. GetCostAndUsage is grouped by + * the cost-allocation tag 'client'; each tag value maps to a client by code and + * upserts one aws_usage row per (client, month). Untagged/unknown tag values are + * returned in the result, never dropped. Credentials arrive as a parameter — + * the caller (server wiring / route) reads them from env, never this module. + */ + +export interface AwsCreds { accessKeyId: string; secretAccessKey: string } +export interface SignedRequest { url: string; method: 'POST'; headers: Record; body: string } + +const REGION = 'us-east-1' // Cost Explorer is a global service pinned to us-east-1 +const SERVICE = 'ce' +const TARGET = 'AWSInsightsIndexService.GetCostAndUsage' + +function sha256Hex(data: string): string { + return createHash('sha256').update(data, 'utf8').digest('hex') +} +function hmac(key: Buffer | string, data: string): Buffer { + return createHmac('sha256', key).update(data, 'utf8').digest() +} + +/** Full SigV4: canonical request → string-to-sign → signing key → Authorization header. */ +export function signAwsRequest(args: { + creds: AwsCreds; region: string; service: string; target: string; body: string; now: Date +}): SignedRequest { + const { creds, region, service, target, body, now } = args + const host = `${service}.${region}.amazonaws.com` + const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '') // 20260201T000000Z + const dateStamp = amzDate.slice(0, 8) // 20260201 + const contentType = 'application/x-amz-json-1.1' + + // Canonical request — headers lowercased and sorted, body SHA-256 hashed. + const payloadHash = sha256Hex(body) + const canonicalHeaders = + `content-type:${contentType}\n` + `host:${host}\n` + + `x-amz-date:${amzDate}\n` + `x-amz-target:${target}\n` + const signedHeaders = 'content-type;host;x-amz-date;x-amz-target' + const canonicalRequest = ['POST', '/', '', canonicalHeaders, signedHeaders, payloadHash].join('\n') + + // String to sign. + const scope = `${dateStamp}/${region}/${service}/aws4_request` + const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n') + + // Derived signing key + signature. + const kDate = hmac(`AWS4${creds.secretAccessKey}`, dateStamp) + const kRegion = hmac(kDate, region) + const kService = hmac(kRegion, service) + const kSigning = hmac(kService, 'aws4_request') + const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex') + + const authorization = + `AWS4-HMAC-SHA256 Credential=${creds.accessKeyId}/${scope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}` + + return { + url: `https://${host}/`, method: 'POST', body, + // 'host' is set by undici on the wire too; keeping it here documents what was signed. + headers: { 'content-type': contentType, host, 'x-amz-date': amzDate, 'x-amz-target': target, authorization }, + } +} + +export interface AwsCostDeps { f: Fetcher; creds: AwsCreds; now?: () => string } +export interface PullResult { upserted: number; unknownTags: { tag: string; costPaise: number }[]; totalPaise: number } + +interface CeGroup { Keys?: string[]; Metrics?: { UnblendedCost?: { Amount?: string; Unit?: string } } } +interface CeResponse { ResultsByTime?: { Groups?: CeGroup[] }[] } + +/** '2026-06' → { start: '2026-06-01', end: '2026-07-01' } (End is exclusive in Cost Explorer). */ +function monthBounds(month: string): { start: string; end: string } { + const [y, m] = month.split('-').map(Number) + return { start: `${month}-01`, end: new Date(Date.UTC(y!, m!, 1)).toISOString().slice(0, 10) } +} + +export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string): Promise { + const { start, end } = monthBounds(month) + const body = JSON.stringify({ + TimePeriod: { Start: start, End: end }, + Granularity: 'MONTHLY', + Metrics: ['UnblendedCost'], + GroupBy: [{ Type: 'TAG', Key: 'client' }], + }) + const now = deps.now !== undefined ? new Date(deps.now()) : new Date() + const signed = signAwsRequest({ creds: deps.creds, region: REGION, service: SERVICE, target: TARGET, body, now }) + const res = await deps.f(signed.url, { method: 'POST', headers: signed.headers, body: signed.body }) + const json = (await res.json().catch(() => ({}))) as CeResponse + if (!res.ok) throw new Error(`Cost Explorer request failed: HTTP ${res.status}`) + + const groups = json.ResultsByTime?.[0]?.Groups ?? [] + const unknownTags: { tag: string; costPaise: number }[] = [] + let upserted = 0 + let totalPaise = 0 + for (const g of groups) { + const cost = g.Metrics?.UnblendedCost + const unit = cost?.Unit ?? 'INR' + if (unit !== 'INR') throw new Error(`Cost Explorer returned ${unit}, expected INR — HQ-3a assumes INR billing`) + const tag = (g.Keys?.[0] ?? '').split('$')[1] ?? '' // 'client$acme' → 'acme'; 'client$' → '' + const costPaise = Math.round(Number(cost?.Amount ?? '0') * 100) + totalPaise += costPaise + const client = tag === '' + ? undefined + : db.prepare(`SELECT id FROM client WHERE lower(code)=lower(?)`).get(tag) as { id: string } | undefined + if (client === undefined) { unknownTags.push({ tag, costPaise }); continue } + upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' }) + upserted += 1 + } + return { upserted, unknownTags, totalPaise } +} + +/** Gate the pull to once per calendar month; the 6-hourly tick calls this. */ +export async function maybePullAwsCosts(db: DB, deps: AwsCostDeps, today: string): Promise { + const target = previousMonth(today) + if (getSetting(db, 'aws.last_pull_month') === target) return null + const out = await pullMonthlyCosts(db, deps, target) + setSetting(db, 'system', 'aws.last_pull_month', target) + return out +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/aws-costs.test.ts` → PASS (verify `123456` paise and the `['', 'GHOST']` unknown set). `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/aws-costs.ts apps/hq/test/aws-costs.test.ts +git commit -m "feat(hq): aws cost explorer ingestion — sigv4 signer and monthly tag-grouped pull" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 4: `repos-reports.ts` — dues aging, module revenue, client profitability + routes + +**Files:** +- Create: `apps/hq/src/repos-reports.ts` +- Modify: `apps/hq/src/repos-payments.ts` (export `splitProRata`) +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/reports.test.ts` + +**Interfaces:** +- Modifies `repos-payments.ts`: change `function splitProRata` → `export function splitProRata` (no behaviour change — the largest-remainder split is now reused by reports so module-wise revenue matches `modulePaidView` to the paisa). +- Produces: + - `DateRange = { from?: string; to?: string }` (ISO dates on `doc_date`; a month range for AWS cost). + - `DuesAgingRow = { clientId; clientName; b0_30; b31_60; b61_90; b90p; totalPaise }` and `duesAging(db, today): DuesAgingRow[]` — per client, outstanding on issued non-settled invoices bucketed by age (`0-30 / 31-60 / 61-90 / 90+`), reusing `outstandingPaise`; sorted by total desc. + - `ModuleRevenueRow = { moduleId; moduleCode; moduleName; billedPaise; settledPaise }` and `moduleRevenue(db, range?): ModuleRevenueRow[]` — per module, billed (Σ line totals) and settled (invoice settlement split pro-rata across lines by `lineTotalPaise`) over invoices in range; sorted by billed desc. + - `ProfitabilityRow = { clientId; clientName; billedPaise; settledPaise; awsCostPaise; marginPaise }` and `clientProfitability(db, range?): ProfitabilityRow[]` — per client: billed + settled (as above) and AWS cost (`awsCostForClient`), `margin = settled − awsCost` (spec §5 "cost vs. what they pay us"); clients with no activity omitted; sorted by margin desc. +- Routes (auth-only reads): `GET /api/reports/dues-aging`, `GET /api/reports/module-revenue?from=&to=`, `GET /api/reports/profitability?from=&to=`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/reports.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice, assignModule } from '../src/repos-modules' +import { createDraft, issueDocument } from '../src/repos-documents' +import { recordPayment } from '../src/repos-payments' +import { upsertAwsUsage } from '../src/repos-aws' +import { duesAging, moduleRevenue, clientProfitability } from '../src/repos-reports' + +function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: string) { + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { + docType: 'INVOICE', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], + }).id) + db.prepare(`UPDATE document SET doc_date=? WHERE id=?`).run(docDate, inv.id) + return inv +} + +function setup() { + const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 + const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) + const pos = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) + setPrice(db, 'u1', { moduleId: pos.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + assignModule(db, 'u1', { clientId: c.id, moduleId: pos.id, kind: 'yearly' }) + return { db, c, pos } +} + +describe('duesAging', () => { + it('buckets each client outstanding by invoice age', () => { + const { db, c, pos } = setup() + issuedInvoice(db, c.id, pos.id, '2026-07-01') // ~9 days old on 2026-07-10 → 0-30 + issuedInvoice(db, c.id, pos.id, '2026-05-20') // ~51 days → 31-60 + issuedInvoice(db, c.id, pos.id, '2026-01-01') // >90 days → 90+ + const rows = duesAging(db, '2026-07-10') + expect(rows).toHaveLength(1) + const r = rows[0]! + expect(r.clientId).toBe(c.id) + expect(r.b0_30).toBe(11_800_00) // 10,000 + 18% GST + expect(r.b31_60).toBe(11_800_00) + expect(r.b90p).toBe(11_800_00) + expect(r.totalPaise).toBe(35_400_00) + }) + it('drops fully-settled invoices', () => { + const { db, c, pos } = setup() + const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] }) + expect(duesAging(db, '2026-07-10')).toHaveLength(0) + }) +}) + +describe('moduleRevenue', () => { + it('reports billed and settled per module, settlement split pro-rata', () => { + const { db, c, pos } = setup() + const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 5_900_00, allocations: [{ documentId: inv.id, amountPaise: 5_900_00 }] }) + const rows = moduleRevenue(db) + expect(rows).toHaveLength(1) + expect(rows[0]!.moduleCode).toBe('POS') + expect(rows[0]!.billedPaise).toBe(11_800_00) + expect(rows[0]!.settledPaise).toBe(5_900_00) + }) + it('honours a date range', () => { + const { db, c, pos } = setup() + issuedInvoice(db, c.id, pos.id, '2026-04-01') + issuedInvoice(db, c.id, pos.id, '2026-07-01') + expect(moduleRevenue(db, { from: '2026-07-01', to: '2026-07-31' })[0]!.billedPaise).toBe(11_800_00) + }) +}) + +describe('clientProfitability', () => { + it('reports billed, settled, aws cost and margin per client', () => { + const { db, c, pos } = setup() + const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') + recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] }) + upsertAwsUsage(db, 'u1', { clientId: c.id, month: '2026-07', costPaise: 1_000_00 }) + const rows = clientProfitability(db, { from: '2026-07-01', to: '2026-07-31' }) + expect(rows).toHaveLength(1) + expect(rows[0]!).toMatchObject({ + billedPaise: 11_800_00, settledPaise: 11_800_00, awsCostPaise: 1_000_00, marginPaise: 10_800_00, + }) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/reports.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** In `apps/hq/src/repos-payments.ts`, promote the helper to an export (change one line): + +```ts +export function splitProRata(amount: number, weights: number[]): number[] { +``` + +Create `apps/hq/src/repos-reports.ts`: + +```ts +import type { DB } from './db' +import { getDocument } from './repos-documents' +import { getModule } from './repos-modules' +import { outstandingPaise, splitProRata } from './repos-payments' +import { awsCostForClient } from './repos-aws' + +/** Read-only reporting aggregations (D12 plain-repo pattern). Settlement per + * invoice is payable − outstandingPaise (= allocations + credit-notes), clamped + * to [0, payable] — identical to modulePaidView, so a paisa split here matches + * a paisa split there. Nothing here mutates. */ + +export interface DateRange { from?: string; to?: string } + +/** Whole-day difference on YYYY-MM-DD (UTC), lexical-safe like scheduler.addDaysIso. */ +function daysBetween(fromIso: string, toIso: string): number { + return Math.floor((Date.parse(toIso) - Date.parse(fromIso)) / 86_400_000) +} + +/** Issued, non-cancelled invoices, optionally within a doc_date range. */ +function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): string[] { + let sql = `SELECT id FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'` + const args: unknown[] = [] + if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) } + if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) } + if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) } + sql += ` ORDER BY doc_date, doc_no` + return (db.prepare(sql).all(...args) as { id: string }[]).map((r) => r.id) +} + +/** Settled amount on an invoice, clamped to [0, payable]. */ +function settledOf(db: DB, docId: string, payablePaise: number): number { + return Math.max(0, Math.min(payablePaise, payablePaise - outstandingPaise(db, docId))) +} + +export interface DuesAgingRow { + clientId: string; clientName: string + b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number +} + +export function duesAging(db: DB, today: string): DuesAgingRow[] { + const invoices = db.prepare( + `SELECT d.id, d.client_id, d.doc_date, c.name AS client_name + FROM document d JOIN client c ON c.id = d.client_id + WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL AND d.status NOT IN ('paid','cancelled','lost')`, + ).all() as { id: string; client_id: string; doc_date: string; client_name: string }[] + const acc = new Map() + for (const inv of invoices) { + const out = outstandingPaise(db, inv.id) + if (out <= 0) continue + const row = acc.get(inv.client_id) + ?? { clientId: inv.client_id, clientName: inv.client_name, b0_30: 0, b31_60: 0, b61_90: 0, b90p: 0, totalPaise: 0 } + const age = daysBetween(inv.doc_date, today) + if (age <= 30) row.b0_30 += out + else if (age <= 60) row.b31_60 += out + else if (age <= 90) row.b61_90 += out + else row.b90p += out + row.totalPaise += out + acc.set(inv.client_id, row) + } + return [...acc.values()].sort((a, b) => b.totalPaise - a.totalPaise) +} + +export interface ModuleRevenueRow { + moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number +} + +export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] { + const acc = new Map() + for (const id of invoiceIds(db, undefined, range)) { + const inv = getDocument(db, id)! + const settledTotal = settledOf(db, inv.id, inv.payablePaise) + const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise)) + inv.payload.lines.forEach((line, i) => { + const e = acc.get(line.itemId) ?? { billed: 0, settled: 0 } + e.billed += line.lineTotalPaise + e.settled += shares[i]! + acc.set(line.itemId, e) + }) + } + return [...acc.entries()].map(([moduleId, v]) => { + const mod = getModule(db, moduleId) + return { + moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId, + billedPaise: v.billed, settledPaise: v.settled, + } + }).sort((a, b) => b.billedPaise - a.billedPaise) +} + +export interface ProfitabilityRow { + clientId: string; clientName: string + billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number +} + +export function clientProfitability(db: DB, range?: DateRange): ProfitabilityRow[] { + const clients = db.prepare(`SELECT id, name FROM client ORDER BY name`).all() as { id: string; name: string }[] + const out: ProfitabilityRow[] = [] + for (const c of clients) { + let billed = 0 + let settled = 0 + for (const id of invoiceIds(db, c.id, range)) { + const inv = getDocument(db, id)! + billed += inv.payablePaise + settled += settledOf(db, inv.id, inv.payablePaise) + } + const awsCostPaise = awsCostForClient(db, c.id, range?.from, range?.to) + if (billed === 0 && settled === 0 && awsCostPaise === 0) continue // omit inactive clients + out.push({ clientId: c.id, clientName: c.name, billedPaise: billed, settledPaise: settled, awsCostPaise, marginPaise: settled - awsCostPaise }) + } + return out.sort((a, b) => b.marginPaise - a.marginPaise) +} +``` + +Wire routes in `apps/hq/src/api.ts`: + +```ts +import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' +``` + +```ts + // ---------- reports ---------- + const rangeOf = (req: Request): DateRange => { + const range: DateRange = {} + if (typeof req.query['from'] === 'string') range.from = req.query['from'] + if (typeof req.query['to'] === 'string') range.to = req.query['to'] + return range + } + r.get('/reports/dues-aging', requireAuth, (_req, res) => { + res.json({ ok: true, rows: duesAging(db, new Date().toISOString().slice(0, 10)) }) + }) + r.get('/reports/module-revenue', requireAuth, (req, res) => { + res.json({ ok: true, rows: moduleRevenue(db, rangeOf(req)) }) + }) + r.get('/reports/profitability', requireAuth, (req, res) => { + res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) }) + }) +``` + +(`Request` is already imported in `api.ts` — `import { Router, type Request, ... } from 'express'`.) + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/reports.test.ts` → PASS (verify the golden `11_800_00` / `35_400_00` / `10_800_00` paise). `npx vitest run apps/hq/test/payments.test.ts` still PASS (the `splitProRata` export is behaviour-neutral). `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-reports.ts apps/hq/src/repos-payments.ts apps/hq/src/api.ts apps/hq/test/reports.test.ts +git commit -m "feat(hq): reports — dues aging, module revenue, client profitability with margin" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 5: Payment receipts — `generateReceipt` (own RCT/ series, zero-GST) + template + route + +**Files:** +- Modify: `apps/hq/src/repos-documents.ts` (`DocPayload.receipt`, `generateReceipt`) +- Modify: `apps/hq/src/templates.ts` (RECEIPT acknowledgment layout) +- Modify: `apps/hq/src/repos-payments.ts` (`issueReceipt` option on `recordPayment` + `receiptForPayment`) +- Modify: `apps/hq/src/api.ts` (`POST /api/payments/:id/receipt`) +- Test: `apps/hq/test/receipt.test.ts` + +**Interfaces:** +- `DocPayload` gains `receipt?: ReceiptMeta` where `ReceiptMeta = { paymentId; receivedOn; mode; reference; amountPaise; tdsPaise; allocations: { docNo; amountPaise }[] }`. No DDL — it rides in the existing `payload` JSON. +- `generateReceipt(db, userId, input: { clientId; paymentId; receivedOn; mode; reference; amountPaise; tdsPaise; allocations }): Doc` — builds a **RECEIPT** with zero tax (`payablePaise = amountPaise`) through the existing private `insertDocRow`, then `issueDocument` assigns the next `RCT/` number (the `RECEIPT` prefix already exists in `series.ts` `TYPE_PREFIX`). Receipts carry **no GST lines** (`payload.lines = []`). +- `recordPayment` gains `issueReceipt?: boolean` on its input and `receipt?: Doc` on its result — when true, a receipt is generated inside the same transaction after allocation (nested `db.transaction` = savepoint, exactly as `generateAmcRenewalInvoice` already nests `createDraft`+`issueDocument`). +- `receiptForPayment(db, userId, paymentId): Doc` — reconstructs the receipt from a stored payment + its allocations (used by the after-the-fact route). +- Route: `POST /api/payments/:id/receipt` (auth) → `{ document }`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/receipt.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft, issueDocument, getDocument } from '../src/repos-documents' +import { recordPayment, receiptForPayment } from '../src/repos-payments' +import { documentHtml } from '../src/templates' + +function setup() { + const db = openDb(':memory:'); seedIfEmpty(db) + const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) + const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) + return { db, c, inv } +} + +describe('payment receipts', () => { + it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', () => { + const { db, c, inv } = setup() + const out = recordPayment(db, 'u1', { + clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, + allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true, + }) + expect(out.receipt).toBeDefined() + const rc = out.receipt! + expect(rc.docType).toBe('RECEIPT') + expect(rc.docNo?.startsWith('RCT/')).toBe(true) + expect(rc.payablePaise).toBe(11_800_00) + expect(rc.cgstPaise + rc.sgstPaise + rc.igstPaise).toBe(0) // acknowledgment, not a tax document + expect(rc.payload.receipt?.allocations[0]).toMatchObject({ docNo: inv.docNo, amountPaise: 11_800_00 }) + }) + it('generates a receipt after the fact from a stored payment', () => { + const { db, c, inv } = setup() + const out = recordPayment(db, 'u1', { + clientId: c.id, receivedOn: '2026-07-10', mode: 'upi', amountPaise: 5_000_00, + allocations: [{ documentId: inv.id, amountPaise: 5_000_00 }], + }) + const rc = receiptForPayment(db, 'u1', out.payment.id) + expect(rc.docType).toBe('RECEIPT') + expect(getDocument(db, rc.id)!.docNo).toBe(rc.docNo) + }) + it('renders a receipt acknowledgment (no SAC/GST table)', () => { + const { db, c, inv } = setup() + const out = recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true }) + const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' }) + expect(html).toContain('RECEIPT') + expect(html).toContain('Received with thanks') + expect(html).not.toContain('>SAC<') // the GST line table header is absent on receipts + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/receipt.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** + +In `apps/hq/src/repos-documents.ts`, extend `DocPayload` and add `generateReceipt` (near `createCreditNote`). `insertDocRow`, `issueDocument`, `getClient`, `todayIso` are already in this file; `BillTotals` is already imported from `@sims/domain`. + +```ts +export interface ReceiptMeta { + paymentId: string; receivedOn: string; mode: string; reference: string + amountPaise: number; tdsPaise: number + allocations: { docNo: string; amountPaise: number }[] +} + +export interface DocPayload { + lines: BillLine[]; totals: BillTotals; terms?: string + /** Per-line "what's included" bullets, parallel to `lines`. */ + lineContents?: string[][] + /** Present only on RECEIPT documents — payment acknowledgment metadata. */ + receipt?: ReceiptMeta +} +``` + +```ts +export interface GenerateReceiptInput { + clientId: string; paymentId: string; receivedOn: string; mode: string + reference: string; amountPaise: number; tdsPaise: number + allocations: { docNo: string; amountPaise: number }[] +} + +/** + * Payment receipt — a RECEIPT document with its own RCT/ series. Receipts + * acknowledge money, they do not levy tax: zero GST, no line items, payable = + * amount received. Issued (numbered) immediately and never edited thereafter. + */ +export function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Doc { + const client = getClient(db, input.clientId) + if (client === null) throw new Error('Client not found') + const totals: BillTotals = { + grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0, + cgstPaise: 0, sgstPaise: 0, igstPaise: 0, cessPaise: 0, + roundOffPaise: 0, payablePaise: input.amountPaise, savingsVsMrpPaise: 0, + } + const payload: DocPayload = { + lines: [], totals, + receipt: { + paymentId: input.paymentId, receivedOn: input.receivedOn, mode: input.mode, + reference: input.reference, amountPaise: input.amountPaise, tdsPaise: input.tdsPaise, + allocations: input.allocations, + }, + } + return db.transaction(() => { + const draft = insertDocRow(db, userId, { + docType: 'RECEIPT', clientId: input.clientId, refDocId: null, + docDate: todayIso(), totals, payload, + }) + return issueDocument(db, userId, draft.id) // assigns RCT/26-27-000N + })() +} +``` + +In `apps/hq/src/templates.ts`, branch `documentHtml` to a receipt layout at the top of the function, and add `receiptHtml`. `formatINR`, `rupeesInWords`, `esc`, `displayDate` are already in this file. + +```ts +export function documentHtml(doc: Doc, client: Client, company: Record): string { + if (doc.docType === 'RECEIPT') return receiptHtml(doc, client, company) + // ... existing body unchanged ... +``` + +```ts +/** Payment acknowledgment — no SAC/GST table; states the sum received in words. */ +function receiptHtml(doc: Doc, client: Client, company: Record): string { + const get = (key: string): string => company[`company.${key}`] ?? '' + const rc = doc.payload.receipt + const amountPaise = rc?.amountPaise ?? doc.payablePaise + const companyMeta = [ + get('address'), + get('gstin') !== '' ? `GSTIN: ${get('gstin')}` : '', + [get('phone'), get('email')].filter((s) => s !== '').join(' · '), + ].filter((s) => s !== '') + const allocRows = (rc?.allocations ?? []).filter((a) => a.docNo !== '' && a.docNo !== '—') + const against = allocRows.length > 0 + ? `` + + allocRows.map((a) => ``).join('') + + `
Towards invoiceAmount
${esc(a.docNo)}${formatINR(a.amountPaise)}
` + : `

Received on account (unallocated advance).

` + const tdsLine = rc !== undefined && rc.tdsPaise > 0 + ? `

TDS deducted at source: ${formatINR(rc.tdsPaise)} (treated as settlement).

` : '' + return ` + + + +${esc(doc.docNo ?? 'Receipt')} + + + +
+

${esc(get('name'))}

+ ${companyMeta.map((line) => `

${esc(line)}

`).join('\n ')} +
+ +
RECEIPT
+ +
+
+

Received From

+

${esc(client.name)} (${esc(client.code)})

+ ${client.address !== '' ? `

${esc(client.address)}

` : ''} +
+
+

Receipt

+

No: ${esc(doc.docNo ?? 'DRAFT')}

+

Date: ${displayDate(doc.docDate)}

+ ${rc !== undefined ? `

Mode: ${esc(rc.mode)}${rc.reference !== '' ? ` · Ref ${esc(rc.reference)}` : ''}

` : ''} +
+
+ +
+

Received with thanks from ${esc(client.name)} the sum of + ${formatINR(amountPaise)}

+

(${esc(rupeesInWords(amountPaise))})

+ ${tdsLine} +
+ + ${against} + +
+

For ${esc(get('name'))}

+

Authorised Signatory

+
+ +` +} +``` + +In `apps/hq/src/repos-payments.ts`, import `generateReceipt`, extend the input/result types, and add `receiptForPayment`. (`getDocument`, `getPayment`, `Doc` are already imported/defined here.) + +```ts +import { generateReceipt, getDocument, listDocuments, type Doc } from './repos-documents' +``` + +```ts +export interface RecordPaymentInput { + clientId: string; receivedOn: string; mode: PaymentMode; reference?: string + amountPaise: number; tdsPaise?: number; allocations?: AllocationInput[] + issueReceipt?: boolean +} + +export interface RecordPaymentResult { + payment: Payment + allocated: { documentId: string; amountPaise: number }[] + receipt?: Doc +} +``` + +At the end of `recordPayment`'s transaction, before `return`, generate the receipt when asked (it re-reads the just-inserted allocations within the same transaction): + +```ts + const receipt = input.issueReceipt === true ? receiptForPayment(db, userId, id) : undefined + return { payment: getPayment(db, id)!, allocated, ...(receipt !== undefined ? { receipt } : {}) } +``` + +And add the standalone rebuilder (used by the route and by `recordPayment`): + +```ts +/** Build a RECEIPT for an already-recorded payment from its stored allocations. */ +export function receiptForPayment(db: DB, userId: string, paymentId: string): Doc { + const p = getPayment(db, paymentId) + if (p === null) throw new Error('Payment not found') + const allocs = db.prepare( + `SELECT a.amount_paise, d.doc_no FROM payment_allocation a + JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`, + ).all(paymentId) as { amount_paise: number; doc_no: string | null }[] + return generateReceipt(db, userId, { + clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode, + reference: p.reference, amountPaise: p.amountPaise, tdsPaise: p.tdsPaise, + allocations: allocs.map((a) => ({ docNo: a.doc_no ?? '—', amountPaise: a.amount_paise })), + }) +} +``` + +Wire the route in `apps/hq/src/api.ts` — add `receiptForPayment` to the `repos-payments` import and the block: + +```ts +import { + clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, +} from './repos-payments' +``` + +```ts + r.post('/payments/:id/receipt', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + try { + res.json({ ok: true, document: receiptForPayment(db, staffId(res), id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/receipt.test.ts` → PASS. `npx vitest run apps/hq/test/payments.test.ts apps/hq/test/documents.test.ts apps/hq/test/templates.test.ts` still PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/repos-documents.ts apps/hq/src/templates.ts apps/hq/src/repos-payments.ts apps/hq/src/api.ts apps/hq/test/receipt.test.ts +git commit -m "feat(hq): payment receipts — own RCT/ series, zero-GST acknowledgment PDF" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 6: Company Profile — `GET`/`PUT /api/settings/company` (owner-gated, audited) + +**Files:** +- Modify: `apps/hq/src/api.ts` +- Test: `apps/hq/test/settings-company.test.ts` + +**Interfaces:** +- Consumes: the existing `companySettings()` helper in `api.ts` (returns the `company.*` map), `setSetting` (from `repos-reminders`, which already audits each key), and `validateGstin` (from `@sims/domain`, already used by `repos-clients`). +- Routes: `GET /api/settings/company` (auth) → `{ company: Record }`; `PUT /api/settings/company` (**owner**) → validates GSTIN (when non-empty) and a two-digit state code, writes each provided field via `setSetting`, returns the fresh map. The letterhead PDFs read these settings server-side at render time, so a change takes effect on the next generated document with no restart. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/settings-company.test.ts +import { describe, it, expect, afterAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' + +function appWith() { + const db = openDb(':memory:'); seedIfEmpty(db) + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + return { db, server, base } +} + +describe('company profile settings', () => { + const { db, server, base } = appWith() + afterAll(() => server.close()) + const login = async (email: string, password: string) => + (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token + const call = async (token: string, method: string, path: string, body?: unknown) => { + const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) + return { status: res.status, json: await res.json() as any } + } + + it('owner reads and updates company.* and it is audited', async () => { + const token = await login('owner@test.in', 'owner-password') + const before = await call(token, 'GET', '/settings/company') + expect(before.json.company['company.name']).toBe('Tecnostac') + const put = await call(token, 'PUT', '/settings/company', { name: 'Tecnostac Pvt Ltd', phone: '0484-1234567', stateCode: '32' }) + expect(put.status).toBe(200) + expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd') + expect(put.json.company['company.phone']).toBe('0484-1234567') + const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`).get() as { n: number } + expect(audits.n).toBeGreaterThanOrEqual(3) + }) + it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => { + const staff = await login('staff@test.in', 'staff-password') + expect((await call(staff, 'PUT', '/settings/company', { name: 'Nope' })).status).toBe(403) + const owner = await login('owner@test.in', 'owner-password') + expect((await call(owner, 'PUT', '/settings/company', { gstin: 'NOTAGSTIN' })).status).toBe(400) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/settings-company.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** In `apps/hq/src/api.ts` add the imports and the route block. `validateGstin` from `@sims/domain`, `setSetting` from `repos-reminders`: + +```ts +import { validateGstin } from '@sims/domain' +import { setSetting } from './repos-reminders' +``` + +```ts + // ---------- company profile (owner-editable; PDFs read these live) ---------- + const COMPANY_FIELDS: Record = { + name: 'company.name', address: 'company.address', gstin: 'company.gstin', + stateCode: 'company.state_code', phone: 'company.phone', email: 'company.email', bank: 'company.bank', + } + r.get('/settings/company', requireAuth, (_req, res) => { + res.json({ ok: true, company: companySettings() }) + }) + r.put('/settings/company', requireAuth, requireOwner, (req, res) => { + const body = req.body as Record + try { + const gstin = body['gstin'] + if (typeof gstin === 'string' && gstin !== '') { + const v = validateGstin(gstin) + if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'})`) + } + const stateCode = body['stateCode'] + if (typeof stateCode === 'string' && !/^\d{2}$/.test(stateCode)) { + throw new Error('State code must be two digits') + } + for (const [field, key] of Object.entries(COMPANY_FIELDS)) { + const val = body[field] + if (typeof val === 'string') setSetting(db, staffId(res), key, val) + } + res.json({ ok: true, company: companySettings() }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/settings-company.test.ts` → PASS. `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/api.ts apps/hq/test/settings-company.test.ts +git commit -m "feat(hq): company profile settings — owner-gated GET/PUT, audited" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 7: Scheduler wiring — monthly AWS pull + `POST /api/aws/pull` (owner, 409 without creds) + +**Files:** +- Modify: `apps/hq/src/server.ts` (scheduler deps gain AWS creds from env; tick calls `maybePullAwsCosts` when present) +- Modify: `apps/hq/src/api.ts` (`POST /api/aws/pull`) +- Test: `apps/hq/test/aws-pull-route.test.ts` + +**Interfaces:** +- `server.ts` `startScheduler` reads `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` at boot; each tick (boot + every 6h) calls `maybePullAwsCosts(db, { f: fetch, creds }, today())` **only when both are non-empty**, wrapped in the same `.catch` console-error guard as the existing scan and bounce poll. When creds are absent the pull is simply not scheduled — no error, no AWS call. +- Route: `POST /api/aws/pull` (**owner**) — reads env creds at request time; `409 { error: 'aws-credentials-not-configured' }` when absent; otherwise pulls the requested `month` (body, default `previousMonth(today)`) via `pullMonthlyCosts` and returns `{ upserted, unknownTags, totalPaise }`; a live-call failure surfaces as `502`. Tests exercise only the 409 path (no live AWS); the happy pull path is covered by `aws-costs.test.ts`. + +- [ ] **Step 1: Write the failing test** + +```ts +// apps/hq/test/aws-pull-route.test.ts +import { describe, it, expect, afterAll, beforeAll } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { apiRouter } from '../src/api' + +describe('POST /api/aws/pull without credentials', () => { + const prevKey = process.env['AWS_ACCESS_KEY_ID'] + const prevSecret = process.env['AWS_SECRET_ACCESS_KEY'] + beforeAll(() => { delete process.env['AWS_ACCESS_KEY_ID']; delete process.env['AWS_SECRET_ACCESS_KEY'] }) + afterAll(() => { + if (prevKey !== undefined) process.env['AWS_ACCESS_KEY_ID'] = prevKey + if (prevSecret !== undefined) process.env['AWS_SECRET_ACCESS_KEY'] = prevSecret + }) + + const db = openDb(':memory:'); seedIfEmpty(db) + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) + const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}/api` + afterAll(() => server.close()) + const login = async (email: string, password: string) => + (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token + + it('returns 409 when AWS creds are absent', async () => { + const token = await login('owner@test.in', 'owner-password') + const res = await fetch(`${base}/aws/pull`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: '{}' }) + expect(res.status).toBe(409) + expect((await res.json() as any).error).toBe('aws-credentials-not-configured') + }) + it('is owner-gated', async () => { + const token = await login('staff@test.in', 'staff-password') + const res = await fetch(`${base}/aws/pull`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: '{}' }) + expect(res.status).toBe(403) + }) +}) +``` + +- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/aws-pull-route.test.ts` → FAIL (route absent → 404). + +- [ ] **Step 3: Implement.** + +In `apps/hq/src/api.ts` add the import and the route (env read at request time, matching how `gmail()` reads env lazily): + +```ts +import { pullMonthlyCosts } from './aws-costs' +``` + +```ts + r.post('/aws/pull', requireAuth, requireOwner, (req, res) => { + const accessKeyId = process.env['AWS_ACCESS_KEY_ID'] ?? '' + const secretAccessKey = process.env['AWS_SECRET_ACCESS_KEY'] ?? '' + if (accessKeyId === '' || secretAccessKey === '') { + res.status(409).json({ ok: false, error: 'aws-credentials-not-configured' }); return + } + const today = new Date().toISOString().slice(0, 10) + const body = req.body as { month?: string } + const month = typeof body.month === 'string' ? body.month : previousMonth(today) + void (async () => { + try { + const out = await pullMonthlyCosts(db, { f: fetch, creds: { accessKeyId, secretAccessKey } }, month) + res.json({ ok: true, month, ...out }) + } catch (err) { + res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + }) +``` + +(`previousMonth` is already imported from `./repos-aws` in Task 2's `api.ts` changes — reuse it; this task only adds the `pullMonthlyCosts` import from `./aws-costs`.) + +In `apps/hq/src/server.ts`, extend `schedulerDeps`/`startScheduler` to add the AWS pull to the tick: + +```ts +import { maybePullAwsCosts } from './aws-costs' +``` + +```ts +export function startScheduler(db: DB): NodeJS.Timeout { + const deps = schedulerDeps(db) + const awsCreds = { + accessKeyId: process.env['AWS_ACCESS_KEY_ID'] ?? '', + secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'] ?? '', + } + const today = (): string => new Date().toISOString().slice(0, 10) + const tick = (): void => { + void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) + void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e)) + // AWS cost pull only when credentials are configured — env-gated, once per month. + if (awsCreds.accessKeyId !== '' && awsCreds.secretAccessKey !== '') { + void maybePullAwsCosts(db, { f: fetch, creds: awsCreds }, today()) + .catch((e: unknown) => console.error('[scheduler] aws cost pull failed', e)) + } + } + tick() + const handle = setInterval(tick, 6 * 60 * 60 * 1000) + handle.unref() + return handle +} +``` + +- [ ] **Step 4: Run tests** — `npx vitest run apps/hq/test/aws-pull-route.test.ts` → PASS. `npx vitest run apps/hq/test/health.test.ts` still PASS (`startServer(0)` with default opts starts no scheduler). `npm run typecheck` → clean. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq/src/server.ts apps/hq/src/api.ts apps/hq/test/aws-pull-route.test.ts +git commit -m "feat(hq): schedule monthly aws cost pull and owner-gated manual pull route" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 8: `apps/hq-web` — API client + Reports page + nav item + +**Files:** +- Modify: `apps/hq-web/src/api.ts` (types + typed calls), `apps/hq-web/src/Layout.tsx` (nav), `apps/hq-web/src/main.tsx` (route) +- Create: `apps/hq-web/src/pages/Reports.tsx` + +**Interfaces / contracts:** +- `api.ts` gains the HQ-3a server shapes (camelCase JSON, mirroring the repos) and typed calls. +- `Reports.tsx` is a plain fetch-render page (`useData` from `Clients`, `@sims/ui` components): a **dues aging** table, a **module revenue** table, a **client profitability** table, and the **AWS cost ranking** as a plain bar list (a `
` whose width is `sharePctBp/100`% — no chart library, per scope). A `` drives the ranking month. All amounts via `formatINR`. +- Nav gains **Reports**; the route `/reports` renders the page. + +- [ ] **Step 1: Implement the API client additions.** Append to `apps/hq-web/src/api.ts`: + +```ts +// ---------- HQ-3a shapes ---------- + +export interface DuesAgingRow { + clientId: string; clientName: string + b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number +} +export interface ModuleRevenueRow { + moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number +} +export interface ProfitabilityRow { + clientId: string; clientName: string + billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number +} +export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number } +export interface AwsUsageRow { + id: string; clientId: string; month: string + storageGb: number; transferGb: number; costPaise: number; source: 'auto' | 'manual'; updatedAt: string +} + +// ---------- HQ-3a calls ---------- + +const rangeQs = (from?: string, to?: string): string => { + const p = new URLSearchParams() + if (from !== undefined && from !== '') p.set('from', from) + if (to !== undefined && to !== '') p.set('to', to) + const q = p.toString() + return q === '' ? '' : `?${q}` +} + +export const getDuesAging = (): Promise => + apiFetch<{ rows: DuesAgingRow[] }>('/reports/dues-aging').then((r) => r.rows) +export const getModuleRevenue = (from?: string, to?: string): Promise => + apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows) +export const getProfitability = (from?: string, to?: string): Promise => + apiFetch<{ rows: ProfitabilityRow[] }>(`/reports/profitability${rangeQs(from, to)}`).then((r) => r.rows) +export const getAwsRanking = (month?: string): Promise<{ month: string; total: number; rows: CostRankRow[] }> => + apiFetch<{ month: string; total: number; rows: CostRankRow[] }>(`/aws/ranking${month !== undefined && month !== '' ? `?month=${month}` : ''}`) +export const getClientAwsUsage = (clientId: string): Promise => + apiFetch<{ usage: AwsUsageRow[] }>(`/clients/${clientId}/aws-usage`).then((r) => r.usage) +export const upsertAwsUsage = (body: Record): Promise => + apiFetch<{ usage: AwsUsageRow }>('/aws/usage', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.usage) +export const pullAwsCosts = (month?: string): Promise<{ upserted: number; totalPaise: number; unknownTags: { tag: string; costPaise: number }[] }> => + apiFetch('/aws/pull', { method: 'POST', body: JSON.stringify(month !== undefined ? { month } : {}) }) + +export const getCompanyProfile = (): Promise> => + apiFetch<{ company: Record }>('/settings/company').then((r) => r.company) +export const putCompanyProfile = (body: Record): Promise> => + apiFetch<{ company: Record }>('/settings/company', { method: 'PUT', body: JSON.stringify(body) }).then((r) => r.company) +``` + +- [ ] **Step 2: Create `apps/hq-web/src/pages/Reports.tsx`** + +```tsx +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { formatINR } from '@sims/domain' +import { DataTable, EmptyState, Field, Notice, PageHeader } from '@sims/ui' +import { getDuesAging, getModuleRevenue, getProfitability, getAwsRanking } from '../api' +import { useData } from './Clients' + +const inr = (p: number) => formatINR(p, { symbol: false }) +const prevMonth = (): string => { + const d = new Date() + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - 1, 1)).toISOString().slice(0, 7) +} + +/** Reports — dues aging, module revenue, client profitability, and the AWS cost chart (spec §5–§6). */ +export function Reports() { + const nav = useNavigate() + const [month, setMonth] = useState(prevMonth()) + const dues = useData(getDuesAging, []) + const revenue = useData(() => getModuleRevenue(), []) + const profit = useData(() => getProfitability(), []) + const ranking = useData(() => getAwsRanking(month), [month]) + + return ( +
+ + +

Dues aging

+ {dues.error !== undefined && {dues.error}} + {dues.data === undefined || dues.data.length === 0 + ? No outstanding dues. + : ( + nav(`/clients/${dues.data![i]!.clientId}`)} + rows={dues.data.map((d) => ({ + client: d.clientName, b1: inr(d.b0_30), b2: inr(d.b31_60), b3: inr(d.b61_90), b4: inr(d.b90p), total: inr(d.totalPaise), + }))} + /> + )} + +

Module-wise revenue

+ {revenue.error !== undefined && {revenue.error}} + {revenue.data === undefined || revenue.data.length === 0 + ? No invoiced revenue yet. + : ( + ({ module: `${m.moduleName} (${m.moduleCode})`, billed: inr(m.billedPaise), settled: inr(m.settledPaise) }))} + /> + )} + +

Client profitability

+ {profit.error !== undefined && {profit.error}} + {profit.data === undefined || profit.data.length === 0 + ? No client activity yet. + : ( + nav(`/clients/${profit.data![i]!.clientId}`)} + rows={profit.data.map((p) => ({ + client: p.clientName, billed: inr(p.billedPaise), settled: inr(p.settledPaise), + aws: inr(p.awsCostPaise), margin: inr(p.marginPaise), + }))} + /> + )} + +

AWS cost chart

+ setMonth(e.target.value)} /> + {ranking.error !== undefined && {ranking.error}} + {ranking.data === undefined || ranking.data.rows.length === 0 + ? No AWS cost recorded for {month}. + : ( +
+ {ranking.data.rows.map((row) => ( +
+
nav(`/clients/${row.clientId}`)}>{row.clientName}
+
+
+
+
{inr(row.costPaise)}
+
{(row.sharePctBp / 100).toFixed(1)}%
+
+ ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 3: Nav + route.** In `apps/hq-web/src/Layout.tsx` add Reports to `NAV` (after Modules): + +```ts +const NAV = [ + { to: '/', label: 'Dashboard' }, + { to: '/clients', label: 'Clients' }, + { to: '/modules', label: 'Modules' }, + { to: '/reports', label: 'Reports' }, + { to: '/documents/new', label: 'New Document' }, +] +``` + +In `apps/hq-web/src/main.tsx` import `Reports` and add the route inside the `Layout` element: + +```tsx +import { Reports } from './pages/Reports' +// ... + } /> +``` + +- [ ] **Step 4: Verify** — `cd apps/hq-web && npm run typecheck && npm run build` → clean. With the server running, `npm run dev`: after login the **Reports** nav item shows the four sections; the AWS chart month picker re-fetches the ranking. + +- [ ] **Step 5: Commit** + +```bash +git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Reports.tsx apps/hq-web/src/Layout.tsx apps/hq-web/src/main.tsx +git commit -m "feat(hq-web): reports page — dues aging, module revenue, profitability, aws cost chart" -m "Co-Authored-By: Claude Fable 5 " +``` + +--- + +### Task 9: `apps/hq-web` — Company Profile page + Client 360 AWS-usage section + +**Files:** +- Create: `apps/hq-web/src/pages/CompanyProfile.tsx` +- Modify: `apps/hq-web/src/Layout.tsx` (owner-only Company nav), `apps/hq-web/src/main.tsx` (route), `apps/hq-web/src/pages/ClientDetail.tsx` (AWS usage section) + +**Interfaces / contracts:** +- `CompanyProfile.tsx` (owner-only): a form over the seven `company.*` fields (name, address, gstin, state code, phone, email, bank), `getCompanyProfile()` on mount, `putCompanyProfile()` on save; a `Notice` on validation error (bad GSTIN / state code). Non-owners are redirected to `/`. +- `Layout.tsx` gains a **Company** nav item, rendered only when `role() === 'owner'` (build the nav array dynamically). +- `ClientDetail.tsx` gains an **AWS usage** section (below Interactions): a last-12-months table (Month, Storage GB, Transfer GB, Cost, Source) from `getClientAwsUsage(id)`. + +- [ ] **Step 1: Create `apps/hq-web/src/pages/CompanyProfile.tsx`** + +```tsx +import { useEffect, useState } from 'react' +import { Navigate } from 'react-router-dom' +import { Button, Field, Notice, PageHeader } from '@sims/ui' +import { getCompanyProfile, putCompanyProfile, role } from '../api' + +const FIELDS: { key: string; setting: string; label: string }[] = [ + { key: 'name', setting: 'company.name', label: 'Company name' }, + { key: 'address', setting: 'company.address', label: 'Address' }, + { key: 'gstin', setting: 'company.gstin', label: 'GSTIN' }, + { key: 'stateCode', setting: 'company.state_code', label: 'State code' }, + { key: 'phone', setting: 'company.phone', label: 'Phone' }, + { key: 'email', setting: 'company.email', label: 'Email' }, + { key: 'bank', setting: 'company.bank', label: 'Bank details' }, +] + +/** Owner-only editor for the company.* settings the letterhead PDFs read. */ +export function CompanyProfile() { + const [f, setF] = useState>({}) + const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>() + const [saving, setSaving] = useState(false) + + useEffect(() => { + getCompanyProfile() + .then((c) => setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? ''])))) + .catch((e: Error) => setMsg({ tone: 'err', text: e.message })) + }, []) + + if (role() !== 'owner') return + + const save = () => { + setSaving(true); setMsg(undefined) + putCompanyProfile(f) + .then((c) => { setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))); setMsg({ tone: 'ok', text: 'Saved. PDFs use the new details immediately.' }) }) + .catch((e: Error) => setMsg({ tone: 'err', text: e.message })) + .finally(() => setSaving(false)) + } + + return ( +
+ + {msg !== undefined && {msg.text}} +
+ {FIELDS.map((x) => ( + + {x.key === 'address' || x.key === 'bank' + ?