feat(hq): aws usage repo — upsert/list, cross-client cost ranking, routes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 3a09feab6f
commit 9d2eedc366

@ -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<GmailDeps>): 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
}

@ -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)
}

@ -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
})
})
Loading…
Cancel
Save