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