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