feat(hq): module catalog with dated price book (HQ-1 task 5)

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

@ -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<PriceInput, 'moduleId'>), 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
}

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

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