|
|
# HQ-3a — AWS Cost Attribution, Reports, Receipts & Company Profile Implementation Plan
|
|
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Every task is a full TDD loop: write the failing test verbatim → run it to watch it fail → write the implementation → run it to green → commit. No task depends on code a later task writes.
|
|
|
|
|
|
**Goal:** Ship the first slice of HQ-3 from [docs/14-SPEC-HQ-CONSOLE.md](../../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** (`Paise` from `@sims/domain`); never floats. Amounts render with `formatINR`. AWS Cost Explorer returns a **decimal currency string** — convert once, at the boundary, via `Math.round(Number(amount) * 100)`.
|
|
|
- **AWS billing currency is INR.** The Mumbai payer account bills in INR, so `UnblendedCost.Amount` is rupees. `pullMonthlyCosts` records the reported `Unit`; a whole-response non-INR unit is surfaced loudly (thrown, caught by the scheduler/route wrapper) rather than silently mis-converted. USD→INR FX is out of scope for HQ-3a.
|
|
|
- All ids are **UUIDv7** via `uuidv7()` from `@sims/domain` (ids sort by creation time — reused for `ORDER BY id`).
|
|
|
- **Every mutation writes an `audit_log` row** via `writeAudit(db, userId, action, entity, entityId, before?, after?)`. Scheduler-originated mutations and seed-style writes use `userId = 'system'`.
|
|
|
- SQL stays **portable** (D12 guardrail): standard SQL; SQLite/Postgres-only dialect (`INSERT … ON CONFLICT`) is commented as a portability quirk, matching `series.ts`/`seed.ts`/`repos-reminders.ts`.
|
|
|
- **New table → `SCHEMA` const in `db.ts` with `CREATE TABLE IF NOT EXISTS`.** No migration entry. HQ-3a adds **no new column** to an existing table — `document.payload` (JSON) carries the receipt block; `DocPayload` gains an optional `receipt?` field, no DDL.
|
|
|
- **Env is read only in wiring.** `pullMonthlyCosts` / `signAwsRequest` take `creds` as a parameter. `server.ts` (scheduler) and the `POST /api/aws/pull` route read `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` and pass them in. Never log credentials or the SigV4 signing key material.
|
|
|
- **AWS pull cadence & idempotency.** `aws_usage` is keyed `UNIQUE (client_id, month)`; re-pulling a month overwrites rather than duplicates. `maybePullAwsCosts` additionally gates to once per calendar month via the `aws.last_pull_month` setting, so the 6-hourly tick does not hit AWS every run. Costs settle after month-end, so the target is **the previous complete month** of `today`.
|
|
|
- Reports are **read-only aggregations** — they compute from issued (`doc_no IS NOT NULL`), non-cancelled invoices and never mutate. Settlement per invoice is `payablePaise − outstandingPaise(db, id)` (= allocations + credit-notes, clamped to `[0, payable]`), identical to `modulePaidView`.
|
|
|
- Issued documents (incl. **receipts**, once numbered) are **never edited or deleted** — a receipt is an issued acknowledgment; its `RCT/` number is consumed permanently.
|
|
|
- HQ prices/documents remain **GST-exclusive** (`priceIncludesTax: false`). Receipts are the one document type that carries **zero tax** (they acknowledge money, they do not levy it).
|
|
|
- Server port **5182**. Secrets: `HQ_SECRET_KEY`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` (HQ-1/2), plus `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` (HQ-3a, optional — absence disables the auto pull and makes `POST /api/aws/pull` return `409`).
|
|
|
- Tests: vitest, colocated under `apps/hq/test/` (already in the vitest `include`). The AWS `Fetcher` is injected and puppeteer is injected as `deps.renderPdf`, so every HQ-3a test runs offline and Chromium-free. No test performs a live AWS or Gmail call.
|
|
|
- **Per-app typecheck is part of the root gate:** `npm run typecheck` runs `tsc -p tsconfig.json && npm run typecheck --workspaces --if-present`. Both `apps/hq` and `apps/hq-web` must typecheck clean at every commit.
|
|
|
- Commit after every task. Each commit message ends with the trailer — use the two-`-m` form:
|
|
|
```bash
|
|
|
git commit -m "feat(hq): <subject>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
## Scope (locked — do not reopen)
|
|
|
|
|
|
**In:** `aws_usage` table; `aws-costs.ts` (SigV4 signer + `pullMonthlyCosts` + `maybePullAwsCosts`, injectable `Fetcher`); `repos-aws.ts` (upsert/get/list + `costRanking` + `awsCostForClient`); `repos-reports.ts` (`duesAging`, `moduleRevenue`, `clientProfitability`); receipt documents (`generateReceipt` + `RCT/` series + zero-GST acknowledgment template + optional generation on `recordPayment`); Company Profile editor (`GET`/`PUT /api/settings/company`, owner-gated, audited); scheduler wiring for the monthly pull; the routes and hq-web pages for all of it (Reports page, Company Profile page, Client 360 AWS-usage trend section).
|
|
|
|
|
|
**Out (do not plan):** SMS pack-balance tracking (founder decision pending); **live AWS credentials / a real Cost Explorer call** (env-gated — all tests use a stubbed fetcher); USD→INR FX conversion; the Postgres engine swap (a separate sequenced task); CloudWatch storage/bandwidth *metric* collection beyond what the manual upsert and the cost pull populate (the `aws_usage.storage_gb`/`transfer_gb` columns exist and are editable via the manual owner route; automated metric harvesting is deferred); APEX historical-cost backfill. State these exclusions where relevant, don't re-litigate them.
|
|
|
|
|
|
## File Structure (HQ-3a additions)
|
|
|
|
|
|
```
|
|
|
apps/hq/
|
|
|
src/db.ts — MODIFY: 1 new CREATE TABLE IF NOT EXISTS (aws_usage)
|
|
|
src/repos-aws.ts — NEW: upsertAwsUsage, getAwsUsage, listAwsUsage, costRanking, awsCostForClient, previousMonth
|
|
|
src/aws-costs.ts — NEW: signAwsRequest (SigV4), pullMonthlyCosts, maybePullAwsCosts
|
|
|
src/repos-reports.ts — NEW: duesAging, moduleRevenue, clientProfitability
|
|
|
src/repos-payments.ts — MODIFY: export splitProRata; add issueReceipt option + receiptForPayment
|
|
|
src/repos-documents.ts — MODIFY: DocPayload.receipt; generateReceipt (RECEIPT via insertDocRow+issueDocument)
|
|
|
src/templates.ts — MODIFY: RECEIPT acknowledgment layout (zero-GST)
|
|
|
src/api.ts — MODIFY: reports / aws / settings-company / payment-receipt / client-aws-usage routes
|
|
|
src/server.ts — MODIFY: schedulerDeps gains AWS creds from env; tick calls maybePullAwsCosts when present
|
|
|
test/*.test.ts — NEW: one file per task
|
|
|
apps/hq-web/
|
|
|
src/api.ts — MODIFY: types + typed calls for reports/aws/company-profile
|
|
|
src/Layout.tsx — MODIFY: nav gains Reports (+ Company for owner)
|
|
|
src/main.tsx — MODIFY: routes /reports and /settings/company
|
|
|
src/pages/Reports.tsx — NEW: dues aging + module revenue + profitability + AWS ranking
|
|
|
src/pages/CompanyProfile.tsx — NEW: owner-only company.* editor
|
|
|
src/pages/ClientDetail.tsx — MODIFY: AWS usage trend section (last 12 months)
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 1: HQ-3a schema — the `aws_usage` table
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq/src/db.ts` (append one table to `SCHEMA`)
|
|
|
- Test: `apps/hq/test/hq3-schema.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Produces (schema only — no new TS exports): table `aws_usage` with `UNIQUE (client_id, month)`. No `migrate()` change — a brand-new table needs no additive column path.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// 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 `SCHEMA` template string in `apps/hq/src/db.ts` (before the closing backtick):
|
|
|
|
|
|
```sql
|
|
|
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**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/db.ts apps/hq/test/hq3-schema.test.ts
|
|
|
git commit -m "feat(hq): hq-3a schema — aws_usage table (per client per month)" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 2: `repos-aws.ts` — usage upsert/read + cross-client cost ranking + routes
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq/src/repos-aws.ts`
|
|
|
- Modify: `apps/hq/src/api.ts`
|
|
|
- Test: `apps/hq/test/aws-repo.test.ts`
|
|
|
|
|
|
Built before `aws-costs.ts` (Task 3) so the ingestion module imports a finished `upsertAwsUsage` — the AWS module dependency runs one way only (`aws-costs → repos-aws`), no cycle. The month helper `previousMonth` also lives here so the ranking route (and later the pull route/scheduler) share one implementation.
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Produces:
|
|
|
- `AwsUsage = { id: string; clientId: string; month: string; storageGb: number; transferGb: number; costPaise: number; source: 'auto'|'manual'; updatedAt: string }`
|
|
|
- `upsertAwsUsage(db, userId, input: { clientId; month; storageGb?; transferGb?; costPaise?; source?: 'auto'|'manual' }): AwsUsage` — validates the client and `month` (`'YYYY-MM'`); merges onto any existing `(client, month)` row (a manual storage edit does not clobber an auto cost, and vice versa); audits.
|
|
|
- `getAwsUsage(db, clientId, month): AwsUsage | null`; `listAwsUsage(db, clientId, limit = 12): AwsUsage[]` (newest month first — the Client 360 trend).
|
|
|
- `CostRankRow = { clientId; clientName; costPaise; sharePctBp }` and `costRanking(db, month): { total: number; rows: CostRankRow[] }` — clients ranked by `cost_paise` desc with share of total in **basis points** (10000 = 100%; the founder's "where do they sit in the AWS cost chart" view).
|
|
|
- `awsCostForClient(db, clientId, from?, to?): number` — Σ `cost_paise` over months in `[from-month, to-month]` (used by profitability).
|
|
|
- `previousMonth(today: string): string` — the previous complete month (what has settled); exported here so `aws-costs.ts` and the pull route import it from `repos-aws`, keeping the AWS module dependency one-directional.
|
|
|
- Routes: `GET /api/aws/ranking?month=` (auth), `GET /api/clients/:id/aws-usage` (auth), `POST /api/aws/usage` (**owner** — the manual fallback; forces `source='manual'`).
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// 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`**
|
|
|
|
|
|
```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 (`GET`s auth-only; the manual upsert is owner-gated):
|
|
|
|
|
|
```ts
|
|
|
import {
|
|
|
costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput,
|
|
|
} from './repos-aws'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
// ---------- 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**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/repos-aws.ts apps/hq/src/api.ts apps/hq/test/aws-repo.test.ts
|
|
|
git commit -m "feat(hq): aws usage repo — upsert/list, cross-client cost ranking, routes" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 3: AWS ingestion — SigV4 signer + `pullMonthlyCosts` (injectable Fetcher)
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq/src/aws-costs.ts`
|
|
|
- Test: `apps/hq/test/aws-costs.test.ts`
|
|
|
|
|
|
Imports `upsertAwsUsage` and `previousMonth` from the Task-2 `repos-aws.ts` (one-directional). Pure module — no env, no SDK; credentials arrive as a parameter and `fetch` is injected so tests stub the network.
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Produces:
|
|
|
- `AwsCreds = { accessKeyId: string; secretAccessKey: string }`
|
|
|
- `SignedRequest = { url: string; method: 'POST'; headers: Record<string, string>; body: string }`
|
|
|
- `signAwsRequest(args: { creds: AwsCreds; region: string; service: string; target: string; body: string; now: Date }): SignedRequest` — pure SigV4-signed POST (`node:crypto`), deterministic for a fixed `now`.
|
|
|
- `AwsCostDeps = { f: Fetcher; creds: AwsCreds; now?: () => string }` (`Fetcher` = `typeof fetch`, reused from `gmail.ts`).
|
|
|
- `PullResult = { upserted: number; unknownTags: { tag: string; costPaise: number }[]; totalPaise: number }`.
|
|
|
- `pullMonthlyCosts(db, deps, month: string): Promise<PullResult>` — signs and POSTs a Cost Explorer `GetCostAndUsage` grouped by the `client` tag for `month` (`'YYYY-MM'`), maps each tag value to a client by `code` (case-insensitive), upserts matched clients' `cost_paise` with `source='auto'` via `upsertAwsUsage`, and returns unmatched/untagged groups in `unknownTags` (never dropped silently).
|
|
|
- `maybePullAwsCosts(db, deps, today: string): Promise<PullResult | null>` — pulls `previousMonth(today)` once per calendar month (guarded by the `aws.last_pull_month` setting); returns `null` when already done.
|
|
|
- Consumes: `upsertAwsUsage`, `previousMonth` (from `repos-aws`); `getSetting`/`setSetting` (from `repos-reminders`); `Fetcher` (from `gmail`).
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// 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`**
|
|
|
|
|
|
```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 (verify `123456` paise and the `['', 'GHOST']` unknown set). `npm run typecheck` → clean.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/aws-costs.ts apps/hq/test/aws-costs.test.ts
|
|
|
git commit -m "feat(hq): aws cost explorer ingestion — sigv4 signer and monthly tag-grouped pull" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 4: `repos-reports.ts` — dues aging, module revenue, client profitability + routes
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq/src/repos-reports.ts`
|
|
|
- Modify: `apps/hq/src/repos-payments.ts` (export `splitProRata`)
|
|
|
- Modify: `apps/hq/src/api.ts`
|
|
|
- Test: `apps/hq/test/reports.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Modifies `repos-payments.ts`: change `function splitProRata` → `export function splitProRata` (no behaviour change — the largest-remainder split is now reused by reports so module-wise revenue matches `modulePaidView` to the paisa).
|
|
|
- Produces:
|
|
|
- `DateRange = { from?: string; to?: string }` (ISO dates on `doc_date`; a month range for AWS cost).
|
|
|
- `DuesAgingRow = { clientId; clientName; b0_30; b31_60; b61_90; b90p; totalPaise }` and `duesAging(db, today): DuesAgingRow[]` — per client, outstanding on issued non-settled invoices bucketed by age (`0-30 / 31-60 / 61-90 / 90+`), reusing `outstandingPaise`; sorted by total desc.
|
|
|
- `ModuleRevenueRow = { moduleId; moduleCode; moduleName; billedPaise; settledPaise }` and `moduleRevenue(db, range?): ModuleRevenueRow[]` — per module, billed (Σ line totals) and settled (invoice settlement split pro-rata across lines by `lineTotalPaise`) over invoices in range; sorted by billed desc.
|
|
|
- `ProfitabilityRow = { clientId; clientName; billedPaise; settledPaise; awsCostPaise; marginPaise }` and `clientProfitability(db, range?): ProfitabilityRow[]` — per client: billed + settled (as above) and AWS cost (`awsCostForClient`), `margin = settled − awsCost` (spec §5 "cost vs. what they pay us"); clients with no activity omitted; sorted by margin desc.
|
|
|
- Routes (auth-only reads): `GET /api/reports/dues-aging`, `GET /api/reports/module-revenue?from=&to=`, `GET /api/reports/profitability?from=&to=`.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// 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):
|
|
|
|
|
|
```ts
|
|
|
export function splitProRata(amount: number, weights: number[]): number[] {
|
|
|
```
|
|
|
|
|
|
Create `apps/hq/src/repos-reports.ts`:
|
|
|
|
|
|
```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`:
|
|
|
|
|
|
```ts
|
|
|
import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
// ---------- 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 golden `11_800_00` / `35_400_00` / `10_800_00` paise). `npx vitest run apps/hq/test/payments.test.ts` still PASS (the `splitProRata` export is behaviour-neutral). `npm run typecheck` → clean.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/repos-reports.ts apps/hq/src/repos-payments.ts apps/hq/src/api.ts apps/hq/test/reports.test.ts
|
|
|
git commit -m "feat(hq): reports — dues aging, module revenue, client profitability with margin" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 5: Payment receipts — `generateReceipt` (own RCT/ series, zero-GST) + template + route
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq/src/repos-documents.ts` (`DocPayload.receipt`, `generateReceipt`)
|
|
|
- Modify: `apps/hq/src/templates.ts` (RECEIPT acknowledgment layout)
|
|
|
- Modify: `apps/hq/src/repos-payments.ts` (`issueReceipt` option on `recordPayment` + `receiptForPayment`)
|
|
|
- Modify: `apps/hq/src/api.ts` (`POST /api/payments/:id/receipt`)
|
|
|
- Test: `apps/hq/test/receipt.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- `DocPayload` gains `receipt?: ReceiptMeta` where `ReceiptMeta = { paymentId; receivedOn; mode; reference; amountPaise; tdsPaise; allocations: { docNo; amountPaise }[] }`. No DDL — it rides in the existing `payload` JSON.
|
|
|
- `generateReceipt(db, userId, input: { clientId; paymentId; receivedOn; mode; reference; amountPaise; tdsPaise; allocations }): Doc` — builds a **RECEIPT** with zero tax (`payablePaise = amountPaise`) through the existing private `insertDocRow`, then `issueDocument` assigns the next `RCT/` number (the `RECEIPT` prefix already exists in `series.ts` `TYPE_PREFIX`). Receipts carry **no GST lines** (`payload.lines = []`).
|
|
|
- `recordPayment` gains `issueReceipt?: boolean` on its input and `receipt?: Doc` on its result — when true, a receipt is generated inside the same transaction after allocation (nested `db.transaction` = savepoint, exactly as `generateAmcRenewalInvoice` already nests `createDraft`+`issueDocument`).
|
|
|
- `receiptForPayment(db, userId, paymentId): Doc` — reconstructs the receipt from a stored payment + its allocations (used by the after-the-fact route).
|
|
|
- Route: `POST /api/payments/:id/receipt` (auth) → `{ document }`.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// apps/hq/test/receipt.test.ts
|
|
|
import { describe, it, expect } from 'vitest'
|
|
|
import { openDb } from '../src/db'
|
|
|
import { seedIfEmpty } from '../src/seed'
|
|
|
import { createClient } from '../src/repos-clients'
|
|
|
import { createModule, setPrice } from '../src/repos-modules'
|
|
|
import { createDraft, issueDocument, getDocument } from '../src/repos-documents'
|
|
|
import { recordPayment, receiptForPayment } from '../src/repos-payments'
|
|
|
import { documentHtml } from '../src/templates'
|
|
|
|
|
|
function setup() {
|
|
|
const db = openDb(':memory:'); seedIfEmpty(db)
|
|
|
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
|
|
|
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
|
|
|
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
|
|
|
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
|
|
|
return { db, c, inv }
|
|
|
}
|
|
|
|
|
|
describe('payment receipts', () => {
|
|
|
it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', () => {
|
|
|
const { db, c, inv } = setup()
|
|
|
const out = recordPayment(db, 'u1', {
|
|
|
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00,
|
|
|
allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true,
|
|
|
})
|
|
|
expect(out.receipt).toBeDefined()
|
|
|
const rc = out.receipt!
|
|
|
expect(rc.docType).toBe('RECEIPT')
|
|
|
expect(rc.docNo?.startsWith('RCT/')).toBe(true)
|
|
|
expect(rc.payablePaise).toBe(11_800_00)
|
|
|
expect(rc.cgstPaise + rc.sgstPaise + rc.igstPaise).toBe(0) // acknowledgment, not a tax document
|
|
|
expect(rc.payload.receipt?.allocations[0]).toMatchObject({ docNo: inv.docNo, amountPaise: 11_800_00 })
|
|
|
})
|
|
|
it('generates a receipt after the fact from a stored payment', () => {
|
|
|
const { db, c, inv } = setup()
|
|
|
const out = recordPayment(db, 'u1', {
|
|
|
clientId: c.id, receivedOn: '2026-07-10', mode: 'upi', amountPaise: 5_000_00,
|
|
|
allocations: [{ documentId: inv.id, amountPaise: 5_000_00 }],
|
|
|
})
|
|
|
const rc = receiptForPayment(db, 'u1', out.payment.id)
|
|
|
expect(rc.docType).toBe('RECEIPT')
|
|
|
expect(getDocument(db, rc.id)!.docNo).toBe(rc.docNo)
|
|
|
})
|
|
|
it('renders a receipt acknowledgment (no SAC/GST table)', () => {
|
|
|
const { db, c, inv } = setup()
|
|
|
const out = recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true })
|
|
|
const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' })
|
|
|
expect(html).toContain('RECEIPT')
|
|
|
expect(html).toContain('Received with thanks')
|
|
|
expect(html).not.toContain('>SAC<') // the GST line table header is absent on receipts
|
|
|
})
|
|
|
})
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: Run to verify 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`.
|
|
|
|
|
|
```ts
|
|
|
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
|
|
|
}
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
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.
|
|
|
|
|
|
```ts
|
|
|
export function documentHtml(doc: Doc, client: Client, company: Record<string, string>): string {
|
|
|
if (doc.docType === 'RECEIPT') return receiptHtml(doc, client, company)
|
|
|
// ... existing body unchanged ...
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
/** 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.)
|
|
|
|
|
|
```ts
|
|
|
import { generateReceipt, getDocument, listDocuments, type Doc } from './repos-documents'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
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):
|
|
|
|
|
|
```ts
|
|
|
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`):
|
|
|
|
|
|
```ts
|
|
|
/** 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:
|
|
|
|
|
|
```ts
|
|
|
import {
|
|
|
clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput,
|
|
|
} from './repos-payments'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
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.ts` still PASS. `npm run typecheck` → clean.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/repos-documents.ts apps/hq/src/templates.ts apps/hq/src/repos-payments.ts apps/hq/src/api.ts apps/hq/test/receipt.test.ts
|
|
|
git commit -m "feat(hq): payment receipts — own RCT/ series, zero-GST acknowledgment PDF" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 6: Company Profile — `GET`/`PUT /api/settings/company` (owner-gated, audited)
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq/src/api.ts`
|
|
|
- Test: `apps/hq/test/settings-company.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- Consumes: the existing `companySettings()` helper in `api.ts` (returns the `company.*` map), `setSetting` (from `repos-reminders`, which already audits each key), and `validateGstin` (from `@sims/domain`, already used by `repos-clients`).
|
|
|
- Routes: `GET /api/settings/company` (auth) → `{ company: Record<string,string> }`; `PUT /api/settings/company` (**owner**) → validates GSTIN (when non-empty) and a two-digit state code, writes each provided field via `setSetting`, returns the fresh map. The letterhead PDFs read these settings server-side at render time, so a change takes effect on the next generated document with no restart.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// apps/hq/test/settings-company.test.ts
|
|
|
import { describe, it, expect, afterAll } from 'vitest'
|
|
|
import express from 'express'
|
|
|
import { openDb } from '../src/db'
|
|
|
import { seedIfEmpty } from '../src/seed'
|
|
|
import { createStaff } from '../src/auth'
|
|
|
import { apiRouter } from '../src/api'
|
|
|
|
|
|
function appWith() {
|
|
|
const db = openDb(':memory:'); seedIfEmpty(db)
|
|
|
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
|
|
|
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
|
|
|
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
|
|
|
const server = app.listen(0)
|
|
|
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
|
|
|
return { db, server, base }
|
|
|
}
|
|
|
|
|
|
describe('company profile settings', () => {
|
|
|
const { db, server, base } = appWith()
|
|
|
afterAll(() => server.close())
|
|
|
const login = async (email: string, password: string) =>
|
|
|
(await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token
|
|
|
const call = async (token: string, method: string, path: string, body?: unknown) => {
|
|
|
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
|
|
|
return { status: res.status, json: await res.json() as any }
|
|
|
}
|
|
|
|
|
|
it('owner reads and updates company.* and it is audited', async () => {
|
|
|
const token = await login('owner@test.in', 'owner-password')
|
|
|
const before = await call(token, 'GET', '/settings/company')
|
|
|
expect(before.json.company['company.name']).toBe('Tecnostac')
|
|
|
const put = await call(token, 'PUT', '/settings/company', { name: 'Tecnostac Pvt Ltd', phone: '0484-1234567', stateCode: '32' })
|
|
|
expect(put.status).toBe(200)
|
|
|
expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd')
|
|
|
expect(put.json.company['company.phone']).toBe('0484-1234567')
|
|
|
const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`).get() as { n: number }
|
|
|
expect(audits.n).toBeGreaterThanOrEqual(3)
|
|
|
})
|
|
|
it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => {
|
|
|
const staff = await login('staff@test.in', 'staff-password')
|
|
|
expect((await call(staff, 'PUT', '/settings/company', { name: 'Nope' })).status).toBe(403)
|
|
|
const owner = await login('owner@test.in', 'owner-password')
|
|
|
expect((await call(owner, 'PUT', '/settings/company', { gstin: 'NOTAGSTIN' })).status).toBe(400)
|
|
|
})
|
|
|
})
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: Run to verify FAIL** — `npx vitest run apps/hq/test/settings-company.test.ts` → FAIL.
|
|
|
|
|
|
- [ ] **Step 3: Implement.** In `apps/hq/src/api.ts` add the imports and the route block. `validateGstin` from `@sims/domain`, `setSetting` from `repos-reminders`:
|
|
|
|
|
|
```ts
|
|
|
import { validateGstin } from '@sims/domain'
|
|
|
import { setSetting } from './repos-reminders'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
// ---------- 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**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/api.ts apps/hq/test/settings-company.test.ts
|
|
|
git commit -m "feat(hq): company profile settings — owner-gated GET/PUT, audited" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 7: Scheduler wiring — monthly AWS pull + `POST /api/aws/pull` (owner, 409 without creds)
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq/src/server.ts` (scheduler deps gain AWS creds from env; tick calls `maybePullAwsCosts` when present)
|
|
|
- Modify: `apps/hq/src/api.ts` (`POST /api/aws/pull`)
|
|
|
- Test: `apps/hq/test/aws-pull-route.test.ts`
|
|
|
|
|
|
**Interfaces:**
|
|
|
- `server.ts` `startScheduler` reads `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` at boot; each tick (boot + every 6h) calls `maybePullAwsCosts(db, { f: fetch, creds }, today())` **only when both are non-empty**, wrapped in the same `.catch` console-error guard as the existing scan and bounce poll. When creds are absent the pull is simply not scheduled — no error, no AWS call.
|
|
|
- Route: `POST /api/aws/pull` (**owner**) — reads env creds at request time; `409 { error: 'aws-credentials-not-configured' }` when absent; otherwise pulls the requested `month` (body, default `previousMonth(today)`) via `pullMonthlyCosts` and returns `{ upserted, unknownTags, totalPaise }`; a live-call failure surfaces as `502`. Tests exercise only the 409 path (no live AWS); the happy pull path is covered by `aws-costs.test.ts`.
|
|
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
|
|
```ts
|
|
|
// 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):
|
|
|
|
|
|
```ts
|
|
|
import { pullMonthlyCosts } from './aws-costs'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
import { maybePullAwsCosts } from './aws-costs'
|
|
|
```
|
|
|
|
|
|
```ts
|
|
|
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.ts` still PASS (`startServer(0)` with default opts starts no scheduler). `npm run typecheck` → clean.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq/src/server.ts apps/hq/src/api.ts apps/hq/test/aws-pull-route.test.ts
|
|
|
git commit -m "feat(hq): schedule monthly aws cost pull and owner-gated manual pull route" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 8: `apps/hq-web` — API client + Reports page + nav item
|
|
|
|
|
|
**Files:**
|
|
|
- Modify: `apps/hq-web/src/api.ts` (types + typed calls), `apps/hq-web/src/Layout.tsx` (nav), `apps/hq-web/src/main.tsx` (route)
|
|
|
- Create: `apps/hq-web/src/pages/Reports.tsx`
|
|
|
|
|
|
**Interfaces / contracts:**
|
|
|
- `api.ts` gains the HQ-3a server shapes (camelCase JSON, mirroring the repos) and typed calls.
|
|
|
- `Reports.tsx` is a plain fetch-render page (`useData` from `Clients`, `@sims/ui` components): a **dues aging** table, a **module revenue** table, a **client profitability** table, and the **AWS cost ranking** as a plain bar list (a `<div>` whose width is `sharePctBp/100`% — no chart library, per scope). A `<input type="month">` drives the ranking month. All amounts via `formatINR`.
|
|
|
- Nav gains **Reports**; the route `/reports` renders the page.
|
|
|
|
|
|
- [ ] **Step 1: Implement the API client additions.** Append to `apps/hq-web/src/api.ts`:
|
|
|
|
|
|
```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`**
|
|
|
|
|
|
```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.tsx` add Reports to `NAV` (after Modules):
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```tsx
|
|
|
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**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Reports.tsx apps/hq-web/src/Layout.tsx apps/hq-web/src/main.tsx
|
|
|
git commit -m "feat(hq-web): reports page — dues aging, module revenue, profitability, aws cost chart" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 9: `apps/hq-web` — Company Profile page + Client 360 AWS-usage section
|
|
|
|
|
|
**Files:**
|
|
|
- Create: `apps/hq-web/src/pages/CompanyProfile.tsx`
|
|
|
- Modify: `apps/hq-web/src/Layout.tsx` (owner-only Company nav), `apps/hq-web/src/main.tsx` (route), `apps/hq-web/src/pages/ClientDetail.tsx` (AWS usage section)
|
|
|
|
|
|
**Interfaces / contracts:**
|
|
|
- `CompanyProfile.tsx` (owner-only): a form over the seven `company.*` fields (name, address, gstin, state code, phone, email, bank), `getCompanyProfile()` on mount, `putCompanyProfile()` on save; a `Notice` on validation error (bad GSTIN / state code). Non-owners are redirected to `/`.
|
|
|
- `Layout.tsx` gains a **Company** nav item, rendered only when `role() === 'owner'` (build the nav array dynamically).
|
|
|
- `ClientDetail.tsx` gains an **AWS usage** section (below Interactions): a last-12-months table (Month, Storage GB, Transfer GB, Cost, Source) from `getClientAwsUsage(id)`.
|
|
|
|
|
|
- [ ] **Step 1: Create `apps/hq-web/src/pages/CompanyProfile.tsx`**
|
|
|
|
|
|
```tsx
|
|
|
import { useEffect, useState } from 'react'
|
|
|
import { Navigate } from 'react-router-dom'
|
|
|
import { Button, Field, Notice, PageHeader } from '@sims/ui'
|
|
|
import { getCompanyProfile, putCompanyProfile, role } from '../api'
|
|
|
|
|
|
const FIELDS: { key: string; setting: string; label: string }[] = [
|
|
|
{ key: 'name', setting: 'company.name', label: 'Company name' },
|
|
|
{ key: 'address', setting: 'company.address', label: 'Address' },
|
|
|
{ key: 'gstin', setting: 'company.gstin', label: 'GSTIN' },
|
|
|
{ key: 'stateCode', setting: 'company.state_code', label: 'State code' },
|
|
|
{ key: 'phone', setting: 'company.phone', label: 'Phone' },
|
|
|
{ key: 'email', setting: 'company.email', label: 'Email' },
|
|
|
{ key: 'bank', setting: 'company.bank', label: 'Bank details' },
|
|
|
]
|
|
|
|
|
|
/** Owner-only editor for the company.* settings the letterhead PDFs read. */
|
|
|
export function CompanyProfile() {
|
|
|
const [f, setF] = useState<Record<string, string>>({})
|
|
|
const [msg, setMsg] = useState<{ tone: 'ok' | 'err'; text: string } | undefined>()
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
getCompanyProfile()
|
|
|
.then((c) => setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))))
|
|
|
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
|
|
|
}, [])
|
|
|
|
|
|
if (role() !== 'owner') return <Navigate to="/" replace />
|
|
|
|
|
|
const save = () => {
|
|
|
setSaving(true); setMsg(undefined)
|
|
|
putCompanyProfile(f)
|
|
|
.then((c) => { setF(Object.fromEntries(FIELDS.map((x) => [x.key, c[x.setting] ?? '']))); setMsg({ tone: 'ok', text: 'Saved. PDFs use the new details immediately.' }) })
|
|
|
.catch((e: Error) => setMsg({ tone: 'err', text: e.message }))
|
|
|
.finally(() => setSaving(false))
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<div className="wf-page">
|
|
|
<PageHeader title="Company profile" desc="These details appear on every quotation, invoice, and receipt." />
|
|
|
{msg !== undefined && <Notice tone={msg.tone}>{msg.text}</Notice>}
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, maxWidth: 520, marginTop: 10 }}>
|
|
|
{FIELDS.map((x) => (
|
|
|
<Field key={x.key} label={x.label}>
|
|
|
{x.key === 'address' || x.key === 'bank'
|
|
|
? <textarea className="wf" rows={2} style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />
|
|
|
: <input className="wf" style={{ width: '100%' }} value={f[x.key] ?? ''} onChange={(e) => setF((p) => ({ ...p, [x.key]: e.target.value }))} />}
|
|
|
</Field>
|
|
|
))}
|
|
|
<div><Button tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save company profile'}</Button></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 2: Nav (owner-only) + route.** In `apps/hq-web/src/Layout.tsx` build the nav dynamically so Company shows only for owners. Import `role` and derive `NAV`:
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```ts
|
|
|
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:
|
|
|
|
|
|
```tsx
|
|
|
import { CompanyProfile } from './pages/CompanyProfile'
|
|
|
// ...
|
|
|
<Route path="/settings/company" element={<CompanyProfile />} />
|
|
|
```
|
|
|
|
|
|
- [ ] **Step 3: ClientDetail AWS usage section.** In `apps/hq-web/src/pages/ClientDetail.tsx` add the import, the fetch, and the section (below the Interactions block, before the closing `</div>`). Reuse the existing `inr`, `useData`, and `id` already in the file:
|
|
|
|
|
|
```tsx
|
|
|
import { getClientAwsUsage } from '../api'
|
|
|
// inside ClientDetail(), with the other useData hooks:
|
|
|
const awsUsage = useData(() => getClientAwsUsage(id), [id])
|
|
|
```
|
|
|
|
|
|
```tsx
|
|
|
<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. Root `npm run typecheck` (both apps) → clean.
|
|
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
|
|
```bash
|
|
|
git add apps/hq-web/src/pages/CompanyProfile.tsx apps/hq-web/src/Layout.tsx apps/hq-web/src/main.tsx apps/hq-web/src/pages/ClientDetail.tsx
|
|
|
git commit -m "feat(hq-web): owner company profile editor and client 360 aws usage trend" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
### Task 10: Full browser verification (the HQ-3a walk)
|
|
|
|
|
|
**Files:** none (verification only — no code changes; if a defect is found, fix it under the owning task's files and note it here).
|
|
|
|
|
|
- [ ] **Step 1: Full suite green** — root `npm test` (HQ-1 + HQ-2 + every HQ-3a test) all pass; every AWS/report/receipt test runs offline (injected `Fetcher` + `renderPdf`, no live AWS or Gmail). `npm run typecheck` clean across root, `apps/hq`, and `apps/hq-web`.
|
|
|
|
|
|
- [ ] **Step 2: Determinism check** — the AWS tests pin `now`/`today`; `signAwsRequest` is asserted deterministic for a fixed clock; re-running `maybePullAwsCosts` for the same month returns `null` (no second pull). No test reads the wall clock for logic.
|
|
|
|
|
|
- [ ] **Step 3: Browser walk.** Start the API (`cd apps/hq && npm start`, note the seeded owner password on first boot), then `cd apps/hq-web && npm run dev` and sign in as owner.
|
|
|
1. **Company profile:** open **Company** (owner-only nav) → edit name/address/GSTIN/state code/bank → **Save**. Compose or open any invoice → its PDF letterhead reflects the change immediately (no restart). Sign in as a staff user in a second browser → **Company** is absent from the nav and `/#/settings/company` redirects to the dashboard.
|
|
|
2. **Manual AWS cost (owner fallback):** with no `AWS_*` env set, from a REST client `POST /api/aws/usage` `{ clientId, month: '2026-06', costPaise: 500000, storageGb: 40 }` (owner token). On **Reports → AWS cost chart** pick `2026-06`: the client appears with a bar and its share of total. On the client's **Client 360 → AWS usage** the `2026-06` row shows cost + storage with source `manual`.
|
|
|
3. **AWS auto pull (env-gated):** without creds, `POST /api/aws/pull` returns `409 aws-credentials-not-configured` (confirmed by Task 7's test). With real `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` set on the server and the `client` cost-allocation tag active, the boot scan pulls the previous month once; matched tags upsert `source='auto'` rows and the ranking/chart populate. (This step is manual-only where live creds exist; CI never runs it.)
|
|
|
4. **Reports:** create a couple of issued invoices, record a partial payment on one → **Reports** shows the unpaid invoice in the right **dues aging** bucket by age, **module-wise revenue** shows billed vs settled per module, and **client profitability** shows billed/settled/AWS cost/margin (margin = settled − AWS cost).
|
|
|
5. **Receipt:** record a payment with the receipt option (`POST /api/payments` `{ …, issueReceipt: true }`, or `POST /api/payments/:id/receipt` after the fact) → a `RCT/26-27-000N` document is issued; open its PDF → a "Received with thanks" acknowledgment with the amount in words, the mode/reference, and the invoices it settled — no SAC/GST table.
|
|
|
6. **Cross-client chart (founder view):** with two clients holding different `2026-06` costs, the ranking orders them by cost desc, the bar widths equal their share, and each row deep-links to that client's 360°.
|
|
|
|
|
|
- [ ] **Step 4: Commit** (only if Step 3 surfaced a fix; otherwise nothing to commit)
|
|
|
|
|
|
```bash
|
|
|
git commit -m "fix(hq): <defect found during HQ-3a browser walk>" -m "Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
|
|
|
```
|
|
|
|
|
|
---
|
|
|
|
|
|
## Deferred out of HQ-3a (explicitly)
|
|
|
|
|
|
SMS pack-balance tracking (founder decision pending); **live AWS credentials / a real Cost Explorer call** (env-gated — every test uses a stubbed `Fetcher`); USD→INR FX conversion (INR billing assumed, non-INR surfaced loudly); automated CloudWatch storage/bandwidth *metric* harvesting (the columns exist and the owner manual route fills them; the auto pull fills cost only); the **Postgres engine swap** (separate sequenced task, before real-data import); APEX historical-cost backfill; a charting library (the AWS "chart" is a plain `@sims/ui` bar list per scope); per-report scheduled email/export (reports are on-demand pages in HQ-3a).
|
|
|
|
|
|
## Verification (whole plan)
|
|
|
|
|
|
1. `npm test` — every HQ-3a task's suite plus HQ-1/HQ-2's original suites stay green. AWS, report, receipt, and settings tests all run offline (injected `fetch` + `renderPdf`); no test performs a live AWS/Gmail call.
|
|
|
2. `npm run typecheck` — clean across root, `apps/hq`, and `apps/hq-web` (per-app typecheck is part of the root gate).
|
|
|
3. Determinism: `signAwsRequest` is asserted deterministic for a fixed clock; `pullMonthlyCosts`/`maybePullAwsCosts` take `now`/`today` explicitly; re-pulling a month is idempotent (upsert on `UNIQUE (client_id, month)`) and the monthly gate returns `null` on the second call.
|
|
|
4. Browser walk (Task 10 Step 3) — company profile edits flow onto PDFs live; manual + auto AWS cost both land in `aws_usage` and rank on the chart; the three reports compute from real invoices/payments/costs; a receipt issues on its own `RCT/` series with a zero-GST acknowledgment PDF; unknown/untagged AWS costs are surfaced, never dropped.
|
|
|
|
|
|
## Self-review against spec §5–§6
|
|
|
|
|
|
- **AWS usage & cost automated (spec §5):** `pullMonthlyCosts` calls Cost Explorer `GetCostAndUsage` grouped by the `client` cost-allocation tag, maps tag→client by code, and upserts one `aws_usage` row per (client, month) with `source='auto'`; unmatched/untagged groups are returned in `unknownTags` (never dropped — the spec's "where tagging is impossible … never manual entry as the primary mechanism" is honoured: auto is primary, the owner manual route is the fallback). SigV4 is implemented with `node:crypto` (no SDK dependency); credentials are read only in server/route wiring and the pull is env-gated. ✅
|
|
|
- **Cross-client cost chart (spec §5/§6):** `costRanking(db, month)` ranks clients by `cost_paise` desc with share-of-total in basis points; the Reports page renders the founder's "where do they sit in the AWS cost chart" as a bar list, each bar deep-linking to that client's 360°, whose AWS-usage section shows the 12-month trend. ✅
|
|
|
- **Reports (spec §6 HQ-3 row):** `duesAging` buckets outstanding by invoice age via `outstandingPaise`; `moduleRevenue` reports billed vs settled per module using the same largest-remainder `splitProRata` as `modulePaidView` (now exported — one split implementation, no drift); `clientProfitability` reports billed, settled, AWS cost, and margin (= settled − AWS cost = spec §5 "cost vs. what they pay us"). All three are read-only and reuse HQ-1/HQ-2 settlement primitives. ✅
|
|
|
- **Receipts (spec §3 "optional receipt"):** `generateReceipt` issues a RECEIPT on its own `RCT/` per-FY series through the existing `insertDocRow`+`issueDocument` path, carrying zero GST and no line items — an acknowledgment, not a tax document; issued receipts are never edited (their number is consumed). Optional on `recordPayment` and available after the fact via `POST /api/payments/:id/receipt`. ✅
|
|
|
- **Company profile:** `GET`/`PUT /api/settings/company` edits the `company.*` settings the letterhead templates already read server-side, so a change takes effect on the next rendered PDF with no restart; owner-gated and audited (each field via `setSetting`, which writes an `audit_log` row). ✅
|
|
|
- **Portability & discipline (D12):** the one new table uses `CREATE TABLE IF NOT EXISTS`; the receipt block rides in existing `payload` JSON (no DDL); every mutation audits; SQLite/Postgres-only `ON CONFLICT` upserts are commented as portability quirks; all money is integer paise, converted once at the AWS decimal boundary. ✅
|