From 9d2eedc3668e63c20072642f4b9a69fb7e692dfd Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 10 Jul 2026 21:13:09 +0530 Subject: [PATCH] =?UTF-8?q?feat(hq):=20aws=20usage=20repo=20=E2=80=94=20up?= =?UTF-8?q?sert/list,=20cross-client=20cost=20ranking,=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- apps/hq/src/api.ts | 24 ++++++++ apps/hq/src/repos-aws.ts | 107 ++++++++++++++++++++++++++++++++++ apps/hq/test/aws-repo.test.ts | 54 +++++++++++++++++ 3 files changed, 185 insertions(+) create mode 100644 apps/hq/src/repos-aws.ts create mode 100644 apps/hq/test/aws-repo.test.ts diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index b8c2d99..140b2c4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -37,6 +37,9 @@ import { } from './repos-reminders' import { sendReminder, type SendReminderDeps } from './send-reminder' import { dashboardView } from './repos-dashboard' +import { + costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, +} from './repos-aws' import { documentHtml } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' @@ -479,5 +482,26 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) }) }) + // ---------- 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) }) + } + }) + return r } diff --git a/apps/hq/src/repos-aws.ts b/apps/hq/src/repos-aws.ts new file mode 100644 index 0000000..34521f4 --- /dev/null +++ b/apps/hq/src/repos-aws.ts @@ -0,0 +1,107 @@ +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) +} diff --git a/apps/hq/test/aws-repo.test.ts b/apps/hq/test/aws-repo.test.ts new file mode 100644 index 0000000..6824ef9 --- /dev/null +++ b/apps/hq/test/aws-repo.test.ts @@ -0,0 +1,54 @@ +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 + }) +})