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 {
id: string; code: string; name: string; sac: string
allowedKinds: Kind[]; multiSubscription: boolean; active: boolean
quoteContent: string[]
}
export interface ModulePrice {
@ -117,7 +118,7 @@ export interface Doc {
docDate: string; status: DocStatus; refDocId: string | null
taxablePaise: number; cgstPaise: number; sgstPaise: number; igstPaise: number
roundOffPaise: number; payablePaise: number
payload: { lines: DocLine[]; terms?: string }
payload: { lines: DocLine[]; terms?: string; lineContents?: string[][] }
source: string; createdBy: string; createdAt: string
}
@ -161,6 +162,8 @@ export const getModules = (): Promise<Module[]> =>
apiFetch<{ modules: Module[] }>('/modules').then((r) => r.modules)
export const createModule = (body: Record<string, unknown>): Promise<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[]> =>
apiFetch<{ prices: ModulePrice[] }>(`/modules/${moduleId}/prices`).then((r) => r.prices)
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 { Badge, Button, DataTable, EmptyState, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
import {
addPrice, createModule, getModules, getPrices, role,
addPrice, createModule, getModules, getPrices, patchModule, role,
KIND_LABEL, type Kind, type Module,
} from '../api'
import { useData } from './Clients'
@ -10,6 +10,9 @@ import { useData } from './Clients'
const ALL_KINDS: Kind[] = ['one_time', 'monthly', 'yearly', 'usage']
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. */
export function Modules() {
const isOwner = role() === 'owner'
@ -48,16 +51,57 @@ export function Modules() {
/>
)}
{selected !== undefined && (
<PriceBook module={selected} isOwner={isOwner} />
<>
<QuoteContent key={selected.id} module={selected} isOwner={isOwner} onSaved={() => modules.reload()} />
<PriceBook module={selected} isOwner={isOwner} />
</>
)}
</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 }) {
const [f, setF] = useState({ code: '', name: '', sac: '998313' })
const [kinds, setKinds] = useState<Kind[]>([...ALL_KINDS])
const [multi, setMulti] = useState(false)
const [quoteText, setQuoteText] = useState('')
const [error, setError] = useState<string | undefined>()
const set = (k: keyof typeof f) => (e: { target: { value: string } }) =>
setF((p) => ({ ...p, [k]: e.target.value }))
@ -70,6 +114,7 @@ function NewModuleForm(props: { onCreated: () => void }) {
createModule({
code: f.code.trim().toUpperCase(), name: f.name.trim(), sac: f.sac.trim(),
allowedKinds: ALL_KINDS.filter((k) => kinds.includes(k)), multiSubscription: multi,
quoteContent: splitLines(quoteText),
})
.then(props.onCreated)
.catch((e: Error) => setError(e.message))
@ -95,6 +140,9 @@ function NewModuleForm(props: { onCreated: () => void }) {
<input type="checkbox" checked={multi} onChange={() => setMulti((v) => !v)} />
multi-subscription
</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>
</div>
{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 { formatINR, fromRupees } from '@sims/domain'
import { Badge, Button, Field, Notice, PageHeader, Toolbar } from '@sims/ui'
@ -12,9 +12,18 @@ const today = () => new Date().toISOString().slice(0, 10)
interface LineDraft {
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. */
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
}
/** 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). */
export function NewDocument() {
const nav = useNavigate()
@ -46,18 +64,23 @@ export function NewDocument() {
return () => clearTimeout(t)
}, [q, client])
// -- per-module price cache for unit-₹ prefill --
const priceCache = useRef(new Map<string, ModulePrice[]>())
const prefill = (index: number, moduleId: string, kind: Kind) => {
const apply = (prices: ModulePrice[]) => {
const paise = latestPrice(prices, kind)
setLines((prev) => prev.map((l, i) =>
i === index ? { ...l, unitRs: paise !== null ? String(paise / 100) : l.unitRs } : l))
}
const cached = priceCache.current.get(moduleId)
if (cached !== undefined) { apply(cached); return }
getPrices(moduleId)
.then((prices) => { priceCache.current.set(moduleId, prices); apply(prices) })
// -- per-module price book, in state so the pack pick-list renders reactively --
const [pricesByModule, setPricesByModule] = useState<Record<string, ModulePrice[]>>({})
const loadPrices = (moduleId: string): Promise<ModulePrice[]> => {
const cached = pricesByModule[moduleId]
if (cached !== undefined) return Promise.resolve(cached)
return getPrices(moduleId).then((prices) => {
setPricesByModule((prev) => ({ ...prev, [moduleId]: prices }))
return prices
})
}
const prefill = (index: number, moduleId: string, kind: Kind, edition: string) => {
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 */ })
}
@ -86,8 +109,10 @@ export function NewDocument() {
clientId: client.id,
lines: ready.map((l) => ({
moduleId: l.moduleId, kind: l.kind, qty: Number(l.qty),
...(l.edition !== 'standard' ? { edition: l.edition } : {}),
...(l.unitRs !== '' ? { unitPricePaise: fromRupees(Number(l.unitRs)) } : {}),
...(l.description !== '' ? { description: l.description } : {}),
contentLines: splitContent(l.contentText),
})),
...(terms.trim() !== '' ? { terms: terms.trim() } : {}),
})
@ -148,6 +173,8 @@ export function NewDocument() {
<h3>Lines</h3>
{lines.map((l, i) => {
const mod = modules.data?.find((m) => m.id === l.moduleId)
const prices = pricesByModule[l.moduleId] ?? []
const editions = editionsOf(prices)
return (
<div key={i} style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end', marginBottom: 8 }}>
<Field label="Module">
@ -155,7 +182,21 @@ export function NewDocument() {
className="wf" value={l.moduleId}
onChange={(e) => {
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>
@ -170,13 +211,30 @@ export function NewDocument() {
onChange={(e) => {
const kind = e.target.value as 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>
{(mod?.allowedKinds ?? []).map((k) => <option key={k} value={k}>{KIND_LABEL[k]}</option>)}
</select>
</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">
<input className="wf num" style={{ width: 70 }} value={l.qty} onChange={(e) => setLine(i, { qty: e.target.value })} />
</Field>
@ -186,6 +244,12 @@ export function NewDocument() {
<Field label="Description (optional)">
<input className="wf" value={l.description} onChange={(e) => setLine(i, { description: e.target.value })} />
</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 && (
<Button tone="danger" onClick={() => setLines((prev) => prev.filter((_x, j) => j !== i))}>Remove</Button>
)}

@ -7,8 +7,9 @@ import {
} from './repos-clients'
import {
assignModule, createModule, getClientModule, getModule, listClientModules,
listModules, listPrices, setPrice, updateClientModule,
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type PriceInput,
listModules, listPrices, setPrice, updateClientModule, updateModule,
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch,
type PriceInput,
} from './repos-modules'
import {
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) })
}
})
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) => {
const id = String(req.params['id'] ?? '')
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,
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"]',
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 (
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.exec(SCHEMA)
migrate(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). */
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 {
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 {
moduleId: string; description?: string; qty: number
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 {
@ -136,9 +142,12 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string): LineInput
if (unitPricePaise === null) {
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 {
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"
qty: line.qty,
unitCode: 'NOS',
@ -188,8 +197,11 @@ export function createDraft(db: DB, userId: string, input: DraftInput): Doc {
placeOfSupplyStateCode: client.stateCode, // client state ≠ ours ⇒ IGST
roundToRupee: true,
}, 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 = {
lines: computed.lines, totals: computed.totals,
lines: computed.lines, totals: computed.totals, lineContents,
...(input.terms !== undefined ? { terms: input.terms } : {}),
}
return db.transaction(() => insertDocRow(db, userId, {

@ -11,11 +11,13 @@ 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
quoteContent: string[] // optional "what's included" lines that flow onto quotes
}
interface ModuleRow {
id: string; code: string; name: string; sac: string
allowed_kinds: string; multi_subscription: number; active: number
quote_content: string
}
function toModule(r: ModuleRow): Module {
@ -24,12 +26,13 @@ function toModule(r: ModuleRow): Module {
allowedKinds: JSON.parse(r.allowed_kinds) as Kind[],
multiSubscription: r.multi_subscription === 1,
active: r.active === 1,
quoteContent: JSON.parse(r.quote_content) as string[],
}
}
export interface ModuleInput {
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 {
@ -49,17 +52,48 @@ export function createModule(db: DB, userId: string, input: ModuleInput): Module
}
const id = uuidv7()
db.prepare(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription)
VALUES (?, ?, ?, ?, ?, ?)`,
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []),
)
const mod = getModule(db, id)!
writeAudit(db, userId, 'create', 'module', id, undefined, 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 {
id: string; moduleId: string; edition: string; kind: Kind
pricePaise: number; effectiveFrom: string

@ -32,10 +32,13 @@ function displayDate(iso: string): string {
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>
<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="r">${l.qty}</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 { background: #f0f0f0; font-size: 10px; text-transform: uppercase; }
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; }
.words { width: 55%; }
table.totals { width: 40%; border-collapse: collapse; }
@ -129,7 +134,7 @@ export function documentHtml(doc: Doc, client: Client, company: Record<string, s
</tr>
</thead>
<tbody>
${doc.payload.lines.map(lineRow).join('\n')}
${doc.payload.lines.map((l, i) => lineRow(l, i, doc.payload.lineContents?.[i] ?? [])).join('\n')}
</tbody>
</table>

@ -47,6 +47,39 @@ describe('documents', () => {
expect(inv.refDocId).toBe(qt.id)
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', () => {
const { db, c, m } = setup()
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 { 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', () => {
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, '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).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', () => {

Loading…
Cancel
Save