feat(hq): HQ-1.5 — module quote content, pack pick-lists, bundle isolation check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent 8c145d5a3b
commit 54d6a63e81

@ -82,6 +82,7 @@ export const KIND_LABEL: Record<Kind, string> = {
export interface Module { export interface Module {
id: string; code: string; name: string; sac: string id: string; code: string; name: string; sac: string
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[]
} }
export interface ModulePrice { export interface ModulePrice {
@ -117,7 +118,7 @@ export interface Doc {
docDate: string; status: DocStatus; refDocId: string | null docDate: string; status: DocStatus; refDocId: string | null
taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number
roundOffPaise: number; payablePaise: number roundOffPaise: number; payablePaise: number
payload: { lines: DocLine[]; terms?: string } payload: { lines: DocLine[]; terms?: string; lineContents?: string[][] }
source: string; createdBy: string; createdAt: string source: string; createdBy: string; createdAt: string
} }
@ -161,6 +162,8 @@ export const getModules = (): Promise<Module[]> =>
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules) apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
export const createModule = (body: Record<string, unknown>): Promise<Module> => export const createModule = (body: Record<string, unknown>): Promise<Module> =>
apiFetch<{ module: Module }>('/modules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.module) apiFetch<{ module: Module }>('/modules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.module)
export const patchModule = (id: string, body: Record<string, unknown>): Promise<Module> =>
apiFetch<{ module: Module }>(`/modules/${id}`, { method: 'PATCH', body: JSON.stringify(body) }).then((r) => r.module)
export const getPrices = (moduleId: string): Promise<ModulePrice[]> => export const getPrices = (moduleId: string): Promise<ModulePrice[]> =>
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`).then((r) => r.prices) apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`).then((r) => r.prices)
export const addPrice = (moduleId: string, body: Record<string, unknown>): Promise<ModulePrice[]> => export const addPrice = (moduleId: string, body: Record<string, unknown>): Promise<ModulePrice[]> =>

@ -2,7 +2,7 @@ import { useState } from 'react'
import { formatINR, fromRupees } from '@sims/domain' import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui' import { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import { import {
addPrice, createModule, getModules, getPrices, role, addPrice, createModule, getModules, getPrices, patchModule, role,
KIND_LABEL, type Kind, type Module, KIND_LABEL, type Kind, type Module,
} from '../api' } from '../api'
import { useData } from './Clients' import { useData } from './Clients'
@ -10,6 +10,9 @@ import { useData } from './Clients'
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage'] const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
const today = () => new Date().toISOString().slice(0, 10) const today = () => new Date().toISOString().slice(0, 10)
/** Textarea ↔ string[]: one "what's included" bullet per row, blanks dropped. */
const splitLines = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '')
/** Module catalog + dated price book. Owner edits; staff read-only. */ /** Module catalog + dated price book. Owner edits; staff read-only. */
export function Modules() { export function Modules() {
const isOwner = role() === 'owner' const isOwner = role() === 'owner'
@ -48,16 +51,57 @@ export function Modules() {
/> />
)} )}
{selected !== undefined && ( {selected !== undefined && (
<PriceBook module={selected} isOwner={isOwner} /> <>
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} />
</>
)} )}
</div> </div>
) )
} }
/** Per-module "what's included" lines — prefilled into the composer, printed under the quote line. */
function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () => void }) {
const [text, setText] = useState(props.module.quoteContent.join('\n'))
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | undefined>()
const save = () => {
setError(undefined)
patchModule(props.module.id, { quoteContent: splitLines(text) })
.then(() => { setSaved(true); props.onSaved() })
.catch((e: Error) => setError(e.message))
}
return (
<>
<h3 style={{ marginTop: 20 }}>Quote content {props.module.name}</h3>
{props.isOwner ? (
<div style={{ maxWidth: 560 }}>
<textarea
className="wf" rows={4} placeholder="One included item per line…"
value={text} onChange={(e) => { setText(e.target.value); setSaved(false) }}
/>
<Toolbar>
<Button tone="primary" onClick={save}>Save quote content</Button>
{saved && <Badge tone="ok">saved</Badge>}
</Toolbar>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
) : props.module.quoteContent.length === 0 ? (
<EmptyState>No quote content.</EmptyState>
) : (
<ul>{props.module.quoteContent.map((line, i) => <li key={i}>{line}</li>)}</ul>
)}
</>
)
}
function NewModuleForm(props: { onCreated: () => void }) { function NewModuleForm(props: { onCreated: () => void }) {
const [f, setF] = useState({ code: '', name: '', sac: '998313' }) const [f, setF] = useState({ code: '', name: '', sac: '998313' })
const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS]) const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS])
const [multi, setMulti] = useState(false) const [multi, setMulti] = useState(false)
const [quoteText, setQuoteText] = useState('')
const [error, setError] = useState<string | undefined>() const [error, setError] = useState<string | undefined>()
const set = (k: keyof typeof f) => (e: { target: { value: string } }) => const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value })) setF((p) => ({ ...p, [k]: e.target.value }))
@ -70,6 +114,7 @@ function NewModuleForm(props: { onCreated: () => void }) {
createModule({ createModule({
code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(), code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(),
allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi, allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi,
quoteContent: splitLines(quoteText),
}) })
.then(props.onCreated) .then(props.onCreated)
.catch((e: Error) => setError(e.message)) .catch((e: Error) => setError(e.message))
@ -95,6 +140,9 @@ function NewModuleForm(props: { onCreated: () => void }) {
<input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} /> <input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} />
multi-subscription multi-subscription
</label> </label>
<Field label="Quote content (one per line, optional)">
<textarea className="wf" rows={2} style={{ minWidth: 220 }} value={quoteText} onChange={(e) => setQuoteText(e.target.value)} />
</Field>
<Button tone="primary" onClick={save}>Save module</Button> <Button tone="primary" onClick={save}>Save module</Button>
</div> </div>
{error !== undefined && <Notice tone="err">{error}</Notice>} {error !== undefined && <Notice tone="err">{error}</Notice>}

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { formatINR, fromRupees } from '@sims/domain' import { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui' import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
@ -12,9 +12,18 @@ const today = () => new Date().toISOString().slice(0, 10)
interface LineDraft { interface LineDraft {
moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string moduleId: string; kind: Kind | ''; qty: string; unitRs: string; description: string
edition: string; contentText: string
} }
const EMPTY_LINE: LineDraft = { moduleId: '', kind: '', qty: '1', unitRs: '', description: '' } const EMPTY_LINE: LineDraft = {
moduleId: '', kind: '', qty: '1', unitRs: '', description: '', edition: 'standard', contentText: '',
}
/** Distinct editions (packs) offered for a module, in price-book order. */
const editionsOf = (prices: ModulePrice[]): string[] => [...new Set(prices.map((p) => p.edition))]
/** Content lines are one bullet per row; blanks are dropped before posting. */
const splitContent = (s: string): string[] => s.split('\n').map((x) => x.trim()).filter((x) => x !== '')
/** Latest effective price for kind+edition on/before today — mirrors the server's priceOn. */ /** Latest effective price for kind+edition on/before today — mirrors the server's priceOn. */
function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): number | null { function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): number | null {
@ -24,6 +33,15 @@ function latestPrice(prices: ModulePrice[], kind: Kind, edition = 'standard'): n
return hit !== undefined ? hit.pricePaise : null return hit !== undefined ? hit.pricePaise : null
} }
/** A pack's headline price for the option label — the chosen kind if set, else the latest row. */
function editionPrice(prices: ModulePrice[], edition: string, kind: Kind | ''): number | null {
if (kind !== '') return latestPrice(prices, kind, edition)
const hit = prices
.filter((p) => p.edition === edition && p.effectiveFrom <= today())
.sort((a, b) => (a.effectiveFrom < b.effectiveFrom ? 1 : -1))[0]
return hit !== undefined ? hit.pricePaise : null
}
/** The quotation-in-minutes composer: client → lines → Save Draft (server computes GST). */ /** The quotation-in-minutes composer: client → lines → Save Draft (server computes GST). */
export function NewDocument() { export function NewDocument() {
const nav = useNavigate() const nav = useNavigate()
@ -46,18 +64,23 @@ export function NewDocument() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [q, client]) }, [q, client])
// -- per-module price cache for unit-₹ prefill -- // -- per-module price book, in state so the pack pick-list renders reactively --
const priceCache = useRef(new Map<string, ModulePrice[]>()) const [pricesByModule, setPricesByModule] = useState<Record<string, ModulePrice[]>>({})
const prefill = (index: number, moduleId: string, kind: Kind) => { const loadPrices = (moduleId: string): Promise<ModulePrice[]> => {
const apply = (prices: ModulePrice[]) => { const cached = pricesByModule[moduleId]
const paise = latestPrice(prices, kind) if (cached !== undefined) return Promise.resolve(cached)
setLines((prev) => prev.map((l, i) => return getPrices(moduleId).then((prices) => {
i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l)) setPricesByModule((prev) => ({ ...prev, [moduleId]: prices }))
} return prices
const cached = priceCache.current.get(moduleId) })
if (cached !== undefined) { apply(cached); return } }
getPrices(moduleId) const prefill = (index: number, moduleId: string, kind: Kind, edition: string) => {
.then((prices) => { priceCache.current.set(moduleId, prices); apply(prices) }) loadPrices(moduleId)
.then((prices) => {
const paise = latestPrice(prices, kind, edition)
setLines((prev) => prev.map((l, i) =>
i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l))
})
.catch(() => { /* no prefill — the unit price stays editable */ }) .catch(() => { /* no prefill — the unit price stays editable */ })
} }
@ -86,8 +109,10 @@ export function NewDocument() {
clientId: client.id, clientId: client.id,
lines: ready.map((l) => ({ lines: ready.map((l) => ({
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty), moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}), ...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}), ...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})), })),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}), ...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
}) })
@ -148,6 +173,8 @@ export function NewDocument() {
<h3>Lines</h3> <h3>Lines</h3>
{lines.map((l, i) => { {lines.map((l, i) => {
const mod = modules.data?.find((m) => m.id === l.moduleId) const mod = modules.data?.find((m) => m.id === l.moduleId)
const prices = pricesByModule[l.moduleId] ?? []
const editions = editionsOf(prices)
return ( return (
<div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}> <div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Module"> <Field label="Module">
@ -155,7 +182,21 @@ export function NewDocument() {
className="wf" value={l.moduleId} className="wf" value={l.moduleId}
onChange={(e) => { onChange={(e) => {
const moduleId = e.target.value const moduleId = e.target.value
setLine(i, { moduleId, kind: '', unitRs: '' }) const picked = modules.data?.find((m) => m.id === moduleId)
setLine(i, {
moduleId, kind: '', unitRs: '', edition: 'standard',
contentText: (picked?.quoteContent ?? []).join('\n'),
})
// Default to the sole/standard edition; packs with no 'standard' pick the first row.
if (moduleId !== '') {
loadPrices(moduleId)
.then((ps) => {
const eds = editionsOf(ps)
const def = eds.includes('standard') ? 'standard' : (eds[0] ?? 'standard')
if (def !== 'standard') setLine(i, { edition: def })
})
.catch(() => { /* pricing stays manual */ })
}
}} }}
> >
<option value="">Module</option> <option value="">Module</option>
@ -170,13 +211,30 @@ export function NewDocument() {
onChange={(e) => { onChange={(e) => {
const kind = e.target.value as Kind const kind = e.target.value as Kind
setLine(i, { kind }) setLine(i, { kind })
if (l.moduleId !== '') prefill(i, l.moduleId, kind) if (l.moduleId !== '') prefill(i, l.moduleId, kind, l.edition)
}} }}
> >
<option value="">Kind</option> <option value="">Kind</option>
{(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)} {(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select> </select>
</Field> </Field>
{editions.length > 1 && (
<Field label="Pack">
<select
className="wf" value={l.edition}
onChange={(e) => {
const edition = e.target.value
setLine(i, { edition })
if (l.moduleId !== '' && l.kind !== '') prefill(i, l.moduleId, l.kind, edition)
}}
>
{editions.map((ed) => {
const p = editionPrice(prices, ed, l.kind)
return <option key={ed} value={ed}>{ed}{p !== null ? `${formatINR(p)}` : ''}</option>
})}
</select>
</Field>
)}
<Field label="Qty"> <Field label="Qty">
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} /> <input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} />
</Field> </Field>
@ -186,6 +244,12 @@ export function NewDocument() {
<Field label="Description (optional)"> <Field label="Description (optional)">
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} /> <input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} />
</Field> </Field>
<Field label="What's included (one per line)">
<textarea
className="wf" rows={2} style={{ minWidth: 220 }}
value={l.contentText} onChange={(e) => setLine(i, { contentText: e.target.value })}
/>
</Field>
{lines.length > 1 && ( {lines.length > 1 && (
<Button tone="danger" onClick={() => setLines((prev) => prev.filter((_x, j) => j !== i))}>Remove</Button> <Button tone="danger" onClick={() => setLines((prev) => prev.filter((_x, j) => j !== i))}>Remove</Button>
)} )}

@ -7,8 +7,9 @@ import {
} from './repos-clients' } from './repos-clients'
import { import {
assignModule, createModule, getClientModule, getModule, listClientModules, assignModule, createModule, getClientModule, getModule, listClientModules,
listModules, listPrices, setPrice, updateClientModule, listModules, listPrices, setPrice, updateClientModule, updateModule,
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type PriceInput, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch,
type PriceInput,
} from './repos-modules' } from './repos-modules'
import { import {
cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, cancelDocument, convertDocument, createCreditNote, createDraft, getDocument,
@ -93,6 +94,18 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
} }
}) })
r.patch('/modules/:id', 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 {
const mod = updateModule(db, staffId(res), id, req.body as ModulePatch)
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) => { r.get('/modules/:id/prices', requireAuth, (req, res) => {
const id = String(req.params['id'] ?? '') const id = String(req.params['id'] ?? '')
if (getModule(db, id) === null) { if (getModule(db, id) === null) {

@ -25,7 +25,8 @@ CREATE TABLE IF NOT EXISTS module (
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
sac TEXT NOT NULL DEFAULT '998313', -- IT services default; CA session confirms per module sac TEXT NOT NULL DEFAULT '998313', -- IT services default; CA session confirms per module
allowed_kinds TEXT NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]', allowed_kinds TEXT NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]',
multi_subscription INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1 multi_subscription INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1,
quote_content TEXT NOT NULL DEFAULT '[]' -- JSON string[]: "what's included" lines on quotes
); );
CREATE TABLE IF NOT EXISTS module_price_book ( CREATE TABLE IF NOT EXISTS module_price_book (
id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard', id TEXT PRIMARY KEY, module_id TEXT NOT NULL, edition TEXT NOT NULL DEFAULT 'standard',
@ -113,5 +114,14 @@ export function openDb(dataDir?: string): DB {
db.pragma('journal_mode = WAL') db.pragma('journal_mode = WAL')
} }
db.exec(SCHEMA) db.exec(SCHEMA)
migrate(db)
return db return db
} }
/** Additive, idempotent column adds for DBs created before a schema change. */
function migrate(db: DB): void {
const moduleCols = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!moduleCols.some((c) => c.name === 'quote_content')) {
db.exec(`ALTER TABLE module ADD COLUMN quote_content TEXT NOT NULL DEFAULT '[]'`)
}
}

@ -19,7 +19,11 @@ export type DocStatus =
/** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */ /** HQ services are B2B, GST-exclusive, one flat class (seeded at install). */
const TAX_CLASS = 'GST18' const TAX_CLASS = 'GST18'
export interface DocPayload { lines: BillLine[]; totals: BillTotals; terms?: string } export interface DocPayload {
lines: BillLine[]; totals: BillTotals; terms?: string
/** Per-line "what's included" bullets, parallel to `lines` (empty array = nothing to print). */
lineContents?: string[][]
}
export interface Doc { export interface Doc {
id: string; docType: DocType; docNo: string | null; fy: string; clientId: string id: string; docType: DocType; docNo: string | null; fy: string; clientId: string
@ -95,6 +99,8 @@ export function listDocumentEvents(db: DB, documentId: string): DocumentEvent[]
export interface DraftLineInput { export interface DraftLineInput {
moduleId: string; description?: string; qty: number moduleId: string; description?: string; qty: number
unitPricePaise?: number /* default: priceOn today */; kind: Kind; edition?: string unitPricePaise?: number /* default: priceOn today */; kind: Kind; edition?: string
/** "What's included" bullets for this line; default = the module's quoteContent. */
contentLines?: string[]
} }
export interface DraftInput { export interface DraftInput {
@ -136,9 +142,12 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string): LineInput
if (unitPricePaise === null) { if (unitPricePaise === null) {
throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`) throw new Error(`No price for module ${mod.code} (${line.kind}/${edition}) on ${onDate}`)
} }
const packSuffix = edition !== 'standard' ? `${edition}` : '' // pack name prints on the line
return { return {
itemId: mod.id, itemId: mod.id,
name: mod.name + (line.description !== undefined && line.description !== '' ? `${line.description}` : ''), name: mod.name
+ (line.description !== undefined && line.description !== '' ? `${line.description}` : '')
+ packSuffix,
hsn: mod.sac, // SAC code for our service lines; PDFs label the column "SAC" hsn: mod.sac, // SAC code for our service lines; PDFs label the column "SAC"
qty: line.qty, qty: line.qty,
unitCode: 'NOS', unitCode: 'NOS',
@ -188,8 +197,11 @@ export function createDraft(db: DB, userId: string, input: DraftInput): Doc {
placeOfSupplyStateCode: client.stateCode, // client state ≠ ours ⇒ IGST placeOfSupplyStateCode: client.stateCode, // client state ≠ ours ⇒ IGST
roundToRupee: true, roundToRupee: true,
}, taxRates(db)) }, taxRates(db))
// Parallel to lines: caller override, else the module's quoteContent (buildLines proved each exists).
const lineContents = input.lines.map((line) =>
line.contentLines ?? getModule(db, line.moduleId)!.quoteContent)
const payload: DocPayload = { const payload: DocPayload = {
lines: computed.lines, totals: computed.totals, lines: computed.lines, totals: computed.totals, lineContents,
...(input.terms !== undefined ? { terms: input.terms } : {}), ...(input.terms !== undefined ? { terms: input.terms } : {}),
} }
return db.transaction(() => insertDocRow(db, userId, { return db.transaction(() => insertDocRow(db, userId, {

@ -11,11 +11,13 @@ const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
export interface Module { export interface Module {
id: string; code: string; name: string; sac: string id: string; code: string; name: string; sac: string
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[] // optional "what's included" lines that flow onto quotes
} }
interface ModuleRow { interface ModuleRow {
id: string; code: string; name: string; sac: string id: string; code: string; name: string; sac: string
allowed_kinds: string; multi_subscription: number; active: number allowed_kinds: string; multi_subscription: number; active: number
quote_content: string
} }
function toModule(r: ModuleRow): Module { function toModule(r: ModuleRow): Module {
@ -24,12 +26,13 @@ function toModule(r: ModuleRow): Module {
allowedKinds: JSON.parse(r.allowed_kinds) as Kind[], allowedKinds: JSON.parse(r.allowed_kinds) as Kind[],
multiSubscription: r.multi_subscription === 1, multiSubscription: r.multi_subscription === 1,
active: r.active === 1, active: r.active === 1,
quoteContent: JSON.parse(r.quote_content) as string[],
} }
} }
export interface ModuleInput { export interface ModuleInput {
code: string; name: string; sac?: string code: string; name: string; sac?: string
allowedKinds?: Kind[]; multiSubscription?: boolean allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
} }
export function getModule(db: DB, id: string): Module | null { export function getModule(db: DB, id: string): Module | null {
@ -49,17 +52,48 @@ export function createModule(db: DB, userId: string, input: ModuleInput): Module
} }
const id = uuidv7() const id = uuidv7()
db.prepare( db.prepare(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription) `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run( ).run(
id, input.code, input.name, input.sac ?? '998313', id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0, JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []),
) )
const mod = getModule(db, id)! const mod = getModule(db, id)!
writeAudit(db, userId, 'create', 'module', id, undefined, mod) writeAudit(db, userId, 'create', 'module', id, undefined, mod)
return mod return mod
} }
export interface ModulePatch {
name?: string; sac?: string; allowedKinds?: Kind[]
multiSubscription?: boolean; active?: boolean; quoteContent?: string[]
}
export function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Module {
const before = getModule(db, id)
if (before === null) throw new Error('Module not found')
if (patch.allowedKinds !== undefined) {
for (const k of patch.allowedKinds) {
if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`)
}
}
const sets: string[] = []
const args: unknown[] = []
if (patch.name !== undefined) { sets.push('name=?'); args.push(patch.name) }
if (patch.sac !== undefined) { sets.push('sac=?'); args.push(patch.sac) }
if (patch.allowedKinds !== undefined) { sets.push('allowed_kinds=?'); args.push(JSON.stringify(patch.allowedKinds)) }
if (patch.multiSubscription !== undefined) { sets.push('multi_subscription=?'); args.push(patch.multiSubscription ? 1 : 0) }
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) }
if (sets.length > 0) {
args.push(id)
db.prepare(`UPDATE module SET ${sets.join(', ')} WHERE id=?`).run(...args)
}
const after = getModule(db, id)!
writeAudit(db, userId, 'update', 'module', id, before, after)
return after
}
export interface ModulePrice { export interface ModulePrice {
id: string; moduleId: string; edition: string; kind: Kind id: string; moduleId: string; edition: string; kind: Kind
pricePaise: number; effectiveFrom: string pricePaise: number; effectiveFrom: string

@ -32,10 +32,13 @@ function displayDate(iso: string): string {
return `${d ?? ''}/${m ?? ''}/${y ?? ''}` return `${d ?? ''}/${m ?? ''}/${y ?? ''}`
} }
function lineRow(l: BillLine, i: number): string { function lineRow(l: BillLine, i: number, contents: string[]): string {
const bullets = contents.length > 0
? `<ul class="line-content">${contents.map((c) => `<li>${esc(c)}</li>`).join('')}</ul>`
: ''
return `<tr> return `<tr>
<td class="c">${i + 1}</td> <td class="c">${i + 1}</td>
<td>${esc(l.name)}</td> <td>${esc(l.name)}${bullets}</td>
<td class="c">${esc(l.hsn)}</td> <td class="c">${esc(l.hsn)}</td>
<td class="r">${l.qty}</td> <td class="r">${l.qty}</td>
<td class="r">${formatINR(l.unitPricePaise)}</td> <td class="r">${formatINR(l.unitPricePaise)}</td>
@ -87,6 +90,8 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
table.lines th, table.lines td { border: 1px solid #999; padding: 4px 6px; } table.lines th, table.lines td { border: 1px solid #999; padding: 4px 6px; }
table.lines th { background: #f0f0f0; font-size: 10px; text-transform: uppercase; } table.lines th { background: #f0f0f0; font-size: 10px; text-transform: uppercase; }
td.r, th.r { text-align: right; } td.c, th.c { text-align: center; } td.r, th.r { text-align: right; } td.c, th.c { text-align: center; }
ul.line-content { margin: 3px 0 0; padding-left: 16px; color: #555; font-size: 10px; }
ul.line-content li { margin: 1px 0; }
.bottom { display: flex; justify-content: space-between; align-items: flex-start; } .bottom { display: flex; justify-content: space-between; align-items: flex-start; }
.words { width: 55%; } .words { width: 55%; }
table.totals { width: 40%; border-collapse: collapse; } table.totals { width: 40%; border-collapse: collapse; }
@ -129,7 +134,7 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${doc.payload.lines.map(lineRow).join('\n')} ${doc.payload.lines.map((l, i) => lineRow(l, i, doc.payload.lineContents?.[i] ?? [])).join('\n')}
</tbody> </tbody>
</table> </table>

@ -47,6 +47,39 @@ describe('documents', () => {
expect(inv.refDocId).toBe(qt.id) expect(inv.refDocId).toBe(qt.id)
expect(inv.payablePaise).toBe(qt.payablePaise) expect(inv.payablePaise).toBe(qt.payablePaise)
}) })
it('line contents default to the module quote content, and can be overridden per line', () => {
const { db, c } = setup()
const sms = createModule(db, 'u1', {
code: 'SMS', name: 'SMS Gateway', quoteContent: ['Bulk SMS gateway', 'DLT registration'],
})
setPrice(db, 'u1', { moduleId: sms.id, kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly' }] })
expect(d.payload.lineContents).toEqual([['Bulk SMS gateway', 'DLT registration']])
const d2 = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly', contentLines: ['Custom scope only'] }] })
expect(d2.payload.lineContents).toEqual([['Custom scope only']])
})
it('a module without quote content contributes an empty content array', () => {
const { db, c, m } = setup()
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.payload.lineContents).toEqual([[]])
})
it('a pack (non-standard edition) prices from that edition and prints the pack name on the line', () => {
const { db, c, m } = setup()
setPrice(db, 'u1', { moduleId: m.id, edition: 'SMS-50K', kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly', edition: 'SMS-50K' }] })
expect(d.taxablePaise).toBe(5_000_00)
expect(d.payload.lines[0]!.name).toContain('— SMS-50K')
const std = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(std.payload.lines[0]!.name).not.toContain('—')
})
it('credit note defaults to full value against the invoice; cancel keeps the number', () => { it('credit note defaults to full value against the invoice; cancel keeps the number', () => {
const { db, c, m } = setup() const { db, c, m } = setup()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,

@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
/**
* Bundle isolation: the store apps (store-server / pos / backoffice) and the HQ
* apps (hq / hq-web) are separate deliverables that must never import each other.
* A static, dependency-free scan of their src/ trees enforces the wall shared
* @sims/* packages are the only sanctioned bridge. See docs/15 (D15 one-repo/two-apps).
*/
const APPS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..')
const STORE_APPS = ['store-server', 'pos', 'backoffice']
const HQ_APPS = ['hq', 'hq-web']
/** Every .ts/.tsx file under apps/<name>/src, recursively. */
function sourceFiles(app: string): string[] {
const root = path.join(APPS_DIR, app, 'src')
const out: string[] = []
const walk = (dir: string): void => {
if (!fs.existsSync(dir)) return
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (/\.tsx?$/.test(entry.name)) out.push(full)
}
}
walk(root)
return out
}
/** Static import / re-export / dynamic-import / require specifiers in a source file. */
function importSpecifiers(file: string): string[] {
const src = fs.readFileSync(file, 'utf8')
const re = /\b(?:from|import|require)\b\s*\(?\s*['"]([^'"]+)['"]/g
const specs: string[] = []
let m: RegExpExecArray | null
while ((m = re.exec(src)) !== null) specs.push(m[1]!)
return specs
}
/** Does `spec`, imported from `file`, reach into one of the forbidden app trees? */
function crossesInto(file: string, spec: string, forbiddenDirs: string[], forbiddenNames: string[]): boolean {
if (spec.startsWith('.')) {
const resolved = path.resolve(path.dirname(file), spec)
return forbiddenDirs.some((d) => resolved === d || resolved.startsWith(d + path.sep))
}
// Bare specifiers: @sims/* and third-party deps are fine; an explicit apps/<other> path is not.
return forbiddenNames.some((name) => spec.includes(`apps/${name}`) || spec.includes(`apps\\${name}`))
}
function violations(fromApps: string[], toApps: string[]): string[] {
const forbiddenDirs = toApps.map((a) => path.join(APPS_DIR, a))
const found: string[] = []
for (const app of fromApps) {
for (const file of sourceFiles(app)) {
for (const spec of importSpecifiers(file)) {
if (crossesInto(file, spec, forbiddenDirs, toApps)) {
found.push(`${path.relative(APPS_DIR, file)} imports '${spec}'`)
}
}
}
}
return found
}
describe('bundle isolation between store apps and HQ apps', () => {
it('scans a non-empty set of source files (guards against a broken path)', () => {
expect([...STORE_APPS, ...HQ_APPS].flatMap(sourceFiles).length).toBeGreaterThan(0)
})
it('store apps never import HQ apps', () => {
expect(violations(STORE_APPS, HQ_APPS)).toEqual([])
})
it('HQ apps never import store apps', () => {
expect(violations(HQ_APPS, STORE_APPS)).toEqual([])
})
})

@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { createModule, setPrice, priceOn } from '../src/repos-modules' import { createModule, updateModule, getModule, listModules, setPrice, priceOn } from '../src/repos-modules'
import { listAudit } from '../src/audit'
describe('module catalog', () => { describe('module catalog', () => {
it('resolves the dated price row', () => { it('resolves the dated price row', () => {
@ -12,4 +13,29 @@ describe('module catalog', () => {
expect(priceOn(db, m.id, 'yearly', 'standard', '2026-09-01')).toBe(1_50_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() expect(priceOn(db, m.id, 'monthly', 'standard', '2026-09-01')).toBeNull()
}) })
it('round-trips quote content and defaults it to an empty list', () => {
const db = openDb(':memory:')
const plain = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
expect(plain.quoteContent).toEqual([])
const sms = createModule(db, 'u1', {
code: 'SMS', name: 'SMS Gateway',
quoteContent: ['Bulk SMS gateway', 'DLT template registration'],
})
expect(sms.quoteContent).toEqual(['Bulk SMS gateway', 'DLT template registration'])
expect(getModule(db, sms.id)!.quoteContent).toEqual(['Bulk SMS gateway', 'DLT template registration'])
expect(listModules(db).find((m) => m.id === sms.id)!.quoteContent).toEqual([
'Bulk SMS gateway', 'DLT template registration',
])
})
it('updateModule edits quote content and writes an audit row', () => {
const db = openDb(':memory:')
const m = createModule(db, 'u1', { code: 'SMS', name: 'SMS Gateway' })
const updated = updateModule(db, 'u1', m.id, { quoteContent: ['Two-way SMS', 'Delivery reports'] })
expect(updated.quoteContent).toEqual(['Two-way SMS', 'Delivery reports'])
expect(getModule(db, m.id)!.quoteContent).toEqual(['Two-way SMS', 'Delivery reports'])
const audit = listAudit(db).find((a) => a.action === 'update' && a.entity === 'module' && a.entity_id === m.id)
expect(audit).toBeDefined()
})
}) })

@ -29,6 +29,19 @@ describe('document html', () => {
expect(html).toContain('QUOTATION') expect(html).toContain('QUOTATION')
expect(html).not.toContain('HDFC') expect(html).not.toContain('HDFC')
}) })
it('renders per-line content bullets when present and nothing when absent', () => {
const withContent = {
...(doc as object),
payload: { lines: (doc as never as { payload: { lines: unknown[] } }).payload.lines,
lineContents: [['Bulk SMS gateway', 'DLT template registration']], totals: {} },
} as never
const html = documentHtml(withContent, client, company)
expect(html).toContain('<ul class="line-content">')
expect(html).toContain('Bulk SMS gateway')
expect(html).toContain('DLT template registration')
// The base fixture has no lineContents, so no stray content list is emitted.
expect(documentHtml(doc, client, company)).not.toContain('<ul class="line-content">')
})
}) })
describe('rupees in words', () => { describe('rupees in words', () => {

Loading…
Cancel
Save