99 KiB
HQ-3a — AWS Cost Attribution, Reports, Receipts & Company Profile Implementation Plan
STATUS: DELIVERED (HQ-3a, 2026-07). Kept as the design/task record; current state lives in STATUS.md.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes.
Goal: Ship the first slice of HQ-3 from docs/14-SPEC-HQ-CONSOLE.md §5–§6 on top of the completed HQ-1 + HQ-2 apps/hq + apps/hq-web codebase: per-client AWS usage & cost captured monthly (automated pull from Cost Explorer grouped by a cost-allocation tag, plus a manual owner fallback), the founder's cross-client cost ranking (rank + share of total), the three reports (dues aging, module-wise revenue, client profitability with margin), optional payment receipts as their own document series, and an owner-only Company Profile editor for the company.* settings the letterhead PDFs read. Everything grounds in the real HQ interfaces (openDb/SCHEMA/migrate, writeAudit, uuidv7, outstandingPaise, splitProRata/modulePaidView, createDraft/issueDocument/insertDocRow, nextDocNo, getSetting/setSetting, documentHtml/renderPdf, the Fetcher pattern from gmail.ts/bounces.ts, dashboardView, and the @sims/ui + useData page style).
Architecture: Extend the HQ server pattern exactly as HQ-1/HQ-2 established it — express + better-sqlite3 behind plain-function repositories (repos-*.ts), pure logic reused from @sims/domain (formatINR, fromRupees, uuidv7, fyOf, validateGstin) and the HQ document engine. The one new table (aws_usage) is added to the SCHEMA string in db.ts via CREATE TABLE IF NOT EXISTS — it appears on next openDb, no migration entry needed. AWS ingestion is a pure module aws-costs.ts with an injected Fetcher (same shape as gmail.ts): signAwsRequest implements SigV4 with node:crypto (no AWS SDK), pullMonthlyCosts(db, deps, month) maps cost-allocation tag values to client codes and upserts rows, and maybePullAwsCosts(db, deps, today) gates it to once per calendar month. Credentials are read only in the server/route wiring (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY), never inside the pure functions; the scheduler tick calls the pull only when creds exist. Reports reuse the exact settlement primitives HQ-1/HQ-2 already ship: outstandingPaise for dues, and the largest-remainder splitProRata (promoted to an export) for module-wise revenue, so a paisa split here matches a paisa split in modulePaidView. Receipts flow through the existing insertDocRow → issueDocument path with their own RCT/ series and carry no GST lines (a payment-acknowledgment layout in templates.ts).
Tech Stack: unchanged from HQ-1/HQ-2 — TypeScript (ESM, strict), express ^4.21, better-sqlite3 ^11.7, puppeteer ^23 (PDF; injected into deps so tests never launch Chromium), vitest, React 19 + Vite 6 + react-router 7, @sims/ui. No new dependencies — SigV4 uses node:crypto (createHash, createHmac), the AWS call is fetch-based exactly like gmail.ts/bounces.ts.
Global Constraints
Everything from the HQ-1/HQ-2 plans still holds; the load-bearing ones for HQ-3a, plus the extensions:
- All money is integer paise (
Paisefrom@sims/domain); never floats. Amounts render withformatINR. AWS Cost Explorer returns a decimal currency string — convert once, at the boundary, viaMath.round(Number(amount) * 100). - AWS billing currency is INR. The Mumbai payer account bills in INR, so
UnblendedCost.Amountis rupees.pullMonthlyCostsrecords the reportedUnit; 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 forORDER BY id). - Every mutation writes an
audit_logrow viawriteAudit(db, userId, action, entity, entityId, before?, after?). Scheduler-originated mutations and seed-style writes useuserId = 'system'. - SQL stays portable (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (
INSERT … ON CONFLICT) is commented as a portability quirk, matchingseries.ts/seed.ts/repos-reminders.ts. - New table →
SCHEMAconst indb.tswithCREATE TABLE IF NOT EXISTS. No migration entry. HQ-3a adds no new column to an existing table —document.payload(JSON) carries the receipt block;DocPayloadgains an optionalreceipt?field, no DDL. - Env is read only in wiring.
pullMonthlyCosts/signAwsRequesttakecredsas a parameter.server.ts(scheduler) and thePOST /api/aws/pullroute readAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYand pass them in. Never log credentials or the SigV4 signing key material. - AWS pull cadence & idempotency.
aws_usageis keyedUNIQUE (client_id, month); re-pulling a month overwrites rather than duplicates.maybePullAwsCostsadditionally gates to once per calendar month via theaws.last_pull_monthsetting, so the 6-hourly tick does not hit AWS every run. Costs settle after month-end, so the target is the previous complete month oftoday. - Reports are read-only aggregations — they compute from issued (
doc_no IS NOT NULL), non-cancelled invoices and never mutate. Settlement per invoice ispayablePaise − outstandingPaise(db, id)(= allocations + credit-notes, clamped to[0, payable]), identical tomodulePaidView. - 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), plusAWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY(HQ-3a, optional — absence disables the auto pull and makesPOST /api/aws/pullreturn409). - Tests: vitest, colocated under
apps/hq/test/(already in the vitestinclude). The AWSFetcheris injected and puppeteer is injected asdeps.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 typecheckrunstsc -p tsconfig.json && npm run typecheck --workspaces --if-present. Bothapps/hqandapps/hq-webmust typecheck clean at every commit. - Commit after every task. Each commit message ends with the trailer — use the two-
-mform: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 toSCHEMA) - Test:
apps/hq/test/hq3-schema.test.ts
Interfaces:
-
Produces (schema only — no new TS exports): table
aws_usagewithUNIQUE (client_id, month). Nomigrate()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 FAIL —
npx vitest run apps/hq/test/hq3-schema.test.ts→ FAIL (table absent). -
Step 3: Implement. Append to the
SCHEMAtemplate string inapps/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 tests —
npx 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 andmonth('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 }andcostRanking(db, month): { total: number; rows: CostRankRow[] }— clients ranked bycost_paisedesc 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_paiseover months in[from-month, to-month](used by profitability).previousMonth(today: string): string— the previous complete month (what has settled); exported here soaws-costs.tsand the pull route import it fromrepos-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; forcessource='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 FAIL —
npx 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 tests —
npx 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 fixednow.AwsCostDeps = { f: Fetcher; creds: AwsCreds; now?: () => string }(Fetcher=typeof fetch, reused fromgmail.ts).PullResult = { upserted: number; unknownTags: { tag: string; costPaise: number }[]; totalPaise: number }.pullMonthlyCosts(db, deps, month: string): Promise<PullResult>— signs and POSTs a Cost ExplorerGetCostAndUsagegrouped by theclienttag formonth('YYYY-MM'), maps each tag value to a client bycode(case-insensitive), upserts matched clients'cost_paisewithsource='auto'viaupsertAwsUsage, and returns unmatched/untagged groups inunknownTags(never dropped silently).maybePullAwsCosts(db, deps, today: string): Promise<PullResult | null>— pullspreviousMonth(today)once per calendar month (guarded by theaws.last_pull_monthsetting); returnsnullwhen already done.
-
Consumes:
upsertAwsUsage,previousMonth(fromrepos-aws);getSetting/setSetting(fromrepos-reminders);Fetcher(fromgmail). -
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 FAIL —
npx 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 tests —
npx vitest run apps/hq/test/aws-costs.test.ts→ PASS (verify123456paise 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(exportsplitProRata) - Modify:
apps/hq/src/api.ts - Test:
apps/hq/test/reports.test.ts
Interfaces:
-
Modifies
repos-payments.ts: changefunction splitProRata→export function splitProRata(no behaviour change — the largest-remainder split is now reused by reports so module-wise revenue matchesmodulePaidViewto the paisa). -
Produces:
DateRange = { from?: string; to?: string }(ISO dates ondoc_date; a month range for AWS cost).DuesAgingRow = { clientId; clientName; b0_30; b31_60; b61_90; b90p; totalPaise }andduesAging(db, today): DuesAgingRow[]— per client, outstanding on issued non-settled invoices bucketed by age (0-30 / 31-60 / 61-90 / 90+), reusingoutstandingPaise; sorted by total desc.ModuleRevenueRow = { moduleId; moduleCode; moduleName; billedPaise; settledPaise }andmoduleRevenue(db, range?): ModuleRevenueRow[]— per module, billed (Σ line totals) and settled (invoice settlement split pro-rata across lines bylineTotalPaise) over invoices in range; sorted by billed desc.ProfitabilityRow = { clientId; clientName; billedPaise; settledPaise; awsCostPaise; marginPaise }andclientProfitability(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 FAIL —
npx vitest run apps/hq/test/reports.test.ts→ FAIL. -
Step 3: Implement. In
apps/hq/src/repos-payments.ts, promote the helper to an export (change one line):
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.ts — import { Router, type Request, ... } from 'express'.)
-
Step 4: Run tests —
npx vitest run apps/hq/test/reports.test.ts→ PASS (verify the golden11_800_00/35_400_00/10_800_00paise).npx vitest run apps/hq/test/payments.test.tsstill PASS (thesplitProRataexport 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(issueReceiptoption onrecordPayment+receiptForPayment) - Modify:
apps/hq/src/api.ts(POST /api/payments/:id/receipt) - Test:
apps/hq/test/receipt.test.ts
Interfaces:
-
DocPayloadgainsreceipt?: ReceiptMetawhereReceiptMeta = { paymentId; receivedOn; mode; reference; amountPaise; tdsPaise; allocations: { docNo; amountPaise }[] }. No DDL — it rides in the existingpayloadJSON. -
generateReceipt(db, userId, input: { clientId; paymentId; receivedOn; mode; reference; amountPaise; tdsPaise; allocations }): Doc— builds a RECEIPT with zero tax (payablePaise = amountPaise) through the existing privateinsertDocRow, thenissueDocumentassigns the nextRCT/number (theRECEIPTprefix already exists inseries.tsTYPE_PREFIX). Receipts carry no GST lines (payload.lines = []). -
recordPaymentgainsissueReceipt?: booleanon its input andreceipt?: Docon its result — when true, a receipt is generated inside the same transaction after allocation (nesteddb.transaction= savepoint, exactly asgenerateAmcRenewalInvoicealready nestscreateDraft+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': 'SiMS' })
expect(html).toContain('RECEIPT')
expect(html).toContain('Received with thanks')
expect(html).not.toContain('>SAC<') // the GST line table header is absent on receipts
})
})
-
Step 2: Run to verify FAIL —
npx vitest run apps/hq/test/receipt.test.ts→ FAIL. -
Step 3: Implement.
In apps/hq/src/repos-documents.ts, extend DocPayload and add generateReceipt (near createCreditNote). insertDocRow, issueDocument, getClient, todayIso are already in this file; BillTotals is already imported from @sims/domain.
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 tests —
npx vitest run apps/hq/test/receipt.test.ts→ PASS.npx vitest run apps/hq/test/payments.test.ts apps/hq/test/documents.test.ts apps/hq/test/templates.test.tsstill 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 inapi.ts(returns thecompany.*map),setSetting(fromrepos-reminders, which already audits each key), andvalidateGstin(from@sims/domain, already used byrepos-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 viasetSetting, 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('SiMS')
const put = await call(token, 'PUT', '/settings/company', { name: 'SiMS Pvt Ltd', phone: '0484-1234567', stateCode: '32' })
expect(put.status).toBe(200)
expect(put.json.company['company.name']).toBe('SiMS Pvt Ltd')
expect(put.json.company['company.phone']).toBe('0484-1234567')
const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`).get() as { n: number }
expect(audits.n).toBeGreaterThanOrEqual(3)
})
it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => {
const staff = await login('staff@test.in', 'staff-password')
expect((await call(staff, 'PUT', '/settings/company', { name: 'Nope' })).status).toBe(403)
const owner = await login('owner@test.in', 'owner-password')
expect((await call(owner, 'PUT', '/settings/company', { gstin: 'NOTAGSTIN' })).status).toBe(400)
})
})
-
Step 2: Run to verify FAIL —
npx vitest run apps/hq/test/settings-company.test.ts→ FAIL. -
Step 3: Implement. In
apps/hq/src/api.tsadd the imports and the route block.validateGstinfrom@sims/domain,setSettingfromrepos-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 tests —
npx 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 callsmaybePullAwsCostswhen present) - Modify:
apps/hq/src/api.ts(POST /api/aws/pull) - Test:
apps/hq/test/aws-pull-route.test.ts
Interfaces:
-
server.tsstartSchedulerreadsAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYat boot; each tick (boot + every 6h) callsmaybePullAwsCosts(db, { f: fetch, creds }, today())only when both are non-empty, wrapped in the same.catchconsole-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 requestedmonth(body, defaultpreviousMonth(today)) viapullMonthlyCostsand returns{ upserted, unknownTags, totalPaise }; a live-call failure surfaces as502. Tests exercise only the 409 path (no live AWS); the happy pull path is covered byaws-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 FAIL —
npx vitest run apps/hq/test/aws-pull-route.test.ts→ FAIL (route absent → 404). -
Step 3: Implement.
In apps/hq/src/api.ts add the import and the route (env read at request time, matching how gmail() reads env lazily):
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 tests —
npx vitest run apps/hq/test/aws-pull-route.test.ts→ PASS.npx vitest run apps/hq/test/health.test.tsstill 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.tsgains the HQ-3a server shapes (camelCase JSON, mirroring the repos) and typed calls. -
Reports.tsxis a plain fetch-render page (useDatafromClients,@sims/uicomponents): 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 issharePctBp/100% — no chart library, per scope). A<input type="month">drives the ranking month. All amounts viaformatINR. -
Nav gains Reports; the route
/reportsrenders 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: '0–30', numeric: true },
{ key: 'b2', label: '31–60', numeric: true }, { key: 'b3', label: '61–90', 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.tsxadd Reports toNAV(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: Verify —
cd apps/hq-web && npm run typecheck && npm run build→ clean. With the server running,npm run dev: after login the Reports nav item shows the four sections; the AWS chart month picker re-fetches the ranking. -
Step 5: Commit
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 sevencompany.*fields (name, address, gstin, state code, phone, email, bank),getCompanyProfile()on mount,putCompanyProfile()on save; aNoticeon validation error (bad GSTIN / state code). Non-owners are redirected to/. -
Layout.tsxgains a Company nav item, rendered only whenrole() === 'owner'(build the nav array dynamically). -
ClientDetail.tsxgains an AWS usage section (below Interactions): a last-12-months table (Month, Storage GB, Transfer GB, Cost, Source) fromgetClientAwsUsage(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.tsxbuild the nav dynamically so Company shows only for owners. Importroleand deriveNAV:
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.tsxadd the import, the fetch, and the section (below the Interactions block, before the closing</div>). Reuse the existinginr,useData, andidalready 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: Verify —
cd apps/hq-web && npm run typecheck && npm run build→ clean. Rootnpm 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 (injectedFetcher+renderPdf, no live AWS or Gmail).npm run typecheckclean across root,apps/hq, andapps/hq-web. -
Step 2: Determinism check — the AWS tests pin
now/today;signAwsRequestis asserted deterministic for a fixed clock; re-runningmaybePullAwsCostsfor the same month returnsnull(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), thencd apps/hq-web && npm run devand sign in as owner.- 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/companyredirects to the dashboard. - Manual AWS cost (owner fallback): with no
AWS_*env set, from a REST clientPOST /api/aws/usage{ clientId, month: '2026-06', costPaise: 500000, storageGb: 40 }(owner token). On Reports → AWS cost chart pick2026-06: the client appears with a bar and its share of total. On the client's Client 360 → AWS usage the2026-06row shows cost + storage with sourcemanual. - AWS auto pull (env-gated): without creds,
POST /api/aws/pullreturns409 aws-credentials-not-configured(confirmed by Task 7's test). With realAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYset on the server and theclientcost-allocation tag active, the boot scan pulls the previous month once; matched tags upsertsource='auto'rows and the ranking/chart populate. (This step is manual-only where live creds exist; CI never runs it.) - 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).
- Receipt: record a payment with the receipt option (
POST /api/payments{ …, issueReceipt: true }, orPOST /api/payments/:id/receiptafter the fact) → aRCT/26-27-000Ndocument 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. - Cross-client chart (founder view): with two clients holding different
2026-06costs, the ranking orders them by cost desc, the bar widths equal their share, and each row deep-links to that client's 360°.
- 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
-
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)
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 (injectedfetch+renderPdf); no test performs a live AWS/Gmail call.npm run typecheck— clean across root,apps/hq, andapps/hq-web(per-app typecheck is part of the root gate).- Determinism:
signAwsRequestis asserted deterministic for a fixed clock;pullMonthlyCosts/maybePullAwsCoststakenow/todayexplicitly; re-pulling a month is idempotent (upsert onUNIQUE (client_id, month)) and the monthly gate returnsnullon the second call. - Browser walk (Task 10 Step 3) — company profile edits flow onto PDFs live; manual + auto AWS cost both land in
aws_usageand rank on the chart; the three reports compute from real invoices/payments/costs; a receipt issues on its ownRCT/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):
pullMonthlyCostscalls Cost ExplorerGetCostAndUsagegrouped by theclientcost-allocation tag, maps tag→client by code, and upserts oneaws_usagerow per (client, month) withsource='auto'; unmatched/untagged groups are returned inunknownTags(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 withnode: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 bycost_paisedesc 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):
duesAgingbuckets outstanding by invoice age viaoutstandingPaise;moduleRevenuereports billed vs settled per module using the same largest-remaindersplitProRataasmodulePaidView(now exported — one split implementation, no drift);clientProfitabilityreports 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"):
generateReceiptissues a RECEIPT on its ownRCT/per-FY series through the existinginsertDocRow+issueDocumentpath, carrying zero GST and no line items — an acknowledgment, not a tax document; issued receipts are never edited (their number is consumed). Optional onrecordPaymentand available after the fact viaPOST /api/payments/:id/receipt. ✅ - Company profile:
GET/PUT /api/settings/companyedits thecompany.*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 viasetSetting, which writes anaudit_logrow). ✅ - Portability & discipline (D12): the one new table uses
CREATE TABLE IF NOT EXISTS; the receipt block rides in existingpayloadJSON (no DDL); every mutation audits; SQLite/Postgres-onlyON CONFLICTupserts are commented as portability quirks; all money is integer paise, converted once at the AWS decimal boundary. ✅