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
parent
9e4d53fb65
commit
fa8bb9c50a
@ -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…
Reference in New Issue