You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-10-hq3a-reports-cos...

1805 lines
99 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 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): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
## 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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<string, string>; 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<PullResult>` — 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<PullResult | null>` — 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<string, string>; 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<PullResult> {
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<PullResult | null> {
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 <noreply@anthropic.com>"
```
---
### 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<string, DuesAgingRow>()
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<string, { billed: number; settled: number }>()
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 <noreply@anthropic.com>"
```
---
### 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, string>): 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, string>): 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
? `<table class="lines"><thead><tr><th>Towards invoice</th><th class="r">Amount</th></tr></thead><tbody>`
+ allocRows.map((a) => `<tr><td>${esc(a.docNo)}</td><td class="r">${formatINR(a.amountPaise)}</td></tr>`).join('')
+ `</tbody></table>`
: `<p>Received on account (unallocated advance).</p>`
const tdsLine = rc !== undefined && rc.tdsPaise > 0
? `<p>TDS deducted at source: <strong>${formatINR(rc.tdsPaise)}</strong> (treated as settlement).</p>` : ''
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${esc(doc.docNo ?? 'Receipt')}</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; font-size: 11px; color: #1a1a1a; margin: 0; }
.letterhead { border-bottom: 2px solid #1a1a1a; padding-bottom: 8px; }
.letterhead h1 { font-size: 20px; margin: 0 0 2px; letter-spacing: 0.5px; }
.letterhead p { margin: 1px 0; color: #444; }
.doc-title { text-align: center; font-size: 13px; font-weight: 700; letter-spacing: 2px; margin: 12px 0 8px; }
.meta { display: flex; justify-content: space-between; margin-bottom: 10px; }
.meta .box { width: 48%; }
.meta h2 { font-size: 10px; text-transform: uppercase; color: #666; margin: 0 0 3px; }
.ack { border: 1px solid #ccc; padding: 10px 12px; margin: 10px 0; font-size: 12px; }
.ack .amount { font-size: 15px; font-weight: 700; }
table.lines { width: 100%; border-collapse: collapse; margin: 8px 0; }
table.lines th, table.lines td { border: 1px solid #999; padding: 4px 6px; }
table.lines th { background: #f0f0f0; font-size: 10px; text-transform: uppercase; }
td.r, th.r { text-align: right; }
.sign { margin-top: 28px; text-align: right; }
</style>
</head>
<body>
<header class="letterhead">
<h1>${esc(get('name'))}</h1>
${companyMeta.map((line) => `<p>${esc(line)}</p>`).join('\n ')}
</header>
<div class="doc-title">RECEIPT</div>
<div class="meta">
<div class="box">
<h2>Received From</h2>
<p><strong>${esc(client.name)}</strong> (${esc(client.code)})</p>
${client.address !== '' ? `<p>${esc(client.address)}</p>` : ''}
</div>
<div class="box">
<h2>Receipt</h2>
<p>No: <strong>${esc(doc.docNo ?? 'DRAFT')}</strong></p>
<p>Date: ${displayDate(doc.docDate)}</p>
${rc !== undefined ? `<p>Mode: ${esc(rc.mode)}${rc.reference !== '' ? ` · Ref ${esc(rc.reference)}` : ''}</p>` : ''}
</div>
</div>
<div class="ack">
<p>Received with thanks from <strong>${esc(client.name)}</strong> the sum of
<span class="amount">${formatINR(amountPaise)}</span></p>
<p>(${esc(rupeesInWords(amountPaise))})</p>
${tdsLine}
</div>
${against}
<div class="sign">
<p>For <strong>${esc(get('name'))}</strong></p>
<p style="margin-top: 36px;">Authorised Signatory</p>
</div>
</body>
</html>`
}
```
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 <noreply@anthropic.com>"
```
---
### 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<string,string> }`; `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<string, string> = {
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<string, unknown>
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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 `<div>` whose width is `sharePctBp/100`% — no chart library, per scope). A `<input type="month">` 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<DuesAgingRow[]> =>
apiFetch<{ rows: DuesAgingRow[] }>('/reports/dues-aging').then((r) => r.rows)
export const getModuleRevenue = (from?: string, to?: string): Promise<ModuleRevenueRow[]> =>
apiFetch<{ rows: ModuleRevenueRow[] }>(`/reports/module-revenue${rangeQs(from, to)}`).then((r) => r.rows)
export const getProfitability = (from?: string, to?: string): Promise<ProfitabilityRow[]> =>
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<AwsUsageRow[]> =>
apiFetch<{ usage: AwsUsageRow[] }>(`/clients/${clientId}/aws-usage`).then((r) => r.usage)
export const upsertAwsUsage = (body: Record<string, unknown>): Promise<AwsUsageRow> =>
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<Record<string, string>> =>
apiFetch<{ company: Record<string, string> }>('/settings/company').then((r) => r.company)
export const putCompanyProfile = (body: Record<string, string>): Promise<Record<string, string>> =>
apiFetch<{ company: Record<string, string> }>('/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 (
<div className="wf-page">
<PageHeader title="Reports" desc="Dues aging, module revenue, client profitability, and the AWS cost chart." />
<h3>Dues aging</h3>
{dues.error !== undefined && <Notice tone="err">{dues.error}</Notice>}
{dues.data === undefined || dues.data.length === 0
? <EmptyState>No outstanding dues.</EmptyState>
: (
<DataTable
columns={[
{ key: 'client', label: 'Client' }, { key: 'b1', label: '030', numeric: true },
{ key: 'b2', label: '3160', numeric: true }, { key: 'b3', label: '6190', numeric: true },
{ key: 'b4', label: '90+', numeric: true }, { key: 'total', label: 'Total', numeric: true },
]}
onRowClick={(_r, i) => 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),
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Module-wise revenue</h3>
{revenue.error !== undefined && <Notice tone="err">{revenue.error}</Notice>}
{revenue.data === undefined || revenue.data.length === 0
? <EmptyState>No invoiced revenue yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'module', label: 'Module' }, { key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true },
]}
rows={revenue.data.map((m) => ({ module: `${m.moduleName} (${m.moduleCode})`, billed: inr(m.billedPaise), settled: inr(m.settledPaise) }))}
/>
)}
<h3 style={{ marginTop: 20 }}>Client profitability</h3>
{profit.error !== undefined && <Notice tone="err">{profit.error}</Notice>}
{profit.data === undefined || profit.data.length === 0
? <EmptyState>No client activity yet.</EmptyState>
: (
<DataTable
columns={[
{ key: 'client', label: 'Client' }, { key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true }, { key: 'aws', label: 'AWS cost', numeric: true },
{ key: 'margin', label: 'Margin', numeric: true },
]}
onRowClick={(_r, i) => 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),
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>AWS cost chart</h3>
<Field label="Month"><input type="month" className="wf" value={month} onChange={(e) => setMonth(e.target.value)} /></Field>
{ranking.error !== undefined && <Notice tone="err">{ranking.error}</Notice>}
{ranking.data === undefined || ranking.data.rows.length === 0
? <EmptyState>No AWS cost recorded for {month}.</EmptyState>
: (
<div style={{ margin: '10px 0' }}>
{ranking.data.rows.map((row) => (
<div key={row.clientId} style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '4px 0' }}>
<div style={{ width: 160, cursor: 'pointer' }} onClick={() => nav(`/clients/${row.clientId}`)}>{row.clientName}</div>
<div style={{ flex: 1, background: 'var(--border)', borderRadius: 4, height: 16 }}>
<div style={{ width: `${row.sharePctBp / 100}%`, background: 'var(--accent, #4a7)', height: '100%', borderRadius: 4 }} />
</div>
<div style={{ width: 120, textAlign: 'right' }}>{inr(row.costPaise)}</div>
<div style={{ width: 56, textAlign: 'right', color: 'var(--muted, #888)' }}>{(row.sharePctBp / 100).toFixed(1)}%</div>
</div>
))}
</div>
)}
</div>
)
}
```
- [ ] **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'
// ...
<Route path="/reports" element={<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 <noreply@anthropic.com>"
```
---
### 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<Record<string, string>>({})
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 <Navigate to="/" replace />
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 (
<div className="wf-page">
<PageHeader title="Company profile" desc="These details appear on every quotation, invoice, and receipt." />
{msg !== undefined && <Notice tone={msg.tone}>{msg.text}</Notice>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 520, marginTop: 10 }}>
{FIELDS.map((x) => (
<Field key={x.key} label={x.label}>
{x.key === 'address' || x.key === 'bank'
? <textarea className="wf" rows={2} style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />
: <input className="wf" style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />}
</Field>
))}
<div><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save company profile'}</Button></div>
</div>
</div>
)
}
```
- [ ] **Step 2: Nav (owner-only) + route.** In `apps/hq-web/src/Layout.tsx` build the nav dynamically so Company shows only for owners. Import `role` and derive `NAV`:
```ts
import { clearSession, displayName, getEmailStatus, hasSession, role } from './api'
const BASE_NAV = [
{ to: '/', label: 'Dashboard' },
{ to: '/clients', label: 'Clients' },
{ to: '/modules', label: 'Modules' },
{ to: '/reports', label: 'Reports' },
{ to: '/documents/new', label: 'New Document' },
]
```
Inside `Layout`, compute the rendered list once:
```ts
const nav_items = role() === 'owner' ? [...BASE_NAV, { to: '/settings/company', label: 'Company' }] : BASE_NAV
```
and map `nav_items` instead of the old constant in the `<nav>` block (keep `end={n.to === '/'}` on the `NavLink`).
In `apps/hq-web/src/main.tsx` import `CompanyProfile` and add the route:
```tsx
import { CompanyProfile } from './pages/CompanyProfile'
// ...
<Route path="/settings/company" element={<CompanyProfile />} />
```
- [ ] **Step 3: ClientDetail AWS usage section.** In `apps/hq-web/src/pages/ClientDetail.tsx` add the import, the fetch, and the section (below the Interactions block, before the closing `</div>`). Reuse the existing `inr`, `useData`, and `id` already in the file:
```tsx
import { getClientAwsUsage } from '../api'
// inside ClientDetail(), with the other useData hooks:
const awsUsage = useData(() => getClientAwsUsage(id), [id])
```
```tsx
<h3 style={{ marginTop: 20 }}>AWS usage</h3>
{awsUsage.error !== undefined && <Notice tone="err">{awsUsage.error}</Notice>}
{awsUsage.data === undefined || awsUsage.data.length === 0
? <EmptyState>No AWS usage recorded for this client.</EmptyState>
: (
<DataTable
columns={[
{ key: 'month', label: 'Month' }, { key: 'storage', label: 'Storage GB', numeric: true },
{ key: 'transfer', label: 'Transfer GB', numeric: true }, { key: 'cost', label: 'Cost', numeric: true },
{ key: 'source', label: 'Source' },
]}
rows={awsUsage.data.map((u) => ({
month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source,
}))}
/>
)}
```
- [ ] **Step 4: Verify**`cd apps/hq-web && npm run typecheck && npm run build` → clean. Root `npm run typecheck` (both apps) → clean.
- [ ] **Step 5: Commit**
```bash
git add apps/hq-web/src/pages/CompanyProfile.tsx apps/hq-web/src/Layout.tsx apps/hq-web/src/main.tsx apps/hq-web/src/pages/ClientDetail.tsx
git commit -m "feat(hq-web): owner company profile editor and client 360 aws usage trend" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
### Task 10: Full browser verification (the HQ-3a walk)
**Files:** none (verification only — no code changes; if a defect is found, fix it under the owning task's files and note it here).
- [ ] **Step 1: Full suite green** — root `npm test` (HQ-1 + HQ-2 + every HQ-3a test) all pass; every AWS/report/receipt test runs offline (injected `Fetcher` + `renderPdf`, no live AWS or Gmail). `npm run typecheck` clean across root, `apps/hq`, and `apps/hq-web`.
- [ ] **Step 2: Determinism check** — the AWS tests pin `now`/`today`; `signAwsRequest` is asserted deterministic for a fixed clock; re-running `maybePullAwsCosts` for the same month returns `null` (no second pull). No test reads the wall clock for logic.
- [ ] **Step 3: Browser walk.** Start the API (`cd apps/hq && npm start`, note the seeded owner password on first boot), then `cd apps/hq-web && npm run dev` and sign in as owner.
1. **Company profile:** open **Company** (owner-only nav) → edit name/address/GSTIN/state code/bank → **Save**. Compose or open any invoice → its PDF letterhead reflects the change immediately (no restart). Sign in as a staff user in a second browser → **Company** is absent from the nav and `/#/settings/company` redirects to the dashboard.
2. **Manual AWS cost (owner fallback):** with no `AWS_*` env set, from a REST client `POST /api/aws/usage` `{ clientId, month: '2026-06', costPaise: 500000, storageGb: 40 }` (owner token). On **Reports → AWS cost chart** pick `2026-06`: the client appears with a bar and its share of total. On the client's **Client 360 → AWS usage** the `2026-06` row shows cost + storage with source `manual`.
3. **AWS auto pull (env-gated):** without creds, `POST /api/aws/pull` returns `409 aws-credentials-not-configured` (confirmed by Task 7's test). With real `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` set on the server and the `client` cost-allocation tag active, the boot scan pulls the previous month once; matched tags upsert `source='auto'` rows and the ranking/chart populate. (This step is manual-only where live creds exist; CI never runs it.)
4. **Reports:** create a couple of issued invoices, record a partial payment on one → **Reports** shows the unpaid invoice in the right **dues aging** bucket by age, **module-wise revenue** shows billed vs settled per module, and **client profitability** shows billed/settled/AWS cost/margin (margin = settled AWS cost).
5. **Receipt:** record a payment with the receipt option (`POST /api/payments` `{ …, issueReceipt: true }`, or `POST /api/payments/:id/receipt` after the fact) → a `RCT/26-27-000N` document is issued; open its PDF → a "Received with thanks" acknowledgment with the amount in words, the mode/reference, and the invoices it settled — no SAC/GST table.
6. **Cross-client chart (founder view):** with two clients holding different `2026-06` costs, the ranking orders them by cost desc, the bar widths equal their share, and each row deep-links to that client's 360°.
- [ ] **Step 4: Commit** (only if Step 3 surfaced a fix; otherwise nothing to commit)
```bash
git commit -m "fix(hq): <defect found during HQ-3a browser walk>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
```
---
## Deferred out of HQ-3a (explicitly)
SMS pack-balance tracking (founder decision pending); **live AWS credentials / a real Cost Explorer call** (env-gated — every test uses a stubbed `Fetcher`); USD→INR FX conversion (INR billing assumed, non-INR surfaced loudly); automated CloudWatch storage/bandwidth *metric* harvesting (the columns exist and the owner manual route fills them; the auto pull fills cost only); the **Postgres engine swap** (separate sequenced task, before real-data import); APEX historical-cost backfill; a charting library (the AWS "chart" is a plain `@sims/ui` bar list per scope); per-report scheduled email/export (reports are on-demand pages in HQ-3a).
## Verification (whole plan)
1. `npm test` — every HQ-3a task's suite plus HQ-1/HQ-2's original suites stay green. AWS, report, receipt, and settings tests all run offline (injected `fetch` + `renderPdf`); no test performs a live AWS/Gmail call.
2. `npm run typecheck` — clean across root, `apps/hq`, and `apps/hq-web` (per-app typecheck is part of the root gate).
3. Determinism: `signAwsRequest` is asserted deterministic for a fixed clock; `pullMonthlyCosts`/`maybePullAwsCosts` take `now`/`today` explicitly; re-pulling a month is idempotent (upsert on `UNIQUE (client_id, month)`) and the monthly gate returns `null` on the second call.
4. Browser walk (Task 10 Step 3) — company profile edits flow onto PDFs live; manual + auto AWS cost both land in `aws_usage` and rank on the chart; the three reports compute from real invoices/payments/costs; a receipt issues on its own `RCT/` series with a zero-GST acknowledgment PDF; unknown/untagged AWS costs are surfaced, never dropped.
## Self-review against spec §5§6
- **AWS usage & cost automated (spec §5):** `pullMonthlyCosts` calls Cost Explorer `GetCostAndUsage` grouped by the `client` cost-allocation tag, maps tag→client by code, and upserts one `aws_usage` row per (client, month) with `source='auto'`; unmatched/untagged groups are returned in `unknownTags` (never dropped — the spec's "where tagging is impossible … never manual entry as the primary mechanism" is honoured: auto is primary, the owner manual route is the fallback). SigV4 is implemented with `node:crypto` (no SDK dependency); credentials are read only in server/route wiring and the pull is env-gated. ✅
- **Cross-client cost chart (spec §5/§6):** `costRanking(db, month)` ranks clients by `cost_paise` desc with share-of-total in basis points; the Reports page renders the founder's "where do they sit in the AWS cost chart" as a bar list, each bar deep-linking to that client's 360°, whose AWS-usage section shows the 12-month trend. ✅
- **Reports (spec §6 HQ-3 row):** `duesAging` buckets outstanding by invoice age via `outstandingPaise`; `moduleRevenue` reports billed vs settled per module using the same largest-remainder `splitProRata` as `modulePaidView` (now exported — one split implementation, no drift); `clientProfitability` reports billed, settled, AWS cost, and margin (= settled AWS cost = spec §5 "cost vs. what they pay us"). All three are read-only and reuse HQ-1/HQ-2 settlement primitives. ✅
- **Receipts (spec §3 "optional receipt"):** `generateReceipt` issues a RECEIPT on its own `RCT/` per-FY series through the existing `insertDocRow`+`issueDocument` path, carrying zero GST and no line items — an acknowledgment, not a tax document; issued receipts are never edited (their number is consumed). Optional on `recordPayment` and available after the fact via `POST /api/payments/:id/receipt`. ✅
- **Company profile:** `GET`/`PUT /api/settings/company` edits the `company.*` settings the letterhead templates already read server-side, so a change takes effect on the next rendered PDF with no restart; owner-gated and audited (each field via `setSetting`, which writes an `audit_log` row). ✅
- **Portability & discipline (D12):** the one new table uses `CREATE TABLE IF NOT EXISTS`; the receipt block rides in existing `payload` JSON (no DDL); every mutation audits; SQLite/Postgres-only `ON CONFLICT` upserts are commented as portability quirks; all money is integer paise, converted once at the AWS decimal boundary. ✅