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...

99 KiB

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 §5§6 on top of the completed HQ-1 + HQ-2 apps/hq + apps/hq-web codebase: per-client AWS usage & cost captured monthly (automated pull from Cost Explorer grouped by a cost-allocation tag, plus a manual owner fallback), the founder's cross-client cost ranking (rank + share of total), the three reports (dues aging, module-wise revenue, client profitability with margin), optional payment receipts as their own document series, and an owner-only Company Profile editor for the company.* settings the letterhead PDFs read. Everything grounds in the real HQ interfaces (openDb/SCHEMA/migrate, writeAudit, uuidv7, outstandingPaise, splitProRata/modulePaidView, createDraft/issueDocument/insertDocRow, nextDocNo, getSetting/setSetting, documentHtml/renderPdf, the Fetcher pattern from gmail.ts/bounces.ts, dashboardView, and the @sims/ui + useData page style).

Architecture: Extend the store-server-shaped HQ pattern exactly as HQ-1/HQ-2 established it — express + better-sqlite3 behind plain-function repositories (repos-*.ts), pure logic reused from @sims/domain (formatINR, fromRupees, uuidv7, fyOf, validateGstin) and the HQ document engine. The one new table (aws_usage) is added to the SCHEMA string in db.ts via CREATE TABLE IF NOT EXISTS — it appears on next openDb, no migration entry needed. AWS ingestion is a pure module aws-costs.ts with an injected Fetcher (same shape as gmail.ts): signAwsRequest implements SigV4 with node:crypto (no AWS SDK), pullMonthlyCosts(db, deps, month) maps cost-allocation tag values to client codes and upserts rows, and maybePullAwsCosts(db, deps, today) gates it to once per calendar month. Credentials are read only in the server/route wiring (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), never inside the pure functions; the scheduler tick calls the pull only when creds exist. Reports reuse the exact settlement primitives HQ-1/HQ-2 already ship: outstandingPaise for dues, and the largest-remainder splitProRata (promoted to an export) for module-wise revenue, so a paisa split here matches a paisa split in modulePaidView. Receipts flow through the existing insertDocRowissueDocument 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:
    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

// 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 FAILnpx 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):

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 testsnpx vitest run apps/hq/test/hq3-schema.test.ts → PASS. npm run typecheck → clean.

  • Step 5: Commit

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

// 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 FAILnpx vitest run apps/hq/test/aws-repo.test.ts → FAIL.

  • Step 3: Implement apps/hq/src/repos-aws.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 (GETs auth-only; the manual upsert is owner-gated):

import {
  costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput,
} from './repos-aws'
  // ---------- 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 testsnpx vitest run apps/hq/test/aws-repo.test.ts → PASS. npm run typecheck → clean.

  • Step 5: Commit

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

// 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 FAILnpx vitest run apps/hq/test/aws-costs.test.ts → FAIL (module not found).

  • Step 3: Implement apps/hq/src/aws-costs.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 testsnpx 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

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 splitProRataexport 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

// 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 FAILnpx 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):

export function splitProRata(amount: number, weights: number[]): number[] {

Create apps/hq/src/repos-reports.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:

import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
  // ---------- 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.tsimport { Router, type Request, ... } from 'express'.)

  • Step 4: Run testsnpx 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

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

// 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 FAILnpx 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.

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
}
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.

export function documentHtml(doc: Doc, client: Client, company: Record<string, string>): string {
  if (doc.docType === 'RECEIPT') return receiptHtml(doc, client, company)
  // ... existing body unchanged ...
/** 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.)

import { generateReceipt, getDocument, listDocuments, type Doc } from './repos-documents'
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):

    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):

/** 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:

import {
  clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput,
} from './repos-payments'
  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 testsnpx 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

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

// 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 FAILnpx 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:

import { validateGstin } from '@sims/domain'
import { setSetting } from './repos-reminders'
  // ---------- 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 testsnpx vitest run apps/hq/test/settings-company.test.ts → PASS. npm run typecheck → clean.

  • Step 5: Commit

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

// 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 FAILnpx 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):

import { pullMonthlyCosts } from './aws-costs'
  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:

import { maybePullAwsCosts } from './aws-costs'
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 testsnpx 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

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:

// ---------- 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
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):
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:

import { Reports } from './pages/Reports'
// ...
          <Route path="/reports" element={<Reports />} />
  • Step 4: Verifycd 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

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

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:
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:

  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:

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:
import { getClientAwsUsage } from '../api'
// inside ClientDetail(), with the other useData hooks:
const awsUsage = useData(() => getClientAwsUsage(id), [id])
      <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: Verifycd apps/hq-web && npm run typecheck && npm run build → clean. Root npm run typecheck (both apps) → clean.

  • Step 5: Commit

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)

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.