diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index aa149e0..0204439 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1,10 +1,14 @@ import { Router, type Response } from 'express' -import { login, requireAuth } from './auth' +import { login, requireAuth, requireOwner } from './auth' import type { DB } from './db' import { createClient, getClient, listClients, updateClient, type ClientInput, type ClientPatch, } from './repos-clients' +import { + createModule, getModule, listModules, listPrices, setPrice, + type ModuleInput, type PriceInput, +} from './repos-modules' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id @@ -51,5 +55,37 @@ export function apiRouter(db: DB): Router { } }) + // ---------- modules ---------- + r.get('/modules', requireAuth, (_req, res) => { + res.json({ ok: true, modules: listModules(db) }) + }) + r.post('/modules', requireAuth, requireOwner, (req, res) => { + try { + const mod = createModule(db, staffId(res), req.body as ModuleInput) + res.json({ ok: true, module: mod }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/modules/:id/prices', requireAuth, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getModule(db, id) === null) { + res.status(404).json({ ok: false, error: 'Module not found' }); return + } + res.json({ ok: true, prices: listPrices(db, id) }) + }) + r.post('/modules/:id/prices', requireAuth, requireOwner, (req, res) => { + const id = String(req.params['id'] ?? '') + if (getModule(db, id) === null) { + res.status(404).json({ ok: false, error: 'Module not found' }); return + } + try { + setPrice(db, staffId(res), { ...(req.body as Omit), moduleId: id }) + res.json({ ok: true, prices: listPrices(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + return r } diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts new file mode 100644 index 0000000..dbf1455 --- /dev/null +++ b/apps/hq/src/repos-modules.ts @@ -0,0 +1,117 @@ +import { uuidv7 } from '@sims/domain' +import { writeAudit } from './audit' +import type { DB } from './db' + +/** Module catalog + dated price book — plain functions over the handle (D12 portable-repo pattern). */ + +export type Kind = 'one_time' | 'monthly' | 'yearly' | 'usage' + +const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage'] + +export interface Module { + id: string; code: string; name: string; sac: string + allowedKinds: Kind[]; multiSubscription: boolean; active: boolean +} + +interface ModuleRow { + id: string; code: string; name: string; sac: string + allowed_kinds: string; multi_subscription: number; active: number +} + +function toModule(r: ModuleRow): Module { + return { + id: r.id, code: r.code, name: r.name, sac: r.sac, + allowedKinds: JSON.parse(r.allowed_kinds) as Kind[], + multiSubscription: r.multi_subscription === 1, + active: r.active === 1, + } +} + +export interface ModuleInput { + code: string; name: string; sac?: string + allowedKinds?: Kind[]; multiSubscription?: boolean +} + +export function getModule(db: DB, id: string): Module | null { + const row = db.prepare(`SELECT * FROM module WHERE id=?`).get(id) as ModuleRow | undefined + return row === undefined ? null : toModule(row) +} + +export function listModules(db: DB): Module[] { + const rows = db.prepare(`SELECT * FROM module ORDER BY code`).all() as ModuleRow[] + return rows.map(toModule) +} + +export function createModule(db: DB, userId: string, input: ModuleInput): Module { + const kinds = input.allowedKinds ?? ALL_KINDS + for (const k of kinds) { + if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`) + } + const id = uuidv7() + db.prepare( + `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run( + id, input.code, input.name, input.sac ?? '998313', + JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0, + ) + const mod = getModule(db, id)! + writeAudit(db, userId, 'create', 'module', id, undefined, mod) + return mod +} + +export interface ModulePrice { + id: string; moduleId: string; edition: string; kind: Kind + pricePaise: number; effectiveFrom: string +} + +interface PriceRow { + id: string; module_id: string; edition: string; kind: string + price_paise: number; effective_from: string +} + +function toPrice(r: PriceRow): ModulePrice { + return { + id: r.id, moduleId: r.module_id, edition: r.edition, + kind: r.kind as Kind, pricePaise: r.price_paise, effectiveFrom: r.effective_from, + } +} + +export interface PriceInput { + moduleId: string; edition?: string; kind: Kind; pricePaise: number; effectiveFrom: string +} + +export function setPrice(db: DB, userId: string, input: PriceInput): void { + if (!ALL_KINDS.includes(input.kind)) throw new Error(`Unknown kind: ${input.kind}`) + if (!Number.isInteger(input.pricePaise) || input.pricePaise < 0) { + throw new Error('pricePaise must be a non-negative integer (paise)') + } + if (getModule(db, input.moduleId) === null) throw new Error('Module not found') + const id = uuidv7() + const edition = input.edition ?? 'standard' + db.prepare( + `INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from) + VALUES (?, ?, ?, ?, ?, ?)`, + ).run(id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom) + writeAudit(db, userId, 'create', 'module_price_book', id, undefined, { + moduleId: input.moduleId, edition, kind: input.kind, + pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom, + }) +} + +export function listPrices(db: DB, moduleId: string): ModulePrice[] { + const rows = db.prepare( + `SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`, + ).all(moduleId) as PriceRow[] + return rows.map(toPrice) +} + +/** Latest effective_from <= onDate wins — the D5 dated-rows philosophy. */ +export function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): number | null { + const row = db.prepare( + `SELECT price_paise FROM module_price_book + WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ? + ORDER BY effective_from DESC LIMIT 1`, + ).get(moduleId, kind, edition, onDate) as { price_paise: number } | undefined + return row === undefined ? null : row.price_paise +} diff --git a/apps/hq/test/modules.test.ts b/apps/hq/test/modules.test.ts new file mode 100644 index 0000000..fbea21e --- /dev/null +++ b/apps/hq/test/modules.test.ts @@ -0,0 +1,15 @@ +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createModule, setPrice, priceOn } from '../src/repos-modules' + +describe('module catalog', () => { + it('resolves the dated price row', () => { + const db = openDb(':memory:') + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_20_000_00, effectiveFrom: '2026-04-01' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_50_000_00, effectiveFrom: '2026-08-01' }) + expect(priceOn(db, m.id, 'yearly', 'standard', '2026-07-10')).toBe(1_20_000_00) + expect(priceOn(db, m.id, 'yearly', 'standard', '2026-09-01')).toBe(1_50_000_00) + expect(priceOn(db, m.id, 'monthly', 'standard', '2026-09-01')).toBeNull() + }) +})