# HQ-3a — AWS Cost Attribution, Reports, Receipts & Company Profile Implementation Plan > **STATUS: DELIVERED (HQ-3a, 2026-07).** Kept as the design/task record; current state lives in STATUS.md. > **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 HQ server 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' ?