feat(db): D19 P1-P3 — async DB interface, dual engines, Postgres adapter

P1: every repo, route, scheduler path and test now speaks one async DB
interface (all/get/run/exec/transaction). better-sqlite3 stays underneath
for dev/tests via SqliteDb (statement cache, manual BEGIN/SAVEPOINT
nesting); openDb stays sync so fixtures remain one-liners. Zero behavior
change: 346/346 tests, typecheck clean.

P2: migrations-pg.ts — numbered Postgres migration set (001-init: bigint
paise, integer flags, TEXT ISO dates, TEXT JSON; reminder CHECK born in
final widened form) + schema_migrations runner.

P3: db-pg.ts — pg Pool behind the same interface: ?->$n rewrite outside
string literals, transactions pinned to one pooled client via
AsyncLocalStorage with savepoint nesting, int8/numeric parsed to JS
numbers so paise math is identical on both engines.

Also: reminder upsert INSERT OR IGNORE -> ON CONFLICT DO NOTHING
(portable, same changes-count semantics).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 08be48817e
commit 5f9f961d20

@ -13,93 +13,95 @@ import { createDraft, issueDocument, markStatus } from '../src/repos-documents'
import { recordPayment } from '../src/repos-payments' import { recordPayment } from '../src/repos-payments'
import { mintShare } from '../src/repos-shares' import { mintShare } from '../src/repos-shares'
const db = openDb(process.env['HQ_DATA_DIR']) void (async () => {
const db = openDb(process.env['HQ_DATA_DIR'])
// Known owner so we can log in for screenshots (before seedIfEmpty, so it skips its own). // Known owner so we can log in for screenshots (before seedIfEmpty, so it skips its own).
createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' }) await createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
seedIfEmpty(db) await seedIfEmpty(db)
const U = 'demo' const U = 'demo'
// A simple monogram logo as an SVG data URI (renders in <img> and puppeteer). // A simple monogram logo as an SVG data URI (renders in <img> and puppeteer).
const LOGO = 'data:image/svg+xml;base64,' + Buffer.from( const LOGO = 'data:image/svg+xml;base64,' + Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120"><rect width="120" height="120" rx="20" fill="#1e3a5f"/><text x="60" y="78" font-family="Segoe UI,Arial" font-size="54" font-weight="700" fill="#ffffff" text-anchor="middle">TS</text></svg>`, `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120"><rect width="120" height="120" rx="20" fill="#1e3a5f"/><text x="60" y="78" font-family="Segoe UI,Arial" font-size="54" font-weight="700" fill="#ffffff" text-anchor="middle">TS</text></svg>`,
).toString('base64') ).toString('base64')
// Company + template letterhead data (what the Document Template page edits). // Company + template letterhead data (what the Document Template page edits).
const settings: Record<string, string> = { const settings: Record<string, string> = {
'company.name': 'Tecnostac Solutions Pvt Ltd', 'company.name': 'Tecnostac Solutions Pvt Ltd',
'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030', 'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030',
'company.gstin': '32ABCDE1234F1Z9', 'company.gstin': '32ABCDE1234F1Z9',
'company.state_code': '32', 'company.state_code': '32',
'company.phone': '+91 98470 12345', 'company.phone': '+91 98470 12345',
'company.email': 'accounts@tecnostac.com', 'company.email': 'accounts@tecnostac.com',
'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad', 'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad',
'template.accent': '#1e3a5f', 'template.accent': '#1e3a5f',
'template.logo': LOGO, 'template.logo': LOGO,
'template.terms': '1. Prices are valid for 15 days from the date of this quotation.\n2. 50% advance on confirmation, balance before go-live.\n3. Annual subscription renews on the anniversary of activation.', 'template.terms': '1. Prices are valid for 15 days from the date of this quotation.\n2. 50% advance on confirmation, balance before go-live.\n3. Annual subscription renews on the anniversary of activation.',
'template.declaration': 'We declare that this invoice shows the actual price of the services described and that all particulars are true and correct.', 'template.declaration': 'We declare that this invoice shows the actual price of the services described and that all particulars are true and correct.',
'template.jurisdiction': 'Subject to Ernakulam jurisdiction', 'template.jurisdiction': 'Subject to Ernakulam jurisdiction',
'template.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345', 'template.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345',
'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd', 'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd',
} }
for (const [k, v] of Object.entries(settings)) setSetting(db, U, k, v) for (const [k, v] of Object.entries(settings)) await setSetting(db, U, k, v)
// Clients — one out-of-state (Karnataka, 29) to show IGST; one lead. // Clients — one out-of-state (Karnataka, 29) to show IGST; one lead.
const malabar = createClient(db, U, { name: 'Malabar Supermarket', stateCode: '32', address: 'MG Road, Kozhikode, Kerala 673001', status: 'active', contacts: [{ name: 'Rajesh Nair', phone: '9847011111', email: 'rajesh@malabarsuper.in' }] }).id const malabar = (await createClient(db, U, { name: 'Malabar Supermarket', stateCode: '32', address: 'MG Road, Kozhikode, Kerala 673001', status: 'active', contacts: [{ name: 'Rajesh Nair', phone: '9847011111', email: 'rajesh@malabarsuper.in' }] })).id
const lulu = createClient(db, U, { name: 'Lulu Hyper Kochi', stateCode: '32', address: 'Edappally, Kochi, Kerala 682024', status: 'active', contacts: [{ name: 'Fathima K', phone: '9847022222', email: 'it@lulukochi.in' }] }).id const lulu = (await createClient(db, U, { name: 'Lulu Hyper Kochi', stateCode: '32', address: 'Edappally, Kochi, Kerala 682024', status: 'active', contacts: [{ name: 'Fathima K', phone: '9847022222', email: 'it@lulukochi.in' }] })).id
const blr = createClient(db, U, { name: 'Bengaluru Fresh Mart', stateCode: '29', address: 'Indiranagar, Bengaluru, Karnataka 560038', status: 'active', contacts: [{ name: 'Anil Kumar', phone: '9880033333', email: 'anil@blrfresh.in' }] }).id const blr = (await createClient(db, U, { name: 'Bengaluru Fresh Mart', stateCode: '29', address: 'Indiranagar, Bengaluru, Karnataka 560038', status: 'active', contacts: [{ name: 'Anil Kumar', phone: '9880033333', email: 'anil@blrfresh.in' }] })).id
createClient(db, U, { name: 'Green Valley Stores', stateCode: '32', address: 'Thrissur, Kerala', status: 'lead', contacts: [{ name: 'Suresh', phone: '9847044444' }] }) await createClient(db, U, { name: 'Green Valley Stores', stateCode: '32', address: 'Thrissur, Kerala', status: 'lead', contacts: [{ name: 'Suresh', phone: '9847044444' }] })
// Modules + dated prices (GST-exclusive; the engine adds 18%). // Modules + dated prices (GST-exclusive; the engine adds 18%).
const pos = createModule(db, U, { code: 'POS', name: 'POS Billing', sac: '998314', allowedKinds: ['yearly', 'one_time'], quoteContent: ['Unlimited counters & cashiers', 'Offline billing with auto-sync', 'GST invoices + thermal print', 'Barcode & weighing-scale support'] }).id const pos = (await createModule(db, U, { code: 'POS', name: 'POS Billing', sac: '998314', allowedKinds: ['yearly', 'one_time'], quoteContent: ['Unlimited counters & cashiers', 'Offline billing with auto-sync', 'GST invoices + thermal print', 'Barcode & weighing-scale support'] })).id
const inv = createModule(db, U, { code: 'INV', name: 'Inventory & GST Filing', sac: '998314', allowedKinds: ['yearly'], quoteContent: ['Stock, purchases, suppliers', 'GSTR-1 / 3B ready exports', 'Expiry & reorder alerts'] }).id const inv = (await createModule(db, U, { code: 'INV', name: 'Inventory & GST Filing', sac: '998314', allowedKinds: ['yearly'], quoteContent: ['Stock, purchases, suppliers', 'GSTR-1 / 3B ready exports', 'Expiry & reorder alerts'] })).id
const cloud = createModule(db, U, { code: 'CLOUD', name: 'Cloud Sync & Backup', sac: '998315', allowedKinds: ['monthly', 'yearly'], multiSubscription: true, quoteContent: ['Real-time multi-store sync', 'Nightly encrypted cloud backup'] }).id const cloud = (await createModule(db, U, { code: 'CLOUD', name: 'Cloud Sync & Backup', sac: '998315', allowedKinds: ['monthly', 'yearly'], multiSubscription: true, quoteContent: ['Real-time multi-store sync', 'Nightly encrypted cloud backup'] })).id
const sms = createModule(db, U, { code: 'SMS', name: 'SMS Pack', sac: '998414', allowedKinds: ['one_time'], quoteContent: ['Bill alerts & payment reminders to customers', 'Sender ID + DLT registration included'] }).id const sms = (await createModule(db, U, { code: 'SMS', name: 'SMS Pack', sac: '998414', allowedKinds: ['one_time'], quoteContent: ['Bill alerts & payment reminders to customers', 'Sender ID + DLT registration included'] })).id
const FROM = '2026-04-01' const FROM = '2026-04-01'
setPrice(db, U, { moduleId: pos, kind: 'yearly', pricePaise: 18_000_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: pos, kind: 'yearly', pricePaise: 18_000_00, effectiveFrom: FROM })
setPrice(db, U, { moduleId: pos, kind: 'one_time', pricePaise: 25_000_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: pos, kind: 'one_time', pricePaise: 25_000_00, effectiveFrom: FROM })
setPrice(db, U, { moduleId: inv, kind: 'yearly', pricePaise: 12_000_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: inv, kind: 'yearly', pricePaise: 12_000_00, effectiveFrom: FROM })
setPrice(db, U, { moduleId: cloud, kind: 'monthly', pricePaise: 1_500_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: cloud, kind: 'monthly', pricePaise: 1_500_00, effectiveFrom: FROM })
setPrice(db, U, { moduleId: sms, edition: 'SMS-50K', kind: 'one_time', pricePaise: 4_000_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: sms, edition: 'SMS-50K', kind: 'one_time', pricePaise: 4_000_00, effectiveFrom: FROM })
setPrice(db, U, { moduleId: sms, edition: 'SMS-1L', kind: 'one_time', pricePaise: 7_000_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: sms, edition: 'SMS-1L', kind: 'one_time', pricePaise: 7_000_00, effectiveFrom: FROM })
// Client 360 modules // Client 360 modules
assignModule(db, U, { clientId: malabar, moduleId: pos, kind: 'yearly' }) await assignModule(db, U, { clientId: malabar, moduleId: pos, kind: 'yearly' })
assignModule(db, U, { clientId: malabar, moduleId: inv, kind: 'yearly' }) await assignModule(db, U, { clientId: malabar, moduleId: inv, kind: 'yearly' })
// A quotation for Malabar (POS yearly + Inventory + SMS 1-Lakh pack) → issued + share link. // A quotation for Malabar (POS yearly + Inventory + SMS 1-Lakh pack) → issued + share link.
const qt = createDraft(db, U, { const qt = await createDraft(db, U, {
docType: 'QUOTATION', clientId: malabar, docType: 'QUOTATION', clientId: malabar,
lines: [ lines: [
{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: pos, qty: 1, kind: 'yearly' },
{ moduleId: inv, qty: 1, kind: 'yearly' }, { moduleId: inv, qty: 1, kind: 'yearly' },
{ moduleId: sms, qty: 1, kind: 'one_time', edition: 'SMS-1L' }, { moduleId: sms, qty: 1, kind: 'one_time', edition: 'SMS-1L' },
], ],
terms: 'Prices valid for 15 days. 50% advance on confirmation.', terms: 'Prices valid for 15 days. 50% advance on confirmation.',
}) })
const qtIssued = issueDocument(db, U, qt.id) const qtIssued = await issueDocument(db, U, qt.id)
markStatus(db, U, qtIssued.id, 'sent') await markStatus(db, U, qtIssued.id, 'sent')
mintShare(db, U, qtIssued.id, { expiresDays: 30 }) await mintShare(db, U, qtIssued.id, { expiresDays: 30 })
// An invoice for Lulu (POS one-time + AMC) → issued, part-paid (drives the dashboard). // An invoice for Lulu (POS one-time + AMC) → issued, part-paid (drives the dashboard).
const amcMod = (db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string }).id const amcMod = ((await db.get<{ id: string }>(`SELECT id FROM module WHERE code='AMC'`))!).id
setPrice(db, U, { moduleId: amcMod, kind: 'yearly', pricePaise: 6_000_00, effectiveFrom: FROM }) await setPrice(db, U, { moduleId: amcMod, kind: 'yearly', pricePaise: 6_000_00, effectiveFrom: FROM })
const inv1 = issueDocument(db, U, createDraft(db, U, { const inv1 = await issueDocument(db, U, (await createDraft(db, U, {
docType: 'INVOICE', clientId: lulu, docType: 'INVOICE', clientId: lulu,
lines: [{ moduleId: pos, qty: 1, kind: 'one_time' }, { moduleId: amcMod, qty: 1, kind: 'yearly' }], lines: [{ moduleId: pos, qty: 1, kind: 'one_time' }, { moduleId: amcMod, qty: 1, kind: 'yearly' }],
}).id) })).id)
recordPayment(db, U, { clientId: lulu, receivedOn: '2026-07-05', mode: 'bank', reference: 'NEFT-88213', amountPaise: 20_000_00 }) await recordPayment(db, U, { clientId: lulu, receivedOn: '2026-07-05', mode: 'bank', reference: 'NEFT-88213', amountPaise: 20_000_00 })
// An out-of-state quotation (IGST) for Bengaluru. // An out-of-state quotation (IGST) for Bengaluru.
issueDocument(db, U, createDraft(db, U, { await issueDocument(db, U, (await createDraft(db, U, {
docType: 'QUOTATION', clientId: blr, docType: 'QUOTATION', clientId: blr,
lines: [{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: cloud, qty: 12, kind: 'monthly' }], lines: [{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: cloud, qty: 12, kind: 'monthly' }],
}).id) })).id)
const shareToken = (db.prepare(`SELECT token FROM document_share LIMIT 1`).get() as { token: string }).token const shareToken = ((await db.get<{ token: string }>(`SELECT token FROM document_share LIMIT 1`))!).token
console.log('DEMO_READY') console.log('DEMO_READY')
console.log('login: owner@tecnostac.com / demo-owner-2026') console.log('login: owner@tecnostac.com / demo-owner-2026')
console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo) console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo)
console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo) console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo)
console.log('share_token:', shareToken) console.log('share_token:', shareToken)
})()

@ -105,7 +105,7 @@ async function main(): Promise<void> {
const code = await waitForCode() const code = await waitForCode()
const { accessToken, refreshToken } = await exchangeCode(code, clientId, clientSecret, redirectUri) const { accessToken, refreshToken } = await exchangeCode(code, clientId, clientSecret, redirectUri)
const address = await fetchAddress(accessToken) const address = await fetchAddress(accessToken)
const account = saveAccount(openDb(process.env['HQ_DATA_DIR']), address, encrypt(refreshToken, keyHex)) const account = await saveAccount(openDb(process.env['HQ_DATA_DIR']), address, encrypt(refreshToken, keyHex))
console.log(`Connected ${account.address} — refresh token stored encrypted. HQ can now send email.`) console.log(`Connected ${account.address} — refresh token stored encrypted. HQ can now send email.`)
} }

File diff suppressed because it is too large Load Diff

@ -16,20 +16,19 @@ function nextIdMs(): number {
return lastIdMs return lastIdMs
} }
export function writeAudit( export async function writeAudit(
db: DB, userId: string, action: string, entity: string, entityId: string, db: DB, userId: string, action: string, entity: string, entityId: string,
before?: unknown, after?: unknown, before?: unknown, after?: unknown,
): void { ): Promise<void> {
db.prepare( await db.run(
`INSERT INTO audit_log (id, at_wall, user_id, action, entity, entity_id, before_json, after_json) `INSERT INTO audit_log (id, at_wall, user_id, action, entity, entity_id, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
uuidv7(nextIdMs()), new Date().toISOString(), userId, action, entity, entityId, uuidv7(nextIdMs()), new Date().toISOString(), userId, action, entity, entityId,
before === undefined ? null : JSON.stringify(before), before === undefined ? null : JSON.stringify(before),
after === undefined ? null : JSON.stringify(after), after === undefined ? null : JSON.stringify(after),
) )
} }
export function listAudit(db: DB, limit = 200): AuditRow[] { export async function listAudit(db: DB, limit = 200): Promise<AuditRow[]> {
return db.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`).all(limit) as AuditRow[] return await db.all<AuditRow>(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`, limit)
} }

@ -8,48 +8,52 @@ import type { DB } from './db'
const SESSION_DAYS = 14 const SESSION_DAYS = 14
export function createStaff(db: DB, input: { export async function createStaff(db: DB, input: {
email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string
}): { id: string } { }): Promise<{ id: string }> {
if (input.password.length < 8) throw new Error('Password must be at least 8 characters') if (input.password.length < 8) throw new Error('Password must be at least 8 characters')
const id = uuidv7() const id = uuidv7()
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
db.prepare( await db.run(
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.email.toLowerCase(), input.displayName, input.role, salt, hash) id, input.email.toLowerCase(), input.displayName, input.role, salt, hash,
writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role }) )
await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role })
return { id } return { id }
} }
interface StaffRow { id: string; email: string; display_name: string; role: string; pw_salt: string; pw_hash: string; active: number } interface StaffRow { id: string; email: string; display_name: string; role: string; pw_salt: string; pw_hash: string; active: number }
export function login(db: DB, email: string, password: string): export async function login(db: DB, email: string, password: string):
{ token: string; staff: { id: string; displayName: string; role: string } } | null { Promise<{ token: string; staff: { id: string; displayName: string; role: string } } | null> {
const row = db.prepare(`SELECT * FROM staff_user WHERE email=? AND active=1`).get(email.toLowerCase()) as StaffRow | undefined const row = await db.get<StaffRow>(`SELECT * FROM staff_user WHERE email=? AND active=1`, email.toLowerCase())
if (!row || !verifyPin(password, { salt: row.pw_salt, hash: row.pw_hash })) return null if (!row || !verifyPin(password, { salt: row.pw_salt, hash: row.pw_hash })) return null
const token = randomBytes(32).toString('hex') const token = randomBytes(32).toString('hex')
const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000).toISOString() const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000).toISOString()
db.prepare(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`).run(token, row.id, expires) await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires)
writeAudit(db, row.id, 'login', 'staff_user', row.id) await writeAudit(db, row.id, 'login', 'staff_user', row.id)
return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } } return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } }
} }
export function verifySession(db: DB, token: string): { id: string; role: string } | null { export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> {
// u.active=1: a deactivated employee's unexpired token must die immediately. // u.active=1: a deactivated employee's unexpired token must die immediately.
const row = db.prepare( const row = await db.get<{ id: string; role: string }>(
`SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id `SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id
WHERE s.token=? AND s.expires_at > ? AND u.active = 1`, WHERE s.token=? AND s.expires_at > ? AND u.active = 1`,
).get(token, new Date().toISOString()) as { id: string; role: string } | undefined token, new Date().toISOString(),
)
return row ?? null return row ?? null
} }
export const requireAuth: RequestHandler = (req, res, next) => { export const requireAuth: RequestHandler = (req, res, next) => {
const db = req.app.locals['db'] as DB void (async () => {
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') const db = req.app.locals['db'] as DB
const staff = token ? verifySession(db, token) : null const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return } const staff = token ? await verifySession(db, token) : null
res.locals['staff'] = staff if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return }
next() res.locals['staff'] = staff
next()
})()
} }
export const requireOwner: RequestHandler = (_req, res, next) => { export const requireOwner: RequestHandler = (_req, res, next) => {

@ -106,9 +106,9 @@ export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string)
totalPaise += costPaise totalPaise += costPaise
const client = tag === '' const client = tag === ''
? undefined ? undefined
: db.prepare(`SELECT id FROM client WHERE lower(code)=lower(?)`).get(tag) as { id: string } | undefined : await db.get<{ id: string }>(`SELECT id FROM client WHERE lower(code)=lower(?)`, tag)
if (client === undefined) { unknownTags.push({ tag, costPaise }); continue } if (client === undefined) { unknownTags.push({ tag, costPaise }); continue }
upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' }) await upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' })
upserted += 1 upserted += 1
} }
return { upserted, unknownTags, totalPaise } return { upserted, unknownTags, totalPaise }
@ -117,8 +117,8 @@ export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string)
/** Gate the pull to once per calendar month; the 6-hourly tick calls this. */ /** Gate the pull to once per calendar month; the 6-hourly tick calls this. */
export async function maybePullAwsCosts(db: DB, deps: AwsCostDeps, today: string): Promise<PullResult | null> { export async function maybePullAwsCosts(db: DB, deps: AwsCostDeps, today: string): Promise<PullResult | null> {
const target = previousMonth(today) const target = previousMonth(today)
if (getSetting(db, 'aws.last_pull_month') === target) return null if (await getSetting(db, 'aws.last_pull_month') === target) return null
const out = await pullMonthlyCosts(db, deps, target) const out = await pullMonthlyCosts(db, deps, target)
setSetting(db, 'system', 'aws.last_pull_month', target) await setSetting(db, 'system', 'aws.last_pull_month', target)
return out return out
} }

@ -33,26 +33,26 @@ function extractRecipients(msg: GmailMessageMeta): string[] {
} }
/** Flip the most recent matching sent log to bounced + raise an email_bounced reminder. */ /** Flip the most recent matching sent log to bounced + raise an email_bounced reminder. */
export function markBounce(db: DB, recipient: string, at: string): number { export async function markBounce(db: DB, recipient: string, at: string): Promise<number> {
const row = db.prepare( const row = await db.get<{ id: string; document_id: string | null }>(
`SELECT id, document_id FROM email_log `SELECT id, document_id FROM email_log
WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0 WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0
ORDER BY id DESC LIMIT 1`, ORDER BY id DESC LIMIT 1`, recipient,
).get(recipient) as { id: string; document_id: string | null } | undefined )
if (row === undefined) return 0 if (row === undefined) return 0
db.prepare(`UPDATE email_log SET bounced=1 WHERE id=?`).run(row.id) await db.run(`UPDATE email_log SET bounced=1 WHERE id=?`, row.id)
const clientId = row.document_id !== null ? (getDocument(db, row.document_id)?.clientId ?? '') : '' const clientId = row.document_id !== null ? ((await getDocument(db, row.document_id))?.clientId ?? '') : ''
upsertReminder(db, { await upsertReminder(db, {
ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId, ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId,
docId: row.document_id, now: at, docId: row.document_id, now: at,
}) })
writeAudit(db, 'system', 'update', 'email_log', row.id, { bounced: 0 }, { bounced: 1, recipient }) await writeAudit(db, 'system', 'update', 'email_log', row.id, { bounced: 0 }, { bounced: 1, recipient })
return 1 return 1
} }
export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned: number; bounced: number }> { export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned: number; bounced: number }> {
const now = deps.now?.() ?? new Date().toISOString() const now = deps.now?.() ?? new Date().toISOString()
const account = getAccount(db) const account = await getAccount(db)
if (account === null || account.status === 'dead') return { scanned: 0, bounced: 0 } if (account === null || account.status === 'dead') return { scanned: 0, bounced: 0 }
let accessToken: string let accessToken: string
try { try {
@ -60,10 +60,10 @@ export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned:
decrypt(account.refreshTokenEnc, deps.gmail.keyHex), deps.gmail.clientId, deps.gmail.clientSecret, deps.gmail.f, decrypt(account.refreshTokenEnc, deps.gmail.keyHex), deps.gmail.clientId, deps.gmail.clientSecret, deps.gmail.f,
) )
} catch (err) { } catch (err) {
if (err instanceof TokenDeadError) markAccountDead(db) if (err instanceof TokenDeadError) await markAccountDead(db)
return { scanned: 0, bounced: 0 } return { scanned: 0, bounced: 0 }
} }
const since = getSetting(db, 'reminders.bounce_last_poll') const since = await getSetting(db, 'reminders.bounce_last_poll')
?? new Date(Date.parse(now) - 2 * 86_400_000).toISOString() ?? new Date(Date.parse(now) - 2 * 86_400_000).toISOString()
const q = `from:mailer-daemon OR from:postmaster after:${Math.floor(Date.parse(since) / 1000)}` const q = `from:mailer-daemon OR from:postmaster after:${Math.floor(Date.parse(since) / 1000)}`
const headers = { authorization: `Bearer ${accessToken}` } const headers = { authorization: `Bearer ${accessToken}` }
@ -78,12 +78,12 @@ export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned:
`${GMAIL_LIST}/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=X-Failed-Recipients`, { headers }, `${GMAIL_LIST}/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=X-Failed-Recipients`, { headers },
) )
const meta = await msg.json().catch(() => ({})) as GmailMessageMeta const meta = await msg.json().catch(() => ({})) as GmailMessageMeta
for (const recipient of extractRecipients(meta)) bounced += markBounce(db, recipient, now) for (const recipient of extractRecipients(meta)) bounced += await markBounce(db, recipient, now)
} }
} catch { } catch {
// Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window. // Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window.
return { scanned, bounced } return { scanned, bounced }
} }
setSetting(db, 'system', 'reminders.bounce_last_poll', now) await setSetting(db, 'system', 'reminders.bounce_last_poll', now)
return { scanned, bounced } return { scanned, bounced }
} }

@ -0,0 +1,126 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import pg from 'pg'
import type { DB } from './db'
import { PG_MIGRATIONS } from './migrations-pg'
/**
* Postgres engine behind the async DB interface (D19). Selected by DATABASE_URL.
* Repos speak `?` placeholders and portable SQL; this adapter owns the dialect:
* `?` `$n`, transactions pinned to one pooled client via AsyncLocalStorage
* (nested calls become savepoints), and bigint/numeric results parsed to JS
* numbers so integer-paise math is identical on both engines.
*/
// int8 (bigint paise, COUNT(*)) and numeric (SUM over bigint) arrive as strings by
// default; paise totals sit far below 2^53, so Number is lossless here.
pg.types.setTypeParser(20, Number)
pg.types.setTypeParser(1700, Number)
/** Rewrite `?` placeholders to `$1..$n`, leaving single-quoted literals untouched. */
export function toDollarParams(sql: string): string {
let out = ''
let n = 0
let inQuote = false
for (let i = 0; i < sql.length; i++) {
const ch = sql[i]!
if (inQuote) {
out += ch
if (ch === "'") {
if (sql[i + 1] === "'") { out += "'"; i++ } else inQuote = false
}
continue
}
if (ch === "'") { inQuote = true; out += ch; continue }
out += ch === '?' ? `$${++n}` : ch
}
return out
}
interface TxnStore { client: pg.PoolClient; depth: number }
export class PgDb implements DB {
readonly engine = 'postgres' as const
private als = new AsyncLocalStorage<TxnStore>()
constructor(readonly pool: pg.Pool) {}
/** Queries inside a transaction ride its pinned client; otherwise the pool. */
private q(): { query: (sql: string, args?: unknown[]) => Promise<pg.QueryResult> } {
return this.als.getStore()?.client ?? this.pool
}
async all<T>(sql: string, ...args: unknown[]): Promise<T[]> {
return (await this.q().query(toDollarParams(sql), args)).rows as T[]
}
async get<T>(sql: string, ...args: unknown[]): Promise<T | undefined> {
return ((await this.q().query(toDollarParams(sql), args)).rows[0] ?? undefined) as T | undefined
}
async run(sql: string, ...args: unknown[]): Promise<{ changes: number }> {
const r = await this.q().query(toDollarParams(sql), args)
return { changes: r.rowCount ?? 0 }
}
async exec(sql: string): Promise<void> {
await this.q().query(sql)
}
async transaction<T>(fn: () => Promise<T> | T): Promise<T> {
const store = this.als.getStore()
if (store !== undefined) {
// Nested: savepoint on the already-pinned client.
const sp = `sp_d${++store.depth}`
await store.client.query(`SAVEPOINT ${sp}`)
try {
const out = await fn()
await store.client.query(`RELEASE SAVEPOINT ${sp}`)
return out
} catch (err) {
await store.client.query(`ROLLBACK TO SAVEPOINT ${sp}`)
await store.client.query(`RELEASE SAVEPOINT ${sp}`)
throw err
} finally {
store.depth--
}
}
const client = await this.pool.connect()
try {
await client.query('BEGIN')
const out = await this.als.run({ client, depth: 0 }, () => Promise.resolve(fn()))
await client.query('COMMIT')
return out
} catch (err) {
await client.query('ROLLBACK')
throw err
} finally {
client.release()
}
}
async close(): Promise<void> {
await this.pool.end()
}
}
/** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */
export async function runPgMigrations(db: PgDb): Promise<string[]> {
await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (id text PRIMARY KEY, applied_at text NOT NULL)`)
const applied: string[] = []
for (const m of PG_MIGRATIONS) {
await db.transaction(async () => {
const done = await db.get(`SELECT id FROM schema_migrations WHERE id=?`, m.id)
if (done !== undefined) return
await db.exec(m.sql)
await db.run(`INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)`, m.id, new Date().toISOString())
applied.push(m.id)
})
}
return applied
}
/** Open the Postgres engine: pool + migrations. Caller runs seedIfEmpty after. */
export async function openPgDb(url: string): Promise<DB> {
const db = new PgDb(new pg.Pool({ connectionString: url, max: 10 }))
await runPgMigrations(db)
return db
}

@ -2,8 +2,71 @@ import Database from 'better-sqlite3'
import fs from 'node:fs' import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
/** HQ console DB — SQLite behind portable repositories, S3 backup at deploy time. */ /**
export type DB = Database.Database * HQ console DB handle (D19): ONE async interface, two engines better-sqlite3
* for dev/tests (this file), Postgres for production (`db-pg.ts`, selected by
* `DATABASE_URL`). Repos speak portable SQL with `?` placeholders; each engine
* adapter owns its dialect differences. Every data-access call is awaited.
*/
export interface DB {
/** All rows. */
all<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>
/** First row, or undefined. */
get<T = unknown>(sql: string, ...args: unknown[]): Promise<T | undefined>
/** INSERT/UPDATE/DELETE — resolves with the affected-row count. */
run(sql: string, ...args: unknown[]): Promise<{ changes: number }>
/** Multi-statement DDL, no params. */
exec(sql: string): Promise<void>
/** Atomic unit; nested calls become savepoints. (No trailing `()` — unlike better-sqlite3.) */
transaction<T>(fn: () => Promise<T> | T): Promise<T>
readonly engine: 'sqlite' | 'postgres'
close(): Promise<void>
}
/** The underlying better-sqlite3 handle — migrations and schema tests only. */
export type SqliteRaw = Database.Database
/** better-sqlite3 behind the async interface — the dev/test engine. */
export class SqliteDb implements DB {
readonly engine = 'sqlite' as const
private stmts = new Map<string, Database.Statement>()
private txnDepth = 0
constructor(readonly raw: SqliteRaw) {}
private stmt(sql: string): Database.Statement {
let s = this.stmts.get(sql)
if (s === undefined) { s = this.raw.prepare(sql); this.stmts.set(sql, s) }
return s
}
async all<T>(sql: string, ...args: unknown[]): Promise<T[]> {
return this.stmt(sql).all(...args) as T[]
}
async get<T>(sql: string, ...args: unknown[]): Promise<T | undefined> {
return this.stmt(sql).get(...args) as T | undefined
}
async run(sql: string, ...args: unknown[]): Promise<{ changes: number }> {
return { changes: this.stmt(sql).run(...args).changes }
}
async exec(sql: string): Promise<void> { this.raw.exec(sql) }
async transaction<T>(fn: () => Promise<T> | T): Promise<T> {
// better-sqlite3's own transaction() rejects async fns, so BEGIN/SAVEPOINT by
// hand with a depth counter. Single connection: interleaved writers would join
// the open txn — fine for the dev/test engine (requests are awaited serially).
const depth = this.txnDepth++
const sp = `sp_d${depth}`
this.raw.exec(depth === 0 ? 'BEGIN' : `SAVEPOINT ${sp}`)
try {
const out = await fn()
this.raw.exec(depth === 0 ? 'COMMIT' : `RELEASE ${sp}`)
return out
} catch (err) {
this.raw.exec(depth === 0 ? 'ROLLBACK' : `ROLLBACK TO ${sp}; RELEASE ${sp}`)
throw err
} finally {
this.txnDepth--
}
}
async close(): Promise<void> { this.raw.close() }
}
const SCHEMA = ` const SCHEMA = `
CREATE TABLE IF NOT EXISTS staff_user ( CREATE TABLE IF NOT EXISTS staff_user (
@ -169,23 +232,26 @@ CREATE TABLE IF NOT EXISTS aws_usage (
); );
` `
/** Open the SQLite engine (dev/tests). Deliberately sync schema + migrate run
* on the raw handle before it is wrapped, so test fixtures stay one-liners. */
export function openDb(dataDir?: string): DB { export function openDb(dataDir?: string): DB {
let db: DB let raw: SqliteRaw
if (dataDir === ':memory:') { if (dataDir === ':memory:') {
db = new Database(':memory:') raw = new Database(':memory:')
} else { } else {
const dir = dataDir ?? path.resolve(process.cwd(), 'data') const dir = dataDir ?? path.resolve(process.cwd(), 'data')
fs.mkdirSync(dir, { recursive: true }) fs.mkdirSync(dir, { recursive: true })
db = new Database(path.join(dir, 'hq.db')) raw = new Database(path.join(dir, 'hq.db'))
db.pragma('journal_mode = WAL') raw.pragma('journal_mode = WAL')
} }
db.exec(SCHEMA) raw.exec(SCHEMA)
migrate(db) migrate(raw)
return db return new SqliteDb(raw)
} }
/** Additive, idempotent column adds for DBs created before a schema change. */ /** Additive, idempotent column adds for DBs created before a schema change.
function migrate(db: DB): void { * SQLite-only path on the raw handle; Postgres migrates via numbered SQL files. */
function migrate(db: SqliteRaw): void {
const moduleCols = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[] const moduleCols = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!moduleCols.some((c) => c.name === 'quote_content')) { if (!moduleCols.some((c) => c.name === 'quote_content')) {
db.exec(`ALTER TABLE module ADD COLUMN quote_content TEXT NOT NULL DEFAULT '[]'`) db.exec(`ALTER TABLE module ADD COLUMN quote_content TEXT NOT NULL DEFAULT '[]'`)
@ -236,7 +302,7 @@ function migrate(db: DB): void {
* ALTER TABLE DROP/ADD CONSTRAINT in the prod migration set. * ALTER TABLE DROP/ADD CONSTRAINT in the prod migration set.
*/ */
export function rebuildTable( export function rebuildTable(
db: DB, table: string, newDdl: string, guardToken: string, columns: string[], db: SqliteRaw, table: string, newDdl: string, guardToken: string, columns: string[],
): void { ): void {
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`) const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.get(table) as { sql: string } | undefined .get(table) as { sql: string } | undefined
@ -251,7 +317,7 @@ export function rebuildTable(
} }
/** Widen staff_user.role CHECK to allow 'manager' on DBs created before the employee slice. */ /** Widen staff_user.role CHECK to allow 'manager' on DBs created before the employee slice. */
export function rebuildStaffUserRoleCheck(db: DB): void { export function rebuildStaffUserRoleCheck(db: SqliteRaw): void {
rebuildTable( rebuildTable(
db, db,
'staff_user', 'staff_user',
@ -272,7 +338,7 @@ export function rebuildStaffUserRoleCheck(db: DB): void {
* Guard token: the old list ends "...'email_bounced')" the new DDL continues * Guard token: the old list ends "...'email_bounced')" the new DDL continues
* ",'quote_followup')" instead, so the fragment vanishes once rebuilt (idempotent). * ",'quote_followup')" instead, so the fragment vanishes once rebuilt (idempotent).
*/ */
export function rebuildReminderRuleKindCheck(db: DB): void { export function rebuildReminderRuleKindCheck(db: SqliteRaw): void {
rebuildTable( rebuildTable(
db, db,
'reminder', 'reminder',

@ -94,15 +94,15 @@ export type SendResult = { ok: true } | { ok: false; error: string }
export async function sendDocumentEmail( export async function sendDocumentEmail(
db: DB, deps: GmailDeps, args: SendDocumentInput, db: DB, deps: GmailDeps, args: SendDocumentInput,
): Promise<SendResult> { ): Promise<SendResult> {
const doc = getDocument(db, args.documentId) const doc = await getDocument(db, args.documentId)
if (doc === null) throw new Error('Document not found') if (doc === null) throw new Error('Document not found')
// Only issued documents leave the building — a draft has no legal number. // Only issued documents leave the building — a draft has no legal number.
if (doc.docNo === null) throw new Error('Only issued documents can be emailed') if (doc.docNo === null) throw new Error('Only issued documents can be emailed')
const account = getAccount(db) const account = await getAccount(db)
if (account === null) throw new Error('Gmail is not connected — run gmail-connect on the server') if (account === null) throw new Error('Gmail is not connected — run gmail-connect on the server')
const fail = (error: string): SendResult => { const fail = async (error: string): Promise<SendResult> => {
logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error }) await logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error })
return { ok: false, error } return { ok: false, error }
} }
if (account.status === 'dead') return fail('gmail-token-dead') if (account.status === 'dead') return fail('gmail-token-dead')
@ -113,7 +113,7 @@ export async function sendDocumentEmail(
accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f) accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f)
} catch (err) { } catch (err) {
if (err instanceof TokenDeadError) { if (err instanceof TokenDeadError) {
markAccountDead(db) await markAccountDead(db)
return fail('gmail-token-dead') return fail('gmail-token-dead')
} }
return fail(err instanceof Error ? err.message : String(err)) return fail(err instanceof Error ? err.message : String(err))
@ -140,18 +140,18 @@ export async function sendDocumentEmail(
return fail(err instanceof Error ? err.message : String(err)) return fail(err instanceof Error ? err.message : String(err))
} }
logEmail(db, { await logEmail(db, {
documentId: args.documentId, to: args.to, subject: args.subject, status: 'sent', documentId: args.documentId, to: args.to, subject: args.subject, status: 'sent',
...(gmailMessageId !== undefined ? { gmailMessageId } : {}), ...(gmailMessageId !== undefined ? { gmailMessageId } : {}),
}) })
if (doc.status === 'draft') { if (doc.status === 'draft') {
// markStatus appends the 'sent' document_event and the audit row. // markStatus appends the 'sent' document_event and the audit row.
markStatus(db, args.userId, args.documentId, 'sent') await markStatus(db, args.userId, args.documentId, 'sent')
} else { } else {
// Re-send of an already-sent/accepted doc: trace the event, status unchanged. // Re-send of an already-sent/accepted doc: trace the event, status unchanged.
db.prepare( await db.run(
`INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`,
).run(uuidv7(), args.documentId, new Date().toISOString(), 'sent', uuidv7(), args.documentId, new Date().toISOString(), 'sent',
JSON.stringify({ to: args.to, resend: true })) JSON.stringify({ to: args.to, resend: true }))
} }
return { ok: true } return { ok: true }
@ -171,14 +171,14 @@ export interface ReminderMailInput {
export async function sendReminderEmail( export async function sendReminderEmail(
db: DB, deps: GmailDeps, args: ReminderMailInput, db: DB, deps: GmailDeps, args: ReminderMailInput,
): Promise<SendResult> { ): Promise<SendResult> {
const logFail = (error: string): SendResult => { const logFail = async (error: string): Promise<SendResult> => {
logEmail(db, { await logEmail(db, {
...(args.documentId !== undefined ? { documentId: args.documentId } : {}), ...(args.documentId !== undefined ? { documentId: args.documentId } : {}),
to: args.to, subject: args.subject, status: 'failed', error, to: args.to, subject: args.subject, status: 'failed', error,
}) })
return { ok: false, error } return { ok: false, error }
} }
const account = getAccount(db) const account = await getAccount(db)
if (account === null) return logFail('gmail-not-connected') if (account === null) return logFail('gmail-not-connected')
if (account.status === 'dead') return logFail('gmail-token-dead') if (account.status === 'dead') return logFail('gmail-token-dead')
@ -187,7 +187,7 @@ export async function sendReminderEmail(
const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex) const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex)
accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f) accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f)
} catch (err) { } catch (err) {
if (err instanceof TokenDeadError) { markAccountDead(db); return logFail('gmail-token-dead') } if (err instanceof TokenDeadError) { await markAccountDead(db); return logFail('gmail-token-dead') }
return logFail(err instanceof Error ? err.message : String(err)) return logFail(err instanceof Error ? err.message : String(err))
} }
@ -211,7 +211,7 @@ export async function sendReminderEmail(
return logFail(err instanceof Error ? err.message : String(err)) return logFail(err instanceof Error ? err.message : String(err))
} }
logEmail(db, { await logEmail(db, {
...(args.documentId !== undefined ? { documentId: args.documentId } : {}), ...(args.documentId !== undefined ? { documentId: args.documentId } : {}),
to: args.to, subject: args.subject, status: 'sent', to: args.to, subject: args.subject, status: 'sent',
...(gmailMessageId !== undefined ? { gmailMessageId } : {}), ...(gmailMessageId !== undefined ? { gmailMessageId } : {}),

@ -44,18 +44,18 @@ function parseCsv(text: string): Record<string, string>[] {
const CLIENT_STATUSES = new Set(['lead', 'active', 'dormant', 'lost']) const CLIENT_STATUSES = new Set(['lead', 'active', 'dormant', 'lost'])
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
function stageClients(db: DB, records: Record<string, string>[]): number { async function stageClients(db: DB, records: Record<string, string>[]): Promise<number> {
db.prepare(`DELETE FROM stg_client`).run() await db.run(`DELETE FROM stg_client`)
const existing = new Set( const existing = new Set(
(db.prepare(`SELECT code FROM client`).all() as { code: string }[]).map((r) => r.code), (await db.all<{ code: string }>(`SELECT code FROM client`)).map((r) => r.code),
) )
const seen = new Set<string>() const seen = new Set<string>()
const insert = db.prepare( const INSERT_SQL =
`INSERT INTO stg_client (row_no, code, name, gstin, state_code, address, phone, email, status, `INSERT INTO stg_client (row_no, code, name, gstin, state_code, address, phone, email, status,
anydesk, os, district, sector, problems) anydesk, os, district, sector, problems)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
) for (let i = 0; i < records.length; i++) {
records.forEach((r, i) => { const r = records[i]!
const problems: string[] = [] const problems: string[] = []
const code = r['code'] ?? '' const code = r['code'] ?? ''
if (code === '') problems.push('missing code') if (code === '') problems.push('missing code')
@ -66,14 +66,15 @@ function stageClients(db: DB, records: Record<string, string>[]): number {
if (gstin !== '' && !validateGstin(gstin).ok) problems.push(`bad GSTIN: ${gstin}`) if (gstin !== '' && !validateGstin(gstin).ok) problems.push(`bad GSTIN: ${gstin}`)
const status = r['status'] ?? '' const status = r['status'] ?? ''
if (status !== '' && !CLIENT_STATUSES.has(status)) problems.push(`bad status: ${status}`) if (status !== '' && !CLIENT_STATUSES.has(status)) problems.push(`bad status: ${status}`)
insert.run( await db.run(
INSERT_SQL,
i + 1, code, r['name'] ?? '', gstin, r['state_code'] ?? '', i + 1, code, r['name'] ?? '', gstin, r['state_code'] ?? '',
r['address'] ?? '', r['phone'] ?? '', r['email'] ?? '', status, r['address'] ?? '', r['phone'] ?? '', r['email'] ?? '', status,
// WS-F support-access fields — optional headers; absent columns stage as ''. // WS-F support-access fields — optional headers; absent columns stage as ''.
r['anydesk'] ?? '', r['os'] ?? '', r['district'] ?? '', r['sector'] ?? '', r['anydesk'] ?? '', r['os'] ?? '', r['district'] ?? '', r['sector'] ?? '',
JSON.stringify(problems), JSON.stringify(problems),
) )
}) }
return records.length return records.length
} }
@ -83,18 +84,18 @@ function paiseOrNull(raw: string): number | null {
return Number.isFinite(n) ? fromRupees(n) : null return Number.isFinite(n) ? fromRupees(n) : null
} }
function stageInvoices(db: DB, records: Record<string, string>[]): number { async function stageInvoices(db: DB, records: Record<string, string>[]): Promise<number> {
db.prepare(`DELETE FROM stg_invoice`).run() await db.run(`DELETE FROM stg_invoice`)
const existing = new Set( const existing = new Set(
(db.prepare(`SELECT doc_no FROM document WHERE doc_no IS NOT NULL`).all() as { doc_no: string }[]) (await db.all<{ doc_no: string }>(`SELECT doc_no FROM document WHERE doc_no IS NOT NULL`))
.map((r) => r.doc_no), .map((r) => r.doc_no),
) )
const seen = new Set<string>() const seen = new Set<string>()
const insert = db.prepare( const INSERT_SQL =
`INSERT INTO stg_invoice (row_no, client_code, doc_no, doc_date, taxable_paise, tax_paise, total_paise, paid, problems) `INSERT INTO stg_invoice (row_no, client_code, doc_no, doc_date, taxable_paise, tax_paise, total_paise, paid, problems)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
) for (let i = 0; i < records.length; i++) {
records.forEach((r, i) => { const r = records[i]!
const problems: string[] = [] const problems: string[] = []
if ((r['client_code'] ?? '') === '') problems.push('missing client_code') if ((r['client_code'] ?? '') === '') problems.push('missing client_code')
const docNo = r['doc_no'] ?? '' const docNo = r['doc_no'] ?? ''
@ -109,24 +110,25 @@ function stageInvoices(db: DB, records: Record<string, string>[]): number {
if (taxable === null) problems.push(`bad taxable: ${r['taxable'] ?? ''}`) if (taxable === null) problems.push(`bad taxable: ${r['taxable'] ?? ''}`)
if (tax === null) problems.push(`bad tax: ${r['tax'] ?? ''}`) if (tax === null) problems.push(`bad tax: ${r['tax'] ?? ''}`)
if (total === null) problems.push(`bad total: ${r['total'] ?? ''}`) if (total === null) problems.push(`bad total: ${r['total'] ?? ''}`)
insert.run( await db.run(
INSERT_SQL,
i + 1, r['client_code'] ?? '', docNo, docDate, i + 1, r['client_code'] ?? '', docNo, docDate,
taxable, tax, total, r['paid'] === '1' ? 1 : 0, taxable, tax, total, r['paid'] === '1' ? 1 : 0,
JSON.stringify(problems), JSON.stringify(problems),
) )
}) }
return records.length return records.length
} }
export function stageCsv(db: DB, kind: 'clients' | 'invoices', csvText: string): { staged: number } { export async function stageCsv(db: DB, kind: 'clients' | 'invoices', csvText: string): Promise<{ staged: number }> {
const records = parseCsv(csvText) const records = parseCsv(csvText)
// One transaction: the DELETE + re-insert of the staging area and its audit row // One transaction: the DELETE + re-insert of the staging area and its audit row
// land (or roll back) together — same-txn audit rule. // land (or roll back) together — same-txn audit rule.
return db.transaction(() => { return db.transaction(async () => {
const staged = kind === 'clients' ? stageClients(db, records) : stageInvoices(db, records) const staged = kind === 'clients' ? await stageClients(db, records) : await stageInvoices(db, records)
writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged }) await writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged })
return { staged } return { staged }
})() })
} }
// ---------- verification report ---------- // ---------- verification report ----------
@ -137,24 +139,24 @@ export interface VerificationReport {
samples: { firstClients: string[]; lastInvoices: string[] } samples: { firstClients: string[]; lastInvoices: string[] }
} }
export function verificationReport(db: DB): VerificationReport { export async function verificationReport(db: DB): Promise<VerificationReport> {
const c = db.prepare( const c = (await db.get<{ staged: number; problems: number }>(
`SELECT COUNT(*) AS staged, `SELECT COUNT(*) AS staged,
COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems
FROM stg_client`, FROM stg_client`,
).get() as { staged: number; problems: number } ))!
const i = db.prepare( const i = (await db.get<{ staged: number; problems: number; totalPaise: number }>(
`SELECT COUNT(*) AS staged, `SELECT COUNT(*) AS staged,
COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems, COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems,
COALESCE(SUM(total_paise), 0) AS totalPaise COALESCE(SUM(total_paise), 0) AS totalPaise
FROM stg_invoice`, FROM stg_invoice`,
).get() as { staged: number; problems: number; totalPaise: number } ))!
const firstClients = (db.prepare( const firstClients = (await db.all<{ code: string; name: string }>(
`SELECT code, name FROM stg_client ORDER BY row_no LIMIT 5`, `SELECT code, name FROM stg_client ORDER BY row_no LIMIT 5`,
).all() as { code: string; name: string }[]).map((r) => `${r.code} ${r.name}`.trim()) )).map((r) => `${r.code} ${r.name}`.trim())
const lastInvoices = (db.prepare( const lastInvoices = (await db.all<{ doc_no: string }>(
`SELECT doc_no FROM stg_invoice ORDER BY row_no DESC LIMIT 5`, `SELECT doc_no FROM stg_invoice ORDER BY row_no DESC LIMIT 5`,
).all() as { doc_no: string }[]).map((r) => r.doc_no) )).map((r) => r.doc_no)
return { return {
clients: { staged: c.staged, problems: c.problems }, clients: { staged: c.staged, problems: c.problems },
invoices: { staged: i.staged, problems: i.problems, totalPaise: i.totalPaise }, invoices: { staged: i.staged, problems: i.problems, totalPaise: i.totalPaise },
@ -181,32 +183,30 @@ export interface CommitResult {
seeded: { fy: string; lastSeq: number } | null seeded: { fy: string; lastSeq: number } | null
} }
function companyStateCode(db: DB): string { async function companyStateCode(db: DB): Promise<string> {
const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`) const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='company.state_code'`)
.get() as { value: string } | undefined
// Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly. // Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly.
if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`) if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`)
return row.value return row.value
} }
export function commitImport(db: DB, userId: string): CommitResult { export async function commitImport(db: DB, userId: string): Promise<CommitResult> {
return db.transaction((): CommitResult => { return db.transaction(async (): Promise<CommitResult> => {
const bad = (db.prepare( const bad = (await db.get<{ n: number }>(
`SELECT (SELECT COUNT(*) FROM stg_client WHERE problems <> '[]') `SELECT (SELECT COUNT(*) FROM stg_client WHERE problems <> '[]')
+ (SELECT COUNT(*) FROM stg_invoice WHERE problems <> '[]') AS n`, + (SELECT COUNT(*) FROM stg_invoice WHERE problems <> '[]') AS n`,
).get() as { n: number }).n ))!.n
if (bad > 0) throw new Error(`${bad} staged row(s) have problems — fix the CSV and re-stage before commit`) if (bad > 0) throw new Error(`${bad} staged row(s) have problems — fix the CSV and re-stage before commit`)
const ourState = companyStateCode(db) const ourState = await companyStateCode(db)
const now = new Date().toISOString() const now = new Date().toISOString()
// Clients — source 'apex' marks the cutover rows. // Clients — source 'apex' marks the cutover rows.
const stgClients = db.prepare(`SELECT * FROM stg_client ORDER BY row_no`).all() as StgClientRow[] const stgClients = await db.all<StgClientRow>(`SELECT * FROM stg_client ORDER BY row_no`)
const insertClient = db.prepare( const INSERT_CLIENT_SQL =
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, source, created_at) anydesk, os, district, sector, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`
)
const orNull = (v: string): string | null => (v !== '' ? v : null) const orNull = (v: string): string | null => (v !== '' ? v : null)
for (const c of stgClients) { for (const c of stgClients) {
const id = uuidv7() const id = uuidv7()
@ -217,28 +217,29 @@ export function commitImport(db: DB, userId: string): CommitResult {
...(c.email !== '' ? { email: c.email } : {}), ...(c.email !== '' ? { email: c.email } : {}),
}] }]
: [] : []
insertClient.run( await db.run(
INSERT_CLIENT_SQL,
id, c.code, c.name, c.gstin !== '' ? c.gstin : null, id, c.code, c.name, c.gstin !== '' ? c.gstin : null,
c.state_code !== '' ? c.state_code : ourState, c.address, c.state_code !== '' ? c.state_code : ourState, c.address,
JSON.stringify(contacts), c.status !== '' ? c.status : 'active', JSON.stringify(contacts), c.status !== '' ? c.status : 'active',
// WS-F: the old book's support-access data rides the cutover, not re-keying. // WS-F: the old book's support-access data rides the cutover, not re-keying.
orNull(c.anydesk), orNull(c.os), orNull(c.district), orNull(c.sector), now, orNull(c.anydesk), orNull(c.os), orNull(c.district), orNull(c.sector), now,
) )
writeAudit(db, userId, 'import', 'client', id, undefined, { code: c.code, name: c.name, source: 'apex' }) await writeAudit(db, userId, 'import', 'client', id, undefined, { code: c.code, name: c.name, source: 'apex' })
} }
// Invoices — issued, history-only documents (empty payload lines) that keep // Invoices — issued, history-only documents (empty payload lines) that keep
// their legacy APEX numbers; the series is seeded past them below. // their legacy APEX numbers; the series is seeded past them below.
const stgInvoices = db.prepare(`SELECT * FROM stg_invoice ORDER BY row_no`).all() as StgInvoiceRow[] const stgInvoices = await db.all<StgInvoiceRow>(`SELECT * FROM stg_invoice ORDER BY row_no`)
const insertDoc = db.prepare( const INSERT_DOC_SQL =
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id, `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, source, created_by, created_at) payload, source, created_by, created_at)
VALUES (?, 'INVOICE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`, VALUES (?, 'INVOICE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`
)
for (const inv of stgInvoices) { for (const inv of stgInvoices) {
const client = db.prepare(`SELECT id, state_code FROM client WHERE code=?`) const client = await db.get<{ id: string; state_code: string }>(
.get(inv.client_code) as { id: string; state_code: string } | undefined `SELECT id, state_code FROM client WHERE code=?`, inv.client_code,
)
if (client === undefined) { if (client === undefined) {
throw new Error(`Invoice ${inv.doc_no}: client code ${inv.client_code} not found — stage clients.csv first`) throw new Error(`Invoice ${inv.doc_no}: client code ${inv.client_code} not found — stage clients.csv first`)
} }
@ -254,12 +255,13 @@ export function commitImport(db: DB, userId: string): CommitResult {
} }
const id = uuidv7() const id = uuidv7()
const status = inv.paid === 1 ? 'paid' : 'sent' const status = inv.paid === 1 ? 'paid' : 'sent'
insertDoc.run( await db.run(
INSERT_DOC_SQL,
id, inv.doc_no, fyOf(inv.doc_date), client.id, inv.doc_date, status, id, inv.doc_no, fyOf(inv.doc_date), client.id, inv.doc_date, status,
inv.taxable_paise, cgst, sgst, igst, roundOff, inv.total_paise, inv.taxable_paise, cgst, sgst, igst, roundOff, inv.total_paise,
JSON.stringify({ lines: [], totals }), userId, now, JSON.stringify({ lines: [], totals }), userId, now,
) )
writeAudit(db, userId, 'import', 'document', id, undefined, await writeAudit(db, userId, 'import', 'document', id, undefined,
{ docNo: inv.doc_no, status, payablePaise: inv.total_paise, source: 'apex' }) { docNo: inv.doc_no, status, payablePaise: inv.total_paise, source: 'apex' })
} }
@ -275,40 +277,42 @@ export function commitImport(db: DB, userId: string): CommitResult {
.map((t) => Number(t)) .map((t) => Number(t))
if (tails.length > 0) { if (tails.length > 0) {
const lastSeq = Math.max(...tails) const lastSeq = Math.max(...tails)
seedSeries(db, 'INVOICE', currentFy, lastSeq) await seedSeries(db, 'INVOICE', currentFy, lastSeq)
seeded = { fy: currentFy, lastSeq } seeded = { fy: currentFy, lastSeq }
writeAudit(db, userId, 'import', 'doc_series', `INVOICE/${currentFy}`, undefined, { lastSeq }) await writeAudit(db, userId, 'import', 'doc_series', `INVOICE/${currentFy}`, undefined, { lastSeq })
} }
// Commit consumes the staging area. // Commit consumes the staging area.
db.prepare(`DELETE FROM stg_client`).run() await db.run(`DELETE FROM stg_client`)
db.prepare(`DELETE FROM stg_invoice`).run() await db.run(`DELETE FROM stg_invoice`)
return { clients: stgClients.length, invoices: stgInvoices.length, seeded } return { clients: stgClients.length, invoices: stgInvoices.length, seeded }
})() })
} }
// ---------- CLI: npm run import -- --dir <folder> [--commit] ---------- // ---------- CLI: npm run import -- --dir <folder> [--commit] ----------
if (process.argv[1] !== undefined && /import-apex\.(ts|cjs|js)$/.test(process.argv[1])) { if (process.argv[1] !== undefined && /import-apex\.(ts|cjs|js)$/.test(process.argv[1])) {
const args = process.argv.slice(2) void (async () => {
const dirIdx = args.indexOf('--dir') const args = process.argv.slice(2)
const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined const dirIdx = args.indexOf('--dir')
if (dir === undefined) { const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined
console.error('Usage: npm run import -- --dir <folder> [--commit]') if (dir === undefined) {
process.exit(1) console.error('Usage: npm run import -- --dir <folder> [--commit]')
} process.exit(1)
const db = openDb(process.env['HQ_DATA_DIR']) }
seedIfEmpty(db) const db = openDb(process.env['HQ_DATA_DIR'])
const clients = stageCsv(db, 'clients', fs.readFileSync(path.join(dir, 'clients.csv'), 'utf8')) await seedIfEmpty(db)
const invoices = stageCsv(db, 'invoices', fs.readFileSync(path.join(dir, 'invoices.csv'), 'utf8')) const clients = await stageCsv(db, 'clients', fs.readFileSync(path.join(dir, 'clients.csv'), 'utf8'))
console.log(`Staged ${clients.staged} clients, ${invoices.staged} invoices from ${dir}`) const invoices = await stageCsv(db, 'invoices', fs.readFileSync(path.join(dir, 'invoices.csv'), 'utf8'))
console.log(JSON.stringify(verificationReport(db), null, 2)) console.log(`Staged ${clients.staged} clients, ${invoices.staged} invoices from ${dir}`)
if (args.includes('--commit')) { console.log(JSON.stringify(await verificationReport(db), null, 2))
const out = commitImport(db, 'system') if (args.includes('--commit')) {
console.log(`Committed: ${out.clients} clients, ${out.invoices} invoices; series seeded:`, const out = await commitImport(db, 'system')
out.seeded ?? 'no current-FY invoices') console.log(`Committed: ${out.clients} clients, ${out.invoices} invoices; series seeded:`,
} else { out.seeded ?? 'no current-FY invoices')
console.log('Dry run — verify the report above, then re-run with --commit to apply.') } else {
} console.log('Dry run — verify the report above, then re-run with --commit to apply.')
}
})()
} }

@ -0,0 +1,186 @@
/**
* Postgres migration set (D19). Numbered, applied in order by db-pg.ts's runner,
* each inside its own transaction, recorded in schema_migrations. Inline strings
* (not .sql files) so esbuild bundles them into dist/server.cjs with no file I/O.
*
* Translation choices vs the SQLite SCHEMA (deliberate, recorded in the D19 spec):
* - every *_paise / money column is BIGINT (int4 overflows at 2.15 crore in paise);
* - 0/1 flags stay INTEGER repos say `active=1` and that SQL must be identical on
* both engines (boolean would break it);
* - JSON payloads stay TEXT node-pg auto-parses json/jsonb columns into objects,
* which would break the repos' JSON.parse at the edge. A jsonb ALTER is a later,
* pg-only optimisation if we ever index into payloads;
* - dates stay TEXT ISO (house convention, index-friendly, no timezone surprises);
* - the reminder.rule_kind CHECK is born in its final widened form (SQLite reaches
* it via the guarded table rebuild; Postgres starts correct).
*/
export interface PgMigration { id: string; sql: string }
export const PG_MIGRATIONS: PgMigration[] = [
{
id: '001-init',
sql: `
CREATE TABLE IF NOT EXISTS staff_user (
id text PRIMARY KEY, email text NOT NULL UNIQUE, display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('owner','manager','staff')),
phone text, title text,
pw_salt text NOT NULL, pw_hash text NOT NULL, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS session (
token text PRIMARY KEY, staff_id text NOT NULL, expires_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS client (
id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
gstin text, state_code text NOT NULL DEFAULT '32', address text NOT NULL DEFAULT '',
contacts text NOT NULL DEFAULT '[]',
status text NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')),
owner_id text,
anydesk text, os text, district text, sector text, db_password_enc text,
notes text NOT NULL DEFAULT '', source text NOT NULL DEFAULT 'hq', created_at text NOT NULL
);
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',
allowed_kinds text NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]',
multi_subscription integer NOT NULL DEFAULT 0, active integer NOT NULL DEFAULT 1,
quote_content text NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS module_price_book (
id text PRIMARY KEY, module_id text NOT NULL, edition text NOT NULL DEFAULT 'standard',
kind text NOT NULL, price_paise bigint NOT NULL, effective_from text NOT NULL
);
CREATE TABLE IF NOT EXISTS client_module (
id text PRIMARY KEY, client_id text NOT NULL, module_id text NOT NULL,
status text NOT NULL DEFAULT 'quoted' CHECK (status IN
('quoted','ordered','installing','installed','trained','live','expired','cancelled')),
kind text NOT NULL, edition text NOT NULL DEFAULT 'standard',
installed_on text, completed_on text, trained_on text,
next_renewal text, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS tax_class (
class_code text NOT NULL, rate_pct_bp integer NOT NULL,
cess_pct_bp integer NOT NULL DEFAULT 0, effective_from text NOT NULL, effective_to text
);
CREATE TABLE IF NOT EXISTS doc_series (
doc_type text NOT NULL, fy text NOT NULL, prefix text NOT NULL, next_seq integer NOT NULL,
PRIMARY KEY (doc_type, fy)
);
CREATE TABLE IF NOT EXISTS document (
id text PRIMARY KEY, doc_type text NOT NULL CHECK (doc_type IN
('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')),
doc_no text, fy text NOT NULL, client_id text NOT NULL, doc_date text NOT NULL,
due_date text,
status text NOT NULL DEFAULT 'draft' CHECK (status IN
('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')),
ref_doc_id text,
taxable_paise bigint NOT NULL, cgst_paise bigint NOT NULL, sgst_paise bigint NOT NULL,
igst_paise bigint NOT NULL, round_off_paise bigint NOT NULL, payable_paise bigint NOT NULL,
payload text NOT NULL,
source text NOT NULL DEFAULT 'hq',
created_by text NOT NULL, created_at text NOT NULL,
UNIQUE (doc_no)
);
CREATE TABLE IF NOT EXISTS document_event (
id text PRIMARY KEY, document_id text NOT NULL, at_wall text NOT NULL,
kind text NOT NULL, meta text NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS document_share (
id text PRIMARY KEY, document_id text NOT NULL, token text NOT NULL UNIQUE,
created_by text NOT NULL, created_at text NOT NULL,
expires_at text,
revoked integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS payment (
id text PRIMARY KEY, client_id text NOT NULL, received_on text NOT NULL,
mode text NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')),
reference text NOT NULL DEFAULT '', amount_paise bigint NOT NULL,
tds_paise bigint NOT NULL DEFAULT 0, created_by text NOT NULL, created_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS payment_allocation (
id text PRIMARY KEY, payment_id text NOT NULL, document_id text NOT NULL,
amount_paise bigint NOT NULL
);
CREATE TABLE IF NOT EXISTS email_account (
id text PRIMARY KEY, address text NOT NULL, refresh_token_enc text NOT NULL,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS email_log (
id text PRIMARY KEY, document_id text, to_addr text NOT NULL, subject text NOT NULL,
status text NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id text,
error text, at_wall text NOT NULL,
bounced integer NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS setting (
key text PRIMARY KEY, value text NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
id text PRIMARY KEY, at_wall text NOT NULL, user_id text NOT NULL, action text NOT NULL,
entity text NOT NULL, entity_id text NOT NULL, before_json text, after_json text
);
CREATE TABLE IF NOT EXISTS stg_client (
row_no integer PRIMARY KEY, code text, name text, gstin text, state_code text,
address text, phone text, email text, status text,
anydesk text, os text, district text, sector text,
problems text NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS stg_invoice (
row_no integer PRIMARY KEY, client_code text, doc_no text, doc_date text,
taxable_paise bigint, tax_paise bigint, total_paise bigint, paid integer,
problems text NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS recurring_plan (
id text PRIMARY KEY, client_id text NOT NULL,
client_module_id text,
cadence text NOT NULL CHECK (cadence IN ('monthly','yearly')),
amount_paise bigint,
next_run text NOT NULL,
policy text NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS amc_contract (
id text PRIMARY KEY, client_id text NOT NULL, coverage text NOT NULL DEFAULT '',
period_from text NOT NULL, period_to text NOT NULL, amount_paise bigint NOT NULL,
renewal_reminder_days integer NOT NULL DEFAULT 30,
legacy_paid integer,
invoice_doc_id text, active integer NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS interaction_type (
code text PRIMARY KEY, label text NOT NULL
);
CREATE TABLE IF NOT EXISTS interaction (
id text PRIMARY KEY, client_id text NOT NULL, type_code text NOT NULL,
on_date text NOT NULL, staff_id text NOT NULL, notes text NOT NULL DEFAULT '',
outcome text CHECK (outcome IN ('positive','neutral','negative')),
follow_up_on text, created_at text NOT NULL
);
CREATE TABLE IF NOT EXISTS reminder (
id text PRIMARY KEY,
rule_kind text NOT NULL CHECK (rule_kind IN
('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
subject_id text NOT NULL, due_period text NOT NULL, client_id text NOT NULL,
doc_id text,
status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
policy_applied text NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
error text, created_at text NOT NULL, sent_at text,
UNIQUE (rule_kind, subject_id, due_period)
);
CREATE TABLE IF NOT EXISTS reminder_schedule (
id text PRIMARY KEY, rule_kind text NOT NULL,
effective_from text NOT NULL, effective_to text,
day_offsets text NOT NULL,
subject text, body text
);
CREATE TABLE IF NOT EXISTS aws_usage (
id text PRIMARY KEY, client_id text NOT NULL,
month text NOT NULL,
storage_gb double precision NOT NULL DEFAULT 0,
transfer_gb double precision NOT NULL DEFAULT 0,
cost_paise bigint NOT NULL DEFAULT 0,
source text NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','manual')),
updated_at text NOT NULL,
UNIQUE (client_id, month)
);
`,
},
]

@ -30,13 +30,13 @@ function toAmc(r: AmcRow): AmcContract {
} }
} }
export function getAmc(db: DB, id: string): AmcContract | null { export async function getAmc(db: DB, id: string): Promise<AmcContract | null> {
const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined const row = await db.get<AmcRow>(`SELECT * FROM amc_contract WHERE id=?`, id)
return row === undefined ? null : toAmc(row) return row === undefined ? null : toAmc(row)
} }
export function listAmc(db: DB, clientId: string): AmcContract[] { export async function listAmc(db: DB, clientId: string): Promise<AmcContract[]> {
const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[] const rows = await db.all<AmcRow>(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`, clientId)
return rows.map(toAmc) return rows.map(toAmc)
} }
@ -45,22 +45,21 @@ export interface CreateAmcInput {
amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean
} }
export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract { export async function createAmc(db: DB, userId: string, input: CreateAmcInput): Promise<AmcContract> {
if (getClient(db, input.clientId) === null) throw new Error('Client not found') if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) { if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) {
throw new Error('amountPaise must be a non-negative integer (paise)') throw new Error('amountPaise must be a non-negative integer (paise)')
} }
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise, `INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise,
renewal_reminder_days, legacy_paid, invoice_doc_id, active) renewal_reminder_days, legacy_paid, invoice_doc_id, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`,
).run(
id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise, id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise,
input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0, input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0,
) )
const amc = getAmc(db, id)! const amc = (await getAmc(db, id))!
writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc) await writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
return amc return amc
} }
@ -69,8 +68,8 @@ export interface AmcPatch {
renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean
} }
export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract { export async function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): Promise<AmcContract> {
const before = getAmc(db, id) const before = await getAmc(db, id)
if (before === null) throw new Error('AMC contract not found') if (before === null) throw new Error('AMC contract not found')
const sets: string[] = [] const sets: string[] = []
const args: unknown[] = [] const args: unknown[] = []
@ -83,63 +82,65 @@ export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch):
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`, ...args)
} }
const after = getAmc(db, id)! const after = (await getAmc(db, id))!
writeAudit(db, userId, 'update', 'amc_contract', id, before, after) await writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
return after return after
} }
export function deactivateAmc(db: DB, userId: string, id: string): AmcContract { export async function deactivateAmc(db: DB, userId: string, id: string): Promise<AmcContract> {
return updateAmc(db, userId, id, { active: false }) return updateAmc(db, userId, id, { active: false })
} }
/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the /** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the
* contract amount, issued and linked back via invoice_doc_id. */ * contract amount, issued and linked back via invoice_doc_id. */
export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc { export async function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Promise<Doc> {
const amc = getAmc(db, amcId) const amc = await getAmc(db, amcId)
if (amc === null) throw new Error('AMC contract not found') if (amc === null) throw new Error('AMC contract not found')
if (amc.invoiceDocId !== null) { if (amc.invoiceDocId !== null) {
const existing = getDocument(db, amc.invoiceDocId) const existing = await getDocument(db, amc.invoiceDocId)
if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) { if (existing !== null && existing.status !== 'cancelled' && (await outstandingOf(db, existing.id)) > 0) {
throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`) throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`)
} }
} }
const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined const amcModule = await db.get<{ id: string }>(`SELECT id FROM module WHERE code='AMC'`)
if (amcModule === undefined) throw new Error('AMC module missing — run the seed') if (amcModule === undefined) throw new Error('AMC module missing — run the seed')
return db.transaction(() => { return db.transaction(async () => {
const draft = createDraft(db, userId, { const draft = await createDraft(db, userId, {
docType: 'INVOICE', clientId: amc.clientId, docType: 'INVOICE', clientId: amc.clientId,
lines: [{ lines: [{
moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance', moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance',
qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise, qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise,
}], }],
}) })
const inv = issueDocument(db, userId, draft.id) const inv = await issueDocument(db, userId, draft.id)
db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId) await db.run(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`, inv.id, amcId)
writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id }) await writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
return inv return inv
})() })
} }
/** Local outstanding read (payable allocations non-cancelled credit notes) /** Local outstanding read (payable allocations non-cancelled credit notes)
* mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can * mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can
* switch to it then, but does not depend on Task 7 to compile. */ * switch to it then, but does not depend on Task 7 to compile. */
function outstandingOf(db: DB, docId: string): number { async function outstandingOf(db: DB, docId: string): Promise<number> {
const doc = getDocument(db, docId) const doc = await getDocument(db, docId)
if (doc === null) return 0 if (doc === null) return 0
const alloc = (db.prepare( const alloc = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`, `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
).get(docId) as { total: number }).total docId,
const credited = (db.prepare( ))!.total
const credited = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`, WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
).get(docId) as { total: number }).total docId,
))!.total
return doc.payablePaise - alloc - credited return doc.payablePaise - alloc - credited
} }
export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' { export async function amcPaidStatus(db: DB, amc: AmcContract): Promise<'paid' | 'unpaid' | 'unbilled'> {
if (amc.legacyPaid === true) return 'paid' if (amc.legacyPaid === true) return 'paid'
if (amc.invoiceDocId === null) return 'unbilled' if (amc.invoiceDocId === null) return 'unbilled'
return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid' return (await outstandingOf(db, amc.invoiceDocId)) <= 0 ? 'paid' : 'unpaid'
} }

@ -27,15 +27,16 @@ function toUsage(r: AwsUsageRow): AwsUsage {
} }
} }
export function getAwsUsage(db: DB, clientId: string, month: string): AwsUsage | null { export async function getAwsUsage(db: DB, clientId: string, month: string): Promise<AwsUsage | null> {
const row = db.prepare(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`).get(clientId, month) as AwsUsageRow | undefined const row = await db.get<AwsUsageRow>(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`, clientId, month)
return row === undefined ? null : toUsage(row) return row === undefined ? null : toUsage(row)
} }
export function listAwsUsage(db: DB, clientId: string, limit = 12): AwsUsage[] { export async function listAwsUsage(db: DB, clientId: string, limit = 12): Promise<AwsUsage[]> {
const rows = db.prepare( const rows = await db.all<AwsUsageRow>(
`SELECT * FROM aws_usage WHERE client_id=? ORDER BY month DESC LIMIT ?`, `SELECT * FROM aws_usage WHERE client_id=? ORDER BY month DESC LIMIT ?`,
).all(clientId, limit) as AwsUsageRow[] clientId, limit,
)
return rows.map(toUsage) return rows.map(toUsage)
} }
@ -44,8 +45,8 @@ export interface UpsertAwsUsageInput {
costPaise?: number; source?: 'auto' | 'manual' costPaise?: number; source?: 'auto' | 'manual'
} }
export function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): AwsUsage { export async function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): Promise<AwsUsage> {
if (getClient(db, input.clientId) === null) throw new Error('Client not found') if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
if (!/^\d{4}-\d{2}$/.test(input.month)) throw new Error("month must be 'YYYY-MM'") if (!/^\d{4}-\d{2}$/.test(input.month)) throw new Error("month must be 'YYYY-MM'")
if (input.costPaise !== undefined && (!Number.isInteger(input.costPaise) || input.costPaise < 0)) { if (input.costPaise !== undefined && (!Number.isInteger(input.costPaise) || input.costPaise < 0)) {
throw new Error('costPaise must be a non-negative integer (paise)') throw new Error('costPaise must be a non-negative integer (paise)')
@ -53,34 +54,36 @@ export function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInpu
for (const [k, v] of [['storageGb', input.storageGb], ['transferGb', input.transferGb]] as const) { for (const [k, v] of [['storageGb', input.storageGb], ['transferGb', input.transferGb]] as const) {
if (v !== undefined && (!Number.isFinite(v) || v < 0)) throw new Error(`${k} must be a non-negative number`) if (v !== undefined && (!Number.isFinite(v) || v < 0)) throw new Error(`${k} must be a non-negative number`)
} }
const before = getAwsUsage(db, input.clientId, input.month) const before = await getAwsUsage(db, input.clientId, input.month)
const id = before?.id ?? uuidv7() const id = before?.id ?? uuidv7()
const storageGb = input.storageGb ?? before?.storageGb ?? 0 const storageGb = input.storageGb ?? before?.storageGb ?? 0
const transferGb = input.transferGb ?? before?.transferGb ?? 0 const transferGb = input.transferGb ?? before?.transferGb ?? 0
const costPaise = input.costPaise ?? before?.costPaise ?? 0 const costPaise = input.costPaise ?? before?.costPaise ?? 0
const source = input.source ?? before?.source ?? 'manual' const source = input.source ?? before?.source ?? 'manual'
db.prepare( await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at) `INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (client_id, month) DO UPDATE SET ON CONFLICT (client_id, month) DO UPDATE SET
storage_gb=excluded.storage_gb, transfer_gb=excluded.transfer_gb, storage_gb=excluded.storage_gb, transfer_gb=excluded.transfer_gb,
cost_paise=excluded.cost_paise, source=excluded.source, updated_at=excluded.updated_at`, cost_paise=excluded.cost_paise, source=excluded.source, updated_at=excluded.updated_at`,
).run(id, input.clientId, input.month, storageGb, transferGb, costPaise, source, new Date().toISOString()) id, input.clientId, input.month, storageGb, transferGb, costPaise, source, new Date().toISOString(),
const after = getAwsUsage(db, input.clientId, input.month)! )
writeAudit(db, userId, before === null ? 'create' : 'update', 'aws_usage', after.id, before ?? undefined, after) const after = (await getAwsUsage(db, input.clientId, input.month))!
await writeAudit(db, userId, before === null ? 'create' : 'update', 'aws_usage', after.id, before ?? undefined, after)
return after return after
} }
export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number } export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number }
/** Cross-client ranking for one month: cost desc, share of total in basis points. */ /** Cross-client ranking for one month: cost desc, share of total in basis points. */
export function costRanking(db: DB, month: string): { total: number; rows: CostRankRow[] } { export async function costRanking(db: DB, month: string): Promise<{ total: number; rows: CostRankRow[] }> {
const rows = db.prepare( const rows = await db.all<{ client_id: string; client_name: string; cost_paise: number }>(
`SELECT a.client_id, c.name AS client_name, a.cost_paise `SELECT a.client_id, c.name AS client_name, a.cost_paise
FROM aws_usage a JOIN client c ON c.id = a.client_id FROM aws_usage a JOIN client c ON c.id = a.client_id
WHERE a.month=? ORDER BY a.cost_paise DESC, c.name`, WHERE a.month=? ORDER BY a.cost_paise DESC, c.name`,
).all(month) as { client_id: string; client_name: string; cost_paise: number }[] month,
)
const total = rows.reduce((s, r) => s + r.cost_paise, 0) const total = rows.reduce((s, r) => s + r.cost_paise, 0)
return { return {
total, total,
@ -92,12 +95,12 @@ export function costRanking(db: DB, month: string): { total: number; rows: CostR
} }
/** Σ cost over months in [from-month, to-month]; unbounded when a bound is omitted. */ /** Σ cost over months in [from-month, to-month]; unbounded when a bound is omitted. */
export function awsCostForClient(db: DB, clientId: string, from?: string, to?: string): number { export async function awsCostForClient(db: DB, clientId: string, from?: string, to?: string): Promise<number> {
let sql = `SELECT COALESCE(SUM(cost_paise), 0) AS total FROM aws_usage WHERE client_id=?` let sql = `SELECT COALESCE(SUM(cost_paise), 0) AS total FROM aws_usage WHERE client_id=?`
const args: unknown[] = [clientId] const args: unknown[] = [clientId]
if (from !== undefined) { sql += ` AND month >= ?`; args.push(from.slice(0, 7)) } if (from !== undefined) { sql += ` AND month >= ?`; args.push(from.slice(0, 7)) }
if (to !== undefined) { sql += ` AND month <= ?`; args.push(to.slice(0, 7)) } if (to !== undefined) { sql += ` AND month <= ?`; args.push(to.slice(0, 7)) }
return (db.prepare(sql).get(...args) as { total: number }).total return (await db.get<{ total: number }>(sql, ...args))!.total
} }
/** '2026-07-10' → '2026-06' (the previous complete month, which is what has settled). */ /** '2026-07-10' → '2026-06' (the previous complete month, which is what has settled). */

@ -49,9 +49,9 @@ function assertGstin(gstin: string): void {
if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`) if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`)
} }
export function listClients( export async function listClients(
db: DB, q?: string, filters: { district?: string; sector?: string } = {}, db: DB, q?: string, filters: { district?: string; sector?: string } = {},
): Client[] { ): Promise<Client[]> {
let where = ' WHERE 1=1' let where = ' WHERE 1=1'
const args: unknown[] = [] const args: unknown[] = []
if (q !== undefined && q !== '') { if (q !== undefined && q !== '') {
@ -67,12 +67,12 @@ export function listClients(
if (filters.sector !== undefined && filters.sector !== '') { if (filters.sector !== undefined && filters.sector !== '') {
where += ` AND sector LIKE ?`; args.push(`%${filters.sector}%`) where += ` AND sector LIKE ?`; args.push(`%${filters.sector}%`)
} }
const rows = db.prepare(`SELECT * FROM client${where} ORDER BY name`).all(...args) as ClientRow[] const rows = await db.all<ClientRow>(`SELECT * FROM client${where} ORDER BY name`, ...args)
return rows.map(toClient) return rows.map(toClient)
} }
export function getClient(db: DB, id: string): Client | null { export async function getClient(db: DB, id: string): Promise<Client | null> {
const row = db.prepare(`SELECT * FROM client WHERE id=?`).get(id) as ClientRow | undefined const row = await db.get<ClientRow>(`SELECT * FROM client WHERE id=?`, id)
return row === undefined ? null : toClient(row) return row === undefined ? null : toClient(row)
} }
@ -82,24 +82,23 @@ export interface ClientInput {
anydesk?: string; os?: string; district?: string; sector?: string anydesk?: string; os?: string; district?: string; sector?: string
} }
export function createClient(db: DB, userId: string, input: ClientInput): Client { export async function createClient(db: DB, userId: string, input: ClientInput): Promise<Client> {
if (input.gstin !== undefined) assertGstin(input.gstin) if (input.gstin !== undefined) assertGstin(input.gstin)
const id = uuidv7() const id = uuidv7()
const n = (db.prepare(`SELECT COUNT(*) AS n FROM client`).get() as { n: number }).n const n = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!).n
const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}` const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}`
db.prepare( await db.run(
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, notes, created_at) anydesk, os, district, sector, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, code, input.name, input.gstin ?? null, input.stateCode, id, code, input.name, input.gstin ?? null, input.stateCode,
input.address ?? '', JSON.stringify(input.contacts ?? []), input.address ?? '', JSON.stringify(input.contacts ?? []),
input.status ?? 'active', input.status ?? 'active',
input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null, input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null,
input.notes ?? '', new Date().toISOString(), input.notes ?? '', new Date().toISOString(),
) )
const client = getClient(db, id)! const client = (await getClient(db, id))!
writeAudit(db, userId, 'create', 'client', id, undefined, client) await writeAudit(db, userId, 'create', 'client', id, undefined, client)
return client return client
} }
@ -110,8 +109,8 @@ export interface ClientPatch {
anydesk?: string; os?: string; district?: string; sector?: string anydesk?: string; os?: string; district?: string; sector?: string
} }
export function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Client { export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise<Client> {
const before = getClient(db, id) const before = await getClient(db, id)
if (before === null) throw new Error('Client not found') if (before === null) throw new Error('Client not found')
if (patch.gstin !== undefined && patch.gstin !== null) assertGstin(patch.gstin) if (patch.gstin !== undefined && patch.gstin !== null) assertGstin(patch.gstin)
const sets: string[] = [] const sets: string[] = []
@ -132,10 +131,10 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
optText('sector', patch.sector) optText('sector', patch.sector)
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE client SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE client SET ${sets.join(', ')} WHERE id=?`, ...args)
} }
const after = getClient(db, id)! const after = (await getClient(db, id))!
writeAudit(db, userId, 'update', 'client', id, before, after) await writeAudit(db, userId, 'update', 'client', id, before, after)
return after return after
} }
@ -145,31 +144,31 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
* owner/manager-gated, audited action, and the value never appears in audit rows. * owner/manager-gated, audited action, and the value never appears in audit rows.
* An empty keyHex fails loudly never store a support credential unencrypted. * An empty keyHex fails loudly never store a support credential unencrypted.
*/ */
export function setClientDbPassword( export async function setClientDbPassword(
db: DB, userId: string, id: string, plain: string, keyHex: string, db: DB, userId: string, id: string, plain: string, keyHex: string,
): Client { ): Promise<Client> {
return db.transaction(() => { return db.transaction(async () => {
const before = getClient(db, id) const before = await getClient(db, id)
if (before === null) throw new Error('Client not found') if (before === null) throw new Error('Client not found')
if (plain !== '' && keyHex === '') { if (plain !== '' && keyHex === '') {
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`) throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`)
} }
const enc = plain === '' ? null : encrypt(plain, keyHex) const enc = plain === '' ? null : encrypt(plain, keyHex)
db.prepare(`UPDATE client SET db_password_enc=? WHERE id=?`).run(enc, id) await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, enc, id)
writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null }) await writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null })
return getClient(db, id)! return (await getClient(db, id))!
})() })
} }
/** Decrypt-and-return the stored DB password — every reveal writes an audit row. */ /** Decrypt-and-return the stored DB password — every reveal writes an audit row. */
export function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): string { export async function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): Promise<string> {
const row = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`) const row = await db.get<{ db_password_enc: string | null }>(
.get(id) as { db_password_enc: string | null } | undefined `SELECT db_password_enc FROM client WHERE id=?`, id)
if (row === undefined) throw new Error('Client not found') if (row === undefined) throw new Error('Client not found')
if (row.db_password_enc === null) throw new Error('No DB password stored for this client') if (row.db_password_enc === null) throw new Error('No DB password stored for this client')
if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`) if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`)
const plain = decrypt(row.db_password_enc, keyHex) const plain = decrypt(row.db_password_enc, keyHex)
writeAudit(db, userId, 'reveal_db_password', 'client', id) await writeAudit(db, userId, 'reveal_db_password', 'client', id)
return plain return plain
} }
@ -178,19 +177,18 @@ export function revealClientDbPassword(db: DB, userId: string, id: string, keyHe
* updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while * updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while
* ownership routing is owner/manager-gated and audited as its own action. * ownership routing is owner/manager-gated and audited as its own action.
*/ */
export function setClientOwner(db: DB, userId: string, id: string, ownerId: string | null): Client { export async function setClientOwner(db: DB, userId: string, id: string, ownerId: string | null): Promise<Client> {
return db.transaction(() => { return db.transaction(async () => {
const before = getClient(db, id) const before = await getClient(db, id)
if (before === null) throw new Error('Client not found') if (before === null) throw new Error('Client not found')
if (ownerId !== null) { if (ownerId !== null) {
const emp = db.prepare(`SELECT active FROM staff_user WHERE id=?`) const emp = await db.get<{ active: number }>(`SELECT active FROM staff_user WHERE id=?`, ownerId)
.get(ownerId) as { active: number } | undefined
if (emp === undefined) throw new Error('Employee not found') if (emp === undefined) throw new Error('Employee not found')
if (emp.active !== 1) throw new Error('Cannot assign an inactive employee as account owner') if (emp.active !== 1) throw new Error('Cannot assign an inactive employee as account owner')
} }
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(ownerId, id) await db.run(`UPDATE client SET owner_id=? WHERE id=?`, ownerId, id)
const after = getClient(db, id)! const after = (await getClient(db, id))!
writeAudit(db, userId, 'set_owner', 'client', id, before, after) await writeAudit(db, userId, 'set_owner', 'client', id, before, after)
return after return after
})() })
} }

@ -29,9 +29,9 @@ function endOfMonth(today: string): string {
return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this
} }
export function dashboardView( export async function dashboardView(
db: DB, today: string, viewer?: { id: string; role: string }, opts: { mine?: boolean } = {}, db: DB, today: string, viewer?: { id: string; role: string }, opts: { mine?: boolean } = {},
): DashboardView { ): Promise<DashboardView> {
// My Day (D18 WS-E): staff are ALWAYS scoped to their own book (ownerScope forces // My Day (D18 WS-E): staff are ALWAYS scoped to their own book (ownerScope forces
// it); owner/manager opt in via mine=true. "Mine" = clients I own, documents and // it); owner/manager opt in via mine=true. "Mine" = clients I own, documents and
// payments I created, my follow-ups. // payments I created, my follow-ups.
@ -43,17 +43,18 @@ export function dashboardView(
// Overdue anchors on the stamped due date when present (D18 WS-A) — an unpaid // Overdue anchors on the stamped due date when present (D18 WS-A) — an unpaid
// invoice whose due date is still in the future is NOT overdue. // invoice whose due date is still in the future is NOT overdue.
const overdueRows = db.prepare( const overdueRows = await db.all<{ id: string; doc_no: string | null; client_id: string; anchor: string; client_name: string }>(
`SELECT d.id, d.doc_no, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name `SELECT d.id, d.doc_no, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id FROM document d JOIN client c ON c.id = d.client_id
WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL
AND d.status NOT IN ('paid','cancelled','lost') AND COALESCE(d.due_date, d.doc_date) <= ?${mineDoc} AND d.status NOT IN ('paid','cancelled','lost') AND COALESCE(d.due_date, d.doc_date) <= ?${mineDoc}
ORDER BY anchor`, ORDER BY anchor`,
).all(...(meId !== undefined ? [today, meId, meId] : [today])) as { id: string; doc_no: string | null; client_id: string; anchor: string; client_name: string }[] ...(meId !== undefined ? [today, meId, meId] : [today]),
)
const overdue: DashboardView['overdue'] = [] const overdue: DashboardView['overdue'] = []
let overduePaise = 0 let overduePaise = 0
for (const r of overdueRows) { for (const r of overdueRows) {
const outstanding = outstandingPaise(db, r.id) const outstanding = await outstandingPaise(db, r.id)
if (outstanding <= 0) continue if (outstanding <= 0) continue
overduePaise += outstanding overduePaise += outstanding
overdue.push({ overdue.push({
@ -63,37 +64,40 @@ export function dashboardView(
} }
const weekEnd = addDaysIso(today, 7) const weekEnd = addDaysIso(today, 7)
const dueThisWeek = db.prepare( const dueThisWeek = (await db.all(
`SELECT rp.id AS plan_id, rp.client_id, rp.next_run, rp.amount_paise, c.name AS client_name `SELECT rp.id AS plan_id, rp.client_id, rp.next_run, rp.amount_paise, c.name AS client_name
FROM recurring_plan rp JOIN client c ON c.id = rp.client_id FROM recurring_plan rp JOIN client c ON c.id = rp.client_id
WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ?${mineClient} ORDER BY rp.next_run`, WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ?${mineClient} ORDER BY rp.next_run`,
).all(...(meId !== undefined ? [today, weekEnd, meId] : [today, weekEnd])).map((r) => { ...(meId !== undefined ? [today, weekEnd, meId] : [today, weekEnd]),
)).map((r) => {
const row = r as { plan_id: string; client_id: string; next_run: string; amount_paise: number | null; client_name: string } const row = r as { plan_id: string; client_id: string; next_run: string; amount_paise: number | null; client_name: string }
return { planId: row.plan_id, clientId: row.client_id, clientName: row.client_name, nextRun: row.next_run, amountPaise: row.amount_paise } return { planId: row.plan_id, clientId: row.client_id, clientName: row.client_name, nextRun: row.next_run, amountPaise: row.amount_paise }
}) })
const renewalsThisMonth = db.prepare( const renewalsThisMonth = (await db.all(
`SELECT cm.id AS cm_id, cm.client_id, cm.next_renewal, c.name AS client_name `SELECT cm.id AS cm_id, cm.client_id, cm.next_renewal, c.name AS client_name
FROM client_module cm JOIN client c ON c.id = cm.client_id FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ?${mineClient} ORDER BY cm.next_renewal`, WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ?${mineClient} ORDER BY cm.next_renewal`,
).all(...(meId !== undefined ? [today, endOfMonth(today), meId] : [today, endOfMonth(today)])).map((r) => { ...(meId !== undefined ? [today, endOfMonth(today), meId] : [today, endOfMonth(today)]),
)).map((r) => {
const row = r as { cm_id: string; client_id: string; next_renewal: string; client_name: string } const row = r as { cm_id: string; client_id: string; next_renewal: string; client_name: string }
return { clientModuleId: row.cm_id, clientId: row.client_id, clientName: row.client_name, nextRenewal: row.next_renewal } return { clientModuleId: row.cm_id, clientId: row.client_id, clientName: row.client_name, nextRenewal: row.next_renewal }
}) })
const followUpsToday = listOpenFollowUps(db, today) const followUpsToday = (await listOpenFollowUps(db, today))
.filter((f) => meId === undefined || f.staffId === meId) // my follow-ups only under My Day .filter((f) => meId === undefined || f.staffId === meId) // my follow-ups only under My Day
.map((f) => ({ .map((f) => ({
id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate, id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate,
followUpOn: f.followUpOn!, notes: f.notes, followUpOn: f.followUpOn!, notes: f.notes,
})) }))
const recentPayments = db.prepare( const recentPayments = (await db.all(
`SELECT p.id, p.client_id, p.received_on, p.amount_paise, p.mode, c.name AS client_name `SELECT p.id, p.client_id, p.received_on, p.amount_paise, p.mode, c.name AS client_name
FROM payment p JOIN client c ON c.id = p.client_id FROM payment p JOIN client c ON c.id = p.client_id
WHERE 1=1${meId !== undefined ? ' AND (p.created_by=? OR c.owner_id=?)' : ''} WHERE 1=1${meId !== undefined ? ' AND (p.created_by=? OR c.owner_id=?)' : ''}
ORDER BY p.id DESC LIMIT 10`, ORDER BY p.id DESC LIMIT 10`,
).all(...(meId !== undefined ? [meId, meId] : [])).map((r) => { ...(meId !== undefined ? [meId, meId] : []),
)).map((r) => {
const row = r as { id: string; client_id: string; received_on: string; amount_paise: number; mode: string; client_name: string } const row = r as { id: string; client_id: string; received_on: string; amount_paise: number; mode: string; client_name: string }
return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode } return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode }
}) })
@ -101,9 +105,9 @@ export function dashboardView(
// Same owner scope as GET /reminders: staff see only their own queue (spec §6e); // Same owner scope as GET /reminders: staff see only their own queue (spec §6e);
// a managerial My Day narrows the queue to their own rows via ownerId. // a managerial My Day narrows the queue to their own rows via ownerId.
const queuePage = viewer !== undefined const queuePage = viewer !== undefined
? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) }) ? await listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) })
: listQueue(db) : await listQueue(db)
const counts = queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined) const counts = await queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined)
return { return {
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments, today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments,

@ -63,8 +63,8 @@ function toDoc(r: DocRow): Doc {
} }
} }
export function getDocument(db: DB, id: string): Doc | null { export async function getDocument(db: DB, id: string): Promise<Doc | null> {
const row = db.prepare(`SELECT * FROM document WHERE id=?`).get(id) as DocRow | undefined const row = await db.get<DocRow>(`SELECT * FROM document WHERE id=?`, id)
return row === undefined ? null : toDoc(row) return row === undefined ? null : toDoc(row)
} }
@ -82,7 +82,7 @@ export type DocListRow = Doc & { clientName: string }
export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number } export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number }
/** Filtered document list, newest first, paginated with a true total (rule 8 — no silent caps). */ /** Filtered document list, newest first, paginated with a true total (rule 8 — no silent caps). */
export function listDocuments(db: DB, filter: DocumentFilter = {}): DocumentsPage { export async function listDocuments(db: DB, filter: DocumentFilter = {}): Promise<DocumentsPage> {
const page = Math.max(1, filter.page ?? 1) const page = Math.max(1, filter.page ?? 1)
const pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 50)) const pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 50))
let where = ` WHERE 1=1` let where = ` WHERE 1=1`
@ -90,14 +90,14 @@ export function listDocuments(db: DB, filter: DocumentFilter = {}): DocumentsPag
if (filter.type !== undefined) { where += ` AND d.doc_type=?`; args.push(filter.type) } if (filter.type !== undefined) { where += ` AND d.doc_type=?`; args.push(filter.type) }
if (filter.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) } if (filter.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) }
if (filter.clientId !== undefined) { where += ` AND d.client_id=?`; args.push(filter.clientId) } if (filter.clientId !== undefined) { where += ` AND d.client_id=?`; args.push(filter.clientId) }
const total = (db.prepare( const total = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM document d${where}`, `SELECT COUNT(*) AS n FROM document d${where}`, ...args,
).get(...args) as { n: number }).n ))!.n
const rows = db.prepare( const rows = await db.all<DocRow & { client_name: string }>(
`SELECT d.*, c.name AS client_name `SELECT d.*, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id${where} FROM document d JOIN client c ON c.id = d.client_id${where}
ORDER BY d.id DESC LIMIT ? OFFSET ?`, // uuidv7 ids sort by creation time ORDER BY d.id DESC LIMIT ? OFFSET ?`, // uuidv7 ids sort by creation time
).all(...args, pageSize, (page - 1) * pageSize) as (DocRow & { client_name: string })[] ...args, pageSize, (page - 1) * pageSize)
return { return {
documents: rows.map((r) => ({ ...toDoc(r), clientName: r.client_name })), documents: rows.map((r) => ({ ...toDoc(r), clientName: r.client_name })),
total, page, pageSize, total, page, pageSize,
@ -112,16 +112,15 @@ export interface DocumentEvent {
interface EventRow { id: string; document_id: string; at_wall: string; kind: string; meta: string } interface EventRow { id: string; document_id: string; at_wall: string; kind: string; meta: string }
function addEvent(db: DB, documentId: string, kind: string, meta: Record<string, unknown> = {}): void { async function addEvent(db: DB, documentId: string, kind: string, meta: Record<string, unknown> = {}): Promise<void> {
db.prepare( await db.run(
`INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`, `INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`,
).run(uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta)) uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta))
} }
export function listDocumentEvents(db: DB, documentId: string): DocumentEvent[] { export async function listDocumentEvents(db: DB, documentId: string): Promise<DocumentEvent[]> {
const rows = db.prepare( const rows = await db.all<EventRow>(
`SELECT * FROM document_event WHERE document_id=? ORDER BY id`, `SELECT * FROM document_event WHERE document_id=? ORDER BY id`, documentId)
).all(documentId) as EventRow[]
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, documentId: r.document_id, atWall: r.at_wall, kind: r.kind, id: r.id, documentId: r.document_id, atWall: r.at_wall, kind: r.kind,
meta: JSON.parse(r.meta) as Record<string, unknown>, meta: JSON.parse(r.meta) as Record<string, unknown>,
@ -154,19 +153,19 @@ const addDays = (dateIso: string, days: number): string => {
return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10) return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10)
} }
function supplyStateCode(db: DB): string { async function supplyStateCode(db: DB): Promise<string> {
const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`) const row = await db.get<{ value: string }>(
.get() as { value: string } | undefined `SELECT value FROM setting WHERE key='company.state_code'`)
// Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly. // Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly.
if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`) if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`)
return row.value return row.value
} }
function taxRates(db: DB): TaxClassRow[] { async function taxRates(db: DB): Promise<TaxClassRow[]> {
const rows = db.prepare(`SELECT * FROM tax_class`).all() as { const rows = await db.all<{
class_code: string; rate_pct_bp: number; cess_pct_bp: number class_code: string; rate_pct_bp: number; cess_pct_bp: number
effective_from: string; effective_to: string | null effective_from: string; effective_to: string | null
}[] }>(`SELECT * FROM tax_class`)
return rows.map((r) => ({ return rows.map((r) => ({
classCode: r.class_code, ratePctBp: r.rate_pct_bp, cessPctBp: r.cess_pct_bp, classCode: r.class_code, ratePctBp: r.rate_pct_bp, cessPctBp: r.cess_pct_bp,
effectiveFrom: r.effective_from, effectiveFrom: r.effective_from,
@ -174,12 +173,14 @@ function taxRates(db: DB): TaxClassRow[] {
})) }))
} }
function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): LineInput[] { async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): Promise<LineInput[]> {
return inputs.map((line) => { const out: LineInput[] = []
const mod = getModule(db, line.moduleId) for (const line of inputs) {
const mod = await getModule(db, line.moduleId)
if (mod === null) throw new Error(`Module not found: ${line.moduleId}`) if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
const edition = line.edition ?? 'standard' const edition = line.edition ?? 'standard'
let unitPricePaise = line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate) let unitPricePaise = line.unitPricePaise
?? await priceOn(db, line.moduleId, line.kind, edition, onDate)
if (unitPricePaise === null) { if (unitPricePaise === null) {
// Strict (no warnings sink): refuse — same message save has always thrown. // Strict (no warnings sink): refuse — same message save has always thrown.
if (warnings === undefined) { if (warnings === undefined) {
@ -190,7 +191,7 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?:
unitPricePaise = 0 unitPricePaise = 0
} }
const packSuffix = edition !== 'standard' ? `${edition}` : '' // pack name prints on the line const packSuffix = edition !== 'standard' ? `${edition}` : '' // pack name prints on the line
return { out.push({
itemId: mod.id, itemId: mod.id,
name: mod.name name: mod.name
+ (line.description !== undefined && line.description !== '' ? `${line.description}` : '') + (line.description !== undefined && line.description !== '' ? `${line.description}` : '')
@ -201,30 +202,29 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?:
unitPricePaise, unitPricePaise,
priceIncludesTax: false, // HQ B2B convention: prices are GST-exclusive priceIncludesTax: false, // HQ B2B convention: prices are GST-exclusive
taxClassCode: TAX_CLASS, taxClassCode: TAX_CLASS,
} })
}) }
return out
} }
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */ /** Insert a draft row + created event + audit. Callers wrap in a transaction. */
function insertDocRow(db: DB, userId: string, a: { async function insertDocRow(db: DB, userId: string, a: {
docType: DocType; clientId: string; refDocId: string | null; docDate: string docType: DocType; clientId: string; refDocId: string | null; docDate: string
totals: BillTotals; payload: DocPayload; dueDate?: string | null totals: BillTotals; payload: DocPayload; dueDate?: string | null
}): Doc { }): Promise<Doc> {
const id = uuidv7() const id = uuidv7()
const t = a.totals const t = a.totals
db.prepare( await db.run(
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id, `INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise, taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, created_by, created_at) payload, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId, id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId,
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise, t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
JSON.stringify(a.payload), userId, new Date().toISOString(), JSON.stringify(a.payload), userId, new Date().toISOString())
) await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {}) const doc = (await getDocument(db, id))!
const doc = getDocument(db, id)! await writeAudit(db, userId, 'create', 'document', id, undefined, {
writeAudit(db, userId, 'create', 'document', id, undefined, {
docType: doc.docType, clientId: doc.clientId, docType: doc.docType, clientId: doc.clientId,
payablePaise: doc.payablePaise, refDocId: doc.refDocId, payablePaise: doc.payablePaise, refDocId: doc.refDocId,
}) })
@ -252,7 +252,7 @@ export interface PreparedDraft {
* price gap (0 + warning) and a not-yet-picked client (intra-state default); * price gap (0 + warning) and a not-yet-picked client (intra-state default);
* strict (the default, used by save) throws exactly as before. * strict (the default, used by save) throws exactly as before.
*/ */
export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): PreparedDraft { export async function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): Promise<PreparedDraft> {
const permissive = opts.permissive === true const permissive = opts.permissive === true
if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) { if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) {
throw new Error(`Cannot draft doc type: ${input.docType}`) throw new Error(`Cannot draft doc type: ${input.docType}`)
@ -262,7 +262,7 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo
if (input.docType !== 'INVOICE') throw new Error('Due date applies to INVOICE drafts only') if (input.docType !== 'INVOICE') throw new Error('Due date applies to INVOICE drafts only')
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.dueDate)) throw new Error('dueDate must be YYYY-MM-DD') if (!/^\d{4}-\d{2}-\d{2}$/.test(input.dueDate)) throw new Error('dueDate must be YYYY-MM-DD')
} }
const client = getClient(db, input.clientId) const client = await getClient(db, input.clientId)
if (client === null && !permissive) throw new Error('Client not found') if (client === null && !permissive) throw new Error('Client not found')
const today = todayIso() const today = todayIso()
const lineInputs = Array.isArray(input.lines) ? input.lines : [] const lineInputs = Array.isArray(input.lines) ? input.lines : []
@ -276,16 +276,18 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo
totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings, totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings,
} }
} }
const supply = supplyStateCode(db) const supply = await supplyStateCode(db)
const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), { const computed = computeBill(await buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
businessDate: today, businessDate: today,
supplyStateCode: supply, supplyStateCode: supply,
placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default
roundToRupee: true, roundToRupee: true,
}, taxRates(db)) }, await taxRates(db))
// Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists). // Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists).
const lineContents = lineInputs.map((line) => const lineContents: string[][] = []
line.contentLines ?? getModule(db, line.moduleId)!.quoteContent) for (const line of lineInputs) {
lineContents.push(line.contentLines ?? (await getModule(db, line.moduleId))!.quoteContent)
}
const payload: DocPayload = { const payload: DocPayload = {
lines: computed.lines, totals: computed.totals, lineContents, lines: computed.lines, totals: computed.totals, lineContents,
...(input.terms !== undefined ? { terms: input.terms } : {}), ...(input.terms !== undefined ? { terms: input.terms } : {}),
@ -297,38 +299,38 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo
} }
} }
export function createDraft(db: DB, userId: string, input: DraftInput): Doc { export async function createDraft(db: DB, userId: string, input: DraftInput): Promise<Doc> {
const prepared = prepareDraft(db, input) // strict — save refuses a bad document const prepared = await prepareDraft(db, input) // strict — save refuses a bad document
return db.transaction(() => insertDocRow(db, userId, { return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null, docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload, docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null, dueDate: prepared.dueDate ?? null,
}))() }))
} }
// ---------- lifecycle ---------- // ---------- lifecycle ----------
/** Issue = number assignment only; status stays draft until sent/marked. */ /** Issue = number assignment only; status stays draft until sent/marked. */
export function issueDocument(db: DB, userId: string, docId: string): Doc { export async function issueDocument(db: DB, userId: string, docId: string): Promise<Doc> {
return db.transaction(() => { return db.transaction(async () => {
const before = getDocument(db, docId) const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found') if (before === null) throw new Error('Document not found')
if (before.docNo !== null) throw new Error(`Document already issued as ${before.docNo}`) if (before.docNo !== null) throw new Error(`Document already issued as ${before.docNo}`)
if (before.status === 'cancelled') throw new Error('Cannot issue a cancelled document') if (before.status === 'cancelled') throw new Error('Cannot issue a cancelled document')
const docNo = nextDocNo(db, before.docType, before.fy) const docNo = await nextDocNo(db, before.docType, before.fy)
db.prepare(`UPDATE document SET doc_no=? WHERE id=?`).run(docNo, docId) await db.run(`UPDATE document SET doc_no=? WHERE id=?`, docNo, docId)
// D18: an invoice that reaches issue without a due date gets doc_date + terms // D18: an invoice that reaches issue without a due date gets doc_date + terms
// stamped NOW — the last moment it is still a draft (issued paper never changes). // stamped NOW — the last moment it is still a draft (issued paper never changes).
let dueDate = before.dueDate let dueDate = before.dueDate
if (before.docType === 'INVOICE' && dueDate === null) { if (before.docType === 'INVOICE' && dueDate === null) {
const terms = getNumberSetting(db, 'billing.payment_terms_days', 15) const terms = await getNumberSetting(db, 'billing.payment_terms_days', 15)
dueDate = addDays(before.docDate, terms) dueDate = addDays(before.docDate, terms)
db.prepare(`UPDATE document SET due_date=? WHERE id=?`).run(dueDate, docId) await db.run(`UPDATE document SET due_date=? WHERE id=?`, dueDate, docId)
} }
addEvent(db, docId, 'issued', { docNo }) await addEvent(db, docId, 'issued', { docNo })
writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) }) await writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) })
return getDocument(db, docId)! return (await getDocument(db, docId))!
})() })
} }
const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = { const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = {
@ -336,23 +338,23 @@ const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = {
sent: ['accepted', 'lost'], sent: ['accepted', 'lost'],
} }
export function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Doc { export async function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Promise<Doc> {
return db.transaction(() => { return db.transaction(async () => {
const before = getDocument(db, docId) const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found') if (before === null) throw new Error('Document not found')
if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) { if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) {
throw new Error(`Illegal status transition: ${before.status}${status}`) throw new Error(`Illegal status transition: ${before.status}${status}`)
} }
db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId) await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
addEvent(db, docId, status) await addEvent(db, docId, status)
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status }) await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
if (status === 'accepted' || status === 'lost') { if (status === 'accepted' || status === 'lost') {
// STOP cleanup (spec §7): a decided quote stops chasing — dismiss its open // STOP cleanup (spec §7): a decided quote stops chasing — dismiss its open
// follow-up nudges in this same transaction, one audited row each. // follow-up nudges in this same transaction, one audited row each.
dismissQuoteFollowups(db, userId, docId) await dismissQuoteFollowups(db, userId, docId)
} }
return getDocument(db, docId)! return (await getDocument(db, docId))!
})() })
} }
const CHAIN: Record<string, number> = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 } const CHAIN: Record<string, number> = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 }
@ -362,9 +364,9 @@ const CHAIN: Record<string, number> = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 }
* QTPI copies totals verbatim (both non-legal); INVOICE recomputes through * QTPI copies totals verbatim (both non-legal); INVOICE recomputes through
* computeBill on the invoice's own doc_date (spec §8 F3, hard rule 2). * computeBill on the invoice's own doc_date (spec §8 F3, hard rule 2).
*/ */
export function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Doc { export async function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Promise<Doc> {
return db.transaction(() => { return db.transaction(async () => {
const src = getDocument(db, docId) const src = await getDocument(db, docId)
if (src === null) throw new Error('Document not found') if (src === null) throw new Error('Document not found')
const from = CHAIN[src.docType] const from = CHAIN[src.docType]
const target = CHAIN[to] const target = CHAIN[to]
@ -375,9 +377,9 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
// Rule-4 guard (spec §8 F4): one sale, one forward document. A live (non-cancelled) // Rule-4 guard (spec §8 F4): one sale, one forward document. A live (non-cancelled)
// child means this document was already converted — a second convert would mint a // child means this document was already converted — a second convert would mint a
// duplicate for the same sale. Cancelling the child re-opens the path. // duplicate for the same sale. Cancelling the child re-opens the path.
const child = db.prepare( const child = await db.get<{ doc_type: string }>(
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`, `SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
).get(src.id) as { doc_type: string } | undefined src.id)
if (child !== undefined) { if (child !== undefined) {
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`) throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
} }
@ -388,7 +390,7 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
// Rule-2 fix (spec §8 F3): an invoice is legal paper — recompute the carried // Rule-2 fix (spec §8 F3): an invoice is legal paper — recompute the carried
// lines on the invoice's date (mirrors createCreditNote), so a dated tax_class // lines on the invoice's date (mirrors createCreditNote), so a dated tax_class
// change between quote/proforma and invoice lands at the rate that is law today. // change between quote/proforma and invoice lands at the rate that is law today.
const client = getClient(db, src.clientId) const client = await getClient(db, src.clientId)
if (client === null) throw new Error('Client not found') if (client === null) throw new Error('Client not found')
const computed = computeBill( const computed = computeBill(
src.payload.lines.map((l): LineInput => ({ src.payload.lines.map((l): LineInput => ({
@ -397,52 +399,51 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
})), })),
{ {
businessDate: docDate, businessDate: docDate,
supplyStateCode: supplyStateCode(db), supplyStateCode: await supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode, placeOfSupplyStateCode: client.stateCode,
roundToRupee: true, roundToRupee: true,
}, taxRates(db)) }, await taxRates(db))
totals = computed.totals totals = computed.totals
payload = { ...src.payload, lines: computed.lines, totals: computed.totals } payload = { ...src.payload, lines: computed.lines, totals: computed.totals }
} }
const doc = insertDocRow(db, userId, { const doc = await insertDocRow(db, userId, {
docType: to, clientId: src.clientId, refDocId: src.id, docType: to, clientId: src.clientId, refDocId: src.id,
docDate, totals, payload, docDate, totals, payload,
}) })
addEvent(db, src.id, 'converted', { to, newDocId: doc.id }) await addEvent(db, src.id, 'converted', { to, newDocId: doc.id })
// ANY conversion moves the source forward — out of the 'sent' set the follow-up // ANY conversion moves the source forward — out of the 'sent' set the follow-up
// scan and the pipeline read, so a converted quote is never chased again (spec §7). // scan and the pipeline read, so a converted quote is never chased again (spec §7).
db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id) await db.run(`UPDATE document SET status='invoiced' WHERE id=?`, src.id)
writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' }) await writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
if (src.docType === 'QUOTATION') { if (src.docType === 'QUOTATION') {
// STOP cleanup (spec §7): a converted quote has moved forward — dismiss its // STOP cleanup (spec §7): a converted quote has moved forward — dismiss its
// open follow-up nudges in this same transaction, one audited row each. // open follow-up nudges in this same transaction, one audited row each.
dismissQuoteFollowups(db, userId, src.id) await dismissQuoteFollowups(db, userId, src.id)
} }
return doc return doc
})() })
} }
/** Only issued, unpaid documents; the consumed number is never reused. */ /** Only issued, unpaid documents; the consumed number is never reused. */
export function cancelDocument(db: DB, userId: string, docId: string): Doc { export async function cancelDocument(db: DB, userId: string, docId: string): Promise<Doc> {
return db.transaction(() => { return db.transaction(async () => {
const before = getDocument(db, docId) const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found') if (before === null) throw new Error('Document not found')
if (before.docNo === null) throw new Error('Only issued documents can be cancelled') if (before.docNo === null) throw new Error('Only issued documents can be cancelled')
if (before.status === 'cancelled') throw new Error('Document is already cancelled') if (before.status === 'cancelled') throw new Error('Document is already cancelled')
const alloc = db.prepare( const alloc = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, `SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, docId))!
).get(docId) as { n: number }
if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it') if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it')
db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId) await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
addEvent(db, docId, 'cancelled') await addEvent(db, docId, 'cancelled')
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' }) await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
if (before.docType === 'QUOTATION') { if (before.docType === 'QUOTATION') {
// STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges // STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges
// must not chase the client. Same transaction, one audited row each. // must not chase the client. Same transaction, one audited row each.
dismissQuoteFollowups(db, userId, docId) await dismissQuoteFollowups(db, userId, docId)
} }
return getDocument(db, docId)! return (await getDocument(db, docId))!
})() })
} }
/** /**
@ -452,9 +453,9 @@ export function cancelDocument(db: DB, userId: string, docId: string): Doc {
* an INVOICE is immutable legal paper (cancel if unpaid, else credit note), and * an INVOICE is immutable legal paper (cancel if unpaid, else credit note), and
* a QUOTATION re-drafts through the normal composer. * a QUOTATION re-drafts through the normal composer.
*/ */
export function supersedeProforma(db: DB, userId: string, docId: string): Doc { export async function supersedeProforma(db: DB, userId: string, docId: string): Promise<Doc> {
return db.transaction(() => { return db.transaction(async () => {
const src = getDocument(db, docId) const src = await getDocument(db, docId)
if (src === null) throw new Error('Document not found') if (src === null) throw new Error('Document not found')
if (src.docType === 'INVOICE') { if (src.docType === 'INVOICE') {
throw new Error('Invoices are immutable — cancel if unpaid, or raise a credit note') throw new Error('Invoices are immutable — cancel if unpaid, or raise a credit note')
@ -462,64 +463,64 @@ export function supersedeProforma(db: DB, userId: string, docId: string): Doc {
if (src.docType !== 'PROFORMA') throw new Error('Only proformas can be superseded') if (src.docType !== 'PROFORMA') throw new Error('Only proformas can be superseded')
if (src.status === 'cancelled') throw new Error('Document is already cancelled') if (src.status === 'cancelled') throw new Error('Document is already cancelled')
// A live forward child means the sale already moved on — superseding would fork it. // A live forward child means the sale already moved on — superseding would fork it.
const child = db.prepare( const child = await db.get<{ doc_type: string }>(
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`, `SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
).get(src.id) as { doc_type: string } | undefined src.id)
if (child !== undefined) { if (child !== undefined) {
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`) throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
} }
if (src.docNo !== null) { if (src.docNo !== null) {
cancelDocument(db, userId, docId) // issued: number stays consumed (rule 4) await cancelDocument(db, userId, docId) // issued: number stays consumed (rule 4)
} else { } else {
// Unissued draft: no number to preserve; retire it the same audited way. // Unissued draft: no number to preserve; retire it the same audited way.
db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId) await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
addEvent(db, docId, 'cancelled') await addEvent(db, docId, 'cancelled')
writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' }) await writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' })
} }
const doc = insertDocRow(db, userId, { const doc = await insertDocRow(db, userId, {
docType: 'PROFORMA', clientId: src.clientId, refDocId: src.id, docType: 'PROFORMA', clientId: src.clientId, refDocId: src.id,
docDate: todayIso(), totals: src.payload.totals, payload: src.payload, docDate: todayIso(), totals: src.payload.totals, payload: src.payload,
}) })
addEvent(db, src.id, 'superseded', { newDocId: doc.id }) await addEvent(db, src.id, 'superseded', { newDocId: doc.id })
return doc return doc
})() })
} }
/** /**
* Credit note against an issued invoice; defaults to full value. Amounts are * Credit note against an issued invoice; defaults to full value. Amounts are
* positive here CN semantics are "negative" only in settlement math (Task 9). * positive here CN semantics are "negative" only in settlement math (Task 9).
*/ */
export function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Doc { export async function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Promise<Doc> {
const inv = getDocument(db, invoiceId) const inv = await getDocument(db, invoiceId)
if (inv === null) throw new Error('Invoice not found') if (inv === null) throw new Error('Invoice not found')
if (inv.docType !== 'INVOICE') throw new Error('Credit notes can only be raised against invoices') if (inv.docType !== 'INVOICE') throw new Error('Credit notes can only be raised against invoices')
if (inv.docNo === null) throw new Error('Invoice must be issued before raising a credit note') if (inv.docNo === null) throw new Error('Invoice must be issued before raising a credit note')
if (inv.status === 'cancelled') throw new Error('Cannot credit a cancelled invoice') if (inv.status === 'cancelled') throw new Error('Cannot credit a cancelled invoice')
const client = getClient(db, inv.clientId) const client = await getClient(db, inv.clientId)
if (client === null) throw new Error('Client not found') if (client === null) throw new Error('Client not found')
// Recompute rather than copy: rates resolve on the invoice's date, so a CN // Recompute rather than copy: rates resolve on the invoice's date, so a CN
// against an old bill uses the rate that was law on that day. // against an old bill uses the rate that was law on that day.
const lineInputs = lines !== undefined const lineInputs = lines !== undefined
? buildLines(db, lines, inv.docDate) ? await buildLines(db, lines, inv.docDate)
: inv.payload.lines.map((l): LineInput => ({ : inv.payload.lines.map((l): LineInput => ({
itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode, itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode,
unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS, unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS,
})) }))
const computed = computeBill(lineInputs, { const computed = computeBill(lineInputs, {
businessDate: inv.docDate, businessDate: inv.docDate,
supplyStateCode: supplyStateCode(db), supplyStateCode: await supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode, placeOfSupplyStateCode: client.stateCode,
roundToRupee: true, roundToRupee: true,
}, taxRates(db)) }, await taxRates(db))
return db.transaction(() => { return db.transaction(async () => {
const doc = insertDocRow(db, userId, { const doc = await insertDocRow(db, userId, {
docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id, docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id,
docDate: todayIso(), totals: computed.totals, docDate: todayIso(), totals: computed.totals,
payload: { lines: computed.lines, totals: computed.totals }, payload: { lines: computed.lines, totals: computed.totals },
}) })
addEvent(db, inv.id, 'credit_note', { creditNoteId: doc.id }) await addEvent(db, inv.id, 'credit_note', { creditNoteId: doc.id })
return doc return doc
})() })
} }
export interface GenerateReceiptInput { export interface GenerateReceiptInput {
@ -533,8 +534,8 @@ export interface GenerateReceiptInput {
* acknowledge money, they do not levy tax: zero GST, no line items, payable = * acknowledge money, they do not levy tax: zero GST, no line items, payable =
* amount received. Issued (numbered) immediately and never edited thereafter. * amount received. Issued (numbered) immediately and never edited thereafter.
*/ */
export function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Doc { export async function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Promise<Doc> {
const client = getClient(db, input.clientId) const client = await getClient(db, input.clientId)
if (client === null) throw new Error('Client not found') if (client === null) throw new Error('Client not found')
const totals: BillTotals = { const totals: BillTotals = {
grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0, grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0,
@ -549,11 +550,11 @@ export function generateReceipt(db: DB, userId: string, input: GenerateReceiptIn
allocations: input.allocations, allocations: input.allocations,
}, },
} }
return db.transaction(() => { return db.transaction(async () => {
const draft = insertDocRow(db, userId, { const draft = await insertDocRow(db, userId, {
docType: 'RECEIPT', clientId: input.clientId, refDocId: null, docType: 'RECEIPT', clientId: input.clientId, refDocId: null,
docDate: todayIso(), totals, payload, docDate: todayIso(), totals, payload,
}) })
return issueDocument(db, userId, draft.id) // assigns RCT/26-27-000N return issueDocument(db, userId, draft.id) // assigns RCT/26-27-000N
})() })
} }

@ -25,33 +25,33 @@ function toAccount(r: AccountRow): EmailAccount {
} }
} }
export function getAccount(db: DB): EmailAccount | null { export async function getAccount(db: DB): Promise<EmailAccount | null> {
const row = db.prepare(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`) const row = await db.get<AccountRow>(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`)
.get() as AccountRow | undefined
return row === undefined ? null : toAccount(row) return row === undefined ? null : toAccount(row)
} }
export function saveAccount(db: DB, address: string, refreshTokenEnc: string): EmailAccount { export async function saveAccount(db: DB, address: string, refreshTokenEnc: string): Promise<EmailAccount> {
return db.transaction(() => { return db.transaction(async () => {
db.prepare(`DELETE FROM email_account`).run() // single sending identity await db.run(`DELETE FROM email_account`) // single sending identity
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO email_account (id, address, refresh_token_enc, status, updated_at) `INSERT INTO email_account (id, address, refresh_token_enc, status, updated_at)
VALUES (?, ?, ?, 'active', ?)`, VALUES (?, ?, ?, 'active', ?)`,
).run(id, address, refreshTokenEnc, new Date().toISOString()) id, address, refreshTokenEnc, new Date().toISOString(),
)
// Audit carries the address only — never the (even encrypted) token. // Audit carries the address only — never the (even encrypted) token.
writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' }) await writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' })
return getAccount(db)! return (await getAccount(db))!
})() })
} }
/** Refresh token revoked/expired (invalid_grant) — flips the dashboard banner. */ /** Refresh token revoked/expired (invalid_grant) — flips the dashboard banner. */
export function markAccountDead(db: DB): void { export async function markAccountDead(db: DB): Promise<void> {
const account = getAccount(db) const account = await getAccount(db)
if (account === null || account.status === 'dead') return if (account === null || account.status === 'dead') return
db.prepare(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`) await db.run(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`,
.run(new Date().toISOString(), account.id) new Date().toISOString(), account.id)
writeAudit(db, 'system', 'update', 'email_account', account.id, await writeAudit(db, 'system', 'update', 'email_account', account.id,
{ status: account.status }, { status: 'dead' }) { status: account.status }, { status: 'dead' })
} }
@ -61,11 +61,10 @@ export interface EmailLogInput {
} }
/** Append-only send trace — the email_log table is itself the record. */ /** Append-only send trace — the email_log table is itself the record. */
export function logEmail(db: DB, e: EmailLogInput): void { export async function logEmail(db: DB, e: EmailLogInput): Promise<void> {
db.prepare( await db.run(
`INSERT INTO email_log (id, document_id, to_addr, subject, status, gmail_message_id, error, at_wall) `INSERT INTO email_log (id, document_id, to_addr, subject, status, gmail_message_id, error, at_wall)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
uuidv7(), e.documentId ?? null, e.to, e.subject, e.status, uuidv7(), e.documentId ?? null, e.to, e.subject, e.status,
e.gmailMessageId ?? null, e.error ?? null, new Date().toISOString(), e.gmailMessageId ?? null, e.error ?? null, new Date().toISOString(),
) )
@ -73,8 +72,8 @@ export function logEmail(db: DB, e: EmailLogInput): void {
export interface EmailStatus { connected: boolean; address?: string; dead: boolean } export interface EmailStatus { connected: boolean; address?: string; dead: boolean }
export function emailStatus(db: DB): EmailStatus { export async function emailStatus(db: DB): Promise<EmailStatus> {
const account = getAccount(db) const account = await getAccount(db)
if (account === null) return { connected: false, dead: false } if (account === null) return { connected: false, dead: false }
return { connected: true, address: account.address, dead: account.status === 'dead' } return { connected: true, address: account.address, dead: account.status === 'dead' }
} }

@ -38,29 +38,30 @@ function assertRole(role: string): asserts role is EmployeeRole {
} }
/** Console users are a small bounded set (not a growable list) — returned whole, with a count for the UI. */ /** Console users are a small bounded set (not a growable list) — returned whole, with a count for the UI. */
export function listEmployees(db: DB): Employee[] { export async function listEmployees(db: DB): Promise<Employee[]> {
const rows = db.prepare( const rows = await db.all<EmployeeRow>(
`SELECT ${COLS} FROM staff_user ORDER BY display_name`, `SELECT ${COLS} FROM staff_user ORDER BY display_name`,
).all() as EmployeeRow[] )
return rows.map(toEmployee) return rows.map(toEmployee)
} }
export function getEmployee(db: DB, id: string): Employee | null { export async function getEmployee(db: DB, id: string): Promise<Employee | null> {
const row = db.prepare(`SELECT ${COLS} FROM staff_user WHERE id=?`).get(id) as EmployeeRow | undefined const row = await db.get<EmployeeRow>(`SELECT ${COLS} FROM staff_user WHERE id=?`, id)
return row ? toEmployee(row) : null return row ? toEmployee(row) : null
} }
/** True when at least one ACTIVE owner other than `excludeId` exists. */ /** True when at least one ACTIVE owner other than `excludeId` exists. */
function otherActiveOwnerExists(db: DB, excludeId: string): boolean { async function otherActiveOwnerExists(db: DB, excludeId: string): Promise<boolean> {
const row = db.prepare( const row = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`, `SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`,
).get(excludeId) as { n: number } excludeId,
))!
return row.n > 0 return row.n > 0
} }
export function createEmployee(db: DB, userId: string, input: { export async function createEmployee(db: DB, userId: string, input: {
email: string; displayName: string; role: EmployeeRole; password: string email: string; displayName: string; role: EmployeeRole; password: string
}): Employee { }): Promise<Employee> {
assertRole(input.role) assertRole(input.role)
if (input.password.length < 8) throw new Error('Password must be at least 8 characters') if (input.password.length < 8) throw new Error('Password must be at least 8 characters')
const email = input.email.trim().toLowerCase() const email = input.email.trim().toLowerCase()
@ -68,30 +69,31 @@ export function createEmployee(db: DB, userId: string, input: {
if (input.displayName.trim() === '') throw new Error('Name is required') if (input.displayName.trim() === '') throw new Error('Name is required')
const id = uuidv7() const id = uuidv7()
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
return db.transaction(() => { return db.transaction(async () => {
// Pre-check the UNIQUE(email) so the caller gets a friendly, engine-neutral // Pre-check the UNIQUE(email) so the caller gets a friendly, engine-neutral
// message instead of raw driver text (SQLite and Postgres word it differently). // message instead of raw driver text (SQLite and Postgres word it differently).
if (db.prepare(`SELECT 1 FROM staff_user WHERE email=?`).get(email) !== undefined) { if (await db.get(`SELECT 1 FROM staff_user WHERE email=?`, email) !== undefined) {
throw new Error('Email already in use') throw new Error('Email already in use')
} }
db.prepare( await db.run(
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, email, input.displayName, input.role, salt, hash) id, email, input.displayName, input.role, salt, hash,
writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role }) )
return getEmployee(db, id)! await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role })
})() return (await getEmployee(db, id))!
})
} }
export function updateEmployee(db: DB, userId: string, id: string, patch: { export async function updateEmployee(db: DB, userId: string, id: string, patch: {
displayName?: string; role?: EmployeeRole; phone?: string; title?: string displayName?: string; role?: EmployeeRole; phone?: string; title?: string
}): Employee { }): Promise<Employee> {
return db.transaction(() => { return db.transaction(async () => {
const before = getEmployee(db, id) const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found') if (!before) throw new Error('Employee not found')
if (patch.role !== undefined) { if (patch.role !== undefined) {
assertRole(patch.role) assertRole(patch.role)
// Demoting the last active owner would lock everyone out of owner-gated routes. // Demoting the last active owner would lock everyone out of owner-gated routes.
if (before.role === 'owner' && before.active && patch.role !== 'owner' && !otherActiveOwnerExists(db, id)) { if (before.role === 'owner' && before.active && patch.role !== 'owner' && !(await otherActiveOwnerExists(db, id))) {
throw new Error('Cannot demote the last active owner') throw new Error('Cannot demote the last active owner')
} }
} }
@ -109,11 +111,11 @@ export function updateEmployee(db: DB, userId: string, id: string, patch: {
// no-op audit row and tell the caller a change landed when nothing did. // no-op audit row and tell the caller a change landed when nothing did.
if (sets.length === 0) throw new Error('Nothing to update') if (sets.length === 0) throw new Error('Nothing to update')
args.push(id) args.push(id)
db.prepare(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`, ...args)
const after = getEmployee(db, id)! const after = (await getEmployee(db, id))!
writeAudit(db, userId, 'update', 'staff_user', id, before, after) await writeAudit(db, userId, 'update', 'staff_user', id, before, after)
return after return after
})() })
} }
/** /**
@ -122,65 +124,65 @@ export function updateEmployee(db: DB, userId: string, id: string, patch: {
* change survives (currentToken), everything else dies with the old credential. * change survives (currentToken), everything else dies with the old credential.
* Audited as change_own_password; hashes never leave this module or reach the audit. * Audited as change_own_password; hashes never leave this module or reach the audit.
*/ */
export function changeOwnPassword( export async function changeOwnPassword(
db: DB, userId: string, currentPassword: string, newPassword: string, currentToken: string, db: DB, userId: string, currentPassword: string, newPassword: string, currentToken: string,
): void { ): Promise<void> {
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters') if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
const row = db.prepare(`SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`) const row = await db.get<{ pw_salt: string; pw_hash: string }>(
.get(userId) as { pw_salt: string; pw_hash: string } | undefined `SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`, userId)
if (row === undefined) throw new Error('Employee not found') if (row === undefined) throw new Error('Employee not found')
if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) { if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) {
throw new Error('Current password is incorrect') throw new Error('Current password is incorrect')
} }
const { salt, hash } = hashPin(newPassword) const { salt, hash } = hashPin(newPassword)
db.transaction(() => { await db.transaction(async () => {
db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, userId) await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, userId)
db.prepare(`DELETE FROM session WHERE staff_id=? AND token != ?`).run(userId, currentToken) await db.run(`DELETE FROM session WHERE staff_id=? AND token != ?`, userId, currentToken)
writeAudit(db, userId, 'change_own_password', 'staff_user', userId) await writeAudit(db, userId, 'change_own_password', 'staff_user', userId)
})() })
} }
/** Reset an employee's password. Audits the action; never logs the hash. /** Reset an employee's password. Audits the action; never logs the hash.
* Existing sessions are purged in the same transaction a reset is the natural * Existing sessions are purged in the same transaction a reset is the natural
* "lock out the old credential" action, so a stolen token must not outlive it. */ * "lock out the old credential" action, so a stolen token must not outlive it. */
export function setEmployeePassword(db: DB, userId: string, id: string, password: string): void { export async function setEmployeePassword(db: DB, userId: string, id: string, password: string): Promise<void> {
if (password.length < 8) throw new Error('Password must be at least 8 characters') if (password.length < 8) throw new Error('Password must be at least 8 characters')
const { salt, hash } = hashPin(password) const { salt, hash } = hashPin(password)
db.transaction(() => { await db.transaction(async () => {
const before = getEmployee(db, id) const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found') if (!before) throw new Error('Employee not found')
db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, id) await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, id)
db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id) await db.run(`DELETE FROM session WHERE staff_id=?`, id)
writeAudit(db, userId, 'reset_password', 'staff_user', id) await writeAudit(db, userId, 'reset_password', 'staff_user', id)
})() })
} }
/** Deactivation takes effect immediately: sessions are purged in the same transaction. */ /** Deactivation takes effect immediately: sessions are purged in the same transaction. */
export function deactivateEmployee(db: DB, userId: string, id: string): Employee { export async function deactivateEmployee(db: DB, userId: string, id: string): Promise<Employee> {
if (id === userId) throw new Error('Cannot deactivate yourself') if (id === userId) throw new Error('Cannot deactivate yourself')
return db.transaction(() => { return db.transaction(async () => {
const before = getEmployee(db, id) const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found') if (!before) throw new Error('Employee not found')
if (!before.active) return before // already inactive — idempotent if (!before.active) return before // already inactive — idempotent
if (before.role === 'owner' && !otherActiveOwnerExists(db, id)) { if (before.role === 'owner' && !(await otherActiveOwnerExists(db, id))) {
throw new Error('Cannot deactivate the last active owner') throw new Error('Cannot deactivate the last active owner')
} }
db.prepare(`UPDATE staff_user SET active=0 WHERE id=?`).run(id) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, id)
db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id) await db.run(`DELETE FROM session WHERE staff_id=?`, id)
const after = getEmployee(db, id)! const after = (await getEmployee(db, id))!
writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after) await writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
return after return after
})() })
} }
export function reactivateEmployee(db: DB, userId: string, id: string): Employee { export async function reactivateEmployee(db: DB, userId: string, id: string): Promise<Employee> {
return db.transaction(() => { return db.transaction(async () => {
const before = getEmployee(db, id) const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found') if (!before) throw new Error('Employee not found')
if (before.active) return before // already active — idempotent if (before.active) return before // already active — idempotent
db.prepare(`UPDATE staff_user SET active=1 WHERE id=?`).run(id) await db.run(`UPDATE staff_user SET active=1 WHERE id=?`, id)
const after = getEmployee(db, id)! const after = (await getEmployee(db, id))!
writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after) await writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
return after return after
})() })
} }

@ -7,14 +7,14 @@ import { getClient } from './repos-clients'
export interface InteractionType { code: string; label: string } export interface InteractionType { code: string; label: string }
export function listInteractionTypes(db: DB): InteractionType[] { export async function listInteractionTypes(db: DB): Promise<InteractionType[]> {
return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[] return db.all<InteractionType>(`SELECT code, label FROM interaction_type ORDER BY label`)
} }
export function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): InteractionType { export async function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): Promise<InteractionType> {
if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required') if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required')
db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim()) await db.run(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`, input.code.trim(), input.label.trim())
writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input) await writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
return { code: input.code.trim(), label: input.label.trim() } return { code: input.code.trim(), label: input.label.trim() }
} }
@ -37,15 +37,16 @@ function toInteraction(r: InteractionRow): Interaction {
} }
} }
export function getInteraction(db: DB, id: string): Interaction | null { export async function getInteraction(db: DB, id: string): Promise<Interaction | null> {
const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined const row = await db.get<InteractionRow>(`SELECT * FROM interaction WHERE id=?`, id)
return row === undefined ? null : toInteraction(row) return row === undefined ? null : toInteraction(row)
} }
export function listInteractions(db: DB, clientId: string): Interaction[] { export async function listInteractions(db: DB, clientId: string): Promise<Interaction[]> {
const rows = db.prepare( const rows = await db.all<InteractionRow>(
`SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`, `SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`,
).all(clientId) as InteractionRow[] clientId,
)
return rows.map(toInteraction) return rows.map(toInteraction)
} }
@ -56,28 +57,27 @@ export interface CreateInteractionInput {
const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative'] const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative']
export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction { export async function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Promise<Interaction> {
if (getClient(db, input.clientId) === null) throw new Error('Client not found') if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
const type = db.prepare(`SELECT code FROM interaction_type WHERE code=?`).get(input.typeCode) const type = await db.get(`SELECT code FROM interaction_type WHERE code=?`, input.typeCode)
if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`) if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`)
if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`) if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`)
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at) `INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '', id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '',
input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(), input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(),
) )
const interaction = getInteraction(db, id)! const interaction = (await getInteraction(db, id))!
writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction) await writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
return interaction return interaction
} }
export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null } export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null }
export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction { export async function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Promise<Interaction> {
const before = getInteraction(db, id) const before = await getInteraction(db, id)
if (before === null) throw new Error('Interaction not found') if (before === null) throw new Error('Interaction not found')
if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) { if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) {
throw new Error(`Unknown outcome: ${patch.outcome}`) throw new Error(`Unknown outcome: ${patch.outcome}`)
@ -89,19 +89,20 @@ export function updateInteraction(db: DB, userId: string, id: string, patch: Int
if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) } if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) }
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`, ...args)
} }
const after = getInteraction(db, id)! const after = (await getInteraction(db, id))!
writeAudit(db, userId, 'update', 'interaction', id, before, after) await writeAudit(db, userId, 'update', 'interaction', id, before, after)
return after return after
} }
/** Interactions with a follow-up date on or before the given date — the follow-ups queue. */ /** Interactions with a follow-up date on or before the given date — the follow-ups queue. */
export function listOpenFollowUps(db: DB, onOrBefore: string): (Interaction & { clientName: string })[] { export async function listOpenFollowUps(db: DB, onOrBefore: string): Promise<(Interaction & { clientName: string })[]> {
const rows = db.prepare( const rows = await db.all<InteractionRow & { client_name: string }>(
`SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id `SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id
WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ? WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ?
ORDER BY i.follow_up_on DESC, i.id DESC`, ORDER BY i.follow_up_on DESC, i.id DESC`,
).all(onOrBefore) as (InteractionRow & { client_name: string })[] onOrBefore,
)
return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name })) return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name }))
} }

@ -35,32 +35,31 @@ export interface ModuleInput {
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[] allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
} }
export function getModule(db: DB, id: string): Module | null { export async function getModule(db: DB, id: string): Promise<Module | null> {
const row = db.prepare(`SELECT * FROM module WHERE id=?`).get(id) as ModuleRow | undefined const row = await db.get<ModuleRow>(`SELECT * FROM module WHERE id=?`, id)
return row === undefined ? null : toModule(row) return row === undefined ? null : toModule(row)
} }
export function listModules(db: DB): Module[] { export async function listModules(db: DB): Promise<Module[]> {
const rows = db.prepare(`SELECT * FROM module ORDER BY code`).all() as ModuleRow[] const rows = await db.all<ModuleRow>(`SELECT * FROM module ORDER BY code`)
return rows.map(toModule) return rows.map(toModule)
} }
export function createModule(db: DB, userId: string, input: ModuleInput): Module { export async function createModule(db: DB, userId: string, input: ModuleInput): Promise<Module> {
const kinds = input.allowedKinds ?? ALL_KINDS const kinds = input.allowedKinds ?? ALL_KINDS
for (const k of kinds) { for (const k of kinds) {
if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`) if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`)
} }
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content) `INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
).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 ?? []), JSON.stringify(input.quoteContent ?? []),
) )
const mod = getModule(db, id)! const mod = (await getModule(db, id))!
writeAudit(db, userId, 'create', 'module', id, undefined, mod) await writeAudit(db, userId, 'create', 'module', id, undefined, mod)
return mod return mod
} }
@ -69,8 +68,8 @@ export interface ModulePatch {
multiSubscription?: boolean; active?: boolean; quoteContent?: string[] multiSubscription?: boolean; active?: boolean; quoteContent?: string[]
} }
export function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Module { export async function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Promise<Module> {
const before = getModule(db, id) const before = await getModule(db, id)
if (before === null) throw new Error('Module not found') if (before === null) throw new Error('Module not found')
if (patch.allowedKinds !== undefined) { if (patch.allowedKinds !== undefined) {
for (const k of patch.allowedKinds) { for (const k of patch.allowedKinds) {
@ -87,10 +86,10 @@ export function updateModule(db: DB, userId: string, id: string, patch: ModulePa
if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) } if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) }
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE module SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args)
} }
const after = getModule(db, id)! const after = (await getModule(db, id))!
writeAudit(db, userId, 'update', 'module', id, before, after) await writeAudit(db, userId, 'update', 'module', id, before, after)
return after return after
} }
@ -115,38 +114,41 @@ export interface PriceInput {
moduleId: string; edition?: string; kind: Kind; pricePaise: number; effectiveFrom: string moduleId: string; edition?: string; kind: Kind; pricePaise: number; effectiveFrom: string
} }
export function setPrice(db: DB, userId: string, input: PriceInput): void { export async function setPrice(db: DB, userId: string, input: PriceInput): Promise<void> {
if (!ALL_KINDS.includes(input.kind)) throw new Error(`Unknown kind: ${input.kind}`) if (!ALL_KINDS.includes(input.kind)) throw new Error(`Unknown kind: ${input.kind}`)
if (!Number.isInteger(input.pricePaise) || input.pricePaise < 0) { if (!Number.isInteger(input.pricePaise) || input.pricePaise < 0) {
throw new Error('pricePaise must be a non-negative integer (paise)') throw new Error('pricePaise must be a non-negative integer (paise)')
} }
if (getModule(db, input.moduleId) === null) throw new Error('Module not found') if ((await getModule(db, input.moduleId)) === null) throw new Error('Module not found')
const id = uuidv7() const id = uuidv7()
const edition = input.edition ?? 'standard' const edition = input.edition ?? 'standard'
db.prepare( await db.run(
`INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from) `INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom) id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom,
writeAudit(db, userId, 'create', 'module_price_book', id, undefined, { )
await writeAudit(db, userId, 'create', 'module_price_book', id, undefined, {
moduleId: input.moduleId, edition, kind: input.kind, moduleId: input.moduleId, edition, kind: input.kind,
pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom, pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom,
}) })
} }
export function listPrices(db: DB, moduleId: string): ModulePrice[] { export async function listPrices(db: DB, moduleId: string): Promise<ModulePrice[]> {
const rows = db.prepare( const rows = await db.all<PriceRow>(
`SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`, `SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`,
).all(moduleId) as PriceRow[] moduleId,
)
return rows.map(toPrice) return rows.map(toPrice)
} }
/** Latest effective_from <= onDate wins — the D5 dated-rows philosophy. */ /** 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 { export async function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): Promise<number | null> {
const row = db.prepare( const row = await db.get<{ price_paise: number }>(
`SELECT price_paise FROM module_price_book `SELECT price_paise FROM module_price_book
WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ? WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ?
ORDER BY effective_from DESC LIMIT 1`, ORDER BY effective_from DESC LIMIT 1`,
).get(moduleId, kind, edition, onDate) as { price_paise: number } | undefined moduleId, kind, edition, onDate,
)
return row === undefined ? null : row.price_paise return row === undefined ? null : row.price_paise
} }
@ -183,15 +185,15 @@ function toClientModule(r: ClientModuleRow): ClientModule {
} }
} }
export function getClientModule(db: DB, id: string): ClientModule | null { export async function getClientModule(db: DB, id: string): Promise<ClientModule | null> {
const row = db.prepare(`SELECT * FROM client_module WHERE id=?`).get(id) as ClientModuleRow | undefined const row = await db.get<ClientModuleRow>(`SELECT * FROM client_module WHERE id=?`, id)
return row === undefined ? null : toClientModule(row) return row === undefined ? null : toClientModule(row)
} }
export function listClientModules(db: DB, clientId: string): ClientModule[] { export async function listClientModules(db: DB, clientId: string): Promise<ClientModule[]> {
const rows = db.prepare( const rows = await db.all<ClientModuleRow>(
`SELECT * FROM client_module WHERE client_id=? ORDER BY id`, `SELECT * FROM client_module WHERE client_id=? ORDER BY id`, clientId,
).all(clientId) as ClientModuleRow[] )
return rows.map(toClientModule) return rows.map(toClientModule)
} }
@ -220,40 +222,46 @@ export interface ModuleClientsPage {
* excluded (stated here, not silently dropped). Paginated with a true total * excluded (stated here, not silently dropped). Paginated with a true total
* (rule 8); price per row via the dated price book on `onDate`. * (rule 8); price per row via the dated price book on `onDate`.
*/ */
export function listClientsByModule( export async function listClientsByModule(
db: DB, moduleId: string, db: DB, moduleId: string,
opts: { page?: number; pageSize?: number; onDate?: string } = {}, opts: { page?: number; pageSize?: number; onDate?: string } = {},
): ModuleClientsPage { ): Promise<ModuleClientsPage> {
const page = Math.max(1, opts.page ?? 1) const page = Math.max(1, opts.page ?? 1)
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50)) const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50))
const onDate = opts.onDate ?? new Date().toISOString().slice(0, 10) const onDate = opts.onDate ?? new Date().toISOString().slice(0, 10)
const total = (db.prepare( const total = ((await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`, `SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`, moduleId,
).get(moduleId) as { n: number }).n ))!).n
const rows = db.prepare( const rows = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string
status: string; kind: string; edition: string; next_renewal: string | null
}>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code, `SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.kind, cm.edition, cm.next_renewal cm.status, cm.kind, cm.edition, cm.next_renewal
FROM client_module cm JOIN client c ON c.id = cm.client_id FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1 WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id ORDER BY c.name, cm.id
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
).all(moduleId, pageSize, (page - 1) * pageSize) as { moduleId, pageSize, (page - 1) * pageSize,
cm_id: string; client_id: string; client_name: string; client_code: string )
status: string; kind: string; edition: string; next_renewal: string | null const clients: ModuleClientRow[] = []
}[] for (const r of rows) {
const clients = rows.map((r): ModuleClientRow => ({ clients.push({
cmId: r.cm_id, cmId: r.cm_id,
clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition, status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
nextRenewal: r.next_renewal, nextRenewal: r.next_renewal,
pricePaise: priceOn(db, moduleId, r.kind as Kind, r.edition, onDate), pricePaise: await priceOn(db, moduleId, r.kind as Kind, r.edition, onDate),
})) })
}
// Revenue total spans ALL active links, not the visible page (the footer figure). // Revenue total spans ALL active links, not the visible page (the footer figure).
const allLinks = db.prepare( const allLinks = await db.all<{ kind: string; edition: string }>(
`SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`, `SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`, moduleId,
).all(moduleId) as { kind: string; edition: string }[] )
const totalPricePaise = allLinks.reduce( let totalPricePaise = 0
(sum, l) => sum + (priceOn(db, moduleId, l.kind as Kind, l.edition, onDate) ?? 0), 0) for (const l of allLinks) {
totalPricePaise += (await priceOn(db, moduleId, l.kind as Kind, l.edition, onDate)) ?? 0
}
return { clients, total, page, pageSize, totalPricePaise } return { clients, total, page, pageSize, totalPricePaise }
} }
@ -262,8 +270,8 @@ export interface AssignModuleInput {
edition?: string; status?: ClientModuleStatus edition?: string; status?: ClientModuleStatus
} }
export function assignModule(db: DB, userId: string, input: AssignModuleInput): ClientModule { export async function assignModule(db: DB, userId: string, input: AssignModuleInput): Promise<ClientModule> {
const mod = getModule(db, input.moduleId) const mod = await getModule(db, input.moduleId)
if (mod === null) throw new Error('Module not found') if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(input.kind)) { if (!mod.allowedKinds.includes(input.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${input.kind}' (allowed: ${mod.allowedKinds.join(', ')})`) throw new Error(`Module ${mod.code} does not allow kind '${input.kind}' (allowed: ${mod.allowedKinds.join(', ')})`)
@ -271,20 +279,22 @@ export function assignModule(db: DB, userId: string, input: AssignModuleInput):
const status = input.status ?? 'quoted' const status = input.status ?? 'quoted'
if (!ALL_STATUSES.includes(status)) throw new Error(`Unknown status: ${status}`) if (!ALL_STATUSES.includes(status)) throw new Error(`Unknown status: ${status}`)
if (!mod.multiSubscription) { if (!mod.multiSubscription) {
const existing = db.prepare( const existing = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`, `SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`,
).get(input.clientId, input.moduleId) as { n: number } input.clientId, input.moduleId,
))!
if (existing.n > 0) { if (existing.n > 0) {
throw new Error(`Client already has an active subscription for module ${mod.code}`) throw new Error(`Client already has an active subscription for module ${mod.code}`)
} }
} }
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition) `INSERT INTO client_module (id, client_id, module_id, status, kind, edition)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard') id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard',
const cm = getClientModule(db, id)! )
writeAudit(db, userId, 'create', 'client_module', id, undefined, cm) const cm = (await getClientModule(db, id))!
await writeAudit(db, userId, 'create', 'client_module', id, undefined, cm)
return cm return cm
} }
@ -295,14 +305,14 @@ export interface ClientModulePatch {
kind?: Kind; edition?: string kind?: Kind; edition?: string
} }
export function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): ClientModule { export async function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): Promise<ClientModule> {
const before = getClientModule(db, id) const before = await getClientModule(db, id)
if (before === null) throw new Error('Client module not found') if (before === null) throw new Error('Client module not found')
if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) { if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) {
throw new Error(`Unknown status: ${patch.status}`) throw new Error(`Unknown status: ${patch.status}`)
} }
if (patch.kind !== undefined) { if (patch.kind !== undefined) {
const mod = getModule(db, before.moduleId) const mod = await getModule(db, before.moduleId)
if (mod === null) throw new Error('Module not found') if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(patch.kind)) { if (!mod.allowedKinds.includes(patch.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${patch.kind}'`) throw new Error(`Module ${mod.code} does not allow kind '${patch.kind}'`)
@ -323,9 +333,9 @@ export function updateClientModule(db: DB, userId: string, id: string, patch: Cl
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
} }
const after = getClientModule(db, id)! const after = (await getClientModule(db, id))!
writeAudit(db, userId, 'update', 'client_module', id, before, after) await writeAudit(db, userId, 'update', 'client_module', id, before, after)
return after return after
} }

@ -39,67 +39,69 @@ function toPayment(r: PaymentRow): Payment {
} }
} }
export function getPayment(db: DB, id: string): Payment | null { export async function getPayment(db: DB, id: string): Promise<Payment | null> {
const row = db.prepare(`SELECT * FROM payment WHERE id=?`).get(id) as PaymentRow | undefined const row = await db.get<PaymentRow>(`SELECT * FROM payment WHERE id=?`, id)
return row === undefined ? null : toPayment(row) return row === undefined ? null : toPayment(row)
} }
export function listPayments(db: DB, clientId: string): Payment[] { export async function listPayments(db: DB, clientId: string): Promise<Payment[]> {
const rows = db.prepare( const rows = await db.all<PaymentRow>(
`SELECT * FROM payment WHERE client_id=? ORDER BY id`, // uuidv7 ids sort by creation time `SELECT * FROM payment WHERE client_id=? ORDER BY id`, // uuidv7 ids sort by creation time
).all(clientId) as PaymentRow[] clientId)
return rows.map(toPayment) return rows.map(toPayment)
} }
// ---------- settlement math ---------- // ---------- settlement math ----------
function allocatedTo(db: DB, documentId: string): number { async function allocatedTo(db: DB, documentId: string): Promise<number> {
const row = db.prepare( const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`, `SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
).get(documentId) as { total: number } documentId))!
return row.total return row.total
} }
/** Credit notes are "negative" only here, in settlement math (Task 8 stores them positive). */ /** Credit notes are "negative" only here, in settlement math (Task 8 stores them positive). */
function creditedTo(db: DB, invoiceId: string): number { async function creditedTo(db: DB, invoiceId: string): Promise<number> {
const row = db.prepare( const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document `SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`, WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
).get(invoiceId) as { total: number } invoiceId))!
return row.total return row.total
} }
function outstandingOf(db: DB, doc: Doc): number { async function outstandingOf(db: DB, doc: Doc): Promise<number> {
return doc.payablePaise - allocatedTo(db, doc.id) - creditedTo(db, doc.id) return doc.payablePaise - await allocatedTo(db, doc.id) - await creditedTo(db, doc.id)
} }
/** Exported outstanding read for the scheduler and dashboard. */ /** Exported outstanding read for the scheduler and dashboard. */
export function outstandingPaise(db: DB, docId: string): number { export async function outstandingPaise(db: DB, docId: string): Promise<number> {
const doc = getDocument(db, docId) const doc = await getDocument(db, docId)
return doc === null ? 0 : outstandingOf(db, doc) return doc === null ? 0 : outstandingOf(db, doc)
} }
/** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */ /** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */
function refreshSettlementStatus(db: DB, userId: string, docId: string): void { async function refreshSettlementStatus(db: DB, userId: string, docId: string): Promise<void> {
const doc = getDocument(db, docId) const doc = await getDocument(db, docId)
if (doc === null || doc.status === 'cancelled') return if (doc === null || doc.status === 'cancelled') return
const allocated = allocatedTo(db, docId) const allocated = await allocatedTo(db, docId)
const outstanding = doc.payablePaise - allocated - creditedTo(db, docId) const outstanding = doc.payablePaise - allocated - await creditedTo(db, docId)
const status = outstanding <= 0 ? 'paid' : allocated > 0 ? 'part_paid' : doc.status const status = outstanding <= 0 ? 'paid' : allocated > 0 ? 'part_paid' : doc.status
if (status === doc.status) return if (status === doc.status) return
db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId) await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status }) await writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status })
} }
/** Issued, unsettled invoices for the client, oldest first. */ /** Issued, unsettled invoices for the client, oldest first. */
function unsettledInvoices(db: DB, clientId: string): Doc[] { async function unsettledInvoices(db: DB, clientId: string): Promise<Doc[]> {
const ids = db.prepare( const ids = await db.all<{ id: string }>(
`SELECT id FROM document `SELECT id FROM document
WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('cancelled', 'paid') AND status NOT IN ('cancelled', 'paid')
ORDER BY doc_date, doc_no`, ORDER BY doc_date, doc_no`,
).all(clientId) as { id: string }[] clientId)
return ids.map((r) => getDocument(db, r.id)!) const docs: Doc[] = []
for (const r of ids) docs.push((await getDocument(db, r.id))!)
return docs
} }
// ---------- recording ---------- // ---------- recording ----------
@ -118,8 +120,8 @@ export interface RecordPaymentResult {
receipt?: Doc receipt?: Doc
} }
export function recordPayment(db: DB, userId: string, input: RecordPaymentInput): RecordPaymentResult { export async function recordPayment(db: DB, userId: string, input: RecordPaymentInput): Promise<RecordPaymentResult> {
if (getClient(db, input.clientId) === null) throw new Error('Client not found') if (await getClient(db, input.clientId) === null) throw new Error('Client not found')
if (!ALL_MODES.includes(input.mode)) throw new Error(`Unknown payment mode: ${input.mode}`) if (!ALL_MODES.includes(input.mode)) throw new Error(`Unknown payment mode: ${input.mode}`)
if (!Number.isInteger(input.amountPaise) || input.amountPaise <= 0) { if (!Number.isInteger(input.amountPaise) || input.amountPaise <= 0) {
throw new Error('amountPaise must be a positive integer (paise)') throw new Error('amountPaise must be a positive integer (paise)')
@ -128,17 +130,15 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
if (!Number.isInteger(tdsPaise) || tdsPaise < 0) { if (!Number.isInteger(tdsPaise) || tdsPaise < 0) {
throw new Error('tdsPaise must be a non-negative integer (paise)') throw new Error('tdsPaise must be a non-negative integer (paise)')
} }
return db.transaction(() => { return db.transaction(async () => {
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise, `INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise,
created_by, created_at) created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.clientId, input.receivedOn, input.mode, input.reference ?? '', id, input.clientId, input.receivedOn, input.mode, input.reference ?? '',
input.amountPaise, tdsPaise, userId, new Date().toISOString(), input.amountPaise, tdsPaise, userId, new Date().toISOString())
) await writeAudit(db, userId, 'create', 'payment', id, undefined, {
writeAudit(db, userId, 'create', 'payment', id, undefined, {
clientId: input.clientId, receivedOn: input.receivedOn, mode: input.mode, clientId: input.clientId, receivedOn: input.receivedOn, mode: input.mode,
amountPaise: input.amountPaise, tdsPaise, amountPaise: input.amountPaise, tdsPaise,
}) })
@ -146,16 +146,16 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
// One pool: TDS certificates settle invoices just like money received. // One pool: TDS certificates settle invoices just like money received.
let pool = input.amountPaise + tdsPaise let pool = input.amountPaise + tdsPaise
const allocated: { documentId: string; amountPaise: number }[] = [] const allocated: { documentId: string; amountPaise: number }[] = []
const insertAllocation = (documentId: string, amountPaise: number): void => { const insertAllocation = async (documentId: string, amountPaise: number): Promise<void> => {
const allocId = uuidv7() const allocId = uuidv7()
db.prepare( await db.run(
`INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`, `INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`,
).run(allocId, id, documentId, amountPaise) allocId, id, documentId, amountPaise)
writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, { await writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, {
paymentId: id, documentId, amountPaise, paymentId: id, documentId, amountPaise,
}) })
allocated.push({ documentId, amountPaise }) allocated.push({ documentId, amountPaise })
refreshSettlementStatus(db, userId, documentId) await refreshSettlementStatus(db, userId, documentId)
} }
if (input.allocations !== undefined) { if (input.allocations !== undefined) {
@ -165,13 +165,13 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
if (!Number.isInteger(a.amountPaise) || a.amountPaise <= 0) { if (!Number.isInteger(a.amountPaise) || a.amountPaise <= 0) {
throw new Error('Allocation amountPaise must be a positive integer (paise)') throw new Error('Allocation amountPaise must be a positive integer (paise)')
} }
const doc = getDocument(db, a.documentId) const doc = await getDocument(db, a.documentId)
if (doc === null) throw new Error(`Document not found: ${a.documentId}`) if (doc === null) throw new Error(`Document not found: ${a.documentId}`)
if (doc.docType !== 'INVOICE') throw new Error(`Can only allocate payments to invoices: ${a.documentId}`) if (doc.docType !== 'INVOICE') throw new Error(`Can only allocate payments to invoices: ${a.documentId}`)
if (doc.docNo === null) throw new Error(`Invoice is not issued: ${a.documentId}`) if (doc.docNo === null) throw new Error(`Invoice is not issued: ${a.documentId}`)
if (doc.status === 'cancelled') throw new Error(`Invoice is cancelled: ${doc.docNo}`) if (doc.status === 'cancelled') throw new Error(`Invoice is cancelled: ${doc.docNo}`)
if (doc.clientId !== input.clientId) throw new Error(`Invoice ${doc.docNo} belongs to another client`) if (doc.clientId !== input.clientId) throw new Error(`Invoice ${doc.docNo} belongs to another client`)
const outstanding = outstandingOf(db, doc) const outstanding = await outstandingOf(db, doc)
if (a.amountPaise > outstanding) { if (a.amountPaise > outstanding) {
throw new Error(`Allocation ${a.amountPaise} exceeds outstanding ${outstanding} on ${doc.docNo}`) throw new Error(`Allocation ${a.amountPaise} exceeds outstanding ${outstanding} on ${doc.docNo}`)
} }
@ -179,33 +179,33 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
throw new Error(`Allocations exceed the payment's settling power (amount + TDS)`) throw new Error(`Allocations exceed the payment's settling power (amount + TDS)`)
} }
pool -= a.amountPaise pool -= a.amountPaise
insertAllocation(doc.id, a.amountPaise) await insertAllocation(doc.id, a.amountPaise)
} }
} else { } else {
// Default: oldest-invoice-first, each up to its outstanding; leftover // Default: oldest-invoice-first, each up to its outstanding; leftover
// stays unallocated (an advance). // stays unallocated (an advance).
for (const doc of unsettledInvoices(db, input.clientId)) { for (const doc of await unsettledInvoices(db, input.clientId)) {
if (pool <= 0) break if (pool <= 0) break
const take = Math.min(pool, outstandingOf(db, doc)) const take = Math.min(pool, await outstandingOf(db, doc))
if (take <= 0) continue if (take <= 0) continue
pool -= take pool -= take
insertAllocation(doc.id, take) await insertAllocation(doc.id, take)
} }
} }
const receipt = input.issueReceipt === true ? receiptForPayment(db, userId, id) : undefined const receipt = input.issueReceipt === true ? await receiptForPayment(db, userId, id) : undefined
return { payment: getPayment(db, id)!, allocated, ...(receipt !== undefined ? { receipt } : {}) } return { payment: (await getPayment(db, id))!, allocated, ...(receipt !== undefined ? { receipt } : {}) }
})() })
} }
/** Build a RECEIPT for an already-recorded payment from its stored allocations. */ /** Build a RECEIPT for an already-recorded payment from its stored allocations. */
export function receiptForPayment(db: DB, userId: string, paymentId: string): Doc { export async function receiptForPayment(db: DB, userId: string, paymentId: string): Promise<Doc> {
const p = getPayment(db, paymentId) const p = await getPayment(db, paymentId)
if (p === null) throw new Error('Payment not found') if (p === null) throw new Error('Payment not found')
const allocs = db.prepare( const allocs = await db.all<{ amount_paise: number; doc_no: string | null }>(
`SELECT a.amount_paise, d.doc_no FROM payment_allocation a `SELECT a.amount_paise, d.doc_no FROM payment_allocation a
JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`, JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`,
).all(paymentId) as { amount_paise: number; doc_no: string | null }[] paymentId)
return generateReceipt(db, userId, { return generateReceipt(db, userId, {
clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode, clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode,
reference: p.reference, amountPaise: p.amountPaise, tdsPaise: p.tdsPaise, reference: p.reference, amountPaise: p.amountPaise, tdsPaise: p.tdsPaise,
@ -217,19 +217,19 @@ export function receiptForPayment(db: DB, userId: string, paymentId: string): Do
export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number } export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number }
export function clientLedger(db: DB, clientId: string): ClientLedger { export async function clientLedger(db: DB, clientId: string): Promise<ClientLedger> {
const payments = listPayments(db, clientId) const payments = await listPayments(db, clientId)
const settling = payments.reduce((sum, p) => sum + p.amountPaise + p.tdsPaise, 0) const settling = payments.reduce((sum, p) => sum + p.amountPaise + p.tdsPaise, 0)
const row = db.prepare( const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(a.amount_paise), 0) AS total `SELECT COALESCE(SUM(a.amount_paise), 0) AS total
FROM payment_allocation a JOIN payment p ON p.id = a.payment_id FROM payment_allocation a JOIN payment p ON p.id = a.payment_id
WHERE p.client_id=?`, WHERE p.client_id=?`,
).get(clientId) as { total: number } clientId))!
// The ledger must show EVERY document for the client (rule 8): walk the // The ledger must show EVERY document for the client (rule 8): walk the
// paginated repo in max-size batches instead of trusting one page. // paginated repo in max-size batches instead of trusting one page.
const documents: Doc[] = [] const documents: Doc[] = []
for (let page = 1; ; page++) { for (let page = 1; ; page++) {
const batch = listDocuments(db, { clientId, page, pageSize: 200 }) const batch = await listDocuments(db, { clientId, page, pageSize: 200 })
documents.push(...batch.documents) documents.push(...batch.documents)
if (page * batch.pageSize >= batch.total) break if (page * batch.pageSize >= batch.total) break
} }
@ -266,17 +266,17 @@ export interface ModulePaidRow { moduleId: string; billedPaise: number; settledP
* across the invoice's lines by lineTotalPaise (largest-remainder, so the * across the invoice's lines by lineTotalPaise (largest-remainder, so the
* split sums exactly). * split sums exactly).
*/ */
export function modulePaidView(db: DB, clientId: string): ModulePaidRow[] { export async function modulePaidView(db: DB, clientId: string): Promise<ModulePaidRow[]> {
const invoices = db.prepare( const invoices = await db.all<{ id: string }>(
`SELECT id FROM document `SELECT id FROM document
WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled' WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'
ORDER BY doc_date, doc_no`, ORDER BY doc_date, doc_no`,
).all(clientId) as { id: string }[] clientId)
const acc = new Map<string, { billedPaise: number; settledPaise: number }>() const acc = new Map<string, { billedPaise: number; settledPaise: number }>()
for (const { id } of invoices) { for (const { id } of invoices) {
const inv = getDocument(db, id)! const inv = (await getDocument(db, id))!
const settledTotal = Math.min( const settledTotal = Math.min(
inv.payablePaise, allocatedTo(db, inv.id) + creditedTo(db, inv.id), inv.payablePaise, await allocatedTo(db, inv.id) + await creditedTo(db, inv.id),
) )
const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise)) const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise))
inv.payload.lines.forEach((line, i) => { inv.payload.lines.forEach((line, i) => {

@ -76,18 +76,18 @@ function ageDaysOf(firstSent: string | null, today: string): number | null {
return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : null return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : null
} }
export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage { export async function listPipeline(db: DB, opts: ListPipelineOpts): Promise<PipelinePage> {
const filter = opts.filter ?? 'all' const filter = opts.filter ?? 'all'
const today = opts.today ?? new Date().toISOString().slice(0, 10) const today = opts.today ?? new Date().toISOString().slice(0, 10)
const page = Math.max(opts.page ?? 1, 1) const page = Math.max(opts.page ?? 1, 1)
const pageSize = Math.min(Math.max(opts.pageSize ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE) const pageSize = Math.min(Math.max(opts.pageSize ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE)
const dayOffsets = resolveSchedule(db, 'quote_followup', today).dayOffsets const dayOffsets = (await resolveSchedule(db, 'quote_followup', today)).dayOffsets
const lastOffset = dayOffsets[dayOffsets.length - 1]! const lastOffset = dayOffsets[dayOffsets.length - 1]!
// Latest non-cancelled QUOTATION per client (uuidv7 ids sort by creation time, so // Latest non-cancelled QUOTATION per client (uuidv7 ids sort by creation time, so
// MAX(id) is the newest — same trick as listDocuments). A cancelled quote is dead // MAX(id) is the newest — same trick as listDocuments). A cancelled quote is dead
// paper: it neither represents the client nor blocks the bare-lead Enquiry row. // paper: it neither represents the client nor blocks the bare-lead Enquiry row.
const quotes = db.prepare( const quotes = await db.all<QuoteRow>(
`SELECT d.id AS doc_id, d.doc_no, d.status AS doc_status, d.payable_paise, d.created_by, `SELECT d.id AS doc_id, d.doc_no, d.status AS doc_status, d.payable_paise, d.created_by,
(SELECT MIN(e.at_wall) FROM document_event e (SELECT MIN(e.at_wall) FROM document_event e
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent, WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent,
@ -97,12 +97,12 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
AND d.id = (SELECT MAX(d2.id) FROM document d2 AND d.id = (SELECT MAX(d2.id) FROM document d2
WHERE d2.client_id = d.client_id AND d2.doc_type = 'QUOTATION' WHERE d2.client_id = d.client_id AND d2.doc_type = 'QUOTATION'
AND d2.status != 'cancelled')`, AND d2.status != 'cancelled')`,
).all() as QuoteRow[] )
// Bare leads: client.status='lead' with no live quotation → derived stage Enquiry. // Bare leads: client.status='lead' with no live quotation → derived stage Enquiry.
// Quote-less LOST clients ride the same arm as stage Lost (spec §9: Lost = latest // Quote-less LOST clients ride the same arm as stage Lost (spec §9: Lost = latest
// quotation lost OR client.status='lost') so the Lost filter can review them. // quotation lost OR client.status='lost') so the Lost filter can review them.
const leads = db.prepare( const leads = await db.all<LeadRow>(
`SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name, `SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name,
c.status AS client_status, c.owner_id c.status AS client_status, c.owner_id
FROM client c FROM client c
@ -110,10 +110,10 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
AND NOT EXISTS (SELECT 1 FROM document d AND NOT EXISTS (SELECT 1 FROM document d
WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION' WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION'
AND d.status != 'cancelled')`, AND d.status != 'cancelled')`,
).all() as LeadRow[] )
const staffNames = new Map( const staffNames = new Map(
(db.prepare(`SELECT id, display_name FROM staff_user`).all() as { id: string; display_name: string }[]) (await db.all<{ id: string; display_name: string }>(`SELECT id, display_name FROM staff_user`))
.map((s) => [s.id, s.display_name]), .map((s) => [s.id, s.display_name]),
) )
const nameOf = (id: string | null): string | null => const nameOf = (id: string | null): string | null =>

@ -27,15 +27,15 @@ function toPlan(r: RecurringRow): RecurringPlan {
} }
} }
export function getRecurringPlan(db: DB, id: string): RecurringPlan | null { export async function getRecurringPlan(db: DB, id: string): Promise<RecurringPlan | null> {
const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined const row = await db.get<RecurringRow>(`SELECT * FROM recurring_plan WHERE id=?`, id)
return row === undefined ? null : toPlan(row) return row === undefined ? null : toPlan(row)
} }
export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] { export async function listRecurringPlans(db: DB, clientId?: string): Promise<RecurringPlan[]> {
const rows = clientId === undefined const rows = clientId === undefined
? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[] ? await db.all<RecurringRow>(`SELECT * FROM recurring_plan ORDER BY id DESC`)
: db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[] : await db.all<RecurringRow>(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`, clientId)
return rows.map(toPlan) return rows.map(toPlan)
} }
@ -44,9 +44,9 @@ export interface CreateRecurringInput {
amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual' amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual'
} }
export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan { export async function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): Promise<RecurringPlan> {
if (getClient(db, input.clientId) === null) throw new Error('Client not found') if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
const cm = getClientModule(db, input.clientModuleId) const cm = await getClientModule(db, input.clientModuleId)
if (cm === null) throw new Error('Client module not found') if (cm === null) throw new Error('Client module not found')
if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client') if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client')
if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) { if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) {
@ -55,20 +55,19 @@ export function createRecurringPlan(db: DB, userId: string, input: CreateRecurri
// A plan must be priceable at generation time: explicit amount, or a resolvable module price. // A plan must be priceable at generation time: explicit amount, or a resolvable module price.
if (input.amountPaise === undefined) { if (input.amountPaise === undefined) {
const kind = CADENCE_KIND[input.cadence] const kind = CADENCE_KIND[input.cadence]
if (priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun) === null) { if ((await priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun)) === null) {
throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`) throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`)
} }
} }
const id = uuidv7() const id = uuidv7()
db.prepare( await db.run(
`INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active) `INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`, VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
).run(
id, input.clientId, input.clientModuleId, input.cadence, id, input.clientId, input.clientModuleId, input.cadence,
input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual', input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual',
) )
const plan = getRecurringPlan(db, id)! const plan = (await getRecurringPlan(db, id))!
writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan) await writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
return plan return plan
} }
@ -77,8 +76,8 @@ export interface RecurringPatch {
policy?: 'auto' | 'manual'; active?: boolean policy?: 'auto' | 'manual'; active?: boolean
} }
export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan { export async function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): Promise<RecurringPlan> {
const before = getRecurringPlan(db, id) const before = await getRecurringPlan(db, id)
if (before === null) throw new Error('Recurring plan not found') if (before === null) throw new Error('Recurring plan not found')
const sets: string[] = [] const sets: string[] = []
const args: unknown[] = [] const args: unknown[] = []
@ -89,13 +88,13 @@ export function updateRecurringPlan(db: DB, userId: string, id: string, patch: R
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) } if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) { if (sets.length > 0) {
args.push(id) args.push(id)
db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args) await db.run(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`, ...args)
} }
const after = getRecurringPlan(db, id)! const after = (await getRecurringPlan(db, id))!
writeAudit(db, userId, 'update', 'recurring_plan', id, before, after) await writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
return after return after
} }
export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan { export async function deactivateRecurringPlan(db: DB, userId: string, id: string): Promise<RecurringPlan> {
return updateRecurringPlan(db, userId, id, { active: false }) return updateRecurringPlan(db, userId, id, { active: false })
} }

@ -8,7 +8,7 @@ import {
/** Reminder rows + settings helpers (D12 plain-repo pattern). The UNIQUE key /** Reminder rows + settings helpers (D12 plain-repo pattern). The UNIQUE key
* (rule_kind, subject_id, due_period) is the whole idempotency story every * (rule_kind, subject_id, due_period) is the whole idempotency story every
* create goes through INSERT OR IGNORE, so catch-up after downtime is safe. */ * create goes through INSERT ON CONFLICT DO NOTHING, so catch-up after downtime is safe. */
export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed' export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed'
@ -33,8 +33,8 @@ function toReminder(r: ReminderRow): Reminder {
} }
} }
export function getReminder(db: DB, id: string): Reminder | null { export async function getReminder(db: DB, id: string): Promise<Reminder | null> {
const row = db.prepare(`SELECT * FROM reminder WHERE id=?`).get(id) as ReminderRow | undefined const row = await db.get<ReminderRow>(`SELECT * FROM reminder WHERE id=?`, id)
return row === undefined ? null : toReminder(row) return row === undefined ? null : toReminder(row)
} }
@ -45,40 +45,41 @@ export interface UpsertReminderInput {
/** Create the reminder for a (rule, subject, period) exactly once. Returns the /** Create the reminder for a (rule, subject, period) exactly once. Returns the
* existing row's id with created:false when the key is already present. */ * existing row's id with created:false when the key is already present. */
export function upsertReminder(db: DB, input: UpsertReminderInput): { id: string; created: boolean } { export async function upsertReminder(db: DB, input: UpsertReminderInput): Promise<{ id: string; created: boolean }> {
const id = uuidv7() const id = uuidv7()
const now = input.now ?? new Date().toISOString() const now = input.now ?? new Date().toISOString()
const res = db.prepare( const res = await db.run(
// Portability quirk: INSERT OR IGNORE is SQLite/Postgres dialect (standard SQL has MERGE). // Portability quirk: ON CONFLICT DO NOTHING is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT OR IGNORE INTO reminder `INSERT INTO reminder
(id, rule_kind, subject_id, due_period, client_id, doc_id, status, policy_applied, error, created_at, sent_at) (id, rule_kind, subject_id, due_period, client_id, doc_id, status, policy_applied, error, created_at, sent_at)
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)`, VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)
).run( ON CONFLICT (rule_kind, subject_id, due_period) DO NOTHING`,
id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId, id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId,
input.docId ?? null, input.policyApplied ?? 'manual', now, input.docId ?? null, input.policyApplied ?? 'manual', now,
) )
if (res.changes === 1) { if (res.changes === 1) {
writeAudit(db, 'system', 'create', 'reminder', id, undefined, { await writeAudit(db, 'system', 'create', 'reminder', id, undefined, {
ruleKind: input.ruleKind, subjectId: input.subjectId, duePeriod: input.duePeriod, ruleKind: input.ruleKind, subjectId: input.subjectId, duePeriod: input.duePeriod,
}) })
return { id, created: true } return { id, created: true }
} }
const existing = db.prepare( const existing = (await db.get<{ id: string }>(
`SELECT id FROM reminder WHERE rule_kind=? AND subject_id=? AND due_period=?`, `SELECT id FROM reminder WHERE rule_kind=? AND subject_id=? AND due_period=?`,
).get(input.ruleKind, input.subjectId, input.duePeriod) as { id: string } input.ruleKind, input.subjectId, input.duePeriod,
))!
return { id: existing.id, created: false } return { id: existing.id, created: false }
} }
export interface ReminderFilter { status?: ReminderStatus; ruleKind?: ReminderRuleKind; clientId?: string } export interface ReminderFilter { status?: ReminderStatus; ruleKind?: ReminderRuleKind; clientId?: string }
export function listReminders(db: DB, filter: ReminderFilter = {}): Reminder[] { export async function listReminders(db: DB, filter: ReminderFilter = {}): Promise<Reminder[]> {
let sql = `SELECT * FROM reminder WHERE 1=1` let sql = `SELECT * FROM reminder WHERE 1=1`
const args: unknown[] = [] const args: unknown[] = []
if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) } if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) }
if (filter.ruleKind !== undefined) { sql += ` AND rule_kind=?`; args.push(filter.ruleKind) } if (filter.ruleKind !== undefined) { sql += ` AND rule_kind=?`; args.push(filter.ruleKind) }
if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) } if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) }
sql += ` ORDER BY id DESC` sql += ` ORDER BY id DESC`
return (db.prepare(sql).all(...args) as ReminderRow[]).map(toReminder) return (await db.all<ReminderRow>(sql, ...args)).map(toReminder)
} }
/** Human labels for queue rows — exhaustive so a new rule kind cannot ship unlabelled. */ /** Human labels for queue rows — exhaustive so a new rule kind cannot ship unlabelled. */
@ -117,7 +118,7 @@ interface QueueRow extends ReminderRow { client_name: string | null; doc_no: str
/** The manual queue: everything awaiting a human queued items and failed sends. /** The manual queue: everything awaiting a human queued items and failed sends.
* Paginated with an honest total (rule 8); owner scope derives via * Paginated with an honest total (rule 8); owner scope derives via
* doc_id document.created_by (spec A3 no owner column on reminder). */ * doc_id document.created_by (spec A3 no owner column on reminder). */
export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage { export async function listQueue(db: DB, opts: QueueOpts = {}): Promise<QueuePage> {
const page = Math.max(opts.page ?? 1, 1) const page = Math.max(opts.page ?? 1, 1)
const pageSize = Math.min(Math.max(opts.pageSize ?? QUEUE_PAGE_SIZE, 1), QUEUE_MAX_PAGE_SIZE) const pageSize = Math.min(Math.max(opts.pageSize ?? QUEUE_PAGE_SIZE, 1), QUEUE_MAX_PAGE_SIZE)
const scoped = opts.viewerRole !== undefined && opts.viewerId !== undefined const scoped = opts.viewerRole !== undefined && opts.viewerId !== undefined
@ -130,10 +131,11 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
// (renewal_due, amc_expiring, follow_up, email_bounced) have no derived owner — // (renewal_due, amc_expiring, follow_up, email_bounced) have no derived owner —
// they are shared work and stay visible to every viewer, staff included. // they are shared work and stay visible to every viewer, staff included.
if (scoped !== undefined) { where += ` AND (d.created_by = ? OR r.doc_id IS NULL)`; args.push(scoped) } if (scoped !== undefined) { where += ` AND (d.created_by = ? OR r.doc_id IS NULL)`; args.push(scoped) }
const total = (db.prepare( const total = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id WHERE ${where}`, `SELECT COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id WHERE ${where}`,
).get(...args) as { n: number }).n ...args,
const rows = db.prepare( ))!.n
const rows = await db.all<QueueRow>(
`SELECT r.*, c.name AS client_name, d.doc_no, d.created_by AS owner_id `SELECT r.*, c.name AS client_name, d.doc_no, d.created_by AS owner_id
FROM reminder r FROM reminder r
LEFT JOIN client c ON c.id = r.client_id LEFT JOIN client c ON c.id = r.client_id
@ -141,7 +143,8 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
WHERE ${where} WHERE ${where}
ORDER BY r.id DESC ORDER BY r.id DESC
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
).all(...args, pageSize, (page - 1) * pageSize) as QueueRow[] ...args, pageSize, (page - 1) * pageSize,
)
return { return {
rows: rows.map((r) => ({ rows: rows.map((r) => ({
...toReminder(r), ...toReminder(r),
@ -157,35 +160,37 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
} }
/** Queue totals (queued/failed) under the same owner scope listQueue applies. */ /** Queue totals (queued/failed) under the same owner scope listQueue applies. */
export function queueCounts(db: DB, ownerId?: string): { queued: number; failed: number } { export async function queueCounts(db: DB, ownerId?: string): Promise<{ queued: number; failed: number }> {
const rows = db.prepare( const rows = await db.all<{ status: string; n: number }>(
`SELECT r.status, COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id `SELECT r.status, COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id
WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND (d.created_by = ? OR r.doc_id IS NULL)` : ''} WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND (d.created_by = ? OR r.doc_id IS NULL)` : ''}
GROUP BY r.status`, GROUP BY r.status`,
).all(...(ownerId !== undefined ? [ownerId] : [])) as { status: string; n: number }[] ...(ownerId !== undefined ? [ownerId] : []),
)
const by = new Map(rows.map((r) => [r.status, r.n])) const by = new Map(rows.map((r) => [r.status, r.n]))
return { queued: by.get('queued') ?? 0, failed: by.get('failed') ?? 0 } return { queued: by.get('queued') ?? 0, failed: by.get('failed') ?? 0 }
} }
export function setReminderStatus( export async function setReminderStatus(
db: DB, userId: string, id: string, status: ReminderStatus, db: DB, userId: string, id: string, status: ReminderStatus,
opts: { error?: string | null; sentAt?: string | null } = {}, opts: { error?: string | null; sentAt?: string | null } = {},
): Reminder { ): Promise<Reminder> {
const before = getReminder(db, id) const before = await getReminder(db, id)
if (before === null) throw new Error('Reminder not found') if (before === null) throw new Error('Reminder not found')
db.prepare(`UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`).run( await db.run(
`UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`,
status, status,
opts.error !== undefined ? opts.error : before.error, opts.error !== undefined ? opts.error : before.error,
opts.sentAt !== undefined ? opts.sentAt : before.sentAt, opts.sentAt !== undefined ? opts.sentAt : before.sentAt,
id, id,
) )
const after = getReminder(db, id)! const after = (await getReminder(db, id))!
writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error }) await writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error })
return after return after
} }
export function dismissReminder(db: DB, userId: string, id: string): Reminder { export async function dismissReminder(db: DB, userId: string, id: string): Promise<Reminder> {
const before = getReminder(db, id) const before = await getReminder(db, id)
if (before === null) throw new Error('Reminder not found') if (before === null) throw new Error('Reminder not found')
if (before.status === 'sent') throw new Error('Cannot dismiss a reminder that was already sent') if (before.status === 'sent') throw new Error('Cannot dismiss a reminder that was already sent')
return setReminderStatus(db, userId, id, 'dismissed') return setReminderStatus(db, userId, id, 'dismissed')
@ -197,12 +202,13 @@ export function dismissReminder(db: DB, userId: string, id: string): Reminder {
* setReminderStatus per row so each dismissal is audited (F14), never a bulk UPDATE. * setReminderStatus per row so each dismissal is audited (F14), never a bulk UPDATE.
* Callers (markStatus / convertDocument) run this inside their own transaction. * Callers (markStatus / convertDocument) run this inside their own transaction.
*/ */
export function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): number { export async function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): Promise<number> {
const rows = db.prepare( const rows = await db.all<{ id: string }>(
`SELECT id FROM reminder `SELECT id FROM reminder
WHERE rule_kind='quote_followup' AND subject_id=? AND status IN ('queued','failed')`, WHERE rule_kind='quote_followup' AND subject_id=? AND status IN ('queued','failed')`,
).all(quoteId) as { id: string }[] quoteId,
for (const r of rows) setReminderStatus(db, userId, r.id, 'dismissed') )
for (const r of rows) await setReminderStatus(db, userId, r.id, 'dismissed')
return rows.length return rows.length
} }
@ -261,13 +267,14 @@ export function parseDayOffsetsStrict(csv: string): number[] {
* where effective_from <= today AND (effective_to IS NULL OR effective_to > today), * where effective_from <= today AND (effective_to IS NULL OR effective_to > today),
* latest effective_from winning. No usable row the code-constant default. A row * latest effective_from winning. No usable row the code-constant default. A row
* that sets cadence but leaves subject/body NULL inherits the default message text. */ * that sets cadence but leaves subject/body NULL inherits the default message text. */
export function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: string): ResolvedSchedule { export async function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: string): Promise<ResolvedSchedule> {
const fallback = SCHEDULE_DEFAULTS[ruleKind] const fallback = SCHEDULE_DEFAULTS[ruleKind]
const row = db.prepare( const row = await db.get<{ day_offsets: string; subject: string | null; body: string | null }>(
`SELECT day_offsets, subject, body FROM reminder_schedule `SELECT day_offsets, subject, body FROM reminder_schedule
WHERE rule_kind=? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?) WHERE rule_kind=? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)
ORDER BY effective_from DESC, id DESC LIMIT 1`, // id DESC breaks effective_from ties deterministically (UUIDv7 is time-ordered → latest insert wins on any engine) ORDER BY effective_from DESC, id DESC LIMIT 1`, // id DESC breaks effective_from ties deterministically (UUIDv7 is time-ordered → latest insert wins on any engine)
).get(ruleKind, today, today) as { day_offsets: string; subject: string | null; body: string | null } | undefined ruleKind, today, today,
)
if (row !== undefined) { if (row !== undefined) {
const dayOffsets = parseDayOffsets(row.day_offsets) const dayOffsets = parseDayOffsets(row.day_offsets)
if (dayOffsets.length > 0) { if (dayOffsets.length > 0) {
@ -290,11 +297,11 @@ export interface ScheduleRow {
const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS) const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS)
/** All dated schedule rows, newest first within each rule kind (bounded set). */ /** All dated schedule rows, newest first within each rule kind (bounded set). */
export function listSchedules(db: DB): ScheduleRow[] { export async function listSchedules(db: DB): Promise<ScheduleRow[]> {
const rows = db.prepare( const rows = await db.all<{ id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }>(
`SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body `SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body
FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`, FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`,
).all() as { id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }[] )
return rows.map((r) => ({ return rows.map((r) => ({
id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to, id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to,
dayOffsets: r.day_offsets, subject: r.subject, body: r.body, dayOffsets: r.day_offsets, subject: r.subject, body: r.body,
@ -302,44 +309,46 @@ export function listSchedules(db: DB): ScheduleRow[] {
} }
/** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */ /** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */
export function insertSchedule(db: DB, userId: string, input: { export async function insertSchedule(db: DB, userId: string, input: {
ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string
}): ScheduleRow { }): Promise<ScheduleRow> {
if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`) if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`)
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD') if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD')
const offsets = parseDayOffsetsStrict(input.dayOffsets) const offsets = parseDayOffsetsStrict(input.dayOffsets)
const id = uuidv7() const id = uuidv7()
const csv = offsets.join(',') const csv = offsets.join(',')
db.transaction(() => { await db.transaction(async () => {
db.prepare( await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, NULL, ?, ?, ?)`, VALUES (?, ?, ?, NULL, ?, ?, ?)`,
).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null) id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null,
writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, { )
await writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv, ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv,
}) })
})() })
return listSchedules(db).find((r) => r.id === id)! return (await listSchedules(db)).find((r) => r.id === id)!
} }
// ---------- settings helpers (shared by scheduler + bounce poller) ---------- // ---------- settings helpers (shared by scheduler + bounce poller) ----------
export function getSetting(db: DB, key: string): string | null { export async function getSetting(db: DB, key: string): Promise<string | null> {
const row = db.prepare(`SELECT value FROM setting WHERE key=?`).get(key) as { value: string } | undefined const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key=?`, key)
return row === undefined ? null : row.value return row === undefined ? null : row.value
} }
export function setSetting(db: DB, userId: string, key: string, value: string): void { export async function setSetting(db: DB, userId: string, key: string, value: string): Promise<void> {
const before = getSetting(db, key) const before = await getSetting(db, key)
db.prepare( await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`, `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
).run(key, value) key, value,
writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value }) )
await writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value })
} }
export function getNumberSetting(db: DB, key: string, fallback: number): number { export async function getNumberSetting(db: DB, key: string, fallback: number): Promise<number> {
const raw = getSetting(db, key) const raw = await getSetting(db, key)
if (raw === null) return fallback if (raw === null) return fallback
const n = Number(raw) const n = Number(raw)
return Number.isFinite(n) ? n : fallback return Number.isFinite(n) ? n : fallback

@ -17,19 +17,19 @@ function daysBetween(fromIso: string, toIso: string): number {
} }
/** Issued, non-cancelled invoices, optionally within a doc_date range. */ /** Issued, non-cancelled invoices, optionally within a doc_date range. */
function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): string[] { async function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): Promise<string[]> {
let sql = `SELECT id FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'` let sql = `SELECT id FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'`
const args: unknown[] = [] const args: unknown[] = []
if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) } if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) }
if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) } if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) }
if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) } if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) }
sql += ` ORDER BY doc_date, doc_no` sql += ` ORDER BY doc_date, doc_no`
return (db.prepare(sql).all(...args) as { id: string }[]).map((r) => r.id) return (await db.all<{ id: string }>(sql, ...args)).map((r) => r.id)
} }
/** Settled amount on an invoice, clamped to [0, payable]. */ /** Settled amount on an invoice, clamped to [0, payable]. */
function settledOf(db: DB, docId: string, payablePaise: number): number { async function settledOf(db: DB, docId: string, payablePaise: number): Promise<number> {
return Math.max(0, Math.min(payablePaise, payablePaise - outstandingPaise(db, docId))) return Math.max(0, Math.min(payablePaise, payablePaise - await outstandingPaise(db, docId)))
} }
export interface DuesAgingRow { export interface DuesAgingRow {
@ -37,16 +37,16 @@ export interface DuesAgingRow {
b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number
} }
export function duesAging(db: DB, today: string): DuesAgingRow[] { export async function duesAging(db: DB, today: string): Promise<DuesAgingRow[]> {
// Buckets age from the stamped due date when present (D18 WS-A), doc date otherwise. // Buckets age from the stamped due date when present (D18 WS-A), doc date otherwise.
const invoices = db.prepare( const invoices = await db.all<{ id: string; client_id: string; anchor: string; client_name: string }>(
`SELECT d.id, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name `SELECT d.id, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id FROM document d JOIN client c ON c.id = d.client_id
WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL AND d.status NOT IN ('paid','cancelled','lost')`, WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL AND d.status NOT IN ('paid','cancelled','lost')`,
).all() as { id: string; client_id: string; anchor: string; client_name: string }[] )
const acc = new Map<string, DuesAgingRow>() const acc = new Map<string, DuesAgingRow>()
for (const inv of invoices) { for (const inv of invoices) {
const out = outstandingPaise(db, inv.id) const out = await outstandingPaise(db, inv.id)
if (out <= 0) continue if (out <= 0) continue
const row = acc.get(inv.client_id) const row = acc.get(inv.client_id)
?? { clientId: inv.client_id, clientName: inv.client_name, b0_30: 0, b31_60: 0, b61_90: 0, b90p: 0, totalPaise: 0 } ?? { clientId: inv.client_id, clientName: inv.client_name, b0_30: 0, b31_60: 0, b61_90: 0, b90p: 0, totalPaise: 0 }
@ -65,11 +65,11 @@ export interface ModuleRevenueRow {
moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number
} }
export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] { export async function moduleRevenue(db: DB, range?: DateRange): Promise<ModuleRevenueRow[]> {
const acc = new Map<string, { billed: number; settled: number }>() const acc = new Map<string, { billed: number; settled: number }>()
for (const id of invoiceIds(db, undefined, range)) { for (const id of await invoiceIds(db, undefined, range)) {
const inv = getDocument(db, id)! const inv = (await getDocument(db, id))!
const settledTotal = settledOf(db, inv.id, inv.payablePaise) const settledTotal = await settledOf(db, inv.id, inv.payablePaise)
const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise)) const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise))
inv.payload.lines.forEach((line, i) => { inv.payload.lines.forEach((line, i) => {
const e = acc.get(line.itemId) ?? { billed: 0, settled: 0 } const e = acc.get(line.itemId) ?? { billed: 0, settled: 0 }
@ -78,13 +78,15 @@ export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] {
acc.set(line.itemId, e) acc.set(line.itemId, e)
}) })
} }
return [...acc.entries()].map(([moduleId, v]) => { const out: ModuleRevenueRow[] = []
const mod = getModule(db, moduleId) for (const [moduleId, v] of acc.entries()) {
return { const mod = await getModule(db, moduleId)
out.push({
moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId, moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId,
billedPaise: v.billed, settledPaise: v.settled, billedPaise: v.billed, settledPaise: v.settled,
} })
}).sort((a, b) => b.billedPaise - a.billedPaise) }
return out.sort((a, b) => b.billedPaise - a.billedPaise)
} }
export interface ProfitabilityRow { export interface ProfitabilityRow {
@ -92,18 +94,18 @@ export interface ProfitabilityRow {
billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number
} }
export function clientProfitability(db: DB, range?: DateRange): ProfitabilityRow[] { export async function clientProfitability(db: DB, range?: DateRange): Promise<ProfitabilityRow[]> {
const clients = db.prepare(`SELECT id, name FROM client ORDER BY name`).all() as { id: string; name: string }[] const clients = await db.all<{ id: string; name: string }>(`SELECT id, name FROM client ORDER BY name`)
const out: ProfitabilityRow[] = [] const out: ProfitabilityRow[] = []
for (const c of clients) { for (const c of clients) {
let billed = 0 let billed = 0
let settled = 0 let settled = 0
for (const id of invoiceIds(db, c.id, range)) { for (const id of await invoiceIds(db, c.id, range)) {
const inv = getDocument(db, id)! const inv = (await getDocument(db, id))!
billed += inv.payablePaise billed += inv.payablePaise
settled += settledOf(db, inv.id, inv.payablePaise) settled += await settledOf(db, inv.id, inv.payablePaise)
} }
const awsCostPaise = awsCostForClient(db, c.id, range?.from, range?.to) const awsCostPaise = await awsCostForClient(db, c.id, range?.from, range?.to)
if (billed === 0 && settled === 0 && awsCostPaise === 0) continue // omit inactive clients if (billed === 0 && settled === 0 && awsCostPaise === 0) continue // omit inactive clients
out.push({ clientId: c.id, clientName: c.name, billedPaise: billed, settledPaise: settled, awsCostPaise, marginPaise: settled - awsCostPaise }) out.push({ clientId: c.id, clientName: c.name, billedPaise: billed, settledPaise: settled, awsCostPaise, marginPaise: settled - awsCostPaise })
} }

@ -39,15 +39,15 @@ function toShare(r: ShareRow): Share {
} }
} }
export function getShare(db: DB, id: string): Share | null { export async function getShare(db: DB, id: string): Promise<Share | null> {
const row = db.prepare(`SELECT * FROM document_share WHERE id=?`).get(id) as ShareRow | undefined const row = await db.get<ShareRow>(`SELECT * FROM document_share WHERE id=?`, id)
return row === undefined ? null : toShare(row) return row === undefined ? null : toShare(row)
} }
export function listShares(db: DB, documentId: string): Share[] { export async function listShares(db: DB, documentId: string): Promise<Share[]> {
const rows = db.prepare( const rows = await db.all<ShareRow>(
`SELECT * FROM document_share WHERE document_id=? ORDER BY id DESC`, // uuidv7 ids: newest first `SELECT * FROM document_share WHERE document_id=? ORDER BY id DESC`, // uuidv7 ids: newest first
).all(documentId) as ShareRow[] documentId)
return rows.map(toShare) return rows.map(toShare)
} }
@ -56,13 +56,13 @@ export function listShares(db: DB, documentId: string): Share[] {
* Read-only safe for preview/context paths that must write nothing; the send * Read-only safe for preview/context paths that must write nothing; the send
* path uses it to reuse an existing link instead of minting a duplicate. * path uses it to reuse an existing link instead of minting a duplicate.
*/ */
export function resolveLiveShare(db: DB, documentId: string): Share | null { export async function resolveLiveShare(db: DB, documentId: string): Promise<Share | null> {
const row = db.prepare( const row = await db.get<ShareRow>(
// ISO-8601 UTC strings compare lexicographically === chronologically. // ISO-8601 UTC strings compare lexicographically === chronologically.
`SELECT * FROM document_share `SELECT * FROM document_share
WHERE document_id=? AND revoked=0 AND (expires_at IS NULL OR expires_at > ?) WHERE document_id=? AND revoked=0 AND (expires_at IS NULL OR expires_at > ?)
ORDER BY id DESC LIMIT 1`, // uuidv7 ids: newest first ORDER BY id DESC LIMIT 1`, // uuidv7 ids: newest first
).get(documentId, new Date().toISOString()) as ShareRow | undefined documentId, new Date().toISOString())
return row === undefined ? null : toShare(row) return row === undefined ? null : toShare(row)
} }
@ -72,32 +72,32 @@ export interface MintShareOpts {
} }
/** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */ /** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */
function defaultExpiryDays(db: DB): number | null { async function defaultExpiryDays(db: DB): Promise<number | null> {
const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) const row = await db.get<{ value: string }>(
.get() as { value: string } | undefined `SELECT value FROM setting WHERE key='share.default_expiry_days'`)
if (row === undefined) return 30 if (row === undefined) return 30
if (row.value === 'never') return null if (row.value === 'never') return null
const n = Number(row.value) const n = Number(row.value)
return Number.isInteger(n) && n > 0 ? n : 30 return Number.isInteger(n) && n > 0 ? n : 30
} }
export function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Share { export async function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Promise<Share> {
if (getDocument(db, documentId) === null) throw new Error('Document not found') if (await getDocument(db, documentId) === null) throw new Error('Document not found')
const id = uuidv7() const id = uuidv7()
const token = randomBytes(32).toString('hex') // 256-bit, unguessable; 64 hex chars const token = randomBytes(32).toString('hex') // 256-bit, unguessable; 64 hex chars
const now = new Date() const now = new Date()
const createdAt = now.toISOString() const createdAt = now.toISOString()
const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays const expiresDays = opts.expiresDays === undefined ? await defaultExpiryDays(db) : opts.expiresDays
const expiresAt = expiresDays === null const expiresAt = expiresDays === null
? null ? null
: new Date(now.getTime() + expiresDays * 86_400_000).toISOString() : new Date(now.getTime() + expiresDays * 86_400_000).toISOString()
db.prepare( await db.run(
`INSERT INTO document_share (id, document_id, token, created_by, created_at, expires_at, revoked) `INSERT INTO document_share (id, document_id, token, created_by, created_at, expires_at, revoked)
VALUES (?, ?, ?, ?, ?, ?, 0)`, VALUES (?, ?, ?, ?, ?, ?, 0)`,
).run(id, documentId, token, userId, createdAt, expiresAt) id, documentId, token, userId, createdAt, expiresAt)
// Audit the mint — but NEVER the token itself (a live share secret). // Audit the mint — but NEVER the token itself (a live share secret).
writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt }) await writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt })
return getShare(db, id)! return (await getShare(db, id))!
} }
/** /**
@ -105,8 +105,8 @@ export function mintShare(db: DB, userId: string, documentId: string, opts: Mint
* revoked, or expired. This is the ONLY gate the public read route trusts it * revoked, or expired. This is the ONLY gate the public read route trusts it
* returns nothing but the single document the live token points at. * returns nothing but the single document the live token points at.
*/ */
export function validateShare(db: DB, token: string): Doc | null { export async function validateShare(db: DB, token: string): Promise<Doc | null> {
const row = db.prepare(`SELECT * FROM document_share WHERE token=?`).get(token) as ShareRow | undefined const row = await db.get<ShareRow>(`SELECT * FROM document_share WHERE token=?`, token)
if (row === undefined) return null // unknown if (row === undefined) return null // unknown
if (row.revoked !== 0) return null // revoked if (row.revoked !== 0) return null // revoked
// ISO-8601 UTC strings compare lexicographically === chronologically. // ISO-8601 UTC strings compare lexicographically === chronologically.
@ -114,12 +114,12 @@ export function validateShare(db: DB, token: string): Doc | null {
return getDocument(db, row.document_id) return getDocument(db, row.document_id)
} }
export function revokeShare(db: DB, userId: string, shareId: string): Share { export async function revokeShare(db: DB, userId: string, shareId: string): Promise<Share> {
const before = getShare(db, shareId) const before = await getShare(db, shareId)
if (before === null) throw new Error('Share not found') if (before === null) throw new Error('Share not found')
db.prepare(`UPDATE document_share SET revoked=1 WHERE id=?`).run(shareId) await db.run(`UPDATE document_share SET revoked=1 WHERE id=?`, shareId)
const after = getShare(db, shareId)! const after = (await getShare(db, shareId))!
writeAudit(db, userId, 'revoke', 'document_share', shareId, await writeAudit(db, userId, 'revoke', 'document_share', shareId,
{ revoked: before.revoked }, { revoked: after.revoked }) { revoked: before.revoked }, { revoked: after.revoked })
return after return after
} }

@ -54,11 +54,11 @@ function bump(created: Record<string, number>, kind: string): void {
type ClaimResult = 'done' | { created: boolean; auto: boolean; reminderId: string } type ClaimResult = 'done' | { created: boolean; auto: boolean; reminderId: string }
/** One period for one plan, atomically. Callers wrap in db.transaction. */ /** One period for one plan, atomically. Callers wrap in db.transaction. */
function claimAndGenerate(db: DB, planId: string, today: string, now: string): ClaimResult { async function claimAndGenerate(db: DB, planId: string, today: string, now: string): Promise<ClaimResult> {
const plan = getRecurringPlan(db, planId) const plan = await getRecurringPlan(db, planId)
if (plan === null || !plan.active || plan.nextRun > today) return 'done' if (plan === null || !plan.active || plan.nextRun > today) return 'done'
const duePeriod = plan.nextRun const duePeriod = plan.nextRun
const up = upsertReminder(db, { const up = await upsertReminder(db, {
ruleKind: 'recurring_generated', subjectId: plan.id, duePeriod, ruleKind: 'recurring_generated', subjectId: plan.id, duePeriod,
clientId: plan.clientId, policyApplied: plan.policy, now, clientId: plan.clientId, policyApplied: plan.policy, now,
}) })
@ -66,38 +66,38 @@ function claimAndGenerate(db: DB, planId: string, today: string, now: string): C
if (!up.created) { if (!up.created) {
// Defensive: the period's reminder already exists but next_run wasn't advanced. // Defensive: the period's reminder already exists but next_run wasn't advanced.
// Only reachable if generation were ever non-transactional; advance, never regenerate. // Only reachable if generation were ever non-transactional; advance, never regenerate.
db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId) await db.run(`UPDATE recurring_plan SET next_run=? WHERE id=?`, nextRun, planId)
return { created: false, auto: false, reminderId: up.id } return { created: false, auto: false, reminderId: up.id }
} }
if (plan.clientModuleId === null) throw new Error(`recurring_plan ${plan.id}: client_module required to generate`) if (plan.clientModuleId === null) throw new Error(`recurring_plan ${plan.id}: client_module required to generate`)
const cm = getClientModule(db, plan.clientModuleId) const cm = await getClientModule(db, plan.clientModuleId)
if (cm === null) throw new Error(`recurring_plan ${plan.id}: client_module not found`) if (cm === null) throw new Error(`recurring_plan ${plan.id}: client_module not found`)
const kind = CADENCE_KIND[plan.cadence] const kind = CADENCE_KIND[plan.cadence]
const draft = createDraft(db, 'system', { const draft = await createDraft(db, 'system', {
docType: 'INVOICE', clientId: plan.clientId, docType: 'INVOICE', clientId: plan.clientId,
lines: [{ lines: [{
moduleId: cm.moduleId, qty: 1, kind, edition: cm.edition, moduleId: cm.moduleId, qty: 1, kind, edition: cm.edition,
...(plan.amountPaise !== null ? { unitPricePaise: plan.amountPaise } : {}), ...(plan.amountPaise !== null ? { unitPricePaise: plan.amountPaise } : {}),
}], }],
}) })
const inv = issueDocument(db, 'system', draft.id) const inv = await issueDocument(db, 'system', draft.id)
db.prepare(`UPDATE reminder SET doc_id=? WHERE id=?`).run(inv.id, up.id) await db.run(`UPDATE reminder SET doc_id=? WHERE id=?`, inv.id, up.id)
db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId) await db.run(`UPDATE recurring_plan SET next_run=? WHERE id=?`, nextRun, planId)
writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id }) await writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id })
return { created: true, auto: plan.policy === 'auto', reminderId: up.id } return { created: true, auto: plan.policy === 'auto', reminderId: up.id }
} }
/** Generate every due period for every active plan; collect auto reminders to send. */ /** Generate every due period for every active plan; collect auto reminders to send. */
function generateRecurring(db: DB, today: string, now: string, created: Record<string, number>): string[] { async function generateRecurring(db: DB, today: string, now: string, created: Record<string, number>): Promise<string[]> {
const plans = db.prepare( const plans = await db.all<{ id: string }>(
`SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`, `SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`, today,
).all(today) as { id: string }[] )
const autoQueue: string[] = [] const autoQueue: string[] = []
for (const { id } of plans) { for (const { id } of plans) {
let guard = 0 let guard = 0
for (;;) { for (;;) {
if (guard++ > 240) break // safety cap: ~20 years of monthly against a corrupt next_run if (guard++ > 240) break // safety cap: ~20 years of monthly against a corrupt next_run
const claim = db.transaction(() => claimAndGenerate(db, id, today, now))() const claim = await db.transaction(() => claimAndGenerate(db, id, today, now))
if (claim === 'done') break if (claim === 'done') break
if (claim.created) { if (claim.created) {
bump(created, 'recurring_generated') bump(created, 'recurring_generated')
@ -118,57 +118,58 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// Catch-up guard (F7): only the HIGHEST crossed milestone enqueues per scan, so a // Catch-up guard (F7): only the HIGHEST crossed milestone enqueues per scan, so a
// scan gap (or the old monthly→dN cutover) nudges once, never the whole ladder; // scan gap (or the old monthly→dN cutover) nudges once, never the whole ladder;
// under daily scans each milestone still fires exactly once as it is crossed. // under daily scans each milestone still fires exactly once as it is crossed.
const invOffsets = resolveSchedule(db, 'invoice_overdue', today).dayOffsets // ascending const invOffsets = (await resolveSchedule(db, 'invoice_overdue', today)).dayOffsets // ascending
const overdueCutoff = addDaysIso(today, -invOffsets[0]!) const overdueCutoff = addDaysIso(today, -invOffsets[0]!)
const overdue = db.prepare( const overdue = await db.all<{ id: string; client_id: string; doc_date: string; due_date: string | null }>(
`SELECT id, client_id, doc_date, due_date FROM document `SELECT id, client_id, doc_date, due_date FROM document
WHERE doc_type='INVOICE' AND doc_no IS NOT NULL WHERE doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('paid','cancelled','lost') AND status NOT IN ('paid','cancelled','lost')
AND COALESCE(due_date, doc_date) <= ?`, AND COALESCE(due_date, doc_date) <= ?`, overdueCutoff,
).all(overdueCutoff) as { id: string; client_id: string; doc_date: string; due_date: string | null }[] )
for (const inv of overdue) { for (const inv of overdue) {
if (outstandingPaise(db, inv.id) <= 0) continue if (await outstandingPaise(db, inv.id) <= 0) continue
const age = daysBetweenIso(inv.due_date ?? inv.doc_date, today) const age = daysBetweenIso(inv.due_date ?? inv.doc_date, today)
const crossed = invOffsets.filter((d) => age >= d) const crossed = invOffsets.filter((d) => age >= d)
if (crossed.length === 0) continue if (crossed.length === 0) continue
const highest = crossed[crossed.length - 1]! const highest = crossed[crossed.length - 1]!
if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: `d${highest}`, clientId: inv.client_id, docId: inv.id, now }).created) { if ((await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: `d${highest}`, clientId: inv.client_id, docId: inv.id, now })).created) {
bump(created, 'invoice_overdue') bump(created, 'invoice_overdue')
} }
} }
// --- renewal_due --- // --- renewal_due ---
const renewalDays = getNumberSetting(db, 'reminders.renewal_days', 15) const renewalDays = await getNumberSetting(db, 'reminders.renewal_days', 15)
const renewalHorizon = addDaysIso(today, renewalDays) const renewalHorizon = addDaysIso(today, renewalDays)
const renewals = db.prepare( const renewals = await db.all<{ id: string; client_id: string; next_renewal: string }>(
`SELECT id, client_id, next_renewal FROM client_module `SELECT id, client_id, next_renewal FROM client_module
WHERE active=1 AND next_renewal IS NOT NULL AND next_renewal >= ? AND next_renewal <= ?`, WHERE active=1 AND next_renewal IS NOT NULL AND next_renewal >= ? AND next_renewal <= ?`,
).all(today, renewalHorizon) as { id: string; client_id: string; next_renewal: string }[] today, renewalHorizon,
)
for (const cm of renewals) { for (const cm of renewals) {
if (upsertReminder(db, { ruleKind: 'renewal_due', subjectId: cm.id, duePeriod: cm.next_renewal, clientId: cm.client_id, now }).created) { if ((await upsertReminder(db, { ruleKind: 'renewal_due', subjectId: cm.id, duePeriod: cm.next_renewal, clientId: cm.client_id, now })).created) {
bump(created, 'renewal_due') bump(created, 'renewal_due')
} }
} }
// --- amc_expiring (per-contract window) --- // --- amc_expiring (per-contract window) ---
const amcs = db.prepare( const amcs = await db.all<{ id: string; client_id: string; period_to: string; renewal_reminder_days: number }>(
`SELECT id, client_id, period_to, renewal_reminder_days FROM amc_contract WHERE active=1`, `SELECT id, client_id, period_to, renewal_reminder_days FROM amc_contract WHERE active=1`,
).all() as { id: string; client_id: string; period_to: string; renewal_reminder_days: number }[] )
for (const a of amcs) { for (const a of amcs) {
const horizon = addDaysIso(today, a.renewal_reminder_days) const horizon = addDaysIso(today, a.renewal_reminder_days)
if (a.period_to < today || a.period_to > horizon) continue if (a.period_to < today || a.period_to > horizon) continue
if (upsertReminder(db, { ruleKind: 'amc_expiring', subjectId: a.id, duePeriod: a.period_to, clientId: a.client_id, now }).created) { if ((await upsertReminder(db, { ruleKind: 'amc_expiring', subjectId: a.id, duePeriod: a.period_to, clientId: a.client_id, now })).created) {
bump(created, 'amc_expiring') bump(created, 'amc_expiring')
} }
} }
// --- follow_up (internal) --- // --- follow_up (internal) ---
const followUps = db.prepare( const followUps = await db.all<{ id: string; client_id: string; follow_up_on: string }>(
`SELECT id, client_id, follow_up_on FROM interaction `SELECT id, client_id, follow_up_on FROM interaction
WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`, WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`, today,
).all(today) as { id: string; client_id: string; follow_up_on: string }[] )
for (const f of followUps) { for (const f of followUps) {
if (upsertReminder(db, { ruleKind: 'follow_up', subjectId: f.id, duePeriod: f.follow_up_on, clientId: f.client_id, now }).created) { if ((await upsertReminder(db, { ruleKind: 'follow_up', subjectId: f.id, duePeriod: f.follow_up_on, clientId: f.client_id, now })).created) {
bump(created, 'follow_up') bump(created, 'follow_up')
} }
} }
@ -176,13 +177,13 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// --- quote_followup (escalating chase on sent quotations — spec §7) --- // --- quote_followup (escalating chase on sent quotations — spec §7) ---
// Age anchors on the FIRST 'sent' event (F15). The dated schedule (rule 3) yields // Age anchors on the FIRST 'sent' event (F15). The dated schedule (rule 3) yields
// the day offsets; send policy is a flat operational setting, default manual. // the day offsets; send policy is a flat operational setting, default manual.
const followupSchedule = resolveSchedule(db, 'quote_followup', today) const followupSchedule = await resolveSchedule(db, 'quote_followup', today)
const followupPolicy: 'auto' | 'manual' = const followupPolicy: 'auto' | 'manual' =
getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual' await getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual'
// A lost client is dead pipeline — never chase (their queued rows can be dismissed // A lost client is dead pipeline — never chase (their queued rows can be dismissed
// by hand). NOT EXISTS covers quotes converted before status-flip-on-convert landed: // by hand). NOT EXISTS covers quotes converted before status-flip-on-convert landed:
// a live forward child means the sale moved on, whatever the quote row still says. // a live forward child means the sale moved on, whatever the quote row still says.
const sentQuotes = db.prepare( const sentQuotes = await db.all<{ id: string; client_id: string; first_sent: string | null }>(
`SELECT d.id, d.client_id, `SELECT d.id, d.client_id,
(SELECT MIN(e.at_wall) FROM document_event e (SELECT MIN(e.at_wall) FROM document_event e
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent
@ -191,7 +192,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
AND c.status != 'lost' AND c.status != 'lost'
AND NOT EXISTS (SELECT 1 FROM document ch AND NOT EXISTS (SELECT 1 FROM document ch
WHERE ch.ref_doc_id = d.id AND ch.status != 'cancelled')`, WHERE ch.ref_doc_id = d.id AND ch.status != 'cancelled')`,
).all() as { id: string; client_id: string; first_sent: string | null }[] )
const followupAuto: string[] = [] const followupAuto: string[] = []
for (const q of sentQuotes) { for (const q of sentQuotes) {
if (q.first_sent === null) continue if (q.first_sent === null) continue
@ -203,7 +204,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// Milestones consumed on their own day stay consumed (threshold-day bucket in the // Milestones consumed on their own day stay consumed (threshold-day bucket in the
// unique key), so daily operation still fires each interval exactly once. // unique key), so daily operation still fires each interval exactly once.
const highest = crossed[crossed.length - 1]! const highest = crossed[crossed.length - 1]!
const up = upsertReminder(db, { const up = await upsertReminder(db, {
ruleKind: 'quote_followup', subjectId: q.id, duePeriod: `d${highest}`, ruleKind: 'quote_followup', subjectId: q.id, duePeriod: `d${highest}`,
clientId: q.client_id, docId: q.id, policyApplied: followupPolicy, now, clientId: q.client_id, docId: q.id, policyApplied: followupPolicy, now,
}) })
@ -214,7 +215,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
} }
// --- recurring_generated (transactional generation, then async auto-send) --- // --- recurring_generated (transactional generation, then async auto-send) ---
const autoQueue = [...followupAuto, ...generateRecurring(db, today, now, created)] const autoQueue = [...followupAuto, ...await generateRecurring(db, today, now, created)]
let autoSent = 0 let autoSent = 0
let autoFailed = 0 let autoFailed = 0
for (const reminderId of autoQueue) { for (const reminderId of autoQueue) {
@ -227,7 +228,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
} catch (err) { } catch (err) {
// A hard guard (no recipient email, share.base_url unset) must not kill the // A hard guard (no recipient email, share.base_url unset) must not kill the
// drain: park THIS reminder as failed — loudly, with the reason — and keep going. // drain: park THIS reminder as failed — loudly, with the reason — and keep going.
setReminderStatus(db, 'system', reminderId, 'failed', { await setReminderStatus(db, 'system', reminderId, 'failed', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) })
autoFailed += 1 autoFailed += 1

@ -31,77 +31,83 @@ const REMINDER_SETTINGS: Record<string, string> = {
'reminders.renewal_days': '15', 'reminders.renewal_days': '15',
} }
export function seedIfEmpty(db: DB): void { // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
const staff = db.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number } const INSERT_SETTING_SQL = `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING`
export async function seedIfEmpty(db: DB): Promise<void> {
const staff = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))!
if (staff.n === 0) { if (staff.n === 0) {
// 9 random bytes → 12 base64url chars; printed exactly once, never stored in clear. // 9 random bytes → 12 base64url chars; printed exactly once, never stored in clear.
const password = randomBytes(9).toString('base64url') const password = randomBytes(9).toString('base64url')
createStaff(db, { email: OWNER_EMAIL, displayName: 'Owner', role: 'owner', password }) await createStaff(db, { email: OWNER_EMAIL, displayName: 'Owner', role: 'owner', password })
console.log(`SiMS HQ first boot — owner ${OWNER_EMAIL} password: ${password} (change after first login)`) console.log(`SiMS HQ first boot — owner ${OWNER_EMAIL} password: ${password} (change after first login)`)
} }
const insert = db.prepare(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING`,
)
let seededSettings = 0 let seededSettings = 0
for (const [key, value] of Object.entries(SETTING_DEFAULTS)) { for (const [key, value] of Object.entries(SETTING_DEFAULTS)) {
seededSettings += insert.run(key, value).changes seededSettings += (await db.run(INSERT_SETTING_SQL, key, value)).changes
} }
if (seededSettings > 0) { if (seededSettings > 0) {
writeAudit(db, 'system', 'seed', 'setting', 'company.*', undefined, SETTING_DEFAULTS) await writeAudit(db, 'system', 'seed', 'setting', 'company.*', undefined, SETTING_DEFAULTS)
} }
const gst = db.prepare(`SELECT COUNT(*) AS n FROM tax_class WHERE class_code='GST18'`).get() as { n: number } const gst = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM tax_class WHERE class_code='GST18'`))!
if (gst.n === 0) { if (gst.n === 0) {
db.prepare( await db.run(
`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`, `INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`,
).run() )
writeAudit(db, 'system', 'seed', 'tax_class', 'GST18', undefined, { ratePctBp: 1800, effectiveFrom: '2017-07-01' }) await writeAudit(db, 'system', 'seed', 'tax_class', 'GST18', undefined, { ratePctBp: 1800, effectiveFrom: '2017-07-01' })
} }
const typeInsert = db.prepare(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`,
)
let seededTypes = 0 let seededTypes = 0
for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes for (const [code, label] of INTERACTION_TYPES) {
if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes }) // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
seededTypes += (await db.run(
`INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`,
code, label,
)).changes
}
if (seededTypes > 0) await writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes })
let seededReminderSettings = 0 let seededReminderSettings = 0
for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes for (const [key, value] of Object.entries(REMINDER_SETTINGS)) {
if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS) seededReminderSettings += (await db.run(INSERT_SETTING_SQL, key, value)).changes
}
if (seededReminderSettings > 0) await writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS)
// Billing defaults (D18): payment terms in days — issueDocument stamps // Billing defaults (D18): payment terms in days — issueDocument stamps
// due_date = doc_date + terms on invoices that don't carry one. // due_date = doc_date + terms on invoices that don't carry one.
const BILLING_SETTINGS: Record<string, string> = { 'billing.payment_terms_days': '15' } const BILLING_SETTINGS: Record<string, string> = { 'billing.payment_terms_days': '15' }
let seededBilling = 0 let seededBilling = 0
for (const [key, value] of Object.entries(BILLING_SETTINGS)) seededBilling += insert.run(key, value).changes for (const [key, value] of Object.entries(BILLING_SETTINGS)) {
if (seededBilling > 0) writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) seededBilling += (await db.run(INSERT_SETTING_SQL, key, value)).changes
}
if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS)
// Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind, // Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind,
// matching the code-constant fallback. A cadence change is a NEW dated row, not an edit. // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit.
const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue']
for (const ruleKind of scheduleKinds) { for (const ruleKind of scheduleKinds) {
const have = db.prepare(`SELECT COUNT(*) AS n FROM reminder_schedule WHERE rule_kind=?`).get(ruleKind) as { n: number } const have = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM reminder_schedule WHERE rule_kind=?`, ruleKind))!
if (have.n > 0) continue if (have.n > 0) continue
const d = SCHEDULE_DEFAULTS[ruleKind] const d = SCHEDULE_DEFAULTS[ruleKind]
const id = uuidv7() const id = uuidv7()
const effectiveFrom = '2020-01-01' // predates all HQ data — active for every business date const effectiveFrom = '2020-01-01' // predates all HQ data — active for every business date
db.transaction(() => { await db.transaction(async () => {
db.prepare( await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, NULL, ?, ?, ?)`, VALUES (?, ?, ?, NULL, ?, ?, ?)`,
).run(id, ruleKind, effectiveFrom, d.dayOffsets.join(','), d.subject, d.body) id, ruleKind, effectiveFrom, d.dayOffsets.join(','), d.subject, d.body,
writeAudit(db, 'system', 'seed', 'reminder_schedule', id, undefined, { )
await writeAudit(db, 'system', 'seed', 'reminder_schedule', id, undefined, {
ruleKind, effectiveFrom, dayOffsets: d.dayOffsets.join(','), ruleKind, effectiveFrom, dayOffsets: d.dayOffsets.join(','),
}) })
})() })
} }
const amc = db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get() as { n: number } const amc = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`))!
if (amc.n === 0) { if (amc.n === 0) {
// The AMC/renewal invoice line hangs off a real module so it flows through // The AMC/renewal invoice line hangs off a real module so it flows through
// createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm. // createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm.
createModule(db, 'system', { await createModule(db, 'system', {
code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719', code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719',
allowedKinds: ['yearly', 'one_time'], allowedKinds: ['yearly', 'one_time'],
}) })

@ -18,7 +18,7 @@ import { documentHtml } from './templates'
export interface SendReminderDeps { export interface SendReminderDeps {
gmail: GmailDeps gmail: GmailDeps
renderPdf: (html: string) => Promise<Buffer> renderPdf: (html: string) => Promise<Buffer>
company: () => Record<string, string> company: () => Record<string, string> | Promise<Record<string, string>>
now?: () => string now?: () => string
} }
@ -27,18 +27,18 @@ export interface SendReminderDeps {
export interface ReminderRender { ctx: ReminderContext; doc: Doc | null; client: Client } export interface ReminderRender { ctx: ReminderContext; doc: Doc | null; client: Client }
/** Public URL for a share token: setting 'share.base_url' + /share/<token> (path-only when unset). */ /** Public URL for a share token: setting 'share.base_url' + /share/<token> (path-only when unset). */
function shareUrlFor(db: DB, token: string): string { async function shareUrlFor(db: DB, token: string): Promise<string> {
const base = getSetting(db, 'share.base_url') const base = await getSetting(db, 'share.base_url')
return `${base !== null ? base.replace(/\/+$/, '') : ''}/share/${token}` return `${base !== null ? base.replace(/\/+$/, '') : ''}/share/${token}`
} }
export function reminderContext(db: DB, reminder: Reminder, companyName: string, today?: string): ReminderRender { export async function reminderContext(db: DB, reminder: Reminder, companyName: string, today?: string): Promise<ReminderRender> {
const client = getClient(db, reminder.clientId) const client = await getClient(db, reminder.clientId)
if (client === null) throw new Error('Client not found') if (client === null) throw new Error('Client not found')
const ctx: ReminderContext = { clientName: client.name, companyName } const ctx: ReminderContext = { clientName: client.name, companyName }
let doc: Doc | null = null let doc: Doc | null = null
if (reminder.docId !== null) { if (reminder.docId !== null) {
doc = getDocument(db, reminder.docId) doc = await getDocument(db, reminder.docId)
if (doc === null) throw new Error('Reminder document not found') if (doc === null) throw new Error('Reminder document not found')
ctx.docNo = doc.docNo ?? undefined ctx.docNo = doc.docNo ?? undefined
ctx.amountPaise = doc.payablePaise ctx.amountPaise = doc.payablePaise
@ -61,23 +61,23 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string,
// `today` is injectable (deps.now via sendReminder) so the send path resolves the // `today` is injectable (deps.now via sendReminder) so the send path resolves the
// SAME dated row as the scan that enqueued it — wall clock only as a fallback. // SAME dated row as the scan that enqueued it — wall clock only as a fallback.
const onDate = today ?? new Date().toISOString().slice(0, 10) const onDate = today ?? new Date().toISOString().slice(0, 10)
const schedule = resolveSchedule(db, 'quote_followup', onDate) // dated message text (rule 3) const schedule = await resolveSchedule(db, 'quote_followup', onDate) // dated message text (rule 3)
ctx.subjectTemplate = schedule.subject ?? undefined ctx.subjectTemplate = schedule.subject ?? undefined
ctx.bodyTemplate = schedule.body ?? undefined ctx.bodyTemplate = schedule.body ?? undefined
// Quotes reach 'sent' without issuance (F10) — never render "quotation null". // Quotes reach 'sent' without issuance (F10) — never render "quotation null".
ctx.ref = doc.docNo ?? `dated ${doc.docDate}` ctx.ref = doc.docNo ?? `dated ${doc.docDate}`
// Resolve-only (F5): this builder is shared with GET /preview and must write // Resolve-only (F5): this builder is shared with GET /preview and must write
// nothing. Minting happens exclusively in sendReminder below. // nothing. Minting happens exclusively in sendReminder below.
const live = resolveLiveShare(db, doc.id) const live = await resolveLiveShare(db, doc.id)
ctx.shareUrl = live !== null ctx.shareUrl = live !== null
? shareUrlFor(db, live.token) ? await shareUrlFor(db, live.token)
: '(a view link is generated when this reminder is sent)' : '(a view link is generated when this reminder is sent)'
} }
} else if (reminder.ruleKind === 'renewal_due') { } else if (reminder.ruleKind === 'renewal_due') {
const cm = getClientModule(db, reminder.subjectId) const cm = await getClientModule(db, reminder.subjectId)
if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal if (cm !== null && cm.nextRenewal !== null) ctx.dueDate = cm.nextRenewal
} else if (reminder.ruleKind === 'amc_expiring') { } else if (reminder.ruleKind === 'amc_expiring') {
const amc = getAmc(db, reminder.subjectId) const amc = await getAmc(db, reminder.subjectId)
if (amc !== null) { ctx.coverage = amc.coverage; ctx.dueDate = amc.periodTo } if (amc !== null) { ctx.coverage = amc.coverage; ctx.dueDate = amc.periodTo }
} }
return { ctx, doc, client } return { ctx, doc, client }
@ -86,33 +86,33 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string,
export async function sendReminder( export async function sendReminder(
db: DB, deps: SendReminderDeps, reminderId: string, userId: string, db: DB, deps: SendReminderDeps, reminderId: string, userId: string,
): Promise<SendResult> { ): Promise<SendResult> {
const reminder = getReminder(db, reminderId) const reminder = await getReminder(db, reminderId)
if (reminder === null) throw new Error('Reminder not found') if (reminder === null) throw new Error('Reminder not found')
if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') { if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`) throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`)
} }
const company = deps.company() const company = await deps.company()
const companyName = company['company.name'] ?? '' const companyName = company['company.name'] ?? ''
const now = deps.now?.() ?? new Date().toISOString() const now = deps.now?.() ?? new Date().toISOString()
// Resolve the recipient BEFORE any write: a send that can never succeed must not // Resolve the recipient BEFORE any write: a send that can never succeed must not
// leave a live public share behind as a side effect (rule 6). // leave a live public share behind as a side effect (rule 6).
const recipient = getClient(db, reminder.clientId) const recipient = await getClient(db, reminder.clientId)
if (recipient === null) throw new Error('Client not found') if (recipient === null) throw new Error('Client not found')
const to = recipient.contacts.find((c) => c.email !== undefined && c.email !== '')?.email const to = recipient.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
if (to === undefined) throw new Error('No recipient: add a contact email to the client') if (to === undefined) throw new Error('No recipient: add a contact email to the client')
if (reminder.ruleKind === 'quote_followup' && reminder.docId !== null) { if (reminder.ruleKind === 'quote_followup' && reminder.docId !== null) {
// The share link IS the point of this mail (spec §7): with no public base URL the // The share link IS the point of this mail (spec §7): with no public base URL the
// body would carry a dead relative path — refuse loudly instead of emailing it. // body would carry a dead relative path — refuse loudly instead of emailing it.
if (getSetting(db, 'share.base_url') === null) { if (await getSetting(db, 'share.base_url') === null) {
throw new Error(`Setting 'share.base_url' is not configured — cannot email a usable share link`) throw new Error(`Setting 'share.base_url' is not configured — cannot email a usable share link`)
} }
// The ONLY place a follow-up share is minted (rule 6 / F5): reuse a live link, // The ONLY place a follow-up share is minted (rule 6 / F5): reuse a live link,
// else mint with an expiry covering the escalation window — never never-expiring (F12). // else mint with an expiry covering the escalation window — never never-expiring (F12).
if (resolveLiveShare(db, reminder.docId) === null) { if (await resolveLiveShare(db, reminder.docId) === null) {
mintShare(db, userId, reminder.docId, { expiresDays: 60 }) await mintShare(db, userId, reminder.docId, { expiresDays: 60 })
} }
} }
const { ctx, doc, client } = reminderContext(db, reminder, companyName, now.slice(0, 10)) const { ctx, doc, client } = await reminderContext(db, reminder, companyName, now.slice(0, 10))
let attachment: { filename: string; data: Buffer } | undefined let attachment: { filename: string; data: Buffer } | undefined
let documentId: string | undefined let documentId: string | undefined
@ -128,7 +128,7 @@ export async function sendReminder(
...(attachment !== undefined ? { attachment } : {}), ...(attachment !== undefined ? { attachment } : {}),
...(documentId !== undefined ? { documentId } : {}), ...(documentId !== undefined ? { documentId } : {}),
}) })
if (out.ok) setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null }) if (out.ok) await setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null })
else setReminderStatus(db, userId, reminderId, 'failed', { error: out.error }) else await setReminderStatus(db, userId, reminderId, 'failed', { error: out.error })
return out return out
} }

@ -5,25 +5,28 @@ export const TYPE_PREFIX: Record<string, string> = {
QUOTATION: 'QT', PROFORMA: 'PI', INVOICE: 'INV', RECEIPT: 'RCT', CREDIT_NOTE: 'CN', QUOTATION: 'QT', PROFORMA: 'PI', INVOICE: 'INV', RECEIPT: 'RCT', CREDIT_NOTE: 'CN',
} }
export function nextDocNo(db: DB, docType: string, fy: string): string { export async function nextDocNo(db: DB, docType: string, fy: string): Promise<string> {
const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}` const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}`
db.prepare( await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 1) `INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, 1)
ON CONFLICT (doc_type, fy) DO NOTHING`, ON CONFLICT (doc_type, fy) DO NOTHING`,
).run(docType, fy, prefix) docType, fy, prefix,
const row = db.prepare(`SELECT prefix, next_seq FROM doc_series WHERE doc_type=? AND fy=?`) )
.get(docType, fy) as { prefix: string; next_seq: number } const row = (await db.get<{ prefix: string; next_seq: number }>(
db.prepare(`UPDATE doc_series SET next_seq = next_seq + 1 WHERE doc_type=? AND fy=?`).run(docType, fy) `SELECT prefix, next_seq FROM doc_series WHERE doc_type=? AND fy=?`, docType, fy,
))!
await db.run(`UPDATE doc_series SET next_seq = next_seq + 1 WHERE doc_type=? AND fy=?`, docType, fy)
return formatDocNo(row.prefix, row.next_seq, 4) return formatDocNo(row.prefix, row.next_seq, 4)
} }
/** Mid-FY cutover (spec §4): continue after the last APEX-issued number. */ /** Mid-FY cutover (spec §4): continue after the last APEX-issued number. */
export function seedSeries(db: DB, docType: string, fy: string, lastUsedSeq: number): void { export async function seedSeries(db: DB, docType: string, fy: string, lastUsedSeq: number): Promise<void> {
const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}` const prefix = `${TYPE_PREFIX[docType]}/${fy.slice(2)}`
db.prepare( await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, ?) `INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, ?)
ON CONFLICT (doc_type, fy) DO UPDATE SET next_seq = excluded.next_seq`, ON CONFLICT (doc_type, fy) DO UPDATE SET next_seq = excluded.next_seq`,
).run(docType, fy, prefix, lastUsedSeq + 1) docType, fy, prefix, lastUsedSeq + 1,
)
} }

@ -21,9 +21,10 @@ const moduleDir = typeof __dirname === 'undefined'
: __dirname : __dirname
function schedulerDeps(db: DB) { function schedulerDeps(db: DB) {
const company = (): Record<string, string> => const company = async (): Promise<Record<string, string>> =>
Object.fromEntries((db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`) Object.fromEntries((await db.all<{ key: string; value: string }>(
.all() as { key: string; value: string }[]).map((r) => [r.key, r.value])) `SELECT key, value FROM setting WHERE key LIKE 'company.%'`,
)).map((r) => [r.key, r.value]))
const gmail = { const gmail = {
f: fetch, f: fetch,
clientId: process.env['GOOGLE_CLIENT_ID'] ?? '', clientId: process.env['GOOGLE_CLIENT_ID'] ?? '',
@ -57,10 +58,10 @@ export function startScheduler(db: DB): NodeJS.Timeout {
} }
/** The letterhead settings map documentHtml reads — company.* identity + template.* text. */ /** The letterhead settings map documentHtml reads — company.* identity + template.* text. */
function companySettings(db: DB): Record<string, string> { async function companySettings(db: DB): Promise<Record<string, string>> {
const rows = db.prepare( const rows = await db.all<{ key: string; value: string }>(
`SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`, `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`,
).all() as { key: string; value: string }[] )
return Object.fromEntries(rows.map((row) => [row.key, row.value])) return Object.fromEntries(rows.map((row) => [row.key, row.value]))
} }
@ -128,12 +129,12 @@ export function mountPublicShare(app: express.Express, db: DB, deps: PublicShare
const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown' const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'
if (!allow(ip)) { gone(res, 429, 'Too many requests. Please try again in a minute.'); return } if (!allow(ip)) { gone(res, 429, 'Too many requests. Please try again in a minute.'); return }
const token = String(req.params['token'] ?? '') const token = String(req.params['token'] ?? '')
const doc = validateShare(db, token) // null for unknown / revoked / expired — a public read, no audit
if (doc === null) { gone(res, 404, 'This link has expired or is invalid.'); return }
const client = getClient(db, doc.clientId)
if (client === null) { gone(res, 404, 'This link has expired or is invalid.'); return }
const company = companySettings(db)
void (async () => { void (async () => {
const doc = await validateShare(db, token) // null for unknown / revoked / expired — a public read, no audit
if (doc === null) { gone(res, 404, 'This link has expired or is invalid.'); return }
const client = await getClient(db, doc.clientId)
if (client === null) { gone(res, 404, 'This link has expired or is invalid.'); return }
const company = await companySettings(db)
try { try {
const pdf = await deps.renderPdf(documentHtml(doc, client, company)) const pdf = await deps.renderPdf(documentHtml(doc, client, company))
res.setHeader('Content-Type', 'application/pdf') res.setHeader('Content-Type', 'application/pdf')
@ -144,14 +145,17 @@ export function mountPublicShare(app: express.Express, db: DB, deps: PublicShare
} catch { } catch {
gone(res, 500, 'This document could not be rendered right now.') gone(res, 500, 'This document could not be rendered right now.')
} }
})() })().catch(() => {
// A DB failure resolving the token must not become an unhandled rejection.
gone(res, 500, 'This document could not be rendered right now.')
})
}) })
} }
export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server { export async function startServer(port: number, opts: { scheduler?: boolean } = {}): Promise<http.Server> {
const app = express() const app = express()
const db = openDb(process.env['HQ_DATA_DIR']) const db = openDb(process.env['HQ_DATA_DIR'])
seedIfEmpty(db) // first boot prints the owner password once await seedIfEmpty(db) // first boot prints the owner password once
app.locals['db'] = db app.locals['db'] = db
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))
// Public, unauthenticated document view — mounted OUTSIDE /api, before static. // Public, unauthenticated document view — mounted OUTSIDE /api, before static.
@ -167,5 +171,6 @@ export function startServer(port: number, opts: { scheduler?: boolean } = {}): h
if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) { if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) {
const PORT = Number(process.env['HQ_PORT'] ?? 5182) const PORT = Number(process.env['HQ_PORT'] ?? 5182)
startServer(PORT, { scheduler: true }) startServer(PORT, { scheduler: true })
console.log(`SiMS HQ console on http://localhost:${PORT}`) .then(() => { console.log(`SiMS HQ console on http://localhost:${PORT}`) })
.catch((err: unknown) => { console.error('[server] failed to start', err); process.exit(1) })
} }

@ -7,33 +7,33 @@ import {
createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc, createAmc, listAmc, generateAmcRenewalInvoice, amcPaidStatus, getAmc,
} from '../src/repos-amc' } from '../src/repos-amc'
function setup() { async function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module const db = openDb(':memory:'); await seedIfEmpty(db) // seeds company.state_code, GST18, AMC module
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
return { db, c } return { db, c }
} }
describe('amc contracts', () => { describe('amc contracts', () => {
it('creates, generates a renewal invoice, and derives paid from settlement', () => { it('creates, generates a renewal invoice, and derives paid from settlement', async () => {
const { db, c } = setup() const { db, c } = await setup()
const amc = createAmc(db, 'u1', { const amc = await createAmc(db, 'u1', {
clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31', clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31',
amountPaise: 20_000_00, amountPaise: 20_000_00,
}) })
expect(listAmc(db, c.id)).toHaveLength(1) expect(await listAmc(db, c.id)).toHaveLength(1)
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled') expect(await amcPaidStatus(db, (await getAmc(db, amc.id))!)).toBe('unbilled')
const inv = generateAmcRenewalInvoice(db, 'u1', amc.id) const inv = await generateAmcRenewalInvoice(db, 'u1', amc.id)
expect(inv.docType).toBe('INVOICE') expect(inv.docType).toBe('INVOICE')
expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid') expect(await amcPaidStatus(db, (await getAmc(db, amc.id))!)).toBe('unpaid')
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 }) await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 })
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid') expect(await amcPaidStatus(db, (await getAmc(db, amc.id))!)).toBe('paid')
}) })
it('honours the legacy_paid flag for imported contracts without an invoice', () => { it('honours the legacy_paid flag for imported contracts without an invoice', async () => {
const { db, c } = setup() const { db, c } = await setup()
const amc = createAmc(db, 'u1', { const amc = await createAmc(db, 'u1', {
clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true, clientId: c.id, periodFrom: '2025-04-01', periodTo: '2026-03-31', amountPaise: 10_000_00, legacyPaid: true,
}) })
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('paid') expect(await amcPaidStatus(db, (await getAmc(db, amc.id))!)).toBe('paid')
}) })
}) })

@ -4,19 +4,19 @@ import { openDb } from '../src/db'
import { createStaff, login, verifySession } from '../src/auth' import { createStaff, login, verifySession } from '../src/auth'
describe('hq auth', () => { describe('hq auth', () => {
it('logs in with correct password, rejects wrong one', () => { it('logs in with correct password, rejects wrong one', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' }) await createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(login(db, 'admin@tecnostac.com', 'wrong')).toBeNull() expect(await login(db, 'admin@tecnostac.com', 'wrong')).toBeNull()
const ok = login(db, 'admin@tecnostac.com', 'let-me-in-9') const ok = await login(db, 'admin@tecnostac.com', 'let-me-in-9')
expect(ok).not.toBeNull() expect(ok).not.toBeNull()
const staff = verifySession(db, ok!.token) const staff = await verifySession(db, ok!.token)
expect(staff).toMatchObject({ role: 'owner' }) expect(staff).toMatchObject({ role: 'owner' })
}) })
it('rejects passwords under 8 chars at creation', () => { it('rejects passwords under 8 chars at creation', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
expect(() => await expect(
createStaff(db, { email: 'a@b.c', displayName: 'X', role: 'staff', password: 'short' }), createStaff(db, { email: 'a@b.c', displayName: 'X', role: 'staff', password: 'short' }),
).toThrow(/8/) ).rejects.toThrow(/8/)
}) })
}) })

@ -49,19 +49,19 @@ function deps(response: unknown, capture?: (url: string, init?: RequestInit) =>
describe('pullMonthlyCosts', () => { describe('pullMonthlyCosts', () => {
it('maps client tags to clients, upserts cost rows, and collects unknown tags', async () => { it('maps client tags to clients, upserts cost rows, and collects unknown tags', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const acme = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) const acme = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
let sentUrl = '' let sentUrl = ''
const out = await pullMonthlyCosts(db, deps(CE_RESPONSE, (u) => { sentUrl = u }), '2026-06') const out = await pullMonthlyCosts(db, deps(CE_RESPONSE, (u) => { sentUrl = u }), '2026-06')
expect(sentUrl).toBe('https://ce.us-east-1.amazonaws.com/') expect(sentUrl).toBe('https://ce.us-east-1.amazonaws.com/')
expect(out.upserted).toBe(1) // only ACME matched expect(out.upserted).toBe(1) // only ACME matched
expect(out.unknownTags.map((u) => u.tag).sort()).toEqual(['', 'GHOST']) // ghost client + untagged expect(out.unknownTags.map((u) => u.tag).sort()).toEqual(['', 'GHOST']) // ghost client + untagged
expect(out.unknownTags.find((u) => u.tag === 'GHOST')?.costPaise).toBe(10_00) expect(out.unknownTags.find((u) => u.tag === 'GHOST')?.costPaise).toBe(10_00)
const row = db.prepare(`SELECT cost_paise, source FROM aws_usage WHERE client_id=?`).get(acme.id) const row = await db.get(`SELECT cost_paise, source FROM aws_usage WHERE client_id=?`, acme.id)
expect(row).toMatchObject({ cost_paise: 123456, source: 'auto' }) expect(row).toMatchObject({ cost_paise: 123456, source: 'auto' })
}) })
it('throws on a non-INR billing currency rather than mis-converting', async () => { it('throws on a non-INR billing currency rather than mis-converting', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const usd = { ResultsByTime: [{ Groups: [{ Keys: ['client$ACME'], Metrics: { UnblendedCost: { Amount: '10.00', Unit: 'USD' } } }] }] } const usd = { ResultsByTime: [{ Groups: [{ Keys: ['client$ACME'], Metrics: { UnblendedCost: { Amount: '10.00', Unit: 'USD' } } }] }] }
await expect(pullMonthlyCosts(db, deps(usd), '2026-06')).rejects.toThrow(/INR/i) await expect(pullMonthlyCosts(db, deps(usd), '2026-06')).rejects.toThrow(/INR/i)
}) })
@ -69,12 +69,12 @@ describe('pullMonthlyCosts', () => {
describe('maybePullAwsCosts', () => { describe('maybePullAwsCosts', () => {
it('pulls the previous month once per calendar month', async () => { it('pulls the previous month once per calendar month', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
expect(previousMonth('2026-07-10')).toBe('2026-06') expect(previousMonth('2026-07-10')).toBe('2026-06')
const first = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-10') const first = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-10')
expect(first).not.toBeNull() expect(first).not.toBeNull()
expect(getSetting(db, 'aws.last_pull_month')).toBe('2026-06') expect(await getSetting(db, 'aws.last_pull_month')).toBe('2026-06')
const second = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-20') const second = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-20')
expect(second).toBeNull() // already pulled 2026-06 this run expect(second).toBeNull() // already pulled 2026-06 this run
}) })

@ -15,9 +15,12 @@ describe('POST /api/aws/pull without credentials', () => {
if (prevSecret !== undefined) process.env['AWS_SECRET_ACCESS_KEY'] = prevSecret if (prevSecret !== undefined) process.env['AWS_SECRET_ACCESS_KEY'] = prevSecret
}) })
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:')
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) beforeAll(async () => {
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) await seedIfEmpty(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
})
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`

@ -3,51 +3,51 @@ import { openDb } from '../src/db'
import { createClient } from '../src/repos-clients' import { createClient } from '../src/repos-clients'
import { upsertAwsUsage, getAwsUsage, listAwsUsage, costRanking, awsCostForClient, previousMonth } from '../src/repos-aws' import { upsertAwsUsage, getAwsUsage, listAwsUsage, costRanking, awsCostForClient, previousMonth } from '../src/repos-aws'
function setup() { async function setup() {
const db = openDb(':memory:') const db = openDb(':memory:')
const a = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) const a = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const b = createClient(db, 'u1', { name: 'Bolt', code: 'BOLT', stateCode: '29' }) const b = await createClient(db, 'u1', { name: 'Bolt', code: 'BOLT', stateCode: '29' })
return { db, a, b } return { db, a, b }
} }
describe('aws usage repo', () => { describe('aws usage repo', () => {
it('upserts and merges a (client, month) row without clobbering the other source', () => { it('upserts and merges a (client, month) row without clobbering the other source', async () => {
const { db, a } = setup() const { db, a } = await setup()
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 5_000_00, source: 'auto' }) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 5_000_00, source: 'auto' })
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', storageGb: 42.5, source: 'manual' }) // merges await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', storageGb: 42.5, source: 'manual' }) // merges
const row = getAwsUsage(db, a.id, '2026-06')! const row = (await getAwsUsage(db, a.id, '2026-06'))!
expect(row.costPaise).toBe(5_000_00) // auto cost preserved expect(row.costPaise).toBe(5_000_00) // auto cost preserved
expect(row.storageGb).toBe(42.5) // manual storage applied expect(row.storageGb).toBe(42.5) // manual storage applied
expect(db.prepare(`SELECT COUNT(*) AS n FROM aws_usage WHERE client_id=?`).get(a.id)).toMatchObject({ n: 1 }) expect(await db.get(`SELECT COUNT(*) AS n FROM aws_usage WHERE client_id=?`, a.id)).toMatchObject({ n: 1 })
const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='aws_usage'`).all() const audits = await db.all(`SELECT action FROM audit_log WHERE entity='aws_usage'`)
expect(audits.length).toBe(2) // create + update expect(audits.length).toBe(2) // create + update
}) })
it('lists newest month first, capped', () => { it('lists newest month first, capped', async () => {
const { db, a } = setup() const { db, a } = await setup()
for (const m of ['2026-04', '2026-05', '2026-06']) upsertAwsUsage(db, 'u1', { clientId: a.id, month: m, costPaise: 100 }) for (const m of ['2026-04', '2026-05', '2026-06']) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: m, costPaise: 100 })
expect(listAwsUsage(db, a.id, 2).map((r) => r.month)).toEqual(['2026-06', '2026-05']) expect((await listAwsUsage(db, a.id, 2)).map((r) => r.month)).toEqual(['2026-06', '2026-05'])
}) })
it('rejects a bad month', () => { it('rejects a bad month', async () => {
const { db, a } = setup() const { db, a } = await setup()
expect(() => upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026/06', costPaise: 1 })).toThrow(/month/i) await expect(upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026/06', costPaise: 1 })).rejects.toThrow(/month/i)
}) })
it('ranks clients by cost with share in basis points', () => { it('ranks clients by cost with share in basis points', async () => {
const { db, a, b } = setup() const { db, a, b } = await setup()
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 7_500_00 }) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 7_500_00 })
upsertAwsUsage(db, 'u1', { clientId: b.id, month: '2026-06', costPaise: 2_500_00 }) await upsertAwsUsage(db, 'u1', { clientId: b.id, month: '2026-06', costPaise: 2_500_00 })
const r = costRanking(db, '2026-06') const r = await costRanking(db, '2026-06')
expect(r.total).toBe(10_000_00) expect(r.total).toBe(10_000_00)
expect(r.rows.map((x) => x.clientId)).toEqual([a.id, b.id]) // Acme first (higher cost) expect(r.rows.map((x) => x.clientId)).toEqual([a.id, b.id]) // Acme first (higher cost)
expect(r.rows[0]!.sharePctBp).toBe(7500) // 75.00% expect(r.rows[0]!.sharePctBp).toBe(7500) // 75.00%
expect(r.rows[1]!.sharePctBp).toBe(2500) expect(r.rows[1]!.sharePctBp).toBe(2500)
}) })
it('sums a client cost over a month range and derives the previous month', () => { it('sums a client cost over a month range and derives the previous month', async () => {
const { db, a } = setup() const { db, a } = await setup()
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-05', costPaise: 100 }) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-05', costPaise: 100 })
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 200 }) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 200 })
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-07', costPaise: 400 }) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-07', costPaise: 400 })
expect(awsCostForClient(db, a.id, '2026-05-01', '2026-06-30')).toBe(300) expect(await awsCostForClient(db, a.id, '2026-05-01', '2026-06-30')).toBe(300)
expect(awsCostForClient(db, a.id)).toBe(700) expect(await awsCostForClient(db, a.id)).toBe(700)
expect(previousMonth('2026-07-10')).toBe('2026-06') expect(previousMonth('2026-07-10')).toBe('2026-06')
expect(previousMonth('2026-01-05')).toBe('2025-12') // year rollover expect(previousMonth('2026-01-05')).toBe('2025-12') // year rollover
}) })

@ -25,24 +25,24 @@ function bounceFetch(recipient: string): typeof fetch {
describe('pollBounces', () => { describe('pollBounces', () => {
it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => { it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' }) await logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' })
const deps: BounceDeps = { const deps: BounceDeps = {
gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
now: () => '2026-07-10T00:00:00Z', now: () => '2026-07-10T00:00:00Z',
} }
const out = await pollBounces(db, deps) const out = await pollBounces(db, deps)
expect(out).toEqual({ scanned: 1, bounced: 1 }) expect(out).toEqual({ scanned: 1, bounced: 1 })
expect(db.prepare(`SELECT bounced FROM email_log WHERE to_addr='ravi@acme.in'`).get()).toMatchObject({ bounced: 1 }) expect(await db.get(`SELECT bounced FROM email_log WHERE to_addr='ravi@acme.in'`)).toMatchObject({ bounced: 1 })
expect(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1) expect(await listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1)
// Re-poll: the row is already bounced=0→1, so nothing new flips and no duplicate reminder. // Re-poll: the row is already bounced=0→1, so nothing new flips and no duplicate reminder.
const again = await pollBounces(db, deps) const again = await pollBounces(db, deps)
expect(again.bounced).toBe(0) expect(again.bounced).toBe(0)
expect(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1) expect(await listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1)
}) })
it('no-ops cleanly when Gmail is not connected', async () => { it('no-ops cleanly when Gmail is not connected', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const deps: BounceDeps = { gmail: { f: bounceFetch('x@y.z'), clientId: '', clientSecret: '', keyHex: KEY }, now: () => '2026-07-10T00:00:00Z' } const deps: BounceDeps = { gmail: { f: bounceFetch('x@y.z'), clientId: '', clientSecret: '', keyHex: KEY }, now: () => '2026-07-10T00:00:00Z' }
expect(await pollBounces(db, deps)).toEqual({ scanned: 0, bounced: 0 }) expect(await pollBounces(db, deps)).toEqual({ scanned: 0, bounced: 0 })
}) })

@ -4,21 +4,21 @@ import { createClient } from '../src/repos-clients'
import { createModule, assignModule, updateClientModule } from '../src/repos-modules' import { createModule, assignModule, updateClientModule } from '../src/repos-modules'
describe('client modules', () => { describe('client modules', () => {
it('enforces allowed kinds and single-subscription', () => { it('enforces allowed kinds and single-subscription', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['one_time','yearly'] }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['one_time','yearly'] })
expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })).toThrow(/allow/) await expect(assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })).rejects.toThrow(/allow/)
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })).toThrow(/already/) await expect(assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })).rejects.toThrow(/already/)
const up = updateClientModule(db, 'u1', cm.id, { status: 'installed', installedOn: '2026-07-01' }) const up = await updateClientModule(db, 'u1', cm.id, { status: 'installed', installedOn: '2026-07-01' })
expect(up.status).toBe('installed') expect(up.status).toBe('installed')
}) })
it('allows concurrent rows when multi_subscription = 1', () => { it('allows concurrent rows when multi_subscription = 1', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', multiSubscription: true }) const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', multiSubscription: true })
assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' }) await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' })
expect(() => assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' })).not.toThrow() await expect(assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' })).resolves.not.toThrow()
}) })
}) })

@ -6,7 +6,7 @@ import express from 'express'
import fs from 'node:fs' import fs from 'node:fs'
import os from 'node:os' import os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, afterAll, beforeAll } from 'vitest'
import { openDb, type DB } from '../src/db' import { openDb, type DB } from '../src/db'
import { createStaff, ownerScope } from '../src/auth' import { createStaff, ownerScope } from '../src/auth'
import { createEmployee, deactivateEmployee } from '../src/repos-employees' import { createEmployee, deactivateEmployee } from '../src/repos-employees'
@ -14,24 +14,24 @@ import { createClient, getClient, listClients, setClientOwner } from '../src/rep
import { listAudit } from '../src/audit' import { listAudit } from '../src/audit'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function withOwner(): { db: DB; ownerId: string } { async function withOwner(): Promise<{ db: DB; ownerId: string }> {
const db = openDb(':memory:') const db = openDb(':memory:')
const { id } = createStaff(db, { const { id } = await createStaff(db, {
email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9', email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
}) })
return { db, ownerId: id } return { db, ownerId: id }
} }
describe('client.owner_id column', () => { describe('client.owner_id column', () => {
it('exists on a fresh DB (SCHEMA born correct) and defaults to NULL', () => { it('exists on a fresh DB (SCHEMA born correct) and defaults to NULL', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const cols = (db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[]).map((c) => c.name) const cols = (await db.all<{ name: string }>(`PRAGMA table_info(client)`)).map((c) => c.name)
expect(cols).toContain('owner_id') expect(cols).toContain('owner_id')
const c = createClient(db, 'u1', { name: 'Fresh Co', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Fresh Co', stateCode: '32' })
expect(getClient(db, c.id)!.ownerId).toBeUndefined() expect((await getClient(db, c.id))!.ownerId).toBeUndefined()
}) })
it('migrate() adds owner_id to a pre-Phase-3 DB, preserving rows, idempotently', () => { it('migrate() adds owner_id to a pre-Phase-3 DB, preserving rows, idempotently', async () => {
// Simulate a DB created before this phase: client table without owner_id + one row. // Simulate a DB created before this phase: client table without owner_id + one row.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-owner-')) const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-owner-'))
const raw = new Database(path.join(dir, 'hq.db')) const raw = new Database(path.join(dir, 'hq.db'))
@ -47,55 +47,55 @@ describe('client.owner_id column', () => {
).run() ).run()
raw.close() raw.close()
const db = openDb(dir) // exec SCHEMA (IF NOT EXISTS skips client) + migrate() adds the column const db = openDb(dir) // exec SCHEMA (IF NOT EXISTS skips client) + migrate() adds the column
const cols = (db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[]).map((c) => c.name) const cols = (await db.all<{ name: string }>(`PRAGMA table_info(client)`)).map((c) => c.name)
expect(cols).toContain('owner_id') expect(cols).toContain('owner_id')
const kept = db.prepare(`SELECT id, name, owner_id FROM client WHERE id='c1'`).get() const kept = await db.get(`SELECT id, name, owner_id FROM client WHERE id='c1'`)
expect(kept).toMatchObject({ id: 'c1', name: 'Old Row', owner_id: null }) expect(kept).toMatchObject({ id: 'c1', name: 'Old Row', owner_id: null })
db.close() await db.close()
const again = openDb(dir) // idempotent re-run loses nothing const again = openDb(dir) // idempotent re-run loses nothing
expect(again.prepare(`SELECT COUNT(*) AS n FROM client`).get()).toMatchObject({ n: 1 }) expect(await again.get(`SELECT COUNT(*) AS n FROM client`)).toMatchObject({ n: 1 })
again.close() await again.close()
fs.rmSync(dir, { recursive: true, force: true }) fs.rmSync(dir, { recursive: true, force: true })
}) })
}) })
describe('setClientOwner', () => { describe('setClientOwner', () => {
it('gives a lead a routable owner and writes an audit row in the same transaction', () => { it('gives a lead a routable owner and writes an audit row in the same transaction', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const lead = createClient(db, ownerId, { name: 'Nagari Sah.', stateCode: '32', status: 'lead' }) const lead = await createClient(db, ownerId, { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
const after = setClientOwner(db, ownerId, lead.id, emp.id) const after = await setClientOwner(db, ownerId, lead.id, emp.id)
expect(after.ownerId).toBe(emp.id) expect(after.ownerId).toBe(emp.id)
expect(getClient(db, lead.id)!.ownerId).toBe(emp.id) expect((await getClient(db, lead.id))!.ownerId).toBe(emp.id)
expect(listClients(db).find((c) => c.id === lead.id)!.ownerId).toBe(emp.id) expect((await listClients(db)).find((c) => c.id === lead.id)!.ownerId).toBe(emp.id)
const audit = listAudit(db).find((a) => a.action === 'set_owner' && a.entity === 'client' && a.entity_id === lead.id) const audit = (await listAudit(db)).find((a) => a.action === 'set_owner' && a.entity === 'client' && a.entity_id === lead.id)
expect(audit).toBeDefined() expect(audit).toBeDefined()
expect(JSON.parse(audit!.before_json!)).not.toHaveProperty('ownerId') expect(JSON.parse(audit!.before_json!)).not.toHaveProperty('ownerId')
expect(JSON.parse(audit!.after_json!)).toMatchObject({ ownerId: emp.id }) expect(JSON.parse(audit!.after_json!)).toMatchObject({ ownerId: emp.id })
}) })
it('clears the owner with null, audited', () => { it('clears the owner with null, audited', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const c = createClient(db, ownerId, { name: 'Clearable', stateCode: '32' }) const c = await createClient(db, ownerId, { name: 'Clearable', stateCode: '32' })
setClientOwner(db, ownerId, c.id, emp.id) await setClientOwner(db, ownerId, c.id, emp.id)
const cleared = setClientOwner(db, ownerId, c.id, null) const cleared = await setClientOwner(db, ownerId, c.id, null)
expect(cleared.ownerId).toBeUndefined() expect(cleared.ownerId).toBeUndefined()
const audits = listAudit(db).filter((a) => a.action === 'set_owner' && a.entity_id === c.id) const audits = (await listAudit(db)).filter((a) => a.action === 'set_owner' && a.entity_id === c.id)
expect(audits).toHaveLength(2) expect(audits).toHaveLength(2)
}) })
it('rejects an unknown employee and an inactive employee; unknown client 404s', () => { it('rejects an unknown employee and an inactive employee; unknown client 404s', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const c = createClient(db, ownerId, { name: 'Guarded', stateCode: '32' }) const c = await createClient(db, ownerId, { name: 'Guarded', stateCode: '32' })
expect(() => setClientOwner(db, ownerId, c.id, 'nope')).toThrow(/employee/i) await expect(setClientOwner(db, ownerId, c.id, 'nope')).rejects.toThrow(/employee/i)
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
deactivateEmployee(db, ownerId, emp.id) await deactivateEmployee(db, ownerId, emp.id)
expect(() => setClientOwner(db, ownerId, c.id, emp.id)).toThrow(/inactive/i) await expect(setClientOwner(db, ownerId, c.id, emp.id)).rejects.toThrow(/inactive/i)
expect(() => setClientOwner(db, ownerId, 'missing', null)).toThrow(/client/i) await expect(setClientOwner(db, ownerId, 'missing', null)).rejects.toThrow(/client/i)
// failed sets leave no owner and no audit row behind // failed sets leave no owner and no audit row behind
expect(getClient(db, c.id)!.ownerId).toBeUndefined() expect((await getClient(db, c.id))!.ownerId).toBeUndefined()
expect(listAudit(db).filter((a) => a.action === 'set_owner')).toHaveLength(0) expect((await listAudit(db)).filter((a) => a.action === 'set_owner')).toHaveLength(0)
}) })
}) })
@ -115,10 +115,13 @@ describe('ownerScope role gate', () => {
describe('PATCH /clients/:id/owner', () => { describe('PATCH /clients/:id/owner', () => {
const db = openDb(':memory:') const db = openDb(':memory:')
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) let staffDbId = ''
createStaff(db, { email: 'manager@test.in', displayName: 'Mgr', role: 'manager', password: 'manager-password' }) beforeAll(async () => {
createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id await createStaff(db, { email: 'manager@test.in', displayName: 'Mgr', role: 'manager', password: 'manager-password' })
await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
staffDbId = ((await db.get<{ id: string }>(`SELECT id FROM staff_user WHERE email='staff@test.in'`)) as { id: string }).id
})
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -141,12 +144,12 @@ describe('PATCH /clients/:id/owner', () => {
it('owner and manager can set an owner; the write is audited; staff gets 403', async () => { it('owner and manager can set an owner; the write is audited; staff gets 403', async () => {
const owner = await tokenOf('owner@test.in', 'owner-password') const owner = await tokenOf('owner@test.in', 'owner-password')
const c = createClient(db, 'seed', { name: 'Routable Lead', stateCode: '32', status: 'lead' }) const c = await createClient(db, 'seed', { name: 'Routable Lead', stateCode: '32', status: 'lead' })
const set = await patchOwner(owner, c.id, { ownerId: staffDbId }) const set = await patchOwner(owner, c.id, { ownerId: staffDbId })
expect(set.status).toBe(200) expect(set.status).toBe(200)
expect(set.json.client.ownerId).toBe(staffDbId) expect(set.json.client.ownerId).toBe(staffDbId)
const audit = listAudit(db).find((a) => a.action === 'set_owner' && a.entity_id === c.id) const audit = (await listAudit(db)).find((a) => a.action === 'set_owner' && a.entity_id === c.id)
expect(audit).toBeDefined() expect(audit).toBeDefined()
const manager = await tokenOf('manager@test.in', 'manager-password') const manager = await tokenOf('manager@test.in', 'manager-password')
@ -157,13 +160,13 @@ describe('PATCH /clients/:id/owner', () => {
const staff = await tokenOf('staff@test.in', 'staff-password') const staff = await tokenOf('staff@test.in', 'staff-password')
const denied = await patchOwner(staff, c.id, { ownerId: staffDbId }) const denied = await patchOwner(staff, c.id, { ownerId: staffDbId })
expect(denied.status).toBe(403) expect(denied.status).toBe(403)
expect(getClient(db, c.id)!.ownerId).toBeUndefined() // staff write did not land expect((await getClient(db, c.id))!.ownerId).toBeUndefined() // staff write did not land
}) })
it('404s an unknown client; 400s a bad employee id or missing ownerId field', async () => { it('404s an unknown client; 400s a bad employee id or missing ownerId field', async () => {
const owner = await tokenOf('owner@test.in', 'owner-password') const owner = await tokenOf('owner@test.in', 'owner-password')
expect((await patchOwner(owner, 'missing', { ownerId: staffDbId })).status).toBe(404) expect((await patchOwner(owner, 'missing', { ownerId: staffDbId })).status).toBe(404)
const c = createClient(db, 'seed', { name: 'Bad Input Co', stateCode: '32' }) const c = await createClient(db, 'seed', { name: 'Bad Input Co', stateCode: '32' })
expect((await patchOwner(owner, c.id, { ownerId: 'not-an-employee' })).status).toBe(400) expect((await patchOwner(owner, c.id, { ownerId: 'not-an-employee' })).status).toBe(400)
expect((await patchOwner(owner, c.id, {})).status).toBe(400) expect((await patchOwner(owner, c.id, {})).status).toBe(400)
}) })

@ -13,62 +13,62 @@ import { apiRouter } from '../src/api'
const KEY = '22'.repeat(32) const KEY = '22'.repeat(32)
describe('client support data (repo)', () => { describe('client support data (repo)', () => {
it('anydesk/os/district/sector round-trip on create and patch; emptying clears to absent', () => { it('anydesk/os/district/sector round-trip on create and patch; emptying clears to absent', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { const c = await createClient(db, 'u1', {
name: 'Acme Bank', stateCode: '32', name: 'Acme Bank', stateCode: '32',
anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop', anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop',
}) })
expect(c).toMatchObject({ anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop' }) expect(c).toMatchObject({ anydesk: '123 456 789', os: 'Windows 11', district: 'Pune', sector: 'Urban Coop' })
const patched = updateClient(db, 'u1', c.id, { anydesk: '', district: 'Satara' }) const patched = await updateClient(db, 'u1', c.id, { anydesk: '', district: 'Satara' })
expect(patched.anydesk).toBeUndefined() // '' clears expect(patched.anydesk).toBeUndefined() // '' clears
expect(patched.district).toBe('Satara') expect(patched.district).toBe('Satara')
}) })
it('district/sector filter the client book', () => { it('district/sector filter the client book', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' }) await createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' })
createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' }) await createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' })
createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' }) await createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' })
expect(listClients(db, undefined, { district: 'Pune' })).toHaveLength(2) expect(await listClients(db, undefined, { district: 'Pune' })).toHaveLength(2)
expect(listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1) expect(await listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1)
expect(listClients(db)).toHaveLength(3) // no filter = everything, unchanged expect(await listClients(db)).toHaveLength(3) // no filter = everything, unchanged
}) })
it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', () => { it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(c.hasDbPassword).toBe(false) expect(c.hasDbPassword).toBe(false)
setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY) await setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY)
const after = getClient(db, c.id)! const after = (await getClient(db, c.id))!
expect(after.hasDbPassword).toBe(true) expect(after.hasDbPassword).toBe(true)
expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload expect(JSON.stringify(after)).not.toContain('oracle#secret') // never in the payload
const raw = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`).get(c.id) as { db_password_enc: string } const raw = (await db.get<{ db_password_enc: string }>(`SELECT db_password_enc FROM client WHERE id=?`, c.id))!
expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest expect(raw.db_password_enc).not.toContain('oracle#secret') // encrypted at rest
expect(revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret') expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret')
const reveals = listAudit(db).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id) const reveals = (await listAudit(db)).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id)
expect(reveals).toHaveLength(1) expect(reveals).toHaveLength(1)
// The set audit records THAT it was set, never the value. // The set audit records THAT it was set, never the value.
const sets = listAudit(db).filter((a) => a.action === 'set_db_password') const sets = (await listAudit(db)).filter((a) => a.action === 'set_db_password')
expect(sets).toHaveLength(1) expect(sets).toHaveLength(1)
expect(sets[0]!.after_json).not.toContain('oracle') expect(sets[0]!.after_json).not.toContain('oracle')
}) })
it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', () => { it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(() => setClientDbPassword(db, 'u1', c.id, 'pw', '')).toThrow(/HQ_SECRET_KEY/) await expect(setClientDbPassword(db, 'u1', c.id, 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/)
setClientDbPassword(db, 'u1', c.id, 'pw', KEY) await setClientDbPassword(db, 'u1', c.id, 'pw', KEY)
expect(() => revealClientDbPassword(db, 'u1', c.id, '')).toThrow(/HQ_SECRET_KEY/) await expect(revealClientDbPassword(db, 'u1', c.id, '')).rejects.toThrow(/HQ_SECRET_KEY/)
}) })
}) })
describe('client support data (routes)', () => { describe('client support data (routes)', () => {
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' })
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const app = express(); app.use(express.json()); app.locals['db'] = db const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY })) app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0) const server = app.listen(0)
@ -84,7 +84,7 @@ describe('client support data (routes)', () => {
})).json()) as { token: string }).token })).json()) as { token: string }).token
it('staff can patch support text fields but NOT the db password; owner can; reveal is gated too', async () => { it('staff can patch support text fields but NOT the db password; owner can; reveal is gated too', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const staffTok = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const staffTok = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const ownerTok = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const ownerTok = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` }) const H = (t: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${t}` })

@ -4,19 +4,19 @@ import { openDb } from '../src/db'
import { createClient, listClients, updateClient } from '../src/repos-clients' import { createClient, listClients, updateClient } from '../src/repos-clients'
describe('client registry', () => { describe('client registry', () => {
it('creates with auto code, searches, updates with audit', () => { it('creates with auto code, searches, updates with audit', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Malabar Stores', stateCode: '32', const c = await createClient(db, 'u1', { name: 'Malabar Stores', stateCode: '32',
contacts: [{ name: 'Ravi', email: 'ravi@malabar.in' }] }) contacts: [{ name: 'Ravi', email: 'ravi@malabar.in' }] })
expect(c.code).toBe('CL0001') expect(c.code).toBe('CL0001')
expect(listClients(db, 'malabar')).toHaveLength(1) expect(await listClients(db, 'malabar')).toHaveLength(1)
const up = updateClient(db, 'u1', c.id, { status: 'active', notes: 'AMC due Oct' }) const up = await updateClient(db, 'u1', c.id, { status: 'active', notes: 'AMC due Oct' })
expect(up.status).toBe('active') expect(up.status).toBe('active')
const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='client'`).all() const audits = await db.all(`SELECT action FROM audit_log WHERE entity='client'`)
expect(audits.length).toBe(2) expect(audits.length).toBe(2)
}) })
it('rejects a bad GSTIN checksum', () => { it('rejects a bad GSTIN checksum', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
expect(() => createClient(db, 'u1', { name: 'X', stateCode: '32', gstin: '32AAAAA0000A1Z9' })).toThrow() await expect(createClient(db, 'u1', { name: 'X', stateCode: '32', gstin: '32AAAAA0000A1Z9' })).rejects.toThrow()
}) })
}) })

@ -16,27 +16,27 @@ import { apiRouter } from '../src/api'
const KEY = '11'.repeat(32) const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake') const fakePdf = async () => Buffer.from('%PDF-fake')
function base(db: DB) { async function base(db: DB) {
seedIfEmpty(db) await seedIfEmpty(db)
const c = createClient(db, 'u1', { const c = await createClient(db, 'u1', {
name: 'Acme Bank', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }], name: 'Acme Bank', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }],
}) })
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' }) const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { c, m } return { c, m }
} }
// ---------- supersedeProforma (repo) ---------- // ---------- supersedeProforma (repo) ----------
describe('supersedeProforma', () => { describe('supersedeProforma', () => {
it('cancels an issued proforma (number stays consumed) and opens a linked draft carrying the payload', () => { it('cancels an issued proforma (number stays consumed) and opens a linked draft carrying the payload', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const { c, m } = base(db) const { c, m } = await base(db)
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }], docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }],
}).id) })).id)
const fresh = supersedeProforma(db, 'u1', pi.id) const fresh = await supersedeProforma(db, 'u1', pi.id)
const old = getDocument(db, pi.id)! const old = (await getDocument(db, pi.id))!
expect(old.status).toBe('cancelled') expect(old.status).toBe('cancelled')
expect(old.docNo).toBe(pi.docNo) // consumed number stays on the corpse expect(old.docNo).toBe(pi.docNo) // consumed number stays on the corpse
expect(fresh.docType).toBe('PROFORMA') expect(fresh.docType).toBe('PROFORMA')
@ -47,52 +47,52 @@ describe('supersedeProforma', () => {
expect(fresh.payablePaise).toBe(pi.payablePaise) expect(fresh.payablePaise).toBe(pi.payablePaise)
}) })
it('supersedes an unissued proforma draft too (retired, no number involved)', () => { it('supersedes an unissued proforma draft too (retired, no number involved)', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const { c, m } = base(db) const { c, m } = await base(db)
const draft = createDraft(db, 'u1', { const draft = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}) })
const fresh = supersedeProforma(db, 'u1', draft.id) const fresh = await supersedeProforma(db, 'u1', draft.id)
expect(getDocument(db, draft.id)!.status).toBe('cancelled') expect((await getDocument(db, draft.id))!.status).toBe('cancelled')
expect(fresh.refDocId).toBe(draft.id) expect(fresh.refDocId).toBe(draft.id)
}) })
it('hard-rejects an INVOICE with credit-note guidance, and non-proformas generally', () => { it('hard-rejects an INVOICE with credit-note guidance, and non-proformas generally', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const { c, m } = base(db) const { c, m } = await base(db)
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id) })).id)
expect(() => supersedeProforma(db, 'u1', inv.id)) await expect(supersedeProforma(db, 'u1', inv.id))
.toThrow(/immutable.*credit note/i) .rejects.toThrow(/immutable.*credit note/i)
const qt = createDraft(db, 'u1', { const qt = await createDraft(db, 'u1', {
docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}) })
expect(() => supersedeProforma(db, 'u1', qt.id)).toThrow(/only proformas/i) await expect(supersedeProforma(db, 'u1', qt.id)).rejects.toThrow(/only proformas/i)
}) })
it('rejects an already-cancelled proforma and one with a live forward child', () => { it('rejects an already-cancelled proforma and one with a live forward child', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const { c, m } = base(db) const { c, m } = await base(db)
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id) })).id)
supersedeProforma(db, 'u1', pi.id) await supersedeProforma(db, 'u1', pi.id)
expect(() => supersedeProforma(db, 'u1', pi.id)).toThrow(/already cancelled/i) await expect(supersedeProforma(db, 'u1', pi.id)).rejects.toThrow(/already cancelled/i)
}) })
}) })
// ---------- POST /documents/:id/convert-and-send (route) ---------- // ---------- POST /documents/:id/convert-and-send (route) ----------
function appWith(fetchImpl: typeof fetch) { async function appWith(fetchImpl: typeof fetch) {
const db = openDb(':memory:') const db = openDb(':memory:')
const { c, m } = base(db) const { c, m } = await base(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id) })).id)
const app = express(); app.use(express.json()); app.locals['db'] = db const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf)) app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
const server = app.listen(0) const server = app.listen(0)
@ -126,67 +126,68 @@ describe('POST /documents/:id/convert-and-send', () => {
afterAll(() => { for (const s of servers) s.close() }) afterAll(() => { for (const s of servers) s.close() })
it('happy path: one call → issued INVOICE, proforma flipped invoiced, email logged sent, invoice marked sent', async () => { it('happy path: one call → issued INVOICE, proforma flipped invoiced, email logged sent, invoice marked sent', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(200) expect(out.status).toBe(200)
const invoice = getDocument(ctx.db, out.json.document!.id)! const invoice = (await getDocument(ctx.db, out.json.document!.id))!
expect(invoice.docType).toBe('INVOICE') expect(invoice.docType).toBe('INVOICE')
expect(invoice.docNo).toMatch(/^INV\//) expect(invoice.docNo).toMatch(/^INV\//)
expect(invoice.status).toBe('sent') // sendDocumentEmail flips draft→sent expect(invoice.status).toBe('sent') // sendDocumentEmail flips draft→sent
expect(invoice.refDocId).toBe(ctx.pi.id) expect(invoice.refDocId).toBe(ctx.pi.id)
expect(getDocument(ctx.db, ctx.pi.id)!.status).toBe('invoiced') expect((await getDocument(ctx.db, ctx.pi.id))!.status).toBe('invoiced')
const log = ctx.db.prepare(`SELECT status, document_id, to_addr FROM email_log ORDER BY id DESC LIMIT 1`) const log = (await ctx.db.get<{ status: string; document_id: string; to_addr: string }>(
.get() as { status: string; document_id: string; to_addr: string } `SELECT status, document_id, to_addr FROM email_log ORDER BY id DESC LIMIT 1`,
))!
expect(log).toMatchObject({ status: 'sent', document_id: invoice.id, to_addr: 'ravi@acme.in' }) expect(log).toMatchObject({ status: 'sent', document_id: invoice.id, to_addr: 'ravi@acme.in' })
}) })
it('double-click cannot mint a second invoice: retry 400s, exactly one live INVOICE exists', async () => { it('double-click cannot mint a second invoice: retry 400s, exactly one live INVOICE exists', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
const again = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) const again = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(again.status).toBe(400) expect(again.status).toBe(400)
const invoices = listDocuments(ctx.db, { type: 'INVOICE' }).documents.filter((d) => d.status !== 'cancelled') const invoices = (await listDocuments(ctx.db, { type: 'INVOICE' })).documents.filter((d) => d.status !== 'cancelled')
expect(invoices).toHaveLength(1) expect(invoices).toHaveLength(1)
}) })
it('a failed send leaves the issued invoice intact and returns a warning', async () => { it('a failed send leaves the issued invoice intact and returns a warning', async () => {
const ctx = appWith(sendFailFetch); servers.push(ctx.server) const ctx = await appWith(sendFailFetch); servers.push(ctx.server)
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(200) expect(out.status).toBe(200)
expect(String(out.json['warning'])).toMatch(/email failed/i) expect(String(out.json['warning'])).toMatch(/email failed/i)
const invoice = getDocument(ctx.db, out.json.document!.id)! const invoice = (await getDocument(ctx.db, out.json.document!.id))!
expect(invoice.docNo).toMatch(/^INV\//) // number assigned and kept expect(invoice.docNo).toMatch(/^INV\//) // number assigned and kept
expect(invoice.status).toBe('draft') // send never happened; nothing rolled back expect(invoice.status).toBe('draft') // send never happened; nothing rolled back
const log = ctx.db.prepare(`SELECT status FROM email_log ORDER BY id DESC LIMIT 1`).get() as { status: string } const log = (await ctx.db.get<{ status: string }>(`SELECT status FROM email_log ORDER BY id DESC LIMIT 1`))!
expect(log.status).toBe('failed') expect(log.status).toBe('failed')
}) })
it('guards fire BEFORE any write: gmail disconnected → 409 and no invoice created', async () => { it('guards fire BEFORE any write: gmail disconnected → 409 and no invoice created', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
ctx.db.prepare(`DELETE FROM email_account`).run() // disconnect await ctx.db.run(`DELETE FROM email_account`) // disconnect
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(409) expect(out.status).toBe(409)
expect(listDocuments(ctx.db, { type: 'INVOICE' }).documents).toHaveLength(0) expect((await listDocuments(ctx.db, { type: 'INVOICE' })).documents).toHaveLength(0)
expect(getDocument(ctx.db, ctx.pi.id)!.status).not.toBe('invoiced') expect((await getDocument(ctx.db, ctx.pi.id))!.status).not.toBe('invoiced')
}) })
it('rejects non-proformas up front', async () => { it('rejects non-proformas up front', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const qt = createDraft(ctx.db, 'u1', { const qt = await createDraft(ctx.db, 'u1', {
docType: 'QUOTATION', docType: 'QUOTATION',
clientId: ctx.c.id, clientId: ctx.c.id,
lines: [{ moduleId: listDocuments(ctx.db, {}).documents[0]!.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }], lines: [{ moduleId: (await listDocuments(ctx.db, {})).documents[0]!.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }],
}) })
const out = await loginAndPost(ctx.baseUrl, `/documents/${qt.id}/convert-and-send`) const out = await loginAndPost(ctx.baseUrl, `/documents/${qt.id}/convert-and-send`)
expect(out.status).toBe(400) expect(out.status).toBe(400)
}) })
it('POST /documents/:id/supersede round-trips over HTTP', async () => { it('POST /documents/:id/supersede round-trips over HTTP', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/supersede`) const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/supersede`)
expect(out.status).toBe(200) expect(out.status).toBe(200)
expect(getDocument(ctx.db, ctx.pi.id)!.status).toBe('cancelled') expect((await getDocument(ctx.db, ctx.pi.id))!.status).toBe('cancelled')
const fresh = getDocument(ctx.db, out.json.document!.id)! const fresh = (await getDocument(ctx.db, out.json.document!.id))!
expect(fresh.docType).toBe('PROFORMA') expect(fresh.docType).toBe('PROFORMA')
expect(fresh.refDocId).toBe(ctx.pi.id) expect(fresh.refDocId).toBe(ctx.pi.id)
}) })

@ -1,5 +1,5 @@
// apps/hq/test/dashboard.test.ts // apps/hq/test/dashboard.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, afterAll, beforeAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -17,24 +17,24 @@ const scanDeps: ScanDeps = {
renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z', renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z',
} }
function seeded() { async function seeded() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
db.prepare(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`).run(inv.id) await db.run(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`, inv.id)
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' }) await createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
return { db, c, inv } return { db, c, inv }
} }
describe('dashboardView', () => { describe('dashboardView', () => {
it('aggregates overdue, renewals, follow-ups and the reminder queue', async () => { it('aggregates overdue, renewals, follow-ups and the reminder queue', async () => {
const { db, inv } = seeded() const { db, inv } = await seeded()
await runDailyScan(db, scanDeps, '2026-07-10') // populates the queue await runDailyScan(db, scanDeps, '2026-07-10') // populates the queue
const view = dashboardView(db, '2026-07-10') const view = await dashboardView(db, '2026-07-10')
expect(view.overdue.map((o) => o.docId)).toContain(inv.id) expect(view.overdue.map((o) => o.docId)).toContain(inv.id)
expect(view.overdue[0]!.outstandingPaise).toBe(11_800_00) expect(view.overdue[0]!.outstandingPaise).toBe(11_800_00)
expect(view.renewalsThisMonth).toHaveLength(1) expect(view.renewalsThisMonth).toHaveLength(1)
@ -43,34 +43,39 @@ describe('dashboardView', () => {
expect(view.totals.overduePaise).toBe(11_800_00) expect(view.totals.overduePaise).toBe(11_800_00)
}) })
it('anchors overdue on the stamped due date, not the issue date (D18 WS-A)', () => { it('anchors overdue on the stamped due date, not the issue date (D18 WS-A)', async () => {
const { db, inv } = seeded() const { db, inv } = await seeded()
// Issued 2026-06-01 but not due until 2026-07-20 → NOT overdue on 2026-07-10. // Issued 2026-06-01 but not due until 2026-07-20 → NOT overdue on 2026-07-10.
db.prepare(`UPDATE document SET due_date='2026-07-20' WHERE id=?`).run(inv.id) await db.run(`UPDATE document SET due_date='2026-07-20' WHERE id=?`, inv.id)
expect(dashboardView(db, '2026-07-10').overdue).toHaveLength(0) expect((await dashboardView(db, '2026-07-10')).overdue).toHaveLength(0)
// Due date passed → overdue, aged from the due date (5 days, not 39). // Due date passed → overdue, aged from the due date (5 days, not 39).
db.prepare(`UPDATE document SET due_date='2026-07-05' WHERE id=?`).run(inv.id) await db.run(`UPDATE document SET due_date='2026-07-05' WHERE id=?`, inv.id)
const view = dashboardView(db, '2026-07-10') const view = await dashboardView(db, '2026-07-10')
expect(view.overdue.map((o) => o.docId)).toContain(inv.id) expect(view.overdue.map((o) => o.docId)).toContain(inv.id)
expect(view.overdue[0]!.daysOverdue).toBe(5) expect(view.overdue[0]!.daysOverdue).toBe(5)
}) })
}) })
describe('GET /api/dashboard + reminder queue routes', () => { describe('GET /api/dashboard + reminder queue routes', () => {
const { db } = seeded() async function routeCtx() {
createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' }) const { db } = await seeded()
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) await createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' })
const server = app.listen(0) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, base }
}
let ctx: Awaited<ReturnType<typeof routeCtx>>
beforeAll(async () => { ctx = await routeCtx() })
let token = '' let token = ''
const call = async (method: string, path: string, body?: unknown) => { const call = async (method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) const res = await fetch(ctx.base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
afterAll(() => server.close()) afterAll(() => ctx.server.close())
it('serves the dashboard, lists the queue and dismisses a reminder', async () => { it('serves the dashboard, lists the queue and dismisses a reminder', async () => {
token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token token = (await call('POST', '/auth/login', { email: 'e2e@test.in', password: 'e2e-password' })).json.token
await runDailyScan(db, scanDeps, '2026-07-10') await runDailyScan(ctx.db, scanDeps, '2026-07-10')
const dash = (await call('GET', '/dashboard')).json const dash = (await call('GET', '/dashboard')).json
expect(dash.ok).toBe(true) expect(dash.ok).toBe(true)
expect(dash.view.overdue.length).toBeGreaterThanOrEqual(1) expect(dash.view.overdue.length).toBeGreaterThanOrEqual(1)
@ -91,27 +96,27 @@ describe('My Day scoping (D18 WS-E)', () => {
const { createModule, setPrice, assignModule, updateClientModule } = await import('../src/repos-modules') const { createModule, setPrice, assignModule, updateClientModule } = await import('../src/repos-modules')
const { dashboardView } = await import('../src/repos-dashboard') const { dashboardView } = await import('../src/repos-dashboard')
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const { id: staffId } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'password-9' }) const { id: staffId } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'password-9' })
const { id: mgrId } = createStaff(db, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password-9' }) const { id: mgrId } = await createStaff(db, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password-9' })
const mine = createClient(db, 'u1', { name: 'Mine Bank', stateCode: '32' }) const mine = await createClient(db, 'u1', { name: 'Mine Bank', stateCode: '32' })
const other = createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' }) const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
setClientOwner(db, mgrId, mine.id, staffId) await setClientOwner(db, mgrId, mine.id, staffId)
const m = createModule(db, 'u1', { code: 'POS2', name: 'POS2' }) const m = await createModule(db, 'u1', { code: 'POS2', name: 'POS2' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_000_00, effectiveFrom: '2026-01-01' })
const today = '2026-07-17' const today = '2026-07-17'
for (const c of [mine, other]) { for (const c of [mine, other]) {
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-25' }) await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-25' })
} }
// Staff: only the client they own shows in renewals. // Staff: only the client they own shows in renewals.
const staffView = dashboardView(db, today, { id: staffId, role: 'staff' }) const staffView = await dashboardView(db, today, { id: staffId, role: 'staff' })
expect(staffView.renewalsThisMonth.map((r) => r.clientName)).toEqual(['Mine Bank']) expect(staffView.renewalsThisMonth.map((r) => r.clientName)).toEqual(['Mine Bank'])
// Managerial default: everyone. // Managerial default: everyone.
const mgrAll = dashboardView(db, today, { id: mgrId, role: 'manager' }) const mgrAll = await dashboardView(db, today, { id: mgrId, role: 'manager' })
expect(mgrAll.renewalsThisMonth).toHaveLength(2) expect(mgrAll.renewalsThisMonth).toHaveLength(2)
// Managerial My Day: narrowed to their own book (they own nothing → empty). // Managerial My Day: narrowed to their own book (they own nothing → empty).
const mgrMine = dashboardView(db, today, { id: mgrId, role: 'manager' }, { mine: true }) const mgrMine = await dashboardView(db, today, { id: mgrId, role: 'manager' }, { mine: true })
expect(mgrMine.renewalsThisMonth).toHaveLength(0) expect(mgrMine.renewalsThisMonth).toHaveLength(0)
}) })
}) })

@ -3,19 +3,19 @@ import { openDb } from '../src/db'
import { writeAudit, listAudit } from '../src/audit' import { writeAudit, listAudit } from '../src/audit'
describe('hq db', () => { describe('hq db', () => {
it('creates every HQ-1 table', () => { it('creates every HQ-1 table', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map(r => r.name) const names = (await db.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE type='table'`)).map(r => r.name)
for (const t of ['staff_user','session','client','module','module_price_book','client_module', for (const t of ['staff_user','session','client','module','module_price_book','client_module',
'tax_class','doc_series','document','payment','payment_allocation','document_event', 'tax_class','doc_series','document','payment','payment_allocation','document_event',
'email_account','email_log','setting','audit_log','stg_client','stg_invoice']) 'email_account','email_log','setting','audit_log','stg_client','stg_invoice'])
expect(names, `missing table ${t}`).toContain(t) expect(names, `missing table ${t}`).toContain(t)
}) })
it('audit writes and lists newest-first', () => { it('audit writes and lists newest-first', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
writeAudit(db, 'u1', 'create', 'client', 'c1', undefined, { name: 'Acme' }) await writeAudit(db, 'u1', 'create', 'client', 'c1', undefined, { name: 'Acme' })
writeAudit(db, 'u1', 'update', 'client', 'c1', { name: 'Acme' }, { name: 'Acme Ltd' }) await writeAudit(db, 'u1', 'update', 'client', 'c1', { name: 'Acme' }, { name: 'Acme Ltd' })
const rows = listAudit(db) const rows = await listAudit(db)
expect(rows).toHaveLength(2) expect(rows).toHaveLength(2)
expect(rows[0]!.action).toBe('update') expect(rows[0]!.action).toBe('update')
expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' }) expect(JSON.parse(rows[0]!.after_json!)).toEqual({ name: 'Acme Ltd' })

@ -14,10 +14,10 @@ import { apiRouter } from '../src/api'
const KEY = '11'.repeat(32) const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake') const fakePdf = async () => Buffer.from('%PDF-fake')
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' }) const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' })
const mkClient = (name: string) => createClient(db, 'u1', { name, stateCode: '32' }) const mkClient = (name: string) => createClient(db, 'u1', { name, stateCode: '32' })
const mkDraft = (clientId: string, docType: 'QUOTATION' | 'PROFORMA' | 'INVOICE' = 'QUOTATION') => const mkDraft = (clientId: string, docType: 'QUOTATION' | 'PROFORMA' | 'INVOICE' = 'QUOTATION') =>
createDraft(db, 'u1', { docType, clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) createDraft(db, 'u1', { docType, clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
@ -25,19 +25,19 @@ function world() {
} }
describe('listDocuments — pagination (rule 8)', () => { describe('listDocuments — pagination (rule 8)', () => {
it('returns { documents, total, page, pageSize } with defaults page=1 pageSize=50, newest first', () => { it('returns { documents, total, page, pageSize } with defaults page=1 pageSize=50, newest first', async () => {
const { db, mkClient, mkDraft } = world() const { db, mkClient, mkDraft } = await world()
const c = mkClient('Acme Bank') const c = await mkClient('Acme Bank')
const ids: string[] = [] const ids: string[] = []
for (let i = 0; i < 55; i++) ids.push(mkDraft(c.id).id) for (let i = 0; i < 55; i++) ids.push((await mkDraft(c.id)).id)
const p1 = listDocuments(db) const p1 = await listDocuments(db)
expect(p1.total).toBe(55) expect(p1.total).toBe(55)
expect(p1.page).toBe(1) expect(p1.page).toBe(1)
expect(p1.pageSize).toBe(50) expect(p1.pageSize).toBe(50)
expect(p1.documents).toHaveLength(50) expect(p1.documents).toHaveLength(50)
// newest first — the last-created draft leads the list // newest first — the last-created draft leads the list
expect(p1.documents[0]!.id).toBe(ids[54]) expect(p1.documents[0]!.id).toBe(ids[54])
const p2 = listDocuments(db, { page: 2 }) const p2 = await listDocuments(db, { page: 2 })
expect(p2.total).toBe(55) // total is stable across pages expect(p2.total).toBe(55) // total is stable across pages
expect(p2.documents).toHaveLength(5) expect(p2.documents).toHaveLength(5)
// no overlap between pages // no overlap between pages
@ -45,55 +45,55 @@ describe('listDocuments — pagination (rule 8)', () => {
expect(p2.documents.some((d) => seen.has(d.id))).toBe(false) expect(p2.documents.some((d) => seen.has(d.id))).toBe(false)
}) })
it('joins the client name onto every row (no N+1 for the list view)', () => { it('joins the client name onto every row (no N+1 for the list view)', async () => {
const { db, mkClient, mkDraft } = world() const { db, mkClient, mkDraft } = await world()
const a = mkClient('Acme Bank'); const b = mkClient('Beta Coop') const a = await mkClient('Acme Bank'); const b = await mkClient('Beta Coop')
mkDraft(a.id); mkDraft(b.id) await mkDraft(a.id); await mkDraft(b.id)
const page = listDocuments(db) const page = await listDocuments(db)
const names = new Map(page.documents.map((d) => [d.clientId, d.clientName])) const names = new Map(page.documents.map((d) => [d.clientId, d.clientName]))
expect(names.get(a.id)).toBe('Acme Bank') expect(names.get(a.id)).toBe('Acme Bank')
expect(names.get(b.id)).toBe('Beta Coop') expect(names.get(b.id)).toBe('Beta Coop')
}) })
it('type/status/clientId filters compose, with an honest filtered total', () => { it('type/status/clientId filters compose, with an honest filtered total', async () => {
const { db, mkClient, mkDraft } = world() const { db, mkClient, mkDraft } = await world()
const a = mkClient('Acme Bank'); const b = mkClient('Beta Coop') const a = await mkClient('Acme Bank'); const b = await mkClient('Beta Coop')
mkDraft(a.id, 'QUOTATION') await mkDraft(a.id, 'QUOTATION')
const invA = mkDraft(a.id, 'INVOICE') const invA = await mkDraft(a.id, 'INVOICE')
issueDocument(db, 'u1', invA.id) await issueDocument(db, 'u1', invA.id)
markStatus(db, 'u1', invA.id, 'sent') await markStatus(db, 'u1', invA.id, 'sent')
mkDraft(b.id, 'INVOICE') // stays draft await mkDraft(b.id, 'INVOICE') // stays draft
const filtered = listDocuments(db, { type: 'INVOICE', clientId: a.id, status: 'sent', pageSize: 1 }) const filtered = await listDocuments(db, { type: 'INVOICE', clientId: a.id, status: 'sent', pageSize: 1 })
expect(filtered.total).toBe(1) expect(filtered.total).toBe(1)
expect(filtered.documents.map((d) => d.id)).toEqual([invA.id]) expect(filtered.documents.map((d) => d.id)).toEqual([invA.id])
const draftInvoices = listDocuments(db, { type: 'INVOICE', status: 'draft' }) const draftInvoices = await listDocuments(db, { type: 'INVOICE', status: 'draft' })
expect(draftInvoices.total).toBe(1) expect(draftInvoices.total).toBe(1)
expect(draftInvoices.documents[0]!.clientId).toBe(b.id) expect(draftInvoices.documents[0]!.clientId).toBe(b.id)
}) })
it('clamps pageSize to 200 and floors page/pageSize at 1', () => { it('clamps pageSize to 200 and floors page/pageSize at 1', async () => {
const { db, mkClient, mkDraft } = world() const { db, mkClient, mkDraft } = await world()
const c = mkClient('Acme Bank') const c = await mkClient('Acme Bank')
for (let i = 0; i < 3; i++) mkDraft(c.id) for (let i = 0; i < 3; i++) await mkDraft(c.id)
expect(listDocuments(db, { pageSize: 9999 }).pageSize).toBe(200) expect((await listDocuments(db, { pageSize: 9999 })).pageSize).toBe(200)
expect(listDocuments(db, { pageSize: 0 }).pageSize).toBe(1) expect((await listDocuments(db, { pageSize: 0 })).pageSize).toBe(1)
expect(listDocuments(db, { page: 0 }).page).toBe(1) expect((await listDocuments(db, { page: 0 })).page).toBe(1)
}) })
it('clientLedger still surfaces EVERY document — the page cap never truncates the ledger', () => { it('clientLedger still surfaces EVERY document — the page cap never truncates the ledger', async () => {
const { db, mkClient, mkDraft } = world() const { db, mkClient, mkDraft } = await world()
const c = mkClient('Acme Bank') const c = await mkClient('Acme Bank')
for (let i = 0; i < 55; i++) mkDraft(c.id) for (let i = 0; i < 55; i++) await mkDraft(c.id)
const ledger = clientLedger(db, c.id) const ledger = await clientLedger(db, c.id)
expect(ledger.documents).toHaveLength(55) expect(ledger.documents).toHaveLength(55)
}) })
}) })
// ---------- GET /documents pass-through ---------- // ---------- GET /documents pass-through ----------
function appWith() { async function appWith() {
const { db, mkClient, mkDraft } = world() const { db, mkClient, mkDraft } = await world()
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db const app = express(); app.use(express.json()); app.locals['db'] = db
const nullFetch = (async () => new Response('{}', { status: 500 })) as typeof fetch const nullFetch = (async () => new Response('{}', { status: 500 })) as typeof fetch
app.use('/api', apiRouter(db, { f: nullFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf)) app.use('/api', apiRouter(db, { f: nullFetch, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
@ -119,9 +119,9 @@ describe('GET /documents — paginated route', () => {
afterAll(() => { for (const s of servers) s.close() }) afterAll(() => { for (const s of servers) s.close() })
it('passes page/pageSize through and reports the honest total + clientName rows', async () => { it('passes page/pageSize through and reports the honest total + clientName rows', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const c = ctx.mkClient('Acme Bank') const c = await ctx.mkClient('Acme Bank')
for (let i = 0; i < 5; i++) ctx.mkDraft(c.id) for (let i = 0; i < 5; i++) await ctx.mkDraft(c.id)
const token = await login(ctx.baseUrl) const token = await login(ctx.baseUrl)
const res = await fetch(`${ctx.baseUrl}/documents?page=2&pageSize=2`, { const res = await fetch(`${ctx.baseUrl}/documents?page=2&pageSize=2`, {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
@ -134,11 +134,11 @@ describe('GET /documents — paginated route', () => {
}) })
it('composes type + status filters with pagination', async () => { it('composes type + status filters with pagination', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const c = ctx.mkClient('Acme Bank') const c = await ctx.mkClient('Acme Bank')
ctx.mkDraft(c.id, 'QUOTATION') await ctx.mkDraft(c.id, 'QUOTATION')
ctx.mkDraft(c.id, 'INVOICE') await ctx.mkDraft(c.id, 'INVOICE')
ctx.mkDraft(c.id, 'INVOICE') await ctx.mkDraft(c.id, 'INVOICE')
const token = await login(ctx.baseUrl) const token = await login(ctx.baseUrl)
const res = await fetch(`${ctx.baseUrl}/documents?type=INVOICE&status=draft&page=1&pageSize=1`, { const res = await fetch(`${ctx.baseUrl}/documents?type=INVOICE&status=draft&page=1&pageSize=1`, {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },

@ -7,20 +7,20 @@ import {
createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument, getDocument, createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument, getDocument,
} from '../src/repos-documents' } from '../src/repos-documents'
function setup() { async function setup() {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { db, c, m } return { db, c, m }
} }
describe('documents', () => { describe('documents', () => {
it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', () => { it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.taxablePaise).toBe(10_000_00) expect(d.taxablePaise).toBe(10_000_00)
expect(d.cgstPaise).toBe(900_00) expect(d.cgstPaise).toBe(900_00)
@ -30,110 +30,111 @@ describe('documents', () => {
expect(d.status).toBe('draft') expect(d.status).toBe('draft')
expect(d.docNo).toBeNull() expect(d.docNo).toBeNull()
}) })
it('inter-state client gets IGST', () => { it('inter-state client gets IGST', async () => {
const { db, m } = setup() const { db, m } = await setup()
const db2 = db const db2 = db
const kar = createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' }) const kar = await createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' })
const d = createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id, const d = await createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.igstPaise).toBe(1_800_00) expect(d.igstPaise).toBe(1_800_00)
expect(d.cgstPaise).toBe(0) expect(d.cgstPaise).toBe(0)
}) })
it('issue assigns a series number; convert QT→INV carries lines and links back', () => { it('issue assigns a series number; convert QT→INV carries lines and links back', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const issued = issueDocument(db, 'u1', qt.id) const issued = await issueDocument(db, 'u1', qt.id)
expect(issued.docNo).toMatch(/^QT\/\d{2}-\d{2}-\d{4}$/) expect(issued.docNo).toMatch(/^QT\/\d{2}-\d{2}-\d{4}$/)
const inv = convertDocument(db, 'u1', qt.id, 'INVOICE') const inv = await convertDocument(db, 'u1', qt.id, 'INVOICE')
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', () => { it('line contents default to the module quote content, and can be overridden per line', async () => {
const { db, c } = setup() const { db, c } = await setup()
const sms = createModule(db, 'u1', { const sms = await createModule(db, 'u1', {
code: 'SMS', name: 'SMS Gateway', quoteContent: ['Bulk SMS gateway', 'DLT registration'], 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' }) await 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, const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly' }] })
expect(d.payload.lineContents).toEqual([['Bulk SMS gateway', 'DLT registration']]) expect(d.payload.lineContents).toEqual([['Bulk SMS gateway', 'DLT registration']])
const d2 = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const d2 = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly', contentLines: ['Custom scope only'] }] }) lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly', contentLines: ['Custom scope only'] }] })
expect(d2.payload.lineContents).toEqual([['Custom scope only']]) expect(d2.payload.lineContents).toEqual([['Custom scope only']])
}) })
it('a module without quote content contributes an empty content array', () => { it('a module without quote content contributes an empty content array', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.payload.lineContents).toEqual([[]]) expect(d.payload.lineContents).toEqual([[]])
}) })
it('a pack (non-standard edition) prices from that edition and prints the pack name on the line', () => { it('a pack (non-standard edition) prices from that edition and prints the pack name on the line', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
setPrice(db, 'u1', { moduleId: m.id, edition: 'SMS-50K', kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' }) await 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, const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly', edition: 'SMS-50K' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly', edition: 'SMS-50K' }] })
expect(d.taxablePaise).toBe(5_000_00) expect(d.taxablePaise).toBe(5_000_00)
expect(d.payload.lines[0]!.name).toContain('— SMS-50K') expect(d.payload.lines[0]!.name).toContain('— SMS-50K')
const std = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const std = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(std.payload.lines[0]!.name).not.toContain('—') expect(std.payload.lines[0]!.name).not.toContain('—')
}) })
it('convert is one-shot: a live forward child blocks re-convert; cancelling it re-opens (rule 4 / F4)', () => { it('convert is one-shot: a live forward child blocks re-convert; cancelling it re-opens (rule 4 / F4)', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const inv = convertDocument(db, 'u1', qt.id, 'INVOICE') const inv = await convertDocument(db, 'u1', qt.id, 'INVOICE')
// one sale, one invoice — a double-click cannot mint a second legal document // one sale, one invoice — a double-click cannot mint a second legal document
expect(() => convertDocument(db, 'u1', qt.id, 'INVOICE')).toThrow(/already converted/i) await expect(convertDocument(db, 'u1', qt.id, 'INVOICE')).rejects.toThrow(/already converted/i)
expect(() => convertDocument(db, 'u1', qt.id, 'PROFORMA')).toThrow(/already converted/i) await expect(convertDocument(db, 'u1', qt.id, 'PROFORMA')).rejects.toThrow(/already converted/i)
// a cancelled child is dead paper: the path re-opens // a cancelled child is dead paper: the path re-opens
issueDocument(db, 'u1', inv.id) await issueDocument(db, 'u1', inv.id)
cancelDocument(db, 'u1', inv.id) await cancelDocument(db, 'u1', inv.id)
const inv2 = convertDocument(db, 'u1', qt.id, 'INVOICE') const inv2 = await convertDocument(db, 'u1', qt.id, 'INVOICE')
expect(inv2.refDocId).toBe(qt.id) expect(inv2.refDocId).toBe(qt.id)
}) })
it('QT→PROFORMA moves the quotation out of the sent set (status → invoiced)', () => { it('QT→PROFORMA moves the quotation out of the sent set (status → invoiced)', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const pi = convertDocument(db, 'u1', qt.id, 'PROFORMA') const pi = await convertDocument(db, 'u1', qt.id, 'PROFORMA')
expect(pi.docType).toBe('PROFORMA') expect(pi.docType).toBe('PROFORMA')
expect(pi.status).toBe('draft') expect(pi.status).toBe('draft')
expect(getDocument(db, qt.id)!.status).toBe('invoiced') // never re-enters scan/pipeline 'sent' sets expect((await getDocument(db, qt.id))!.status).toBe('invoiced') // never re-enters scan/pipeline 'sent' sets
}) })
it('→INVOICE recomputes GST on the invoice date (rule 2 / F3); QT→PI copies verbatim', () => { it('→INVOICE recomputes GST on the invoice date (rule 2 / F3); QT→PI copies verbatim', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) // 18% → ₹11,800 lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) // 18% → ₹11,800
const pi = convertDocument(db, 'u1', qt.id, 'PROFORMA') const pi = await convertDocument(db, 'u1', qt.id, 'PROFORMA')
expect(pi.payablePaise).toBe(11_800_00) // copy-only: both are non-legal paper expect(pi.payablePaise).toBe(11_800_00) // copy-only: both are non-legal paper
// The rate changes (new dated tax_class row) before the invoice is drawn. // The rate changes (new dated tax_class row) before the invoice is drawn.
const today = new Date().toISOString().slice(0, 10) const today = new Date().toISOString().slice(0, 10)
db.prepare( await db.run(
`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 2800, ?)`, `INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 2800, ?)`,
).run(today) today,
const inv = convertDocument(db, 'u1', pi.id, 'INVOICE') )
const inv = await convertDocument(db, 'u1', pi.id, 'INVOICE')
expect(inv.taxablePaise).toBe(10_000_00) expect(inv.taxablePaise).toBe(10_000_00)
expect(inv.cgstPaise).toBe(1_400_00) expect(inv.cgstPaise).toBe(1_400_00)
expect(inv.sgstPaise).toBe(1_400_00) expect(inv.sgstPaise).toBe(1_400_00)
expect(inv.payablePaise).toBe(12_800_00) // the rate that is law on the invoice's own date expect(inv.payablePaise).toBe(12_800_00) // the rate that is law on the invoice's own date
expect(getDocument(db, pi.id)!.payablePaise).toBe(11_800_00) // source untouched expect((await getDocument(db, pi.id))!.payablePaise).toBe(11_800_00) // source untouched
}) })
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', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
const cn = createCreditNote(db, 'u1', inv.id) const cn = await createCreditNote(db, 'u1', inv.id)
expect(cn.docType).toBe('CREDIT_NOTE') expect(cn.docType).toBe('CREDIT_NOTE')
expect(cn.payablePaise).toBe(inv.payablePaise) expect(cn.payablePaise).toBe(inv.payablePaise)
const cancelled = cancelDocument(db, 'u1', inv.id) const cancelled = await cancelDocument(db, 'u1', inv.id)
expect(cancelled.status).toBe('cancelled') expect(cancelled.status).toBe('cancelled')
expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused expect(cancelled.docNo).toBe(inv.docNo) // number consumed, never reused
}) })

@ -23,11 +23,11 @@ function addDaysIso(iso: string, days: number): string {
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
} }
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) // seeds billing.payment_terms_days=15 const db = openDb(':memory:'); await seedIfEmpty(db) // seeds billing.payment_terms_days=15
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const invoiceDraft = () => createDraft(db, 'u1', { const invoiceDraft = () => createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}) })
@ -35,105 +35,105 @@ function world() {
} }
describe('due dates (D18)', () => { describe('due dates (D18)', () => {
it('issue stamps doc_date + terms on an invoice without one (manual issue path)', () => { it('issue stamps doc_date + terms on an invoice without one (manual issue path)', async () => {
const { db, invoiceDraft } = world() const { db, invoiceDraft } = await world()
const draft = invoiceDraft() const draft = await invoiceDraft()
expect(draft.dueDate).toBeNull() expect(draft.dueDate).toBeNull()
db.prepare(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`).run(draft.id) await db.run(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`, draft.id)
const inv = issueDocument(db, 'u1', draft.id) const inv = await issueDocument(db, 'u1', draft.id)
expect(inv.dueDate).toBe('2026-07-16') // 2026-07-01 + 15 expect(inv.dueDate).toBe('2026-07-16') // 2026-07-01 + 15
}) })
it('a caller-provided due date survives issue untouched', () => { it('a caller-provided due date survives issue untouched', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const draft = createDraft(db, 'u1', { const draft = await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, dueDate: '2026-09-30', docType: 'INVOICE', clientId: c.id, dueDate: '2026-09-30',
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}) })
expect(draft.dueDate).toBe('2026-09-30') expect(draft.dueDate).toBe('2026-09-30')
expect(issueDocument(db, 'u1', draft.id).dueDate).toBe('2026-09-30') expect((await issueDocument(db, 'u1', draft.id)).dueDate).toBe('2026-09-30')
}) })
it('the terms setting is honoured when changed (dated behavior lives in the stamp)', () => { it('the terms setting is honoured when changed (dated behavior lives in the stamp)', async () => {
const { db, invoiceDraft } = world() const { db, invoiceDraft } = await world()
setSetting(db, 'u1', 'billing.payment_terms_days', '30') await setSetting(db, 'u1', 'billing.payment_terms_days', '30')
const draft = invoiceDraft() const draft = await invoiceDraft()
db.prepare(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`).run(draft.id) await db.run(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`, draft.id)
expect(issueDocument(db, 'u1', draft.id).dueDate).toBe('2026-07-31') expect((await issueDocument(db, 'u1', draft.id)).dueDate).toBe('2026-07-31')
}) })
it('quotes and proformas never get a due date — at draft or at issue', () => { it('quotes and proformas never get a due date — at draft or at issue', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
expect(() => createDraft(db, 'u1', { await expect(createDraft(db, 'u1', {
docType: 'QUOTATION', clientId: c.id, dueDate: '2026-08-01', docType: 'QUOTATION', clientId: c.id, dueDate: '2026-08-01',
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).toThrow(/INVOICE/) })).rejects.toThrow(/INVOICE/)
const pi = createDraft(db, 'u1', { const pi = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}) })
expect(issueDocument(db, 'u1', pi.id).dueDate).toBeNull() expect((await issueDocument(db, 'u1', pi.id)).dueDate).toBeNull()
}) })
it('proforma → invoice conversion gets stamped at issue (convert-and-send path)', () => { it('proforma → invoice conversion gets stamped at issue (convert-and-send path)', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', { const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id) })).id)
const invDraft = convertDocument(db, 'u1', pi.id, 'INVOICE') const invDraft = await convertDocument(db, 'u1', pi.id, 'INVOICE')
const inv = issueDocument(db, 'u1', invDraft.id) const inv = await issueDocument(db, 'u1', invDraft.id)
expect(inv.dueDate).not.toBeNull() // stamped from terms on the invoice's own doc_date expect(inv.dueDate).not.toBeNull() // stamped from terms on the invoice's own doc_date
}) })
it('recurring generation stamps the due date on the invoice it issues (third issue path)', async () => { it('recurring generation stamps the due date on the invoice it issues (third issue path)', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-17', policy: 'manual' }) await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-17', policy: 'manual' })
await runDailyScan(db, deps, '2026-07-17') await runDailyScan(db, deps, '2026-07-17')
const [inv] = listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents const [inv] = (await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents
expect(inv).toBeDefined() expect(inv).toBeDefined()
expect(inv!.dueDate).toBe(addDaysIso(inv!.docDate, 15)) // doc_date + seeded 15-day terms expect(inv!.dueDate).toBe(addDaysIso(inv!.docDate, 15)) // doc_date + seeded 15-day terms
}) })
it('bad dueDate format is rejected at draft', () => { it('bad dueDate format is rejected at draft', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
expect(() => createDraft(db, 'u1', { await expect(createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, dueDate: '17-07-2026', docType: 'INVOICE', clientId: c.id, dueDate: '17-07-2026',
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).toThrow(/YYYY-MM-DD/) })).rejects.toThrow(/YYYY-MM-DD/)
}) })
it('overdue scan anchors on the due date: not yet due ⇒ silent, past due ⇒ milestone from due_date', async () => { it('overdue scan anchors on the due date: not yet due ⇒ silent, past due ⇒ milestone from due_date', async () => {
const { db, invoiceDraft } = world() const { db, invoiceDraft } = await world()
// Old issue date but due date in the future → NOT overdue. // Old issue date but due date in the future → NOT overdue.
const a = issueDocument(db, 'u1', invoiceDraft().id) const a = await issueDocument(db, 'u1', (await invoiceDraft()).id)
db.prepare(`UPDATE document SET doc_date='2026-05-01', due_date='2026-08-01' WHERE id=?`).run(a.id) await db.run(`UPDATE document SET doc_date='2026-05-01', due_date='2026-08-01' WHERE id=?`, a.id)
// Due 16 days ago → d15 milestone (7/15/30 ladder), counted from due_date not doc_date. // Due 16 days ago → d15 milestone (7/15/30 ladder), counted from due_date not doc_date.
const b = issueDocument(db, 'u1', invoiceDraft().id) const b = await issueDocument(db, 'u1', (await invoiceDraft()).id)
db.prepare(`UPDATE document SET doc_date='2026-05-01', due_date='2026-07-01' WHERE id=?`).run(b.id) await db.run(`UPDATE document SET doc_date='2026-05-01', due_date='2026-07-01' WHERE id=?`, b.id)
await runDailyScan(db, deps, '2026-07-17') await runDailyScan(db, deps, '2026-07-17')
const rows = listReminders(db, {}).filter((r) => r.ruleKind === 'invoice_overdue') const rows = (await listReminders(db, {})).filter((r) => r.ruleKind === 'invoice_overdue')
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ subjectId: b.id, duePeriod: 'd15' }) expect(rows[0]).toMatchObject({ subjectId: b.id, duePeriod: 'd15' })
}) })
it('the reminder email says "was due on X and is now N day(s) overdue"', () => { it('the reminder email says "was due on X and is now N day(s) overdue"', async () => {
const { db, c, invoiceDraft } = world() const { db, c, invoiceDraft } = await world()
const inv = issueDocument(db, 'u1', invoiceDraft().id) const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
db.prepare(`UPDATE document SET due_date='2026-07-02' WHERE id=?`).run(inv.id) await db.run(`UPDATE document SET due_date='2026-07-02' WHERE id=?`, inv.id)
const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' })
const { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-17') const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
expect(ctx.dueDate).toBe('2026-07-02') expect(ctx.dueDate).toBe('2026-07-02')
expect(ctx.daysOverdue).toBe(15) expect(ctx.daysOverdue).toBe(15)
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/was due on 2026-07-02 and is now 15 day\(s\) overdue/) expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/was due on 2026-07-02 and is now 15 day\(s\) overdue/)
}) })
it('legacy invoices without a due date keep the past-due-since-issue wording and doc_date anchor', () => { it('legacy invoices without a due date keep the past-due-since-issue wording and doc_date anchor', async () => {
const { db, c, invoiceDraft } = world() const { db, c, invoiceDraft } = await world()
const inv = issueDocument(db, 'u1', invoiceDraft().id) const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
db.prepare(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`).run(inv.id) await db.run(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`, inv.id)
const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd7', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd7', clientId: c.id, docId: inv.id, now: '2026-07-17T00:00:00Z' })
const { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-17') const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
expect(ctx.dueDate).toBeUndefined() expect(ctx.dueDate).toBeUndefined()
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/is outstanding and is now 10 day\(s\) past due/) expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/is outstanding and is now 10 day\(s\) past due/)
}) })

@ -1,5 +1,5 @@
// apps/hq/test/e2e.test.ts // apps/hq/test/e2e.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -7,11 +7,15 @@ import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
const db = openDb(':memory:') const db = openDb(':memory:')
seedIfEmpty(db)
createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) let server: ReturnType<typeof app.listen>
const base = `http://localhost:${(server.address() as { port: number }).port}/api` let base = ''
beforeAll(async () => {
await seedIfEmpty(db)
await createStaff(db, { email: 'e2e@test.in', displayName: 'E2E', role: 'owner', password: 'e2e-password' })
server = app.listen(0)
base = `http://localhost:${(server.address() as { port: number }).port}/api`
})
let token = '' let token = ''
const call = async (method: string, path: string, body?: unknown) => { const call = async (method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, const res = await fetch(base + path, { method,

@ -1,16 +1,16 @@
// apps/hq/test/employees-routes.test.ts — Phase 2: /employees API surface. // apps/hq/test/employees-routes.test.ts — Phase 2: /employees API surface.
// Mutations are owner-only; GET is any signed-in user (name/owner picker). // Mutations are owner-only; GET is any signed-in user (name/owner picker).
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createStaff, login } from '../src/auth' import { createStaff, login } from '../src/auth'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staffer', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Staffer', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -18,7 +18,8 @@ function appWith() {
} }
describe('/employees routes', () => { describe('/employees routes', () => {
const ctx = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close()) afterAll(() => ctx.server.close())
const tokenOf = async (email: string, password: string) => const tokenOf = async (email: string, password: string) =>
@ -81,8 +82,8 @@ describe('/employees routes', () => {
const pw = await call(token, 'POST', `/employees/${id}/password`, { password: 'brand-new-secret' }) const pw = await call(token, 'POST', `/employees/${id}/password`, { password: 'brand-new-secret' })
expect(pw.status).toBe(200) expect(pw.status).toBe(200)
expect(login(ctx.db, 'new@test.in', 'password-9')).toBeNull() expect(await login(ctx.db, 'new@test.in', 'password-9')).toBeNull()
expect(login(ctx.db, 'new@test.in', 'brand-new-secret')).not.toBeNull() expect(await login(ctx.db, 'new@test.in', 'brand-new-secret')).not.toBeNull()
}) })
it('non-owner gets 403 on every mutation route', async () => { it('non-owner gets 403 on every mutation route', async () => {
@ -141,10 +142,11 @@ describe('/employees routes', () => {
email: 'audited@test.in', displayName: 'Audited', role: 'staff', password: 'password-9', email: 'audited@test.in', displayName: 'Audited', role: 'staff', password: 'password-9',
}) })
const id = created.json.employee.id as string const id = created.json.employee.id as string
const ownerId = (ctx.db.prepare(`SELECT id FROM staff_user WHERE email='owner@test.in'`).get() as { id: string }).id const ownerId = (await ctx.db.get<{ id: string }>(`SELECT id FROM staff_user WHERE email='owner@test.in'`))!.id
const row = ctx.db.prepare( const row = (await ctx.db.get<{ user_id: string }>(
`SELECT user_id FROM audit_log WHERE entity='staff_user' AND entity_id=? AND action='create'`, `SELECT user_id FROM audit_log WHERE entity='staff_user' AND entity_id=? AND action='create'`,
).get(id) as { user_id: string } id,
))!
expect(row.user_id).toBe(ownerId) expect(row.user_id).toBe(ownerId)
}) })
}) })

@ -9,20 +9,20 @@ import {
} from '../src/repos-employees' } from '../src/repos-employees'
import { listAudit } from '../src/audit' import { listAudit } from '../src/audit'
function withOwner(): { db: DB; ownerId: string } { async function withOwner(): Promise<{ db: DB; ownerId: string }> {
const db = openDb(':memory:') const db = openDb(':memory:')
const { id } = createStaff(db, { const { id } = await createStaff(db, {
email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9', email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9',
}) })
return { db, ownerId: id } return { db, ownerId: id }
} }
describe('employee foundation', () => { describe('employee foundation', () => {
it('creates manager and staff; list never exposes password columns', () => { it('creates manager and staff; list never exposes password columns', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' }) await createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' })
createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const all = listEmployees(db) const all = await listEmployees(db)
expect(all).toHaveLength(3) expect(all).toHaveLength(3)
expect(all.map((e) => e.role).sort()).toEqual(['manager', 'owner', 'staff']) expect(all.map((e) => e.role).sort()).toEqual(['manager', 'owner', 'staff'])
expect(all.find((e) => e.displayName === 'Mgr')!.email).toBe('m@x.co') // lowercased expect(all.find((e) => e.displayName === 'Mgr')!.email).toBe('m@x.co') // lowercased
@ -32,105 +32,105 @@ describe('employee foundation', () => {
} }
}) })
it('rejects a bad role and a short password in the repo', () => { it('rejects a bad role and a short password in the repo', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
expect(() => await expect(
createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }), createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }),
).toThrow(/role/i) ).rejects.toThrow(/role/i)
expect(() => await expect(
createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'short' }), createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'staff', password: 'short' }),
).toThrow(/8/) ).rejects.toThrow(/8/)
}) })
it('deactivation kills the live session immediately and purges its rows', () => { it('deactivation kills the live session immediately and purges its rows', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const session = login(db, 's@x.co', 'password2')! const session = (await login(db, 's@x.co', 'password2'))!
expect(verifySession(db, session.token)).toMatchObject({ id: emp.id }) expect(await verifySession(db, session.token)).toMatchObject({ id: emp.id })
deactivateEmployee(db, ownerId, emp.id) await deactivateEmployee(db, ownerId, emp.id)
expect(verifySession(db, session.token)).toBeNull() expect(await verifySession(db, session.token)).toBeNull()
const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number } const rows = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`, emp.id))!
expect(rows.n).toBe(0) expect(rows.n).toBe(0)
}) })
it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', () => { it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
expect(() => updateEmployee(db, ownerId, ownerId, { role: 'staff' })).toThrow(/last active owner/) await expect(updateEmployee(db, ownerId, ownerId, { role: 'staff' })).rejects.toThrow(/last active owner/)
expect(() => deactivateEmployee(db, ownerId, ownerId)).toThrow(/yourself/) await expect(deactivateEmployee(db, ownerId, ownerId)).rejects.toThrow(/yourself/)
// second owner unlocks the demotion of the first // second owner unlocks the demotion of the first
const second = createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' }) const second = await createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' })
expect(updateEmployee(db, second.id, ownerId, { role: 'manager' }).role).toBe('manager') expect((await updateEmployee(db, second.id, ownerId, { role: 'manager' })).role).toBe('manager')
}) })
it('deactivate/reactivate round-trips and login respects active', () => { it('deactivate/reactivate round-trips and login respects active', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
deactivateEmployee(db, ownerId, emp.id) await deactivateEmployee(db, ownerId, emp.id)
expect(login(db, 's@x.co', 'password2')).toBeNull() expect(await login(db, 's@x.co', 'password2')).toBeNull()
expect(getEmployee(db, emp.id)!.active).toBe(false) expect((await getEmployee(db, emp.id))!.active).toBe(false)
reactivateEmployee(db, ownerId, emp.id) await reactivateEmployee(db, ownerId, emp.id)
expect(login(db, 's@x.co', 'password2')).not.toBeNull() expect(await login(db, 's@x.co', 'password2')).not.toBeNull()
}) })
it('password reset invalidates existing sessions immediately (compromised-credential lockout)', () => { it('password reset invalidates existing sessions immediately (compromised-credential lockout)', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const session = login(db, 's@x.co', 'password2')! const session = (await login(db, 's@x.co', 'password2'))!
expect(verifySession(db, session.token)).toMatchObject({ id: emp.id }) expect(await verifySession(db, session.token)).toMatchObject({ id: emp.id })
setEmployeePassword(db, ownerId, emp.id, 'new-secret-9') await setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
expect(verifySession(db, session.token)).toBeNull() expect(await verifySession(db, session.token)).toBeNull()
const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number } const rows = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`, emp.id))!
expect(rows.n).toBe(0) expect(rows.n).toBe(0)
}) })
it('rejects a duplicate email with a friendly, engine-neutral message', () => { it('rejects a duplicate email with a friendly, engine-neutral message', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
createEmployee(db, ownerId, { email: 'dup@x.co', displayName: 'A', role: 'staff', password: 'password1' }) await createEmployee(db, ownerId, { email: 'dup@x.co', displayName: 'A', role: 'staff', password: 'password1' })
expect(() => await expect(
createEmployee(db, ownerId, { email: 'Dup@X.co', displayName: 'B', role: 'staff', password: 'password2' }), createEmployee(db, ownerId, { email: 'Dup@X.co', displayName: 'B', role: 'staff', password: 'password2' }),
).toThrow(/already in use/i) // not the raw 'UNIQUE constraint failed: …' SQLite text ).rejects.toThrow(/already in use/i) // not the raw 'UNIQUE constraint failed: …' SQLite text
}) })
it('rejects an empty patch instead of writing a no-op audit row', () => { it('rejects an empty patch instead of writing a no-op audit row', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const auditBefore = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length const auditBefore = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length
expect(() => updateEmployee(db, ownerId, emp.id, {})).toThrow(/nothing to update/i) await expect(updateEmployee(db, ownerId, emp.id, {})).rejects.toThrow(/nothing to update/i)
const auditAfter = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length const auditAfter = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length
expect(auditAfter).toBe(auditBefore) expect(auditAfter).toBe(auditBefore)
}) })
it('password reset works and is audited without the hash', () => { it('password reset works and is audited without the hash', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
setEmployeePassword(db, ownerId, emp.id, 'new-secret-9') await setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
expect(login(db, 's@x.co', 'password2')).toBeNull() expect(await login(db, 's@x.co', 'password2')).toBeNull()
expect(login(db, 's@x.co', 'new-secret-9')).not.toBeNull() expect(await login(db, 's@x.co', 'new-secret-9')).not.toBeNull()
const audit = listAudit(db).find((a) => a.action === 'reset_password' && a.entity_id === emp.id) const audit = (await listAudit(db)).find((a) => a.action === 'reset_password' && a.entity_id === emp.id)
expect(audit).toBeDefined() expect(audit).toBeDefined()
expect(audit!.before_json ?? '').not.toMatch(/hash/) expect(audit!.before_json ?? '').not.toMatch(/hash/)
expect(audit!.after_json ?? '').not.toMatch(/hash/) expect(audit!.after_json ?? '').not.toMatch(/hash/)
}) })
it('every employee mutation writes an audit row', () => { it('every employee mutation writes an audit row', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' }) const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' }) await updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' })
deactivateEmployee(db, ownerId, emp.id) await deactivateEmployee(db, ownerId, emp.id)
reactivateEmployee(db, ownerId, emp.id) await reactivateEmployee(db, ownerId, emp.id)
const actions = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).map((a) => a.action) const actions = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).map((a) => a.action)
expect(actions).toEqual(expect.arrayContaining(['create', 'update', 'deactivate', 'reactivate'])) expect(actions).toEqual(expect.arrayContaining(['create', 'update', 'deactivate', 'reactivate']))
}) })
it('fresh DBs accept manager directly (SCHEMA born correct)', () => { it('fresh DBs accept manager directly (SCHEMA born correct)', async () => {
const { db, ownerId } = withOwner() const { db, ownerId } = await withOwner()
const m = createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' }) const m = await createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' })
expect(getEmployee(db, m.id)!.role).toBe('manager') expect((await getEmployee(db, m.id))!.role).toBe('manager')
}) })
it('rebuild migrates an old-CHECK DB preserving rows, and is idempotent', () => { it('rebuild migrates an old-CHECK DB preserving rows, and is idempotent', () => {
// Simulate a DB created before the employee slice: old two-role CHECK + one row. // Simulate a DB created before the employee slice: old two-role CHECK + one row.
const raw = new Database(':memory:') as DB const raw = new Database(':memory:')
raw.exec(`CREATE TABLE staff_user ( raw.exec(`CREATE TABLE staff_user (
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner','staff')), role TEXT NOT NULL CHECK (role IN ('owner','staff')),

@ -1,10 +1,11 @@
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { startServer } from '../src/server' import { startServer } from '../src/server'
const server = startServer(0) let server: Awaited<ReturnType<typeof startServer>>
const base = () => `http://localhost:${(server.address() as { port: number }).port}` const base = () => `http://localhost:${(server.address() as { port: number }).port}`
describe('hq health', () => { describe('hq health', () => {
beforeAll(async () => { server = await startServer(0) })
afterAll(() => server.close()) afterAll(() => server.close())
it('answers /api/health', async () => { it('answers /api/health', async () => {
const res = await fetch(`${base()}/api/health`) const res = await fetch(`${base()}/api/health`)

@ -3,32 +3,30 @@ import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
describe('hq2 schema', () => { describe('hq2 schema', () => {
it('creates every HQ-2 table', () => { it('creates every HQ-2 table', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name) const names = (await db.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE type='table'`)).map((r) => r.name)
for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder']) for (const t of ['recurring_plan', 'amc_contract', 'interaction', 'interaction_type', 'reminder'])
expect(names, `missing table ${t}`).toContain(t) expect(names, `missing table ${t}`).toContain(t)
}) })
it('adds the bounced column to email_log', () => { it('adds the bounced column to email_log', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const cols = (db.prepare(`PRAGMA table_info(email_log)`).all() as { name: string }[]).map((c) => c.name) const cols = (await db.all<{ name: string }>(`PRAGMA table_info(email_log)`)).map((c) => c.name)
expect(cols).toContain('bounced') expect(cols).toContain('bounced')
}) })
it('enforces the reminder idempotency key', () => { it('enforces the reminder idempotency key', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const ins = db.prepare( const ins = `INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
`INSERT OR IGNORE INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`
VALUES (?, 'invoice_overdue', 's1', '2026-07', 'c1', 'queued', 'manual', '2026-07-10T00:00:00Z')`, expect((await db.run(ins, 'r1')).changes).toBe(1)
) expect((await db.run(ins, 'r2')).changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored
expect(ins.run('r1').changes).toBe(1)
expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored
}) })
it('seeds interaction types, the AMC module and reminder settings', () => { it('seeds interaction types, the AMC module and reminder settings', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code) const types = (await db.all<{ code: string }>(`SELECT code FROM interaction_type`)).map((r) => r.code)
expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint'])) expect(types).toEqual(expect.arrayContaining(['call', 'site_visit', 'training', 'complaint']))
expect(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 }) expect(await db.get(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`)).toMatchObject({ n: 1 })
const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined) const s = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='reminders.overdue_days'`)
expect(s?.value).toBe('7') expect(s?.value).toBe('7')
}) })
}) })

@ -2,24 +2,22 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db' import { openDb } from '../src/db'
describe('hq3 schema', () => { describe('hq3 schema', () => {
it('creates the aws_usage table', () => { it('creates the aws_usage table', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const names = (db.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all() as { name: string }[]).map((r) => r.name) const names = (await db.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE type='table'`)).map((r) => r.name)
expect(names).toContain('aws_usage') expect(names).toContain('aws_usage')
}) })
it('has the expected columns', () => { it('has the expected columns', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const cols = (db.prepare(`PRAGMA table_info(aws_usage)`).all() as { name: string }[]).map((c) => c.name) const cols = (await db.all<{ name: string }>(`PRAGMA table_info(aws_usage)`)).map((c) => c.name)
for (const c of ['id', 'client_id', 'month', 'storage_gb', 'transfer_gb', 'cost_paise', 'source', 'updated_at']) for (const c of ['id', 'client_id', 'month', 'storage_gb', 'transfer_gb', 'cost_paise', 'source', 'updated_at'])
expect(cols, `missing column ${c}`).toContain(c) expect(cols, `missing column ${c}`).toContain(c)
}) })
it('enforces one row per (client, month)', () => { it('enforces one row per (client, month)', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const ins = db.prepare( const ins = `INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at)
`INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at) VALUES (?, 'c1', '2026-06', 0, 0, 0, 'auto', '2026-07-01T00:00:00Z')`
VALUES (?, 'c1', '2026-06', 0, 0, 0, 'auto', '2026-07-01T00:00:00Z')`, expect((await db.run(ins, 'a1')).changes).toBe(1)
) await expect(db.run(ins, 'a2')).rejects.toThrow() // UNIQUE (client_id, month)
expect(ins.run('a1').changes).toBe(1)
expect(() => ins.run('a2')).toThrow() // UNIQUE (client_id, month)
}) })
}) })

@ -14,10 +14,10 @@ const BAD_CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status\n
// Current-FY doc date so the INVOICE series seeds past the legacy number. // Current-FY doc date so the INVOICE series seeds past the legacy number.
const INVOICES_CSV = 'client_code,doc_no,doc_date,taxable,tax,total,paid\nAPX001,INV/26-27-0042,2026-07-01,1000,180,1180,1\n' const INVOICES_CSV = 'client_code,doc_no,doc_date,taxable,tax,total,paid\nAPX001,INV/26-27-0042,2026-07-01,1000,180,1180,1\n'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json({ limit: '5mb' })); app.locals['db'] = db const app = express(); app.use(express.json({ limit: '5mb' })); app.locals['db'] = db
app.use('/api', apiRouter(db)) app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
@ -36,7 +36,7 @@ describe('APEX import routes (owner-only)', () => {
afterAll(() => { for (const s of servers) s.close() }) afterAll(() => { for (const s of servers) s.close() })
it('staff get 403 on every import route', async () => { it('staff get 403 on every import route', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const t = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const t = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
for (const [method, path] of [['POST', '/import/stage'], ['GET', '/import/status'], ['POST', '/import/commit']] as const) { for (const [method, path] of [['POST', '/import/stage'], ['GET', '/import/status'], ['POST', '/import/commit']] as const) {
const res = await fetch(`${ctx.baseUrl}${path}`, { method, headers: H(t), ...(method === 'POST' ? { body: '{}' } : {}) }) const res = await fetch(`${ctx.baseUrl}${path}`, { method, headers: H(t), ...(method === 'POST' ? { body: '{}' } : {}) })
@ -45,7 +45,7 @@ describe('APEX import routes (owner-only)', () => {
}) })
it('stage → status → commit round-trip; problems lock the commit until re-staged clean', async () => { it('stage → status → commit round-trip; problems lock the commit until re-staged clean', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const t = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const t = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
// Stage a BAD clients file → problems reported, commit refused. // Stage a BAD clients file → problems reported, commit refused.
const bad = await fetch(`${ctx.baseUrl}/import/stage`, { const bad = await fetch(`${ctx.baseUrl}/import/stage`, {
@ -74,9 +74,9 @@ describe('APEX import routes (owner-only)', () => {
expect(cj.result.invoices).toBe(1) expect(cj.result.invoices).toBe(1)
expect(cj.result.seeded).not.toBeNull() // INVOICE series seeded past the legacy number expect(cj.result.seeded).not.toBeNull() // INVOICE series seeded past the legacy number
// The imported rows are real now — including the WS-F support-access fields. // The imported rows are real now — including the WS-F support-access fields.
const imported = ctx.db.prepare(`SELECT source, status FROM document WHERE doc_no='INV/26-27-0042'`).get() const imported = await ctx.db.get(`SELECT source, status FROM document WHERE doc_no='INV/26-27-0042'`)
expect(imported).toMatchObject({ source: 'apex' }) expect(imported).toMatchObject({ source: 'apex' })
const client = ctx.db.prepare(`SELECT anydesk, os, district, sector FROM client WHERE code='APX001'`).get() const client = await ctx.db.get(`SELECT anydesk, os, district, sector FROM client WHERE code='APX001'`)
expect(client).toEqual({ anydesk: '123456789', os: 'Windows 10', district: 'Ernakulam', sector: 'Co-op' }) expect(client).toEqual({ anydesk: '123456789', os: 'Windows 10', district: 'Ernakulam', sector: 'Co-op' })
}) })
}) })

@ -12,22 +12,22 @@ AC001,TS/26-27/0411,2026-05-02,10000.00,1800.00,11800.00,1
AC001,TS/26-27/0412,2026-06-15,5000.00,900.00,5900.00,0` AC001,TS/26-27/0412,2026-06-15,5000.00,900.00,5900.00,0`
describe('apex import', () => { describe('apex import', () => {
it('stages, reports problems, refuses commit until clean', () => { it('stages, reports problems, refuses commit until clean', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
stageCsv(db, 'clients', CLIENTS) await stageCsv(db, 'clients', CLIENTS)
stageCsv(db, 'invoices', INVOICES) await stageCsv(db, 'invoices', INVOICES)
const rep = verificationReport(db) const rep = await verificationReport(db)
expect(rep.clients.staged).toBe(2) expect(rep.clients.staged).toBe(2)
expect(rep.clients.problems).toBe(1) // missing code expect(rep.clients.problems).toBe(1) // missing code
expect(() => commitImport(db, 'u1')).toThrow(/problem/) await expect(commitImport(db, 'u1')).rejects.toThrow(/problem/)
}) })
it('commits clean data and seeds the invoice series from the last APEX number', () => { it('commits clean data and seeds the invoice series from the last APEX number', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
stageCsv(db, 'clients', CLIENTS.split('\n').slice(0, 2).join('\n')) await stageCsv(db, 'clients', CLIENTS.split('\n').slice(0, 2).join('\n'))
stageCsv(db, 'invoices', INVOICES) await stageCsv(db, 'invoices', INVOICES)
const out = commitImport(db, 'u1') const out = await commitImport(db, 'u1')
expect(out).toMatchObject({ clients: 1, invoices: 2, seeded: { fy: '2026-27', lastSeq: 412 } }) expect(out).toMatchObject({ clients: 1, invoices: 2, seeded: { fy: '2026-27', lastSeq: 412 } })
const paid = db.prepare(`SELECT status FROM document WHERE doc_no='TS/26-27/0411'`).get() as { status: string } const paid = await db.get(`SELECT status FROM document WHERE doc_no='TS/26-27/0411'`) as { status: string }
expect(paid.status).toBe('paid') expect(paid.status).toBe('paid')
}) })
}) })

@ -7,24 +7,24 @@ import {
} from '../src/repos-interactions' } from '../src/repos-interactions'
describe('interactions', () => { describe('interactions', () => {
it('logs a typed interaction, updates outcome, and surfaces due follow-ups', () => { it('logs a typed interaction, updates outcome, and surfaces due follow-ups', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
expect(listInteractionTypes(db).length).toBeGreaterThanOrEqual(7) expect((await listInteractionTypes(db)).length).toBeGreaterThanOrEqual(7)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const i = createInteraction(db, 'u1', { const i = await createInteraction(db, 'u1', {
clientId: c.id, typeCode: 'site_visit', onDate: '2026-07-01', clientId: c.id, typeCode: 'site_visit', onDate: '2026-07-01',
notes: 'Installed POS; owner wants training', followUpOn: '2026-07-08', notes: 'Installed POS; owner wants training', followUpOn: '2026-07-08',
}) })
expect(listInteractions(db, c.id)).toHaveLength(1) expect(await listInteractions(db, c.id)).toHaveLength(1)
const up = updateInteraction(db, 'u1', i.id, { outcome: 'positive' }) const up = await updateInteraction(db, 'u1', i.id, { outcome: 'positive' })
expect(up.outcome).toBe('positive') expect(up.outcome).toBe('positive')
expect(listOpenFollowUps(db, '2026-07-10').map((f) => f.id)).toContain(i.id) expect((await listOpenFollowUps(db, '2026-07-10')).map((f) => f.id)).toContain(i.id)
expect(listOpenFollowUps(db, '2026-07-05')).toHaveLength(0) // not yet due expect(await listOpenFollowUps(db, '2026-07-05')).toHaveLength(0) // not yet due
}) })
it('rejects an unknown interaction type', () => { it('rejects an unknown interaction type', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'X', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'X', stateCode: '32' })
expect(() => createInteraction(db, 'u1', { clientId: c.id, typeCode: 'telepathy', onDate: '2026-07-01' })) await expect(createInteraction(db, 'u1', { clientId: c.id, typeCode: 'telepathy', onDate: '2026-07-01' }))
.toThrow(/type/i) .rejects.toThrow(/type/i)
}) })
}) })

@ -16,10 +16,10 @@ import { apiRouter } from '../src/api'
const KEY = '11'.repeat(32) const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake') const fakePdf = async () => Buffer.from('%PDF-fake')
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' }) const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' })
const mkClient = (name: string, email?: string) => createClient(db, 'u1', { const mkClient = (name: string, email?: string) => createClient(db, 'u1', {
name, stateCode: '32', name, stateCode: '32',
contacts: email !== undefined ? [{ name: 'C', email }] : [], contacts: email !== undefined ? [{ name: 'C', email }] : [],
@ -28,77 +28,77 @@ function world() {
} }
describe('listClientsByModule', () => { describe('listClientsByModule', () => {
it('lists active links with price + renewal, ordered by client name, with a true total', () => { it('lists active links with price + renewal, ordered by client name, with a true total', async () => {
const { db, m, mkClient } = world() const { db, m, mkClient } = await world()
const a = mkClient('Acme Bank'); const z = mkClient('Zeta CCS'); const b = mkClient('Beta Coop') const a = await mkClient('Acme Bank'); const z = await mkClient('Zeta CCS'); const b = await mkClient('Beta Coop')
const cmA = assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' }) const cmA = await assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' }) await updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' })
assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' })
const page = listClientsByModule(db, m.id, { onDate: '2026-07-17' }) const page = await listClientsByModule(db, m.id, { onDate: '2026-07-17' })
expect(page.total).toBe(3) expect(page.total).toBe(3)
expect(page.clients.map((c) => c.clientName)).toEqual(['Acme Bank', 'Beta Coop', 'Zeta CCS']) expect(page.clients.map((c) => c.clientName)).toEqual(['Acme Bank', 'Beta Coop', 'Zeta CCS'])
expect(page.clients[0]).toMatchObject({ pricePaise: 9_000_00, nextRenewal: '2026-09-01' }) expect(page.clients[0]).toMatchObject({ pricePaise: 9_000_00, nextRenewal: '2026-09-01' })
expect(page.totalPricePaise).toBe(27_000_00) // spans all links expect(page.totalPricePaise).toBe(27_000_00) // spans all links
}) })
it('excludes inactive links (historical assignments) and paginates with a stable total', () => { it('excludes inactive links (historical assignments) and paginates with a stable total', async () => {
const { db, m, mkClient } = world() const { db, m, mkClient } = await world()
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
assignModule(db, 'u1', { clientId: mkClient(`Bank ${i}`).id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: (await mkClient(`Bank ${i}`)).id, moduleId: m.id, kind: 'yearly' })
} }
const dead = assignModule(db, 'u1', { clientId: mkClient('Gone Bank').id, moduleId: m.id, kind: 'yearly' }) const dead = await assignModule(db, 'u1', { clientId: (await mkClient('Gone Bank')).id, moduleId: m.id, kind: 'yearly' })
db.prepare(`UPDATE client_module SET active=0 WHERE id=?`).run(dead.id) await db.run(`UPDATE client_module SET active=0 WHERE id=?`, dead.id)
const p1 = listClientsByModule(db, m.id, { page: 1, pageSize: 2 }) const p1 = await listClientsByModule(db, m.id, { page: 1, pageSize: 2 })
const p3 = listClientsByModule(db, m.id, { page: 3, pageSize: 2 }) const p3 = await listClientsByModule(db, m.id, { page: 3, pageSize: 2 })
expect(p1.total).toBe(5) // Gone Bank excluded expect(p1.total).toBe(5) // Gone Bank excluded
expect(p1.clients).toHaveLength(2) expect(p1.clients).toHaveLength(2)
expect(p3.clients).toHaveLength(1) expect(p3.clients).toHaveLength(1)
expect([...p1.clients, ...p3.clients].some((c) => c.clientName === 'Gone Bank')).toBe(false) expect([...p1.clients, ...p3.clients].some((c) => c.clientName === 'Gone Bank')).toBe(false)
}) })
it('a module nobody uses returns an honest empty page', () => { it('a module nobody uses returns an honest empty page', async () => {
const { db, m } = world() const { db, m } = await world()
const page = listClientsByModule(db, m.id) const page = await listClientsByModule(db, m.id)
expect(page).toMatchObject({ total: 0, clients: [], totalPricePaise: 0 }) expect(page).toMatchObject({ total: 0, clients: [], totalPricePaise: 0 })
}) })
it('rows carry cmId, and roster edits round-trip through updateClientModule (WS-G full-edit)', () => { it('rows carry cmId, and roster edits round-trip through updateClientModule (WS-G full-edit)', async () => {
const { db, m, mkClient } = world() const { db, m, mkClient } = await world()
const c = mkClient('Acme Bank') const c = await mkClient('Acme Bank')
assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = listClientsByModule(db, m.id).clients[0]! const row = (await listClientsByModule(db, m.id)).clients[0]!
expect(row.cmId).toBeTruthy() expect(row.cmId).toBeTruthy()
// Same audited write path Client 360 uses — kind validated against allowedKinds. // Same audited write path Client 360 uses — kind validated against allowedKinds.
updateClientModule(db, 'u1', row.cmId, { kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' }) await updateClientModule(db, 'u1', row.cmId, { kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
const after = listClientsByModule(db, m.id).clients[0]! const after = (await listClientsByModule(db, m.id)).clients[0]!
expect(after).toMatchObject({ kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' }) expect(after).toMatchObject({ kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
// Unassign = deactivate: the row leaves the roster and the total shrinks. // Unassign = deactivate: the row leaves the roster and the total shrinks.
updateClientModule(db, 'u1', row.cmId, { active: false }) await updateClientModule(db, 'u1', row.cmId, { active: false })
expect(listClientsByModule(db, m.id).total).toBe(0) expect((await listClientsByModule(db, m.id)).total).toBe(0)
}) })
it('roster kind edit rejects a kind the module does not allow', () => { it('roster kind edit rejects a kind the module does not allow', async () => {
const { db, mkClient } = world() const { db, mkClient } = await world()
const restricted = createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] }) const restricted = await createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] })
const c = mkClient('Beta Coop') const c = await mkClient('Beta Coop')
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' })
expect(() => updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).toThrow(/does not allow/) await expect(updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).rejects.toThrow(/does not allow/)
expect(() => updateClientModule(db, 'u1', cm.id, { edition: ' ' })).toThrow(/edition/i) await expect(updateClientModule(db, 'u1', cm.id, { edition: ' ' })).rejects.toThrow(/edition/i)
}) })
}) })
// ---------- routes ---------- // ---------- routes ----------
function appWith(fetchImpl: typeof fetch) { async function appWith(fetchImpl: typeof fetch) {
const { db, m, mkClient } = world() const { db, m, mkClient } = await world()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const withMail = mkClient('Acme Bank', 'ravi@acme.in') const withMail = await mkClient('Acme Bank', 'ravi@acme.in')
const noMail = mkClient('Beta Coop') // no contact email — must be reported, not skipped silently const noMail = await mkClient('Beta Coop') // no contact email — must be reported, not skipped silently
assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: noMail.id, moduleId: m.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: noMail.id, moduleId: m.id, kind: 'yearly' })
const app = express(); app.use(express.json()); app.locals['db'] = db const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf)) app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
const server = app.listen(0) const server = app.listen(0)
@ -122,7 +122,7 @@ describe('module roster routes', () => {
afterAll(() => { for (const s of servers) s.close() }) afterAll(() => { for (const s of servers) s.close() })
it('GET /modules/:id/clients returns the paginated roster to any signed-in user', async () => { it('GET /modules/:id/clients returns the paginated roster to any signed-in user', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients`, { const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients`, {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
@ -134,7 +134,7 @@ describe('module roster routes', () => {
}) })
it('GET /modules/:id/clients.csv exports every row as an attachment', async () => { it('GET /modules/:id/clients.csv exports every row as an attachment', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients.csv`, { const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients.csv`, {
headers: { authorization: `Bearer ${token}` }, headers: { authorization: `Bearer ${token}` },
@ -151,7 +151,7 @@ describe('module roster routes', () => {
}) })
it('POST /modules/:id/notify is owner/manager-only', async () => { it('POST /modules/:id/notify is owner/manager-only', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password') const token = await login(ctx.baseUrl, 'staff@test.in', 'staff-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST', method: 'POST',
@ -162,7 +162,7 @@ describe('module roster routes', () => {
}) })
it('notify sends to every reachable client, names the unreachable, and audits per recipient', async () => { it('notify sends to every reachable client, names the unreachable, and audits per recipient', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { const res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST', method: 'POST',
@ -174,18 +174,18 @@ describe('module roster routes', () => {
expect(json.sent).toBe(1) // Acme has a contact email expect(json.sent).toBe(1) // Acme has a contact email
expect(json.total).toBe(2) expect(json.total).toBe(2)
expect(json.failed).toEqual([{ client: 'Beta Coop', error: 'no contact email' }]) expect(json.failed).toEqual([{ client: 'Beta Coop', error: 'no contact email' }])
const log = ctx.db.prepare(`SELECT to_addr, status, subject FROM email_log ORDER BY id DESC LIMIT 1`) const log = await ctx.db.get(`SELECT to_addr, status, subject FROM email_log ORDER BY id DESC LIMIT 1`) as
.get() as { to_addr: string; status: string; subject: string } { to_addr: string; status: string; subject: string }
expect(log).toMatchObject({ to_addr: 'ravi@acme.in', status: 'sent', subject: 'Core Banking maintenance' }) expect(log).toMatchObject({ to_addr: 'ravi@acme.in', status: 'sent', subject: 'Core Banking maintenance' })
// One audit row per attempted recipient; the unreachable client is named in // One audit row per attempted recipient; the unreachable client is named in
// the response (warn-not-truncate) but no send happened, so nothing to audit. // the response (warn-not-truncate) but no send happened, so nothing to audit.
const audits = listAudit(ctx.db).filter((a) => a.action === 'notify') const audits = (await listAudit(ctx.db)).filter((a) => a.action === 'notify')
expect(audits).toHaveLength(1) expect(audits).toHaveLength(1)
expect(JSON.parse(audits[0]!.after_json ?? '{}')).toMatchObject({ status: 'sent', subject: 'Core Banking maintenance' }) expect(JSON.parse(audits[0]!.after_json ?? '{}')).toMatchObject({ status: 'sent', subject: 'Core Banking maintenance' })
}) })
it('notify validates subject/body and the gmail account before sending anything', async () => { it('notify validates subject/body and the gmail account before sending anything', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server) const ctx = await appWith(okFetch); servers.push(ctx.server)
const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password') const token = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
const bad = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { const bad = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST', method: 'POST',
@ -193,13 +193,13 @@ describe('module roster routes', () => {
body: JSON.stringify({ subject: '', body: '' }), body: JSON.stringify({ subject: '', body: '' }),
}) })
expect(bad.status).toBe(400) expect(bad.status).toBe(400)
ctx.db.prepare(`DELETE FROM email_account`).run() await ctx.db.run(`DELETE FROM email_account`)
const disconnected = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, { const disconnected = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ subject: 'S', body: 'B' }), body: JSON.stringify({ subject: 'S', body: 'B' }),
}) })
expect(disconnected.status).toBe(409) expect(disconnected.status).toBe(409)
expect(ctx.db.prepare(`SELECT COUNT(*) AS n FROM email_log`).get()).toMatchObject({ n: 0 }) expect(await ctx.db.get(`SELECT COUNT(*) AS n FROM email_log`)).toMatchObject({ n: 0 })
}) })
}) })

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

@ -6,40 +6,40 @@ import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, getDocument } from '../src/repos-documents' import { createDraft, issueDocument, getDocument } from '../src/repos-documents'
import { recordPayment, clientLedger, modulePaidView } from '../src/repos-payments' import { recordPayment, clientLedger, modulePaidView } from '../src/repos-payments'
function invoiceFor(db: any, clientId: string, moduleId: string) { async function invoiceFor(db: any, clientId: string, moduleId: string) {
return issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId, return issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId,
lines: [{ moduleId, qty: 1, kind: 'yearly' }] }).id) lines: [{ moduleId, qty: 1, kind: 'yearly' }] })).id)
} }
describe('payments', () => { describe('payments', () => {
it('oldest-first default allocation; part_paid then paid; leftover is an advance', () => { it('oldest-first default allocation; part_paid then paid; leftover is an advance', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv1 = invoiceFor(db, c.id, m.id) // ₹11,800 const inv1 = await invoiceFor(db, c.id, m.id) // ₹11,800
const inv2 = invoiceFor(db, c.id, m.id) // ₹11,800 const inv2 = await invoiceFor(db, c.id, m.id) // ₹11,800
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 15_000_00 }) await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 15_000_00 })
expect(getDocument(db, inv1.id)!.status).toBe('paid') // 11,800 settled expect((await getDocument(db, inv1.id))!.status).toBe('paid') // 11,800 settled
expect(getDocument(db, inv2.id)!.status).toBe('part_paid') // 3,200 of 11,800 expect((await getDocument(db, inv2.id))!.status).toBe('part_paid') // 3,200 of 11,800
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 10_000_00 }) await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 10_000_00 })
expect(getDocument(db, inv2.id)!.status).toBe('paid') expect((await getDocument(db, inv2.id))!.status).toBe('paid')
expect(clientLedger(db, c.id).advancePaise).toBe(1_400_00) // 25,000 23,600 expect((await clientLedger(db, c.id)).advancePaise).toBe(1_400_00) // 25,000 23,600
}) })
it('invoice-minus-TDS settles in full', () => { it('invoice-minus-TDS settles in full', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = invoiceFor(db, c.id, m.id) // ₹11,800; client pays minus 10% TDS on ₹10,000 const inv = await invoiceFor(db, c.id, m.id) // ₹11,800; client pays minus 10% TDS on ₹10,000
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank',
amountPaise: 10_800_00, tdsPaise: 1_000_00 }) amountPaise: 10_800_00, tdsPaise: 1_000_00 })
expect(getDocument(db, inv.id)!.status).toBe('paid') expect((await getDocument(db, inv.id))!.status).toBe('paid')
const view = modulePaidView(db, c.id) const view = await modulePaidView(db, c.id)
expect(view[0]).toMatchObject({ moduleId: m.id, billedPaise: 11_800_00, settledPaise: 11_800_00 }) expect(view[0]).toMatchObject({ moduleId: m.id, billedPaise: 11_800_00, settledPaise: 11_800_00 })
}) })
}) })

@ -1,5 +1,5 @@
// apps/hq/test/pdf-download.test.ts // apps/hq/test/pdf-download.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -16,17 +16,17 @@ import { apiRouter } from '../src/api'
* "draft.pdf"). renderPdf is injected (a fake %PDF- buffer) so the header logic is * "draft.pdf"). renderPdf is injected (a fake %PDF- buffer) so the header logic is
* exercised without launching Chrome the house pattern for PDF-touching tests. * exercised without launching Chrome the house pattern for PDF-touching tests.
*/ */
function setup() { async function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
// An issued quotation carries a docNo with a '/' (QT/26-27-0001)… // An issued quotation carries a docNo with a '/' (QT/26-27-0001)…
const issued = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const issued = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
// …a separate, un-issued draft keeps docNo=null. // …a separate, un-issued draft keeps docNo=null.
const draftDoc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const draftDoc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const fakePdf = async (): Promise<Buffer> => Buffer.from('%PDF-1.4 fake') const fakePdf = async (): Promise<Buffer> => Buffer.from('%PDF-1.4 fake')
const app = express(); app.use(express.json()); app.locals['db'] = db const app = express(); app.use(express.json()); app.locals['db'] = db
@ -37,7 +37,8 @@ function setup() {
} }
describe('GET /documents/:id/pdf — download filename (Content-Disposition)', () => { describe('GET /documents/:id/pdf — download filename (Content-Disposition)', () => {
const ctx = setup() let ctx: Awaited<ReturnType<typeof setup>>
beforeAll(async () => { ctx = await setup() })
afterAll(() => ctx.server.close()) afterAll(() => ctx.server.close())
const login = async (): Promise<string> => const login = async (): Promise<string> =>
(await (await fetch(`${ctx.base}/auth/login`, { (await (await fetch(`${ctx.base}/auth/login`, {

@ -3,7 +3,7 @@
// (bands from resolveSchedule — never hardcoded 3/7/14); staff server-forced to own rows; // (bands from resolveSchedule — never hardcoded 3/7/14); staff server-forced to own rows;
// actionable-oldest-first sort; LIMIT/OFFSET pagination with total. // actionable-oldest-first sort; LIMIT/OFFSET pagination with total.
import express from 'express' import express from 'express'
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db' import { openDb, type DB } from '../src/db'
import { createStaff } from '../src/auth' import { createStaff } from '../src/auth'
import { createClient } from '../src/repos-clients' import { createClient } from '../src/repos-clients'
@ -19,42 +19,42 @@ function daysAgo(n: number): string {
return new Date(Date.parse(TODAY) - n * 86_400_000).toISOString() return new Date(Date.parse(TODAY) - n * 86_400_000).toISOString()
} }
function setup(): { db: DB; moduleId: string } { async function setup(): Promise<{ db: DB; moduleId: string }> {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { db, moduleId: m.id } return { db, moduleId: m.id }
} }
/** Draft QUOTATION for the client; optionally marked sent with the event backdated `ageDays`. */ /** Draft QUOTATION for the client; optionally marked sent with the event backdated `ageDays`. */
function quote(db: DB, clientId: string, moduleId: string, opts: { by?: string; sentAgeDays?: number } = {}) { async function quote(db: DB, clientId: string, moduleId: string, opts: { by?: string; sentAgeDays?: number } = {}) {
const by = opts.by ?? 'u1' const by = opts.by ?? 'u1'
const d = createDraft(db, by, { const d = await createDraft(db, by, {
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
}) })
if (opts.sentAgeDays !== undefined) { if (opts.sentAgeDays !== undefined) {
markStatus(db, by, d.id, 'sent') await markStatus(db, by, d.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`) await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`,
.run(daysAgo(opts.sentAgeDays), d.id) daysAgo(opts.sentAgeDays), d.id)
} }
return d return d
} }
describe('listPipeline — stage derivation (spec §9)', () => { describe('listPipeline — stage derivation (spec §9)', () => {
it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', () => { it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const emp = createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' }) const emp = await createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' })
const lead = createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' }) const lead = await createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(emp.id, lead.id) await db.run(`UPDATE client SET owner_id=? WHERE id=?`, emp.id, lead.id)
// an active client with no quotation is NOT pipeline material // an active client with no quotation is NOT pipeline material
createClient(db, 'u1', { name: 'Steady Customer', stateCode: '32', status: 'active' }) await createClient(db, 'u1', { name: 'Steady Customer', stateCode: '32', status: 'active' })
// a lead WITH a quotation shows as its quote row, not a duplicate enquiry // a lead WITH a quotation shows as its quote row, not a duplicate enquiry
const quotedLead = createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' }) const quotedLead = await createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' })
quote(db, quotedLead.id, moduleId) await quote(db, quotedLead.id, moduleId)
const out = listPipeline(db, { ...OWNER_VIEW }) const out = await listPipeline(db, { ...OWNER_VIEW })
const enquiry = out.rows.find((r) => r.clientId === lead.id) const enquiry = out.rows.find((r) => r.clientId === lead.id)
expect(enquiry).toMatchObject({ expect(enquiry).toMatchObject({
stage: 'enquiry', nextAction: 'send_quote', docId: null, amountPaise: null, stage: 'enquiry', nextAction: 'send_quote', docId: null, amountPaise: null,
@ -65,21 +65,21 @@ describe('listPipeline — stage derivation (spec §9)', () => {
expect(out.rows.some((r) => r.clientName === 'Steady Customer')).toBe(false) expect(out.rows.some((r) => r.clientName === 'Steady Customer')).toBe(false)
}) })
it('derives New Project / Quoted-Waiting / Won from the latest quotation status', () => { it('derives New Project / Quoted-Waiting / Won from the latest quotation status', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const draft = createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' }) const draft = await createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' })
quote(db, draft.id, moduleId) await quote(db, draft.id, moduleId)
const sent = createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' }) const sent = await createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' })
quote(db, sent.id, moduleId, { sentAgeDays: 1 }) await quote(db, sent.id, moduleId, { sentAgeDays: 1 })
const accepted = createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' }) const accepted = await createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' })
const qa = quote(db, accepted.id, moduleId, { sentAgeDays: 2 }) const qa = await quote(db, accepted.id, moduleId, { sentAgeDays: 2 })
markStatus(db, 'u1', qa.id, 'accepted') await markStatus(db, 'u1', qa.id, 'accepted')
const invoiced = createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' }) const invoiced = await createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' })
const qi = quote(db, invoiced.id, moduleId, { sentAgeDays: 2 }) const qi = await quote(db, invoiced.id, moduleId, { sentAgeDays: 2 })
markStatus(db, 'u1', qi.id, 'accepted') await markStatus(db, 'u1', qi.id, 'accepted')
convertDocument(db, 'u1', qi.id, 'INVOICE') await convertDocument(db, 'u1', qi.id, 'INVOICE')
const by = new Map(listPipeline(db, { ...OWNER_VIEW }).rows.map((r) => [r.clientName, r])) const by = new Map((await listPipeline(db, { ...OWNER_VIEW })).rows.map((r) => [r.clientName, r]))
expect(by.get('Draft Co')).toMatchObject({ stage: 'new_project', nextAction: 'send_quote' }) expect(by.get('Draft Co')).toMatchObject({ stage: 'new_project', nextAction: 'send_quote' })
expect(by.get('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 }) expect(by.get('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 })
expect(by.get('Sent Co')!.amountPaise).toBe(11_800_00) expect(by.get('Sent Co')!.amountPaise).toBe(11_800_00)
@ -87,66 +87,66 @@ describe('listPipeline — stage derivation (spec §9)', () => {
expect(by.get('Invoiced Co')).toMatchObject({ stage: 'won', nextAction: 'none' }) expect(by.get('Invoiced Co')).toMatchObject({ stage: 'won', nextAction: 'none' })
}) })
it('the LATEST quotation wins when a client has several', () => { it('the LATEST quotation wins when a client has several', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const c = createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' })
const old = quote(db, c.id, moduleId, { sentAgeDays: 40 }) const old = await quote(db, c.id, moduleId, { sentAgeDays: 40 })
markStatus(db, 'u1', old.id, 'lost') await markStatus(db, 'u1', old.id, 'lost')
quote(db, c.id, moduleId, { sentAgeDays: 2 }) await quote(db, c.id, moduleId, { sentAgeDays: 2 })
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id) const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 }) expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 })
}) })
it('Lost (quote lost or client lost) is hidden unless filtered', () => { it('Lost (quote lost or client lost) is hidden unless filtered', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const lostQuote = createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' }) const lostQuote = await createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' })
const q = quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 }) const q = await quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 })
markStatus(db, 'u1', q.id, 'lost') await markStatus(db, 'u1', q.id, 'lost')
const lostClient = createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' }) const lostClient = await createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' })
quote(db, lostClient.id, moduleId, { sentAgeDays: 5 }) await quote(db, lostClient.id, moduleId, { sentAgeDays: 5 })
const alive = createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' }) const alive = await createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' })
quote(db, alive.id, moduleId, { sentAgeDays: 1 }) await quote(db, alive.id, moduleId, { sentAgeDays: 1 })
const all = listPipeline(db, { ...OWNER_VIEW }) const all = await listPipeline(db, { ...OWNER_VIEW })
expect(all.rows.map((r) => r.clientName)).toEqual(['Alive Co']) expect(all.rows.map((r) => r.clientName)).toEqual(['Alive Co'])
expect(all.total).toBe(1) expect(all.total).toBe(1)
const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' }) const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost']) expect(lost.rows.map((r) => r.stage)).toEqual(['lost', 'lost'])
expect(lost.total).toBe(2) expect(lost.total).toBe(2)
}) })
it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', () => { it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', async () => {
const { db } = setup() const { db } = await setup()
createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' }) await createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' })
expect(listPipeline(db, { ...OWNER_VIEW }).total).toBe(0) expect((await listPipeline(db, { ...OWNER_VIEW })).total).toBe(0)
const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' }) const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
expect(lost.total).toBe(1) expect(lost.total).toBe(1)
expect(lost.rows[0]).toMatchObject({ expect(lost.rows[0]).toMatchObject({
clientName: 'Quote-less Lost Co', stage: 'lost', nextAction: 'none', docId: null, clientName: 'Quote-less Lost Co', stage: 'lost', nextAction: 'none', docId: null,
}) })
}) })
it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', () => { it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const c = createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' })
const q = quote(db, c.id, moduleId, { sentAgeDays: 10 }) const q = await quote(db, c.id, moduleId, { sentAgeDays: 10 })
convertDocument(db, 'u1', q.id, 'PROFORMA') await convertDocument(db, 'u1', q.id, 'PROFORMA')
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id) const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ stage: 'won', nextAction: 'none' }) // moved forward, no chase buttons expect(rows[0]).toMatchObject({ stage: 'won', nextAction: 'none' }) // moved forward, no chase buttons
}) })
}) })
describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => { describe('listPipeline — next action + band from resolveSchedule (no hardcoded 3/7/14)', () => {
it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', () => { it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const mk = (name: string, age: number) => { const mk = async (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' }) const c = await createClient(db, 'u1', { name, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: age }) await quote(db, c.id, moduleId, { sentAgeDays: age })
} }
mk('Age2', 2); mk('Age5', 5); mk('Age8', 8); mk('Age15', 15) await mk('Age2', 2); await mk('Age5', 5); await mk('Age8', 8); await mk('Age15', 15)
const out = listPipeline(db, { ...OWNER_VIEW }) const out = await listPipeline(db, { ...OWNER_VIEW })
expect(out.dayOffsets).toEqual([3, 7, 14]) expect(out.dayOffsets).toEqual([3, 7, 14])
const by = new Map(out.rows.map((r) => [r.clientName, r])) const by = new Map(out.rows.map((r) => [r.clientName, r]))
expect(by.get('Age2')).toMatchObject({ nextAction: 'waiting', band: 'green', ageDays: 2 }) expect(by.get('Age2')).toMatchObject({ nextAction: 'waiting', band: 'green', ageDays: 2 })
@ -155,15 +155,15 @@ describe('listPipeline — next action + band from resolveSchedule (no hardcoded
expect(by.get('Age15')).toMatchObject({ nextAction: 'final_nudge', band: 'red', ageDays: 15 }) expect(by.get('Age15')).toMatchObject({ nextAction: 'final_nudge', band: 'red', ageDays: 15 })
}) })
it('a dated reminder_schedule row changes the bands without a code edit', () => { it('a dated reminder_schedule row changes the bands without a code edit', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
db.prepare( await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES ('rs1', 'quote_followup', '2026-01-01', NULL, '2,4,6', NULL, NULL)`, VALUES ('rs1', 'quote_followup', '2026-01-01', NULL, '2,4,6', NULL, NULL)`,
).run() )
const c = createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: 5 }) await quote(db, c.id, moduleId, { sentAgeDays: 5 })
const out = listPipeline(db, { ...OWNER_VIEW }) const out = await listPipeline(db, { ...OWNER_VIEW })
expect(out.dayOffsets).toEqual([2, 4, 6]) expect(out.dayOffsets).toEqual([2, 4, 6])
// age 5 crossed 2 and 4 but not 6 → nudge, amber (would be 'chase' under 3/7/14) // age 5 crossed 2 and 4 but not 6 → nudge, amber (would be 'chase' under 3/7/14)
expect(out.rows[0]).toMatchObject({ nextAction: 'nudge', band: 'amber' }) expect(out.rows[0]).toMatchObject({ nextAction: 'nudge', band: 'amber' })
@ -171,89 +171,89 @@ describe('listPipeline — next action + band from resolveSchedule (no hardcoded
}) })
describe('listPipeline — role gate (staff server-forced to own rows)', () => { describe('listPipeline — role gate (staff server-forced to own rows)', () => {
function team(db: DB) { async function team(db: DB) {
const a = createStaff(db, { email: 'a@x.co', displayName: 'Asha', role: 'staff', password: 'password2' }) const a = await createStaff(db, { email: 'a@x.co', displayName: 'Asha', role: 'staff', password: 'password2' })
const b = createStaff(db, { email: 'b@x.co', displayName: 'Priya', role: 'staff', password: 'password2' }) const b = await createStaff(db, { email: 'b@x.co', displayName: 'Priya', role: 'staff', password: 'password2' })
return { a: a.id, b: b.id } return { a: a.id, b: b.id }
} }
it('staff see only quotes they created and leads they own; widening params are ignored', () => { it('staff see only quotes they created and leads they own; widening params are ignored', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const { a, b } = team(db) const { a, b } = await team(db)
const c1 = createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' }) const c1 = await createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' })
quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 }) await quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 })
const c2 = createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' }) const c2 = await createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' })
quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 }) await quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 })
const leadA = createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' }) const leadA = await createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' })
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(a, leadA.id) await db.run(`UPDATE client SET owner_id=? WHERE id=?`, a, leadA.id)
createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' }) await createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' })
const mine = listPipeline(db, { viewerRole: 'staff', viewerId: a, today: TODAY }) const mine = await listPipeline(db, { viewerRole: 'staff', viewerId: a, today: TODAY })
expect(mine.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co']) expect(mine.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co'])
// a widening owner param cannot escape the self scope // a widening owner param cannot escape the self scope
const widened = listPipeline(db, { viewerRole: 'staff', viewerId: a, ownerId: b, today: TODAY }) const widened = await listPipeline(db, { viewerRole: 'staff', viewerId: a, ownerId: b, today: TODAY })
expect(widened.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co']) expect(widened.rows.map((r) => r.clientName).sort()).toEqual(['Asha Lead', 'Asha Quote Co'])
// owner/manager see everything, including the unassigned lead (F13 safety net) // owner/manager see everything, including the unassigned lead (F13 safety net)
const boss = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', today: TODAY }) const boss = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', today: TODAY })
expect(boss.total).toBe(4) expect(boss.total).toBe(4)
// ...and may narrow to one owner, or to themselves via filter=mine // ...and may narrow to one owner, or to themselves via filter=mine
const narrowed = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', ownerId: b, today: TODAY }) const narrowed = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', ownerId: b, today: TODAY })
expect(narrowed.rows.map((r) => r.clientName)).toEqual(['Priya Quote Co']) expect(narrowed.rows.map((r) => r.clientName)).toEqual(['Priya Quote Co'])
const bossMine = listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', filter: 'mine', today: TODAY }) const bossMine = await listPipeline(db, { viewerRole: 'manager', viewerId: 'boss', filter: 'mine', today: TODAY })
expect(bossMine.total).toBe(0) expect(bossMine.total).toBe(0)
}) })
}) })
describe('listPipeline — sort, overdue filter, pagination', () => { describe('listPipeline — sort, overdue filter, pagination', () => {
it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', () => { it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const mkSent = (name: string, age: number) => { const mkSent = async (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' }) const c = await createClient(db, 'u1', { name, stateCode: '32' })
return quote(db, c.id, moduleId, { sentAgeDays: age }) return quote(db, c.id, moduleId, { sentAgeDays: age })
} }
mkSent('Mid 5d', 5) await mkSent('Mid 5d', 5)
mkSent('Oldest 15d', 15) await mkSent('Oldest 15d', 15)
mkSent('Waiting 2d', 2) await mkSent('Waiting 2d', 2)
createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' }) await createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' })
const won = createClient(db, 'u1', { name: 'Won Co', stateCode: '32' }) const won = await createClient(db, 'u1', { name: 'Won Co', stateCode: '32' })
markStatus(db, 'u1', quote(db, won.id, moduleId, { sentAgeDays: 30 }).id, 'accepted') await markStatus(db, 'u1', (await quote(db, won.id, moduleId, { sentAgeDays: 30 })).id, 'accepted')
const names = listPipeline(db, { ...OWNER_VIEW }).rows.map((r) => r.clientName) const names = (await listPipeline(db, { ...OWNER_VIEW })).rows.map((r) => r.clientName)
expect(names).toEqual(['Oldest 15d', 'Mid 5d', 'Waiting 2d', 'A Lead', 'Won Co']) expect(names).toEqual(['Oldest 15d', 'Mid 5d', 'Waiting 2d', 'A Lead', 'Won Co'])
}) })
it('filter=overdue keeps only sent quotes past the first offset', () => { it('filter=overdue keeps only sent quotes past the first offset', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
const mkSent = (name: string, age: number) => { const mkSent = async (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' }) const c = await createClient(db, 'u1', { name, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: age }) await quote(db, c.id, moduleId, { sentAgeDays: age })
} }
mkSent('Fresh 1d', 1) await mkSent('Fresh 1d', 1)
mkSent('Over 9d', 9) await mkSent('Over 9d', 9)
createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' }) await createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' })
const out = listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' }) const out = await listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' })
expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d']) expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d'])
expect(out.total).toBe(1) expect(out.total).toBe(1)
}) })
it('paginates with LIMIT/OFFSET semantics and an honest total', () => { it('paginates with LIMIT/OFFSET semantics and an honest total', async () => {
const { db, moduleId } = setup() const { db, moduleId } = await setup()
for (let i = 0; i < 5; i += 1) { for (let i = 0; i < 5; i += 1) {
const c = createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' }) const c = await createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: 10 + i }) await quote(db, c.id, moduleId, { sentAgeDays: 10 + i })
} }
const p1 = listPipeline(db, { ...OWNER_VIEW, page: 1, pageSize: 2 }) const p1 = await listPipeline(db, { ...OWNER_VIEW, page: 1, pageSize: 2 })
expect(p1.total).toBe(5) expect(p1.total).toBe(5)
expect(p1.rows).toHaveLength(2) expect(p1.rows).toHaveLength(2)
expect(p1.rows[0]!.ageDays).toBe(14) // oldest first expect(p1.rows[0]!.ageDays).toBe(14) // oldest first
const p3 = listPipeline(db, { ...OWNER_VIEW, page: 3, pageSize: 2 }) const p3 = await listPipeline(db, { ...OWNER_VIEW, page: 3, pageSize: 2 })
expect(p3.rows).toHaveLength(1) expect(p3.rows).toHaveLength(1)
expect(p3.page).toBe(3) expect(p3.page).toBe(3)
expect(p3.pageSize).toBe(2) expect(p3.pageSize).toBe(2)
// no overlap, nothing dropped // no overlap, nothing dropped
const all = [ const all = [
...p1.rows, ...p1.rows,
...listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 }).rows, ...(await listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 })).rows,
...p3.rows, ...p3.rows,
] ]
expect(new Set(all.map((r) => r.clientId)).size).toBe(5) expect(new Set(all.map((r) => r.clientId)).size).toBe(5)
@ -261,17 +261,21 @@ describe('listPipeline — sort, overdue filter, pagination', () => {
}) })
describe('GET /pipeline', () => { describe('GET /pipeline', () => {
const { db, moduleId } = setup() let server: ReturnType<ReturnType<typeof express>['listen']>
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) let base: string
createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) beforeAll(async () => {
const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id const { db, moduleId } = await setup()
const mineClient = createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 }) await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const otherClient = createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' }) const staffDbId = (await db.get(`SELECT id FROM staff_user WHERE email='staff@test.in'`) as { id: string }).id
quote(db, otherClient.id, moduleId, { sentAgeDays: 4 }) const mineClient = await createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) await quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 })
const server = app.listen(0) const otherClient = await createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' })
const base = `http://localhost:${(server.address() as { port: number }).port}/api` await quote(db, otherClient.id, moduleId, { sentAgeDays: 4 })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
server = app.listen(0)
base = `http://localhost:${(server.address() as { port: number }).port}/api`
})
afterAll(() => server.close()) afterAll(() => server.close())
const tokenOf = async (email: string, password: string) => const tokenOf = async (email: string, password: string) =>

@ -5,25 +5,25 @@ import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules' import { createModule, setPrice } from '../src/repos-modules'
import { prepareDraft, createDraft, type DraftInput } from '../src/repos-documents' import { prepareDraft, createDraft, type DraftInput } from '../src/repos-documents'
function setup() { async function setup() {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const kar = createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' }) const kar = await createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS'] }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row const noPrice = await createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row
return { db, c, kar, m, noPrice } return { db, c, kar, m, noPrice }
} }
describe('prepareDraft ↔ createDraft parity', () => { describe('prepareDraft ↔ createDraft parity', () => {
it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', () => { it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', async () => {
const { db, c, m } = setup() const { db, c, m } = await setup()
const input: DraftInput = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15', const input: DraftInput = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] } lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
const prepared = prepareDraft(db, input) const prepared = await prepareDraft(db, input)
const saved = createDraft(db, 'u1', input) const saved = await createDraft(db, 'u1', input)
expect(saved.payload).toEqual(prepared.payload) expect(saved.payload).toEqual(prepared.payload)
expect({ expect({
taxable: saved.taxablePaise, cgst: saved.cgstPaise, sgst: saved.sgstPaise, taxable: saved.taxablePaise, cgst: saved.cgstPaise, sgst: saved.sgstPaise,
@ -34,36 +34,36 @@ describe('prepareDraft ↔ createDraft parity', () => {
}) })
expect(prepared.warnings).toEqual([]) expect(prepared.warnings).toEqual([])
}) })
it('resolves IGST for an out-of-state client, exactly as createDraft would', () => { it('resolves IGST for an out-of-state client, exactly as createDraft would', async () => {
const { db, kar, m } = setup() const { db, kar, m } = await setup()
const input: DraftInput = { docType: 'INVOICE', clientId: kar.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] } const input: DraftInput = { docType: 'INVOICE', clientId: kar.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }
expect(prepareDraft(db, input).totals.igstPaise).toBe(1_800_00) expect((await prepareDraft(db, input)).totals.igstPaise).toBe(1_800_00)
}) })
}) })
describe('prepareDraft strict vs permissive', () => { describe('prepareDraft strict vs permissive', () => {
it('STRICT: a line with no price throws (save must refuse the same document)', () => { it('STRICT: a line with no price throws (save must refuse the same document)', async () => {
const { db, c, noPrice } = setup() const { db, c, noPrice } = await setup()
expect(() => prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, await expect(prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] })) lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }))
.toThrow(/No price for module ANALYTICS/) .rejects.toThrow(/No price for module ANALYTICS/)
}) })
it('PERMISSIVE: the same line computes at ₹0 and is reported in warnings', () => { it('PERMISSIVE: the same line computes at ₹0 and is reported in warnings', async () => {
const { db, c, noPrice } = setup() const { db, c, noPrice } = await setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, const out = await prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }, { permissive: true }) lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
expect(out.totals.payablePaise).toBe(0) expect(out.totals.payablePaise).toBe(0)
expect(out.warnings.some((w) => /ANALYTICS/.test(w))).toBe(true) expect(out.warnings.some((w) => /ANALYTICS/.test(w))).toBe(true)
}) })
it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', () => { it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', async () => {
const { db, c } = setup() const { db, c } = await setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true }) const out = await prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true })
expect(out.payload.lines).toEqual([]) expect(out.payload.lines).toEqual([])
expect(out.totals.payablePaise).toBe(0) expect(out.totals.payablePaise).toBe(0)
}) })
it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', () => { it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', async () => {
const { db, m } = setup() const { db, m } = await setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }, { permissive: true }) const out = await prepareDraft(db, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
expect(out.totals.cgstPaise).toBe(900_00) // intra-state default (our state 32) expect(out.totals.cgstPaise).toBe(900_00) // intra-state default (our state 32)
expect(out.totals.igstPaise).toBe(0) expect(out.totals.igstPaise).toBe(0)
expect(out.warnings.some((w) => /client/i.test(w))).toBe(true) expect(out.warnings.some((w) => /client/i.test(w))).toBe(true)

@ -1,5 +1,5 @@
// apps/hq/test/preview-fidelity.test.ts // apps/hq/test/preview-fidelity.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -10,28 +10,35 @@ import { createDraft } from '../src/repos-documents'
import { documentHtml } from '../src/templates' import { documentHtml } from '../src/templates'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function companyMap(db: any): Record<string, string> { async function companyMap(db: any): Promise<Record<string, string>> {
const rows = db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`).all() as { key: string; value: string }[] const rows = await db.all(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`) as { key: string; value: string }[]
return Object.fromEntries(rows.map((r) => [r.key, r.value])) return Object.fromEntries(rows.map((r) => [r.key, r.value]))
} }
describe('preview fidelity (string identity with the PDF path)', () => { async function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', address: 'Kochi', gstin: '32ABCDE1234F1Z9' }) const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', address: 'Kochi', gstin: '32ABCDE1234F1Z9' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS', 'GST filing'] }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS', 'GST filing'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
afterAll(() => server.close()) return { db, c, m, server, base }
}
describe('preview fidelity (string identity with the PDF path)', () => {
let ctx: Awaited<ReturnType<typeof setup>>
beforeAll(async () => { ctx = await setup() })
afterAll(() => ctx.server.close())
it('preview.html === documentHtml the /pdf route would feed renderPdf', async () => { it('preview.html === documentHtml the /pdf route would feed renderPdf', async () => {
const { db, c, m, base } = ctx
const input = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15', const input = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] } lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
// The fixture draft: exactly what /documents/:id/pdf renders (un-issued draft, docNo=null). // The fixture draft: exactly what /documents/:id/pdf renders (un-issued draft, docNo=null).
const fixture = createDraft(db, 'u1', input) const fixture = await createDraft(db, 'u1', input)
const expected = documentHtml(fixture, getClient(db, c.id)!, companyMap(db)) const expected = documentHtml(fixture, (await getClient(db, c.id))!, await companyMap(db))
const token = (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token const token = (await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
const res = await fetch(`${base}/documents/preview`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(input) }) const res = await fetch(`${base}/documents/preview`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify(input) })

@ -1,5 +1,5 @@
// apps/hq/test/preview-route.test.ts // apps/hq/test/preview-route.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -8,14 +8,14 @@ import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules' import { createModule, setPrice } from '../src/repos-modules'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const kar = createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' }) const kar = await createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) const noPrice = await createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -23,7 +23,8 @@ function appWith() {
} }
describe('POST /documents/preview', () => { describe('POST /documents/preview', () => {
const ctx = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close()) afterAll(() => ctx.server.close())
const login = async () => const login = async () =>
(await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
@ -56,8 +57,8 @@ describe('POST /documents/preview', () => {
expect(out.json.totals.cgstPaise).toBe(900_00) expect(out.json.totals.cgstPaise).toBe(900_00)
expect(out.json.warnings).toEqual([]) expect(out.json.warnings).toEqual([])
// Persisted nothing: // Persisted nothing:
expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM document`).get() as any).n).toBe(0) expect((await ctx.db.get(`SELECT COUNT(*) AS n FROM document`) as any).n).toBe(0)
expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='document'`).get() as any).n).toBe(0) expect((await ctx.db.get(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='document'`) as any).n).toBe(0)
}) })
it('shows the IGST split live for an out-of-state client', async () => { it('shows the IGST split live for an out-of-state client', async () => {

@ -1,5 +1,5 @@
// apps/hq/test/preview-sample.test.ts // apps/hq/test/preview-sample.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -31,9 +31,9 @@ describe('templates: screen paper styles + documentHtmlSample', () => {
}) })
}) })
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -41,12 +41,13 @@ function appWith() {
} }
describe('POST /previews/sample', () => { describe('POST /previews/sample', () => {
const { server, base } = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
afterAll(() => server.close()) beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
const login = async () => const login = async () =>
(await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token
const call = async (token: string | null, body: unknown) => { const call = async (token: string | null, body: unknown) => {
const res = await fetch(`${base}/previews/sample`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) }) const res = await fetch(`${ctx.base}/previews/sample`, { method: 'POST', headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(body) })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
it('requires auth', async () => { expect((await call(null, {})).status).toBe(401) }) it('requires auth', async () => { expect((await call(null, {})).status).toBe(401) })

@ -8,9 +8,9 @@ import { changeOwnPassword, getEmployee, updateEmployee } from '../src/repos-emp
import { listAudit } from '../src/audit' import { listAudit } from '../src/audit'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'staff@test.in', displayName: 'Priya', role: 'staff', password: 'first-pass-9' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Priya', role: 'staff', password: 'first-pass-9' })
const app = express(); app.use(express.json()); app.locals['db'] = db const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db)) app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
@ -34,7 +34,7 @@ describe('self-service profile (/me)', () => {
afterAll(() => { for (const s of servers) s.close() }) afterAll(() => { for (const s of servers) s.close() })
it('GET /me returns the caller, without password columns', async () => { it('GET /me returns the caller, without password columns', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))!
const res = await fetch(`${ctx.baseUrl}/me`, { headers: H(token) }) const res = await fetch(`${ctx.baseUrl}/me`, { headers: H(token) })
const json = await res.json() as { employee: Record<string, unknown> } const json = await res.json() as { employee: Record<string, unknown> }
@ -45,7 +45,7 @@ describe('self-service profile (/me)', () => {
}) })
it('PATCH /me updates name + phone (audited); role/email in the body are ignored', async () => { it('PATCH /me updates name + phone (audited); role/email in the body are ignored', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))!
const res = await fetch(`${ctx.baseUrl}/me`, { const res = await fetch(`${ctx.baseUrl}/me`, {
method: 'PATCH', headers: H(token), method: 'PATCH', headers: H(token),
@ -57,31 +57,31 @@ describe('self-service profile (/me)', () => {
displayName: 'Priya N', phone: '+91 98765', displayName: 'Priya N', phone: '+91 98765',
role: 'staff', email: 'staff@test.in', // untouchable via /me role: 'staff', email: 'staff@test.in', // untouchable via /me
}) })
const audit = listAudit(ctx.db).find((a) => a.action === 'update' && a.entity === 'staff_user') const audit = (await listAudit(ctx.db)).find((a) => a.action === 'update' && a.entity === 'staff_user')
expect(audit).toBeDefined() expect(audit).toBeDefined()
}) })
it('PATCH /me with an empty patch is rejected', async () => { it('PATCH /me with an empty patch is rejected', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))!
const res = await fetch(`${ctx.baseUrl}/me`, { method: 'PATCH', headers: H(token), body: '{}' }) const res = await fetch(`${ctx.baseUrl}/me`, { method: 'PATCH', headers: H(token), body: '{}' })
expect(res.status).toBe(400) expect(res.status).toBe(400)
}) })
it('POST /me/password rejects a wrong current password and leaves sessions alive', async () => { it('POST /me/password rejects a wrong current password and leaves sessions alive', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! const token = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))!
const res = await fetch(`${ctx.baseUrl}/me/password`, { const res = await fetch(`${ctx.baseUrl}/me/password`, {
method: 'POST', headers: H(token), method: 'POST', headers: H(token),
body: JSON.stringify({ currentPassword: 'wrong-guess', newPassword: 'brand-new-pw-9' }), body: JSON.stringify({ currentPassword: 'wrong-guess', newPassword: 'brand-new-pw-9' }),
}) })
expect(res.status).toBe(400) expect(res.status).toBe(400)
expect(verifySession(ctx.db, token)).not.toBeNull() // unchanged expect(await verifySession(ctx.db, token)).not.toBeNull() // unchanged
expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).not.toBeNull() // old pw still works expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).not.toBeNull() // old pw still works
}) })
it('POST /me/password with the right current password: other sessions die, THIS one survives', async () => { it('POST /me/password with the right current password: other sessions die, THIS one survives', async () => {
const ctx = appWith(); servers.push(ctx.server) const ctx = await appWith(); servers.push(ctx.server)
const other = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // e.g. old laptop const other = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // e.g. old laptop
const current = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // the one changing it const current = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // the one changing it
const res = await fetch(`${ctx.baseUrl}/me/password`, { const res = await fetch(`${ctx.baseUrl}/me/password`, {
@ -89,27 +89,27 @@ describe('self-service profile (/me)', () => {
body: JSON.stringify({ currentPassword: 'first-pass-9', newPassword: 'brand-new-pw-9' }), body: JSON.stringify({ currentPassword: 'first-pass-9', newPassword: 'brand-new-pw-9' }),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(verifySession(ctx.db, current)).not.toBeNull() // survives expect(await verifySession(ctx.db, current)).not.toBeNull() // survives
expect(verifySession(ctx.db, other)).toBeNull() // dead with the old credential expect(await verifySession(ctx.db, other)).toBeNull() // dead with the old credential
expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).toBeNull() expect(await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9')).toBeNull()
expect(await login(ctx.baseUrl, 'staff@test.in', 'brand-new-pw-9')).not.toBeNull() expect(await login(ctx.baseUrl, 'staff@test.in', 'brand-new-pw-9')).not.toBeNull()
const audit = listAudit(ctx.db).find((a) => a.action === 'change_own_password') const audit = (await listAudit(ctx.db)).find((a) => a.action === 'change_own_password')
expect(audit).toBeDefined() expect(audit).toBeDefined()
expect(audit!.after_json ?? '').not.toMatch(/hash/) expect(audit!.after_json ?? '').not.toMatch(/hash/)
}) })
it('short new passwords are rejected in the repo', () => { it('short new passwords are rejected in the repo', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' }) const { id } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
expect(() => changeOwnPassword(db, id, 'first-pass-9', 'short', 'tok')).toThrow(/8/) await expect(changeOwnPassword(db, id, 'first-pass-9', 'short', 'tok')).rejects.toThrow(/8/)
}) })
it('phone/title round-trip through updateEmployee, and emptying stores NULL', () => { it('phone/title round-trip through updateEmployee, and emptying stores NULL', async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' }) const { id } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' }) await updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' })
expect(getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' }) expect(await getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' })
updateEmployee(db, id, id, { phone: '', title: ' ' }) await updateEmployee(db, id, id, { phone: '', title: ' ' })
expect(getEmployee(db, id)).toMatchObject({ phone: null, title: null }) expect(await getEmployee(db, id)).toMatchObject({ phone: null, title: null })
}) })
}) })

@ -19,13 +19,13 @@ import { mountPublicShare } from '../src/server'
* renderPdf is injected (a fake %PDF- buffer) so the route logic is exercised without * renderPdf is injected (a fake %PDF- buffer) so the route logic is exercised without
* launching Chrome the house pattern for every PDF-touching test in this suite. * launching Chrome the house pattern for every PDF-touching test in this suite.
*/ */
function setup(rateLimit?: { limit: number; windowMs: number }) { async function setup(rateLimit?: { limit: number; windowMs: number }) {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const rendered: string[] = [] const rendered: string[] = []
const fakePdf = async (html: string): Promise<Buffer> => { rendered.push(html); return Buffer.from('%PDF-1.4 fake') } const fakePdf = async (html: string): Promise<Buffer> => { rendered.push(html); return Buffer.from('%PDF-1.4 fake') }
@ -40,9 +40,9 @@ function setup(rateLimit?: { limit: number; windowMs: number }) {
describe('public GET /share/:token', () => { describe('public GET /share/:token', () => {
it('serves the one document inline as a PDF for a live token — with NO auth header', async () => { it('serves the one document inline as a PDF for a live token — with NO auth header', async () => {
const ctx = setup() const ctx = await setup()
try { try {
const s = mintShare(ctx.db, 'u1', ctx.doc.id) const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
const res = await fetch(`${ctx.base}/share/${s.token}`) // deliberately no authorization header const res = await fetch(`${ctx.base}/share/${s.token}`) // deliberately no authorization header
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/pdf') expect(res.headers.get('content-type')).toContain('application/pdf')
@ -55,16 +55,16 @@ describe('public GET /share/:token', () => {
}) })
it('serves the PDF inline (Content-Disposition inline, not attachment)', async () => { it('serves the PDF inline (Content-Disposition inline, not attachment)', async () => {
const ctx = setup() const ctx = await setup()
try { try {
const s = mintShare(ctx.db, 'u1', ctx.doc.id) const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
const res = await fetch(`${ctx.base}/share/${s.token}`) const res = await fetch(`${ctx.base}/share/${s.token}`)
expect(res.headers.get('content-disposition')).toMatch(/^inline/) expect(res.headers.get('content-disposition')).toMatch(/^inline/)
} finally { ctx.server.close() } } finally { ctx.server.close() }
}) })
it('404s with a plain HTML "expired or invalid" page for an unknown token — renders nothing', async () => { it('404s with a plain HTML "expired or invalid" page for an unknown token — renders nothing', async () => {
const ctx = setup() const ctx = await setup()
try { try {
const res = await fetch(`${ctx.base}/share/deadbeefdeadbeef`) const res = await fetch(`${ctx.base}/share/deadbeefdeadbeef`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
@ -76,9 +76,9 @@ describe('public GET /share/:token', () => {
}) })
it('404s for an expired token', async () => { it('404s for an expired token', async () => {
const ctx = setup() const ctx = await setup()
try { try {
const s = mintShare(ctx.db, 'u1', ctx.doc.id, { expiresDays: -1 }) // already expired const s = await mintShare(ctx.db, 'u1', ctx.doc.id, { expiresDays: -1 }) // already expired
const res = await fetch(`${ctx.base}/share/${s.token}`) const res = await fetch(`${ctx.base}/share/${s.token}`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
expect(ctx.rendered.length).toBe(0) expect(ctx.rendered.length).toBe(0)
@ -86,10 +86,10 @@ describe('public GET /share/:token', () => {
}) })
it('404s for a revoked token', async () => { it('404s for a revoked token', async () => {
const ctx = setup() const ctx = await setup()
try { try {
const s = mintShare(ctx.db, 'u1', ctx.doc.id) const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
revokeShare(ctx.db, 'u1', s.id) await revokeShare(ctx.db, 'u1', s.id)
const res = await fetch(`${ctx.base}/share/${s.token}`) const res = await fetch(`${ctx.base}/share/${s.token}`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
expect(ctx.rendered.length).toBe(0) expect(ctx.rendered.length).toBe(0)
@ -97,7 +97,7 @@ describe('public GET /share/:token', () => {
}) })
it('rate-limits lookups per IP to blunt token brute-forcing', async () => { it('rate-limits lookups per IP to blunt token brute-forcing', async () => {
const ctx = setup({ limit: 3, windowMs: 60_000 }) const ctx = await setup({ limit: 3, windowMs: 60_000 })
try { try {
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
const r = await fetch(`${ctx.base}/share/badtoken${i}`) const r = await fetch(`${ctx.base}/share/badtoken${i}`)

@ -1,6 +1,7 @@
// apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7) // apps/hq/test/quote-followup.test.ts — Phase 6: escalating quote follow-up (spec §7)
import express from 'express' import express from 'express'
import { describe, it, expect, afterAll } from 'vitest' import Database from 'better-sqlite3'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db' import { openDb, rebuildReminderRuleKindCheck, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth' import { createStaff } from '../src/auth'
@ -29,27 +30,27 @@ const deps: ScanDeps & SendReminderDeps = {
now: () => '2026-07-10T09:00:00Z', now: () => '2026-07-10T09:00:00Z',
} }
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') // sends refuse a dead relative link without it await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') // sends refuse a dead relative link without it
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m } return { db, c, m }
} }
/** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */ /** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */
function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc { async function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Promise<Doc> {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) const q = await createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent') await markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id) await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, sentAt, q.id)
return q return q
} }
const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' }) const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' })
const shareCount = (db: DB): number => const shareCount = async (db: DB): Promise<number> =>
(db.prepare(`SELECT COUNT(*) AS n FROM document_share`).get() as { n: number }).n ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM document_share`))!).n
// ---------- schema migration (rebuild-once via the shared helper) ---------- // ---------- schema migration (rebuild-once via the shared helper) ----------
@ -67,14 +68,15 @@ CREATE TABLE reminder (
)` )`
describe('reminder.rule_kind CHECK widening', () => { describe('reminder.rule_kind CHECK widening', () => {
it('a fresh DB accepts quote_followup directly', () => { it('a fresh DB accepts quote_followup directly', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const up = upsertReminder(db, { ruleKind: 'quote_followup', subjectId: 'q1', duePeriod: 'd3', clientId: 'c1', docId: 'q1', now: '2026-07-10T00:00:00Z' }) const up = await upsertReminder(db, { ruleKind: 'quote_followup', subjectId: 'q1', duePeriod: 'd3', clientId: 'c1', docId: 'q1', now: '2026-07-10T00:00:00Z' })
expect(up.created).toBe(true) expect(up.created).toBe(true)
}) })
it('rebuilds an old-CHECK table preserving rows, UNIQUE key and status CHECK; idempotent', () => { it('rebuilds an old-CHECK table preserving rows, UNIQUE key and status CHECK; idempotent', () => {
const db = openDb(':memory:') // Raw better-sqlite3 handle: the rebuild helper is a SQLite-only migration that
db.exec(`DROP TABLE reminder`) // operates below the async DB interface (it takes SqliteRaw).
const db = new Database(':memory:')
db.exec(OLD_REMINDER_DDL) db.exec(OLD_REMINDER_DDL)
db.prepare( db.prepare(
`INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at) `INSERT INTO reminder (id, rule_kind, subject_id, due_period, client_id, status, policy_applied, created_at)
@ -112,79 +114,79 @@ describe('reminder.rule_kind CHECK widening', () => {
describe('runDailyScan — quote_followup escalation', () => { describe('runDailyScan — quote_followup escalation', () => {
it('fires each interval at most once per quote, day by day', async () => { it('fires each interval at most once per quote, day by day', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
expect((await runDailyScan(db, deps, '2026-07-02')).created['quote_followup'] ?? 0).toBe(0) // age 1 < 3 expect((await runDailyScan(db, deps, '2026-07-02')).created['quote_followup'] ?? 0).toBe(0) // age 1 < 3
expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup']).toBe(1) // d3 expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup']).toBe(1) // d3
expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup'] ?? 0).toBe(0) // same day re-run expect((await runDailyScan(db, deps, '2026-07-04')).created['quote_followup'] ?? 0).toBe(0) // same day re-run
expect((await runDailyScan(db, deps, '2026-07-08')).created['quote_followup']).toBe(1) // d7 expect((await runDailyScan(db, deps, '2026-07-08')).created['quote_followup']).toBe(1) // d7
expect((await runDailyScan(db, deps, '2026-07-15')).created['quote_followup']).toBe(1) // d14 expect((await runDailyScan(db, deps, '2026-07-15')).created['quote_followup']).toBe(1) // d14
expect((await runDailyScan(db, deps, '2026-07-20')).created['quote_followup'] ?? 0).toBe(0) // ladder consumed expect((await runDailyScan(db, deps, '2026-07-20')).created['quote_followup'] ?? 0).toBe(0) // ladder consumed
const rows = followups(db) const rows = await followups(db)
expect(rows.map((r) => r.duePeriod).sort()).toEqual(['d14', 'd3', 'd7']) expect(rows.map((r) => r.duePeriod).sort()).toEqual(['d14', 'd3', 'd7'])
expect(rows.every((r) => r.subjectId === q.id && r.docId === q.id && r.policyApplied === 'manual')).toBe(true) expect(rows.every((r) => r.subjectId === q.id && r.docId === q.id && r.policyApplied === 'manual')).toBe(true)
}) })
it('catch-up fires ONLY the single highest crossed milestone', async () => { it('catch-up fires ONLY the single highest crossed milestone', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
sentQuote(db, c, m, '2026-06-01T09:00:00Z') // age 39 on first scan — past 3, 7 and 14 await sentQuote(db, c, m, '2026-06-01T09:00:00Z') // age 39 on first scan — past 3, 7 and 14
const res = await runDailyScan(db, deps, '2026-07-10') const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup']).toBe(1) expect(res.created['quote_followup']).toBe(1)
const rows = followups(db) const rows = await followups(db)
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]!.duePeriod).toBe('d14') expect(rows[0]!.duePeriod).toBe('d14')
}) })
it('ignores quotes that are not sent (draft / accepted / lost / cancelled leave the set)', async () => { it('ignores quotes that are not sent (draft / accepted / lost / cancelled leave the set)', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const q = sentQuote(db, c, m, '2026-06-01T09:00:00Z') const q = await sentQuote(db, c, m, '2026-06-01T09:00:00Z')
markStatus(db, 'u1', q.id, 'accepted') await markStatus(db, 'u1', q.id, 'accepted')
const res = await runDailyScan(db, deps, '2026-07-10') const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup'] ?? 0).toBe(0) expect(res.created['quote_followup'] ?? 0).toBe(0)
}) })
it('a converted quote is NEVER chased again — the next scan creates nothing', async () => { it('a converted quote is NEVER chased again — the next scan creates nothing', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') // d3 queued await runDailyScan(db, deps, '2026-07-04') // d3 queued
convertDocument(db, 'u1', q.id, 'PROFORMA') await convertDocument(db, 'u1', q.id, 'PROFORMA')
expect(getDocument(db, q.id)!.status).toBe('invoiced') // left the 'sent' set for good expect((await getDocument(db, q.id))!.status).toBe('invoiced') // left the 'sent' set for good
const res = await runDailyScan(db, deps, '2026-07-09') // would be d7 const res = await runDailyScan(db, deps, '2026-07-09') // would be d7
expect(res.created['quote_followup'] ?? 0).toBe(0) expect(res.created['quote_followup'] ?? 0).toBe(0)
expect(followups(db).every((r) => r.status === 'dismissed')).toBe(true) expect((await followups(db)).every((r) => r.status === 'dismissed')).toBe(true)
}) })
it('a pre-fix converted quote (still status=sent) is excluded via its live forward child', async () => { it('a pre-fix converted quote (still status=sent) is excluded via its live forward child', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
convertDocument(db, 'u1', q.id, 'PROFORMA') await convertDocument(db, 'u1', q.id, 'PROFORMA')
db.prepare(`UPDATE document SET status='sent' WHERE id=?`).run(q.id) // data converted before the status flip landed await db.run(`UPDATE document SET status='sent' WHERE id=?`, q.id) // data converted before the status flip landed
const res = await runDailyScan(db, deps, '2026-07-09') const res = await runDailyScan(db, deps, '2026-07-09')
expect(res.created['quote_followup'] ?? 0).toBe(0) expect(res.created['quote_followup'] ?? 0).toBe(0)
}) })
it('a lost client is never chased, whatever their quote row says', async () => { it('a lost client is never chased, whatever their quote row says', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
sentQuote(db, c, m, '2026-06-01T09:00:00Z') await sentQuote(db, c, m, '2026-06-01T09:00:00Z')
db.prepare(`UPDATE client SET status='lost' WHERE id=?`).run(c.id) await db.run(`UPDATE client SET status='lost' WHERE id=?`, c.id)
const res = await runDailyScan(db, deps, '2026-07-10') const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup'] ?? 0).toBe(0) expect(res.created['quote_followup'] ?? 0).toBe(0)
}) })
it('auto policy drains sends after the scan, at-most-once, minting one ~60-day share', async () => { it('auto policy drains sends after the scan, at-most-once, minting one ~60-day share', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
setSetting(db, 'u1', 'quote.followup.policy', 'auto') await setSetting(db, 'u1', 'quote.followup.policy', 'auto')
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const res = await runDailyScan(db, deps, '2026-07-04') // d3 const res = await runDailyScan(db, deps, '2026-07-04') // d3
expect(res.created['quote_followup']).toBe(1) expect(res.created['quote_followup']).toBe(1)
expect(res.autoSent).toBe(1) expect(res.autoSent).toBe(1)
const row = followups(db)[0]! const row = (await followups(db))[0]!
expect(row.policyApplied).toBe('auto') expect(row.policyApplied).toBe('auto')
expect(row.status).toBe('sent') expect(row.status).toBe('sent')
expect(shareCount(db)).toBe(1) expect(await shareCount(db)).toBe(1)
const share = db.prepare(`SELECT expires_at FROM document_share`).get() as { expires_at: string | null } const share = (await db.get<{ expires_at: string | null }>(`SELECT expires_at FROM document_share`))!
expect(share.expires_at).not.toBeNull() // never-expiring links are refused (F12) expect(share.expires_at).not.toBeNull() // never-expiring links are refused (F12)
const days = (Date.parse(share.expires_at!) - Date.now()) / 86_400_000 const days = (Date.parse(share.expires_at!) - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(59); expect(days).toBeLessThan(61) expect(days).toBeGreaterThan(59); expect(days).toBeLessThan(61)
// Next milestone reuses the live share instead of duplicating it. // Next milestone reuses the live share instead of duplicating it.
const res2 = await runDailyScan(db, deps, '2026-07-08') // d7 const res2 = await runDailyScan(db, deps, '2026-07-08') // d7
expect(res2.autoSent).toBe(1) expect(res2.autoSent).toBe(1)
expect(shareCount(db)).toBe(1) expect(await shareCount(db)).toBe(1)
// Re-run creates nothing and sends nothing (unique key → at-most-once). // Re-run creates nothing and sends nothing (unique key → at-most-once).
const res3 = await runDailyScan(db, deps, '2026-07-08') const res3 = await runDailyScan(db, deps, '2026-07-08')
expect(res3.created['quote_followup'] ?? 0).toBe(0) expect(res3.created['quote_followup'] ?? 0).toBe(0)
@ -196,22 +198,22 @@ describe('runDailyScan — quote_followup escalation', () => {
describe('quote_followup context, preview and send', () => { describe('quote_followup context, preview and send', () => {
it('preview writes nothing — no share minted, no audit rows', async () => { it('preview writes nothing — no share minted, no audit rows', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]! const rem = (await followups(db))[0]!
const auditBefore = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n const auditBefore = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n
const { ctx } = reminderContext(db, rem, 'Tecnostac') const { ctx } = await reminderContext(db, rem, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx) const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).toContain('Tecnostac') expect(mail.subject).toContain('Tecnostac')
expect(shareCount(db)).toBe(0) // resolve-only: preview NEVER mints expect(await shareCount(db)).toBe(0) // resolve-only: preview NEVER mints
expect((db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n).toBe(auditBefore) expect(((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n).toBe(auditBefore)
}) })
it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => { it('degrades gracefully when the quote has no docNo — never renders "quotation null"', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10) const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10)
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac') const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx) const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).not.toContain('null') expect(mail.subject).not.toContain('null')
expect(mail.bodyText).not.toContain('null') expect(mail.bodyText).not.toContain('null')
@ -219,75 +221,75 @@ describe('quote_followup context, preview and send', () => {
expect(mail.bodyText).toContain('Acme') // clientName substituted expect(mail.bodyText).toContain('Acme') // clientName substituted
}) })
it('uses the doc number when the quote is issued', async () => { it('uses the doc number when the quote is issued', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const issued = issueDocument(db, 'u1', q.id) const issued = await issueDocument(db, 'u1', q.id)
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac') const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'Tecnostac')
expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!) expect(reminderEmail('quote_followup', ctx).subject).toContain(issued.docNo!)
}) })
it('send resolves the dated subject/body on the injected clock, not the wall clock', async () => { it('send resolves the dated subject/body on the injected clock, not the wall clock', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
// A dated row that takes over on 2026-07-11: resolving on 07-10 must not see it. // A dated row that takes over on 2026-07-11: resolving on 07-10 must not see it.
db.prepare( await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES ('rs-future','quote_followup','2026-07-11',NULL,'3,7,14','FUTURE {ref}','future body {shareUrl}')`, VALUES ('rs-future','quote_followup','2026-07-11',NULL,'3,7,14','FUTURE {ref}','future body {shareUrl}')`,
).run() )
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]! const rem = (await followups(db))[0]!
const before = reminderContext(db, rem, 'Tecnostac', '2026-07-10') const before = await reminderContext(db, rem, 'Tecnostac', '2026-07-10')
expect(reminderEmail('quote_followup', before.ctx).subject).not.toContain('FUTURE') expect(reminderEmail('quote_followup', before.ctx).subject).not.toContain('FUTURE')
const after = reminderContext(db, rem, 'Tecnostac', '2026-07-11') const after = await reminderContext(db, rem, 'Tecnostac', '2026-07-11')
expect(reminderEmail('quote_followup', after.ctx).subject).toContain('FUTURE') expect(reminderEmail('quote_followup', after.ctx).subject).toContain('FUTURE')
}) })
it('refuses to send when share.base_url is unset — loud error, NO share minted, row still queued', async () => { it('refuses to send when share.base_url is unset — loud error, NO share minted, row still queued', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
db.prepare(`DELETE FROM setting WHERE key='share.base_url'`).run() await db.run(`DELETE FROM setting WHERE key='share.base_url'`)
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]! const rem = (await followups(db))[0]!
await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/share\.base_url/) await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/share\.base_url/)
expect(shareCount(db)).toBe(0) // nothing public left behind by the refused send expect(await shareCount(db)).toBe(0) // nothing public left behind by the refused send
expect(getReminder(db, rem.id)!.status).toBe('queued') expect((await getReminder(db, rem.id))!.status).toBe('queued')
}) })
it('auto mode parks a hard-failing send as failed instead of crashing the scan', async () => { it('auto mode parks a hard-failing send as failed instead of crashing the scan', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
db.prepare(`DELETE FROM setting WHERE key='share.base_url'`).run() await db.run(`DELETE FROM setting WHERE key='share.base_url'`)
setSetting(db, 'u1', 'quote.followup.policy', 'auto') await setSetting(db, 'u1', 'quote.followup.policy', 'auto')
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const res = await runDailyScan(db, deps, '2026-07-04') const res = await runDailyScan(db, deps, '2026-07-04')
expect(res.created['quote_followup']).toBe(1) expect(res.created['quote_followup']).toBe(1)
expect(res.autoFailed).toBe(1) expect(res.autoFailed).toBe(1)
const row = followups(db)[0]! const row = (await followups(db))[0]!
expect(row.status).toBe('failed') expect(row.status).toBe('failed')
expect(row.error).toContain('share.base_url') expect(row.error).toContain('share.base_url')
expect(shareCount(db)).toBe(0) expect(await shareCount(db)).toBe(0)
}) })
it('resolves the recipient BEFORE minting — a client without email leaves no share behind', async () => { it('resolves the recipient BEFORE minting — a client without email leaves no share behind', async () => {
const { db, m } = world() const { db, m } = await world()
const bare = createClient(db, 'u1', { name: 'NoMail Co', stateCode: '32' }) const bare = await createClient(db, 'u1', { name: 'NoMail Co', stateCode: '32' })
sentQuote(db, bare, m, '2026-07-01T09:00:00Z') await sentQuote(db, bare, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db).find((r) => r.clientId === bare.id)! const rem = (await followups(db)).find((r) => r.clientId === bare.id)!
await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/recipient/i) await expect(sendReminder(db, deps, rem.id, 'u1')).rejects.toThrow(/recipient/i)
expect(shareCount(db)).toBe(0) expect(await shareCount(db)).toBe(0)
}) })
it('manual send mints one ~60-day share and a second send reuses it', async () => { it('manual send mints one ~60-day share and a second send reuses it', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') // d3, manual await runDailyScan(db, deps, '2026-07-04') // d3, manual
const first = followups(db)[0]! const first = (await followups(db))[0]!
const out = await sendReminder(db, deps, first.id, 'u1') const out = await sendReminder(db, deps, first.id, 'u1')
expect(out).toEqual({ ok: true }) expect(out).toEqual({ ok: true })
expect(shareCount(db)).toBe(1) expect(await shareCount(db)).toBe(1)
// The sent body carries a live share link. // The sent body carries a live share link.
const { ctx } = reminderContext(db, getReminder(db, first.id)!, 'Tecnostac') const { ctx } = await reminderContext(db, (await getReminder(db, first.id))!, 'Tecnostac')
expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/') expect(reminderEmail('quote_followup', ctx).bodyText).toContain('/share/')
await runDailyScan(db, deps, '2026-07-08') // d7 await runDailyScan(db, deps, '2026-07-08') // d7
const second = followups(db).find((r) => r.duePeriod === 'd7')! const second = (await followups(db)).find((r) => r.duePeriod === 'd7')!
expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true) expect((await sendReminder(db, deps, second.id, 'u1')).ok).toBe(true)
expect(shareCount(db)).toBe(1) // reused, not duplicated expect(await shareCount(db)).toBe(1) // reused, not duplicated
}) })
}) })
@ -295,42 +297,43 @@ describe('quote_followup context, preview and send', () => {
describe('quote_followup STOP cleanup', () => { describe('quote_followup STOP cleanup', () => {
async function withOpenNudge(status?: 'accepted' | 'lost') { async function withOpenNudge(status?: 'accepted' | 'lost') {
const { db, c, m } = world() const { db, c, m } = await world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]! const rem = (await followups(db))[0]!
expect(rem.status).toBe('queued') expect(rem.status).toBe('queued')
if (status !== undefined) markStatus(db, 'u1', q.id, status) if (status !== undefined) await markStatus(db, 'u1', q.id, status)
return { db, q, remId: rem.id } return { db, q, remId: rem.id }
} }
it('markStatus accepted dismisses open nudges, each audited', async () => { it('markStatus accepted dismisses open nudges, each audited', async () => {
const { db, remId } = await withOpenNudge('accepted') const { db, remId } = await withOpenNudge('accepted')
expect(getReminder(db, remId)!.status).toBe('dismissed') expect((await getReminder(db, remId))!.status).toBe('dismissed')
const audit = db.prepare( const audit = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`, `SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder' AND entity_id=? AND action='update'`,
).get(remId) as { n: number } remId,
))!
expect(audit.n).toBe(1) // per-row setReminderStatus, not a silent bulk UPDATE expect(audit.n).toBe(1) // per-row setReminderStatus, not a silent bulk UPDATE
}) })
it('markStatus lost dismisses open nudges', async () => { it('markStatus lost dismisses open nudges', async () => {
const { db, remId } = await withOpenNudge('lost') const { db, remId } = await withOpenNudge('lost')
expect(getReminder(db, remId)!.status).toBe('dismissed') expect((await getReminder(db, remId))!.status).toBe('dismissed')
}) })
it('convertDocument dismisses the quotes open nudges in the same transaction', async () => { it('convertDocument dismisses the quotes open nudges in the same transaction', async () => {
const { db, q, remId } = await withOpenNudge() const { db, q, remId } = await withOpenNudge()
convertDocument(db, 'u1', q.id, 'PROFORMA') await convertDocument(db, 'u1', q.id, 'PROFORMA')
expect(getReminder(db, remId)!.status).toBe('dismissed') expect((await getReminder(db, remId))!.status).toBe('dismissed')
}) })
it('cancelDocument dismisses the quotations open nudges — dead paper is not chased', async () => { it('cancelDocument dismisses the quotations open nudges — dead paper is not chased', async () => {
const { db, q, remId } = await withOpenNudge() const { db, q, remId } = await withOpenNudge()
issueDocument(db, 'u1', q.id) // only issued documents can cancel await issueDocument(db, 'u1', q.id) // only issued documents can cancel
cancelDocument(db, 'u1', q.id) await cancelDocument(db, 'u1', q.id)
expect(getReminder(db, remId)!.status).toBe('dismissed') expect((await getReminder(db, remId))!.status).toBe('dismissed')
}) })
it('leaves already-sent follow-ups untouched', async () => { it('leaves already-sent follow-ups untouched', async () => {
const { db, q, remId } = await withOpenNudge() const { db, q, remId } = await withOpenNudge()
await sendReminder(db, deps, remId, 'u1') await sendReminder(db, deps, remId, 'u1')
markStatus(db, 'u1', q.id, 'accepted') await markStatus(db, 'u1', q.id, 'accepted')
expect(getReminder(db, remId)!.status).toBe('sent') // history is history expect((await getReminder(db, remId))!.status).toBe('sent') // history is history
}) })
}) })
@ -338,56 +341,56 @@ describe('quote_followup STOP cleanup', () => {
describe('listQueue — quote_followup labelling, owner scope, pagination', () => { describe('listQueue — quote_followup labelling, owner scope, pagination', () => {
it('labels quote follow-ups and derives the owner from document.created_by', async () => { it('labels quote follow-ups and derives the owner from document.created_by', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a') await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const page = listQueue(db) const page = await listQueue(db)
expect(page.total).toBe(1) expect(page.total).toBe(1)
expect(page.rows[0]!.label).toBe('Quote follow-up (d3)') expect(page.rows[0]!.label).toBe('Quote follow-up (d3)')
expect(page.rows[0]!.ownerId).toBe('staff-a') expect(page.rows[0]!.ownerId).toBe('staff-a')
expect(page.rows[0]!.clientName).toBe('Acme') expect(page.rows[0]!.clientName).toBe('Acme')
}) })
it('staff see only their own rows; managerial viewers see everything', async () => { it('staff see only their own rows; managerial viewers see everything', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a') await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b') await sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const a = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' }) const a = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' })
expect(a.total).toBe(1) expect(a.total).toBe(1)
expect(a.rows[0]!.ownerId).toBe('staff-a') expect(a.rows[0]!.ownerId).toBe('staff-a')
const boss = listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' }) const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
expect(boss.total).toBe(2) expect(boss.total).toBe(2)
// A staff request cannot widen its scope via ownerId. // A staff request cannot widen its scope via ownerId.
const sneaky = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a', ownerId: 'staff-b' }) const sneaky = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a', ownerId: 'staff-b' })
expect(sneaky.total).toBe(1) expect(sneaky.total).toBe(1)
expect(sneaky.rows[0]!.ownerId).toBe('staff-a') expect(sneaky.rows[0]!.ownerId).toBe('staff-a')
}) })
it('paginates with an honest total', async () => { it('paginates with an honest total', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z') await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z') await sentQuote(db, c2, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
const p1 = listQueue(db, { page: 1, pageSize: 1 }) const p1 = await listQueue(db, { page: 1, pageSize: 1 })
const p2 = listQueue(db, { page: 2, pageSize: 1 }) const p2 = await listQueue(db, { page: 2, pageSize: 1 })
expect(p1.total).toBe(2); expect(p2.total).toBe(2) expect(p1.total).toBe(2); expect(p2.total).toBe(2)
expect(p1.rows).toHaveLength(1); expect(p2.rows).toHaveLength(1) expect(p1.rows).toHaveLength(1); expect(p2.rows).toHaveLength(1)
expect(p1.rows[0]!.id).not.toBe(p2.rows[0]!.id) expect(p1.rows[0]!.id).not.toBe(p2.rows[0]!.id)
}) })
it('doc-less reminders (no derived owner) stay visible to staff viewers and their counts', async () => { it('doc-less reminders (no derived owner) stay visible to staff viewers and their counts', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-b') // someone else's quote nudge await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-b') // someone else's quote nudge
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
upsertReminder(db, { await upsertReminder(db, {
ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01', ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01',
clientId: c.id, now: '2026-07-04T00:00:00Z', clientId: c.id, now: '2026-07-04T00:00:00Z',
}) })
const a = listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' }) const a = await listQueue(db, { viewerRole: 'staff', viewerId: 'staff-a' })
expect(a.total).toBe(1) // the shared renewal — NOT staff-b's quote nudge expect(a.total).toBe(1) // the shared renewal — NOT staff-b's quote nudge
expect(a.rows[0]!.ruleKind).toBe('renewal_due') expect(a.rows[0]!.ruleKind).toBe('renewal_due')
expect(queueCounts(db, 'staff-a')).toEqual({ queued: 1, failed: 0 }) expect(await queueCounts(db, 'staff-a')).toEqual({ queued: 1, failed: 0 })
const boss = listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' }) const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
expect(boss.total).toBe(2) expect(boss.total).toBe(2)
}) })
}) })
@ -395,29 +398,34 @@ describe('listQueue — quote_followup labelling, owner scope, pagination', () =
// ---------- GET /reminders — ?status= narrows the SAME scoped, paginated view ---------- // ---------- GET /reminders — ?status= narrows the SAME scoped, paginated view ----------
describe('GET /reminders?status=', () => { describe('GET /reminders?status=', () => {
const { db, c, m } = world() async function setup() {
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) const { db, c, m } = await world()
const staff = createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const staff = await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const server = app.listen(0) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const server = app.listen(0)
afterAll(() => server.close()) const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, c, m, staff, server, base }
}
let ctx: Awaited<ReturnType<typeof setup>>
beforeAll(async () => { ctx = await setup() })
afterAll(() => ctx.server.close())
const tokenOf = async (email: string, password: string) => const tokenOf = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, { (await (await fetch(`${ctx.base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' }, method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
})).json() as { token: string }).token })).json() as { token: string }).token
const get = async (token: string, qs: string) => { const get = async (token: string, qs: string) => {
const res = await fetch(`${base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } }) const res = await fetch(`${ctx.base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
it('is owner-scoped and paginated for staff — no unscoped flat list one query-param away', async () => { it('is owner-scoped and paginated for staff — no unscoped flat list one query-param away', async () => {
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) const c2 = await createClient(ctx.db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
const mine = sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) const mine = await sentQuote(ctx.db, ctx.c, ctx.m, '2026-07-01T09:00:00Z', ctx.staff.id)
sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') await sentQuote(ctx.db, c2, ctx.m, '2026-07-01T09:00:00Z', 'someone-else')
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(ctx.db, deps, '2026-07-04')
const staffTok = await tokenOf('staff@test.in', 'staff-password') const staffTok = await tokenOf('staff@test.in', 'staff-password')
const own = await get(staffTok, '?status=queued') const own = await get(staffTok, '?status=queued')
expect(own.status).toBe(200) expect(own.status).toBe(200)

@ -8,19 +8,19 @@ import { createDraft, issueDocument, getDocument } from '../src/repos-documents'
import { recordPayment, receiptForPayment } from '../src/repos-payments' import { recordPayment, receiptForPayment } from '../src/repos-payments'
import { documentHtml } from '../src/templates' import { documentHtml } from '../src/templates'
function setup() { async function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
return { db, c, inv } return { db, c, inv }
} }
describe('payment receipts', () => { describe('payment receipts', () => {
it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', () => { it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', async () => {
const { db, c, inv } = setup() const { db, c, inv } = await setup()
const out = recordPayment(db, 'u1', { const out = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00,
allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true,
}) })
@ -32,19 +32,19 @@ describe('payment receipts', () => {
expect(rc.cgstPaise + rc.sgstPaise + rc.igstPaise).toBe(0) // acknowledgment, not a tax document expect(rc.cgstPaise + rc.sgstPaise + rc.igstPaise).toBe(0) // acknowledgment, not a tax document
expect(rc.payload.receipt?.allocations[0]).toMatchObject({ docNo: inv.docNo, amountPaise: 11_800_00 }) expect(rc.payload.receipt?.allocations[0]).toMatchObject({ docNo: inv.docNo, amountPaise: 11_800_00 })
}) })
it('generates a receipt after the fact from a stored payment', () => { it('generates a receipt after the fact from a stored payment', async () => {
const { db, c, inv } = setup() const { db, c, inv } = await setup()
const out = recordPayment(db, 'u1', { const out = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'upi', amountPaise: 5_000_00, clientId: c.id, receivedOn: '2026-07-10', mode: 'upi', amountPaise: 5_000_00,
allocations: [{ documentId: inv.id, amountPaise: 5_000_00 }], allocations: [{ documentId: inv.id, amountPaise: 5_000_00 }],
}) })
const rc = receiptForPayment(db, 'u1', out.payment.id) const rc = await receiptForPayment(db, 'u1', out.payment.id)
expect(rc.docType).toBe('RECEIPT') expect(rc.docType).toBe('RECEIPT')
expect(getDocument(db, rc.id)!.docNo).toBe(rc.docNo) expect((await getDocument(db, rc.id))!.docNo).toBe(rc.docNo)
}) })
it('renders a receipt acknowledgment (no SAC/GST table)', () => { it('renders a receipt acknowledgment (no SAC/GST table)', async () => {
const { db, c, inv } = setup() const { db, c, inv } = await setup()
const out = recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true }) const out = await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }], issueReceipt: true })
const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' }) const html = documentHtml(out.receipt!, c, { 'company.name': 'Tecnostac' })
expect(html).toContain('RECEIPT') expect(html).toContain('RECEIPT')
expect(html).toContain('Received with thanks') expect(html).toContain('Received with thanks')

@ -6,45 +6,45 @@ import {
createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan, createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan,
} from '../src/repos-recurring' } from '../src/repos-recurring'
function setup() { async function setup() {
const db = openDb(':memory:') const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] }) const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
return { db, c, m, cm } return { db, c, m, cm }
} }
describe('recurring plans', () => { describe('recurring plans', () => {
it('creates a plan, lists it, updates and deactivates with audit', () => { it('creates a plan, lists it, updates and deactivates with audit', async () => {
const { db, c, cm } = setup() const { db, c, cm } = await setup()
const p = createRecurringPlan(db, 'u1', { const p = await createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto', clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto',
}) })
expect(p.policy).toBe('auto') expect(p.policy).toBe('auto')
expect(p.amountPaise).toBeNull() // price resolves from the module at generation expect(p.amountPaise).toBeNull() // price resolves from the module at generation
expect(listRecurringPlans(db, c.id)).toHaveLength(1) expect(await listRecurringPlans(db, c.id)).toHaveLength(1)
const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 }) const up = await updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 })
expect(up.amountPaise).toBe(2_500_00) expect(up.amountPaise).toBe(2_500_00)
const off = deactivateRecurringPlan(db, 'u1', p.id) const off = await deactivateRecurringPlan(db, 'u1', p.id)
expect(off.active).toBe(false) expect(off.active).toBe(false)
const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='recurring_plan'`).all() const audits = await db.all(`SELECT action FROM audit_log WHERE entity='recurring_plan'`)
expect(audits.length).toBe(3) // create + update + deactivate expect(audits.length).toBe(3) // create + update + deactivate
}) })
it('rejects a plan whose module has no price and no explicit amount', () => { it('rejects a plan whose module has no price and no explicit amount', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'X', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'X', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
expect(() => createRecurringPlan(db, 'u1', { await expect(createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01', clientId: c.id, clientModuleId: cm.id, cadence: 'yearly', nextRun: '2026-08-01',
})).toThrow(/price/i) })).rejects.toThrow(/price/i)
}) })
it('rejects a client_module that belongs to another client', () => { it('rejects a client_module that belongs to another client', async () => {
const { db, cm } = setup() const { db, cm } = await setup()
const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' }) const other = await createClient(db, 'u1', { name: 'Other', stateCode: '32' })
expect(() => createRecurringPlan(db, 'u1', { await expect(createRecurringPlan(db, 'u1', {
clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00, clientId: other.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', amountPaise: 100_00,
})).toThrow(/client/i) })).rejects.toThrow(/client/i)
}) })
}) })

@ -1,5 +1,5 @@
// apps/hq/test/reminder-preview.test.ts // apps/hq/test/reminder-preview.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -10,15 +10,15 @@ import { createDraft, issueDocument } from '../src/repos-documents'
import { upsertReminder } from '../src/repos-reminders' import { upsertReminder } from '../src/repos-reminders'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.name='Tecnostac' const db = openDb(':memory:'); await seedIfEmpty(db) // company.name='Tecnostac'
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
const overdue = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id }) const overdue = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id })
const internal = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id }) const internal = await upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -26,7 +26,8 @@ function appWith() {
} }
describe('GET /reminders/:id/preview', () => { describe('GET /reminders/:id/preview', () => {
const ctx = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close()) afterAll(() => ctx.server.close())
const login = async () => const login = async () =>
(await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email: 'owner@test.in', password: 'owner-password' }) })).json() as any).token

@ -11,82 +11,84 @@ interface Row {
} }
/** Raw insert — proves a cadence change is a dated DB row, no code edit (rule 3). */ /** Raw insert — proves a cadence change is a dated DB row, no code edit (rule 3). */
function insertSchedule(db: DB, r: Row): void { async function insertSchedule(db: DB, r: Row): Promise<void> {
db.prepare( await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(uuidv7(), r.ruleKind, r.from, r.to ?? null, r.offsets, r.subject ?? null, r.body ?? null) uuidv7(), r.ruleKind, r.from, r.to ?? null, r.offsets, r.subject ?? null, r.body ?? null,
)
} }
describe('resolveSchedule', () => { describe('resolveSchedule', () => {
it('falls back to the code defaults when no row exists', () => { it('falls back to the code defaults when no row exists', async () => {
const db = openDb(':memory:') // unseeded — table empty const db = openDb(':memory:') // unseeded — table empty
const qf = resolveSchedule(db, 'quote_followup', '2026-07-17') const qf = await resolveSchedule(db, 'quote_followup', '2026-07-17')
expect(qf.dayOffsets).toEqual([3, 7, 14]) expect(qf.dayOffsets).toEqual([3, 7, 14])
expect(qf.source).toBe('default') expect(qf.source).toBe('default')
expect(qf.subject).toContain('{ref}') expect(qf.subject).toContain('{ref}')
expect(qf.body).toContain('{shareUrl}') expect(qf.body).toContain('{shareUrl}')
const inv = resolveSchedule(db, 'invoice_overdue', '2026-07-17') const inv = await resolveSchedule(db, 'invoice_overdue', '2026-07-17')
expect(inv.dayOffsets).toEqual([7, 15, 30]) expect(inv.dayOffsets).toEqual([7, 15, 30])
expect(inv.source).toBe('default') expect(inv.source).toBe('default')
expect(inv.subject).toBeNull() // invoice mail text stays in reminder-templates expect(inv.subject).toBeNull() // invoice mail text stays in reminder-templates
expect(inv.body).toBeNull() expect(inv.body).toBeNull()
}) })
it('returns the dated row active on today; latest effective_from wins', () => { it('returns the dated row active on today; latest effective_from wins', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '3,7,14' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '3,7,14' })
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-07-01', offsets: '2,5,9' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-07-01', offsets: '2,5,9' })
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([2, 5, 9]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([2, 5, 9])
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').source).toBe('db') expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).source).toBe('db')
expect(resolveSchedule(db, 'quote_followup', '2026-03-01').dayOffsets).toEqual([3, 7, 14]) expect((await resolveSchedule(db, 'quote_followup', '2026-03-01')).dayOffsets).toEqual([3, 7, 14])
// a row dated in the future is not active yet // a row dated in the future is not active yet
expect(resolveSchedule(db, 'quote_followup', '2025-12-31').source).toBe('default') expect((await resolveSchedule(db, 'quote_followup', '2025-12-31')).source).toBe('default')
}) })
it('tied effective_from resolves deterministically: highest id wins on any engine', () => { it('tied effective_from resolves deterministically: highest id wins on any engine', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const ins = (id: string, offsets: string) => db.prepare( const ins = (id: string, offsets: string) => db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, 'quote_followup', '2026-01-01', NULL, ?, NULL, NULL)`, VALUES (?, 'quote_followup', '2026-01-01', NULL, ?, NULL, NULL)`,
).run(id, offsets) id, offsets,
)
// Insert the higher id FIRST so plain scan order would pick the other row — // Insert the higher id FIRST so plain scan order would pick the other row —
// the tiebreaker (id DESC; UUIDv7 ids are time-ordered) must decide, not the plan. // the tiebreaker (id DESC; UUIDv7 ids are time-ordered) must decide, not the plan.
ins('zzzz-high', '2,4') await ins('zzzz-high', '2,4')
ins('aaaa-low', '5,10') await ins('aaaa-low', '5,10')
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([2, 4]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([2, 4])
}) })
it('effective_to closes the window: active requires effective_to > today', () => { it('effective_to closes the window: active requires effective_to > today', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', to: '2026-07-01', offsets: '1,2' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', to: '2026-07-01', offsets: '1,2' })
expect(resolveSchedule(db, 'quote_followup', '2026-06-30').dayOffsets).toEqual([1, 2]) expect((await resolveSchedule(db, 'quote_followup', '2026-06-30')).dayOffsets).toEqual([1, 2])
expect(resolveSchedule(db, 'quote_followup', '2026-07-01').source).toBe('default') // boundary day excluded expect((await resolveSchedule(db, 'quote_followup', '2026-07-01')).source).toBe('default') // boundary day excluded
}) })
it('a new dated row changes cadence with no code edit', () => { it('a new dated row changes cadence with no code edit', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
seedIfEmpty(db) await seedIfEmpty(db)
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14])
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-08-01', offsets: '5,10' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-08-01', offsets: '5,10' })
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14]) // unchanged before expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14]) // unchanged before
expect(resolveSchedule(db, 'quote_followup', '2026-08-01').dayOffsets).toEqual([5, 10]) // new cadence after expect((await resolveSchedule(db, 'quote_followup', '2026-08-01')).dayOffsets).toEqual([5, 10]) // new cadence after
}) })
it('parses messy CSV ascending and falls back on garbage offsets', () => { it('parses messy CSV ascending and falls back on garbage offsets', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: ' 14, 3 ,7 ' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: ' 14, 3 ,7 ' })
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14])
insertSchedule(db, { ruleKind: 'invoice_overdue', from: '2026-01-01', offsets: 'a,b,-3,0' }) await insertSchedule(db, { ruleKind: 'invoice_overdue', from: '2026-01-01', offsets: 'a,b,-3,0' })
const inv = resolveSchedule(db, 'invoice_overdue', '2026-07-17') const inv = await resolveSchedule(db, 'invoice_overdue', '2026-07-17')
expect(inv.dayOffsets).toEqual([7, 15, 30]) // nothing usable → code default expect(inv.dayOffsets).toEqual([7, 15, 30]) // nothing usable → code default
expect(inv.source).toBe('default') expect(inv.source).toBe('default')
}) })
it('a cadence-only row keeps the code-constant message text', () => { it('a cadence-only row keeps the code-constant message text', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '4,8' }) await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '4,8' })
const r = resolveSchedule(db, 'quote_followup', '2026-07-17') const r = await resolveSchedule(db, 'quote_followup', '2026-07-17')
expect(r.dayOffsets).toEqual([4, 8]) expect(r.dayOffsets).toEqual([4, 8])
expect(r.subject).toBe(SCHEDULE_DEFAULTS.quote_followup.subject) expect(r.subject).toBe(SCHEDULE_DEFAULTS.quote_followup.subject)
expect(r.body).toBe(SCHEDULE_DEFAULTS.quote_followup.body) expect(r.body).toBe(SCHEDULE_DEFAULTS.quote_followup.body)
@ -94,22 +96,22 @@ describe('resolveSchedule', () => {
}) })
describe('seeded schedule rows', () => { describe('seeded schedule rows', () => {
it('seedIfEmpty seeds one dated row per rule kind, audited, and is idempotent', () => { it('seedIfEmpty seeds one dated row per rule kind, audited, and is idempotent', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
seedIfEmpty(db) await seedIfEmpty(db)
seedIfEmpty(db) // idempotent — no duplicates await seedIfEmpty(db) // idempotent — no duplicates
const rows = db.prepare( const rows = await db.all<{ rule_kind: string; n: number }>(
`SELECT rule_kind, COUNT(*) AS n FROM reminder_schedule GROUP BY rule_kind ORDER BY rule_kind`, `SELECT rule_kind, COUNT(*) AS n FROM reminder_schedule GROUP BY rule_kind ORDER BY rule_kind`,
).all() as { rule_kind: string; n: number }[] )
expect(rows).toEqual([ expect(rows).toEqual([
{ rule_kind: 'invoice_overdue', n: 1 }, { rule_kind: 'invoice_overdue', n: 1 },
{ rule_kind: 'quote_followup', n: 1 }, { rule_kind: 'quote_followup', n: 1 },
]) ])
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').source).toBe('db') expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).source).toBe('db')
expect(resolveSchedule(db, 'invoice_overdue', '2026-07-17').source).toBe('db') expect((await resolveSchedule(db, 'invoice_overdue', '2026-07-17')).source).toBe('db')
const audits = db.prepare( const audits = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder_schedule'`, `SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder_schedule'`,
).get() as { n: number } ))!
expect(audits.n).toBe(2) expect(audits.n).toBe(2)
}) })
}) })

@ -3,7 +3,7 @@
// Mine/All toggle onto ?owner=. Queued/failed were already covered (quote-followup.test.ts); // Mine/All toggle onto ?owner=. Queued/failed were already covered (quote-followup.test.ts);
// this pins the sent/dismissed chips, ?owner= at HTTP level, and staff scoping across chips. // this pins the sent/dismissed chips, ?owner= at HTTP level, and staff scoping across chips.
import express from 'express' import express from 'express'
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { openDb, type DB } from '../src/db' import { openDb, type DB } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth' import { createStaff } from '../src/auth'
@ -27,57 +27,63 @@ const deps: ScanDeps & SendReminderDeps = {
now: () => '2026-07-10T09:00:00Z', now: () => '2026-07-10T09:00:00Z',
} }
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' }) const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m } return { db, c, m }
} }
/** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */ /** A QUOTATION marked sent, with the first-sent event pinned to `sentAt` for age math. */
function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Doc { async function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Promise<Doc> {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) const q = await createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent') await markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id) await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, sentAt, q.id)
return q return q
} }
describe('GET /reminders — Reminders page contract (status chips, Mine/All, staff scope)', () => { describe('GET /reminders — Reminders page contract (status chips, Mine/All, staff scope)', () => {
const { db, c, m } = world() async function setup() {
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) const { db, c, m } = await world()
const staff = createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const staff = await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const server = app.listen(0) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const server = app.listen(0)
afterAll(() => server.close()) const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, c, m, staff, server, base }
}
let ctx: Awaited<ReturnType<typeof setup>>
beforeAll(async () => { ctx = await setup() })
afterAll(() => ctx.server.close())
const tokenOf = async (email: string, password: string) => const tokenOf = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, { (await (await fetch(`${ctx.base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' }, method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
})).json() as { token: string }).token })).json() as { token: string }).token
const get = async (token: string, qs: string) => { const get = async (token: string, qs: string) => {
const res = await fetch(`${base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } }) const res = await fetch(`${ctx.base}/reminders${qs}`, { headers: { authorization: `Bearer ${token}` } })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
it('every chip maps to ?status= and Mine (?owner=) composes with it; staff scope holds on every chip', async () => { it('every chip maps to ?status= and Mine (?owner=) composes with it; staff scope holds on every chip', async () => {
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] }) const { db, c, m, staff } = ctx
const mine = sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) // → queued nudge owned by staff const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
const theirs = sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') // → queued nudge owned by someone else const mine = await sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) // → queued nudge owned by staff
const theirs = await sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') // → queued nudge owned by someone else
await runDailyScan(db, deps, '2026-07-04') await runDailyScan(db, deps, '2026-07-04')
// Flip someone-else's nudge to sent; queue a doc-less shared renewal and dismiss it. // Flip someone-else's nudge to sent; queue a doc-less shared renewal and dismiss it.
const nudges = listReminders(db, { ruleKind: 'quote_followup' }) const nudges = await listReminders(db, { ruleKind: 'quote_followup' })
const theirsRem = nudges.find((r) => r.docId === theirs.id)! const theirsRem = nudges.find((r) => r.docId === theirs.id)!
expect((await sendReminder(db, deps, theirsRem.id, 'u1')).ok).toBe(true) expect((await sendReminder(db, deps, theirsRem.id, 'u1')).ok).toBe(true)
const renewal = upsertReminder(db, { const renewal = await upsertReminder(db, {
ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01', ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01',
clientId: c.id, now: '2026-07-04T00:00:00Z', clientId: c.id, now: '2026-07-04T00:00:00Z',
}) })
dismissReminder(db, 'u1', renewal.id) await dismissReminder(db, 'u1', renewal.id)
const ownerTok = await tokenOf('owner@test.in', 'owner-password') const ownerTok = await tokenOf('owner@test.in', 'owner-password')
// Each chip narrows to exactly its status. // Each chip narrows to exactly its status.

@ -19,31 +19,31 @@ describe('reminder templates', () => {
}) })
describe('reminder repo', () => { describe('reminder repo', () => {
it('is idempotent on (rule_kind, subject_id, due_period)', () => { it('is idempotent on (rule_kind, subject_id, due_period)', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const a = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T00:00:00Z' }) const a = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T00:00:00Z' })
expect(a.created).toBe(true) expect(a.created).toBe(true)
const b = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T06:00:00Z' }) const b = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv1', duePeriod: '2026-07', clientId: 'c1', docId: 'inv1', now: '2026-07-10T06:00:00Z' })
expect(b.created).toBe(false) expect(b.created).toBe(false)
expect(b.id).toBe(a.id) // same row returned, not a duplicate expect(b.id).toBe(a.id) // same row returned, not a duplicate
expect(listQueue(db).rows).toHaveLength(1) // Phase 6: listQueue paginates ({ rows, total, page, pageSize }) expect((await listQueue(db)).rows).toHaveLength(1) // Phase 6: listQueue paginates ({ rows, total, page, pageSize })
expect(listQueue(db).total).toBe(1) expect((await listQueue(db)).total).toBe(1)
expect(db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder'`).get()).toMatchObject({ n: 1 }) expect(await db.get(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder'`)).toMatchObject({ n: 1 })
}) })
it('transitions and dismisses, refusing to dismiss a sent reminder', () => { it('transitions and dismisses, refusing to dismiss a sent reminder', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: 'c1', now: '2026-07-10T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: 'c1', now: '2026-07-10T00:00:00Z' })
const dismissed = dismissReminder(db, 'u1', id) const dismissed = await dismissReminder(db, 'u1', id)
expect(dismissed.status).toBe('dismissed') expect(dismissed.status).toBe('dismissed')
const { id: id2 } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv2', duePeriod: '2026-07', clientId: 'c1', docId: 'inv2', now: '2026-07-10T00:00:00Z' }) const { id: id2 } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv2', duePeriod: '2026-07', clientId: 'c1', docId: 'inv2', now: '2026-07-10T00:00:00Z' })
setReminderStatus(db, 'u1', id2, 'sent', { sentAt: '2026-07-10T09:00:00Z' }) await setReminderStatus(db, 'u1', id2, 'sent', { sentAt: '2026-07-10T09:00:00Z' })
expect(getReminder(db, id2)!.status).toBe('sent') expect((await getReminder(db, id2))!.status).toBe('sent')
expect(() => dismissReminder(db, 'u1', id2)).toThrow(/sent/i) await expect(dismissReminder(db, 'u1', id2)).rejects.toThrow(/sent/i)
}) })
it('reads number settings with a fallback', () => { it('reads number settings with a fallback', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(7) expect(await getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(7)
setSetting(db, 'u1', 'reminders.overdue_days', '10') await setSetting(db, 'u1', 'reminders.overdue_days', '10')
expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(10) expect(await getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(10)
}) })
}) })

@ -8,32 +8,32 @@ import { recordPayment } from '../src/repos-payments'
import { upsertAwsUsage } from '../src/repos-aws' import { upsertAwsUsage } from '../src/repos-aws'
import { duesAging, moduleRevenue, clientProfitability } from '../src/repos-reports' import { duesAging, moduleRevenue, clientProfitability } from '../src/repos-reports'
function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: string) { async function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: string) {
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], docType: 'INVOICE', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
}).id) })).id)
// Rewind to a legacy (pre-due-date) invoice: aging must anchor on doc_date, // Rewind to a legacy (pre-due-date) invoice: aging must anchor on doc_date,
// so the issue-stamped due_date is cleared along with the rewind. // so the issue-stamped due_date is cleared along with the rewind.
db.prepare(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`).run(docDate, inv.id) await db.run(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`, docDate, inv.id)
return inv return inv
} }
function setup() { async function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const pos = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] }) const pos = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
setPrice(db, 'u1', { moduleId: pos.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: pos.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
assignModule(db, 'u1', { clientId: c.id, moduleId: pos.id, kind: 'yearly' }) await assignModule(db, 'u1', { clientId: c.id, moduleId: pos.id, kind: 'yearly' })
return { db, c, pos } return { db, c, pos }
} }
describe('duesAging', () => { describe('duesAging', () => {
it('buckets each client outstanding by invoice age', () => { it('buckets each client outstanding by invoice age', async () => {
const { db, c, pos } = setup() const { db, c, pos } = await setup()
issuedInvoice(db, c.id, pos.id, '2026-07-01') // ~9 days old on 2026-07-10 → 0-30 await issuedInvoice(db, c.id, pos.id, '2026-07-01') // ~9 days old on 2026-07-10 → 0-30
issuedInvoice(db, c.id, pos.id, '2026-05-20') // ~51 days → 31-60 await issuedInvoice(db, c.id, pos.id, '2026-05-20') // ~51 days → 31-60
issuedInvoice(db, c.id, pos.id, '2026-01-01') // >90 days → 90+ await issuedInvoice(db, c.id, pos.id, '2026-01-01') // >90 days → 90+
const rows = duesAging(db, '2026-07-10') const rows = await duesAging(db, '2026-07-10')
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
const r = rows[0]! const r = rows[0]!
expect(r.clientId).toBe(c.id) expect(r.clientId).toBe(c.id)
@ -42,40 +42,40 @@ describe('duesAging', () => {
expect(r.b90p).toBe(11_800_00) expect(r.b90p).toBe(11_800_00)
expect(r.totalPaise).toBe(35_400_00) expect(r.totalPaise).toBe(35_400_00)
}) })
it('drops fully-settled invoices', () => { it('drops fully-settled invoices', async () => {
const { db, c, pos } = setup() const { db, c, pos } = await setup()
const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') const inv = await issuedInvoice(db, c.id, pos.id, '2026-07-01')
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] }) await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] })
expect(duesAging(db, '2026-07-10')).toHaveLength(0) expect(await duesAging(db, '2026-07-10')).toHaveLength(0)
}) })
}) })
describe('moduleRevenue', () => { describe('moduleRevenue', () => {
it('reports billed and settled per module, settlement split pro-rata', () => { it('reports billed and settled per module, settlement split pro-rata', async () => {
const { db, c, pos } = setup() const { db, c, pos } = await setup()
const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') const inv = await issuedInvoice(db, c.id, pos.id, '2026-07-01')
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 5_900_00, allocations: [{ documentId: inv.id, amountPaise: 5_900_00 }] }) await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 5_900_00, allocations: [{ documentId: inv.id, amountPaise: 5_900_00 }] })
const rows = moduleRevenue(db) const rows = await moduleRevenue(db)
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]!.moduleCode).toBe('POS') expect(rows[0]!.moduleCode).toBe('POS')
expect(rows[0]!.billedPaise).toBe(11_800_00) expect(rows[0]!.billedPaise).toBe(11_800_00)
expect(rows[0]!.settledPaise).toBe(5_900_00) expect(rows[0]!.settledPaise).toBe(5_900_00)
}) })
it('honours a date range', () => { it('honours a date range', async () => {
const { db, c, pos } = setup() const { db, c, pos } = await setup()
issuedInvoice(db, c.id, pos.id, '2026-04-01') await issuedInvoice(db, c.id, pos.id, '2026-04-01')
issuedInvoice(db, c.id, pos.id, '2026-07-01') await issuedInvoice(db, c.id, pos.id, '2026-07-01')
expect(moduleRevenue(db, { from: '2026-07-01', to: '2026-07-31' })[0]!.billedPaise).toBe(11_800_00) expect((await moduleRevenue(db, { from: '2026-07-01', to: '2026-07-31' }))[0]!.billedPaise).toBe(11_800_00)
}) })
}) })
describe('clientProfitability', () => { describe('clientProfitability', () => {
it('reports billed, settled, aws cost and margin per client', () => { it('reports billed, settled, aws cost and margin per client', async () => {
const { db, c, pos } = setup() const { db, c, pos } = await setup()
const inv = issuedInvoice(db, c.id, pos.id, '2026-07-01') const inv = await issuedInvoice(db, c.id, pos.id, '2026-07-01')
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] }) await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-05', mode: 'bank', amountPaise: 11_800_00, allocations: [{ documentId: inv.id, amountPaise: 11_800_00 }] })
upsertAwsUsage(db, 'u1', { clientId: c.id, month: '2026-07', costPaise: 1_000_00 }) await upsertAwsUsage(db, 'u1', { clientId: c.id, month: '2026-07', costPaise: 1_000_00 })
const rows = clientProfitability(db, { from: '2026-07-01', to: '2026-07-31' }) const rows = await clientProfitability(db, { from: '2026-07-01', to: '2026-07-31' })
expect(rows).toHaveLength(1) expect(rows).toHaveLength(1)
expect(rows[0]!).toMatchObject({ expect(rows[0]!).toMatchObject({
billedPaise: 11_800_00, settledPaise: 11_800_00, awsCostPaise: 1_000_00, marginPaise: 10_800_00, billedPaise: 11_800_00, settledPaise: 11_800_00, awsCostPaise: 1_000_00, marginPaise: 10_800_00,

@ -20,61 +20,61 @@ function deps(f: typeof fetch): ScanDeps {
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z' } return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: async () => Buffer.from('%PDF-'), company: () => ({ 'company.name': 'Tecnostac' }), now: () => '2026-07-10T00:00:00Z' }
} }
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] }) const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] })
setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
return { db, c, m, cm } return { db, c, m, cm }
} }
describe('runDailyScan — recurring generation', () => { describe('runDailyScan — recurring generation', () => {
it('auto plan: generates+issues an invoice, advances next_run one cadence, sends, marks sent', async () => { it('auto plan: generates+issues an invoice, advances next_run one cadence, sends, marks sent', async () => {
const { db, c, cm } = world() const { db, c, cm } = await world()
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' }) const plan = await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' })
const res = await runDailyScan(db, deps(okFetch), '2026-07-10') const res = await runDailyScan(db, deps(okFetch), '2026-07-10')
expect(res.created['recurring_generated']).toBe(1) expect(res.created['recurring_generated']).toBe(1)
expect(res.autoSent).toBe(1) expect(res.autoSent).toBe(1)
const invoices = listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents const invoices = (await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents
expect(invoices).toHaveLength(1) expect(invoices).toHaveLength(1)
expect(invoices[0]!.docNo).toMatch(/^INV\//) expect(invoices[0]!.docNo).toMatch(/^INV\//)
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // advanced exactly one month expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') // advanced exactly one month
const rem = listReminders(db, { ruleKind: 'recurring_generated' })[0]! const rem = (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]!
expect(rem.status).toBe('sent') expect(rem.status).toBe('sent')
expect(rem.docId).toBe(invoices[0]!.id) expect(rem.docId).toBe(invoices[0]!.id)
}) })
it('failed send: invoice exists and next_run advanced, reminder is failed (queued for manual), no regeneration on re-run', async () => { it('failed send: invoice exists and next_run advanced, reminder is failed (queued for manual), no regeneration on re-run', async () => {
const { db, c, cm } = world() const { db, c, cm } = await world()
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' }) const plan = await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' })
const res = await runDailyScan(db, deps(deadFetch), '2026-07-10') const res = await runDailyScan(db, deps(deadFetch), '2026-07-10')
expect(res.created['recurring_generated']).toBe(1) expect(res.created['recurring_generated']).toBe(1)
expect(res.autoFailed).toBe(1) expect(res.autoFailed).toBe(1)
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(1) // invoice still issued expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(1) // invoice still issued
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // schedule advanced on generation expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') // schedule advanced on generation
const rem = listReminders(db, { ruleKind: 'recurring_generated' })[0]! const rem = (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]!
expect(rem.status).toBe('failed') // waits in the manual queue expect(rem.status).toBe('failed') // waits in the manual queue
// Re-run: next_run is now in the future → no second invoice, no duplicate reminder. // Re-run: next_run is now in the future → no second invoice, no duplicate reminder.
const again = await runDailyScan(db, deps(deadFetch), '2026-07-10') const again = await runDailyScan(db, deps(deadFetch), '2026-07-10')
expect(again.created['recurring_generated'] ?? 0).toBe(0) expect(again.created['recurring_generated'] ?? 0).toBe(0)
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(1) expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(1)
}) })
it('manual plan: generates the invoice but queues (no send)', async () => { it('manual plan: generates the invoice but queues (no send)', async () => {
const { db, c, cm } = world() const { db, c, cm } = await world()
createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'manual' }) await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'manual' })
const res = await runDailyScan(db, deps(okFetch), '2026-07-10') const res = await runDailyScan(db, deps(okFetch), '2026-07-10')
expect(res.autoSent).toBe(0) expect(res.autoSent).toBe(0)
expect(getReminder(db, listReminders(db, { ruleKind: 'recurring_generated' })[0]!.id)!.status).toBe('queued') expect((await getReminder(db, (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]!.id))!.status).toBe('queued')
}) })
it('catch-up: a plan three months in arrears bills every missed month exactly once', async () => { it('catch-up: a plan three months in arrears bills every missed month exactly once', async () => {
const { db, c, cm } = world() const { db, c, cm } = await world()
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-05-10', policy: 'manual' }) const plan = await createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-05-10', policy: 'manual' })
const res = await runDailyScan(db, deps(okFetch), '2026-07-10') const res = await runDailyScan(db, deps(okFetch), '2026-07-10')
expect(res.created['recurring_generated']).toBe(3) // May, Jun, Jul expect(res.created['recurring_generated']).toBe(3) // May, Jun, Jul
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(3) expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(3)
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10')
const periods = listReminders(db, { ruleKind: 'recurring_generated' }).map((r) => r.duePeriod).sort() const periods = (await listReminders(db, { ruleKind: 'recurring_generated' })).map((r) => r.duePeriod).sort()
expect(periods).toEqual(['2026-05-10', '2026-06-10', '2026-07-10']) expect(periods).toEqual(['2026-05-10', '2026-06-10', '2026-07-10'])
}) })
}) })

@ -18,100 +18,101 @@ const deps: ScanDeps = {
now: () => '2026-07-10T00:00:00Z', now: () => '2026-07-10T00:00:00Z',
} }
function world() { async function world() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m } return { db, c, m }
} }
describe('runDailyScan — detection rules', () => { describe('runDailyScan — detection rules', () => {
it('raises overdue, renewal, amc and follow-up reminders, and is idempotent', async () => { it('raises overdue, renewal, amc and follow-up reminders, and is idempotent', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
// Overdue invoice: issued 2026-06-01, unpaid, > 7 days before 2026-07-10. // Overdue invoice: issued 2026-06-01, unpaid, > 7 days before 2026-07-10.
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
db.prepare(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`).run(inv.id) await db.run(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`, inv.id)
// Renewal within 15 days. // Renewal within 15 days.
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' }) const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' }) await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
// AMC expiring within 30 days. // AMC expiring within 30 days.
createAmc(db, 'u1', { clientId: c.id, coverage: 'Support', periodFrom: '2025-08-01', periodTo: '2026-08-01', amountPaise: 20_000_00 }) await createAmc(db, 'u1', { clientId: c.id, coverage: 'Support', periodFrom: '2025-08-01', periodTo: '2026-08-01', amountPaise: 20_000_00 })
// Follow-up due. // Follow-up due.
createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' }) await createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
const res = await runDailyScan(db, deps, '2026-07-10') const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['invoice_overdue']).toBe(1) expect(res.created['invoice_overdue']).toBe(1)
expect(res.created['renewal_due']).toBe(1) expect(res.created['renewal_due']).toBe(1)
expect(res.created['amc_expiring']).toBe(1) expect(res.created['amc_expiring']).toBe(1)
expect(res.created['follow_up']).toBe(1) expect(res.created['follow_up']).toBe(1)
expect(listReminders(db, { status: 'queued' })).toHaveLength(4) expect(await listReminders(db, { status: 'queued' })).toHaveLength(4)
// Re-run same day → the idempotency key blocks every duplicate. // Re-run same day → the idempotency key blocks every duplicate.
const again = await runDailyScan(db, deps, '2026-07-10') const again = await runDailyScan(db, deps, '2026-07-10')
expect(Object.values(again.created).reduce((a, b) => a + b, 0)).toBe(0) expect(Object.values(again.created).reduce((a, b) => a + b, 0)).toBe(0)
expect(listReminders(db, {})).toHaveLength(4) expect(await listReminders(db, {})).toHaveLength(4)
}) })
it('does not raise a reminder for an invoice that is not yet overdue', async () => { it('does not raise a reminder for an invoice that is not yet overdue', async () => {
const { db, c, m } = world() const { db, c, m } = await world()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
db.prepare(`UPDATE document SET doc_date='2026-07-08', due_date=NULL WHERE id=?`).run(inv.id) // only 2 days old await db.run(`UPDATE document SET doc_date='2026-07-08', due_date=NULL WHERE id=?`, inv.id) // only 2 days old
const res = await runDailyScan(db, deps, '2026-07-10') const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['invoice_overdue'] ?? 0).toBe(0) expect(res.created['invoice_overdue'] ?? 0).toBe(0)
}) })
}) })
describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () => { describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () => {
function agedInvoice(docDate: string) { async function agedInvoice(docDate: string) {
const { db, c, m } = world() const { db, c, m } = await world()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }], docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id) })).id)
db.prepare(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`).run(docDate, inv.id) await db.run(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`, docDate, inv.id)
return { db, c, inv } return { db, c, inv }
} }
const invoiceRows = (db: ReturnType<typeof openDb>) => const invoiceRows = async (db: ReturnType<typeof openDb>) =>
listReminders(db, {}).filter((r) => r.ruleKind === 'invoice_overdue') (await listReminders(db, {})).filter((r) => r.ruleKind === 'invoice_overdue')
it('fires d7, then d15, then d30 — each exactly once as it is crossed', async () => { it('fires d7, then d15, then d30 — each exactly once as it is crossed', async () => {
const { db } = agedInvoice('2026-06-01') const { db } = await agedInvoice('2026-06-01')
await runDailyScan(db, deps, '2026-06-08') // age 7 → d7 await runDailyScan(db, deps, '2026-06-08') // age 7 → d7
await runDailyScan(db, deps, '2026-06-10') // age 9 → d7 already consumed await runDailyScan(db, deps, '2026-06-10') // age 9 → d7 already consumed
await runDailyScan(db, deps, '2026-06-16') // age 15 → d15 await runDailyScan(db, deps, '2026-06-16') // age 15 → d15
await runDailyScan(db, deps, '2026-07-01') // age 30 → d30 await runDailyScan(db, deps, '2026-07-01') // age 30 → d30
await runDailyScan(db, deps, '2026-07-05') // age 34 → d30 already consumed await runDailyScan(db, deps, '2026-07-05') // age 34 → d30 already consumed
expect(invoiceRows(db).map((r) => r.duePeriod).sort()).toEqual(['d15', 'd30', 'd7']) expect((await invoiceRows(db)).map((r) => r.duePeriod).sort()).toEqual(['d15', 'd30', 'd7'])
}) })
it('catch-up after a scan gap fires ONLY the highest crossed milestone (F7)', async () => { it('catch-up after a scan gap fires ONLY the highest crossed milestone (F7)', async () => {
const { db } = agedInvoice('2026-06-01') const { db } = await agedInvoice('2026-06-01')
await runDailyScan(db, deps, '2026-07-10') // first scan ever, age 39 await runDailyScan(db, deps, '2026-07-10') // first scan ever, age 39
expect(invoiceRows(db).map((r) => r.duePeriod)).toEqual(['d30']) expect((await invoiceRows(db)).map((r) => r.duePeriod)).toEqual(['d30'])
}) })
it('monthly→dN cutover: an already-reminded aged invoice gains one dN row, never the ladder', async () => { it('monthly→dN cutover: an already-reminded aged invoice gains one dN row, never the ladder', async () => {
const { db, c, inv } = agedInvoice('2026-06-01') const { db, c, inv } = await agedInvoice('2026-06-01')
// Legacy row from the old monthly-bucket scheme. // Legacy row from the old monthly-bucket scheme.
upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-06', clientId: c.id, docId: inv.id, now: '2026-06-15T00:00:00Z' }) await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-06', clientId: c.id, docId: inv.id, now: '2026-06-15T00:00:00Z' })
await runDailyScan(db, deps, '2026-07-10') // age 39 await runDailyScan(db, deps, '2026-07-10') // age 39
const periods = invoiceRows(db).map((r) => r.duePeriod).sort() const periods = (await invoiceRows(db)).map((r) => r.duePeriod).sort()
expect(periods).toEqual(['2026-06', 'd30']) // one legacy + one highest milestone expect(periods).toEqual(['2026-06', 'd30']) // one legacy + one highest milestone
}) })
it('a dated reminder_schedule row changes the cadence with no code change', async () => { it('a dated reminder_schedule row changes the cadence with no code change', async () => {
const { db } = agedInvoice('2026-07-07') // age 3 on the 10th — below the 7/15/30 default const { db } = await agedInvoice('2026-07-07') // age 3 on the 10th — below the 7/15/30 default
db.prepare( await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, day_offsets) VALUES (?, ?, ?, ?)`, `INSERT INTO reminder_schedule (id, rule_kind, effective_from, day_offsets) VALUES (?, ?, ?, ?)`,
).run('sched-inv-fast', 'invoice_overdue', '2026-07-01', '3') 'sched-inv-fast', 'invoice_overdue', '2026-07-01', '3',
)
const res = await runDailyScan(db, deps, '2026-07-10') const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['invoice_overdue']).toBe(1) expect(res.created['invoice_overdue']).toBe(1)
expect(invoiceRows(db).map((r) => r.duePeriod)).toEqual(['d3']) expect((await invoiceRows(db)).map((r) => r.duePeriod)).toEqual(['d3'])
}) })
it('the email renders "N day(s) past due" from ctx.daysOverdue', async () => { it('the email renders "N day(s) past due" from ctx.daysOverdue', async () => {
const { db, c, inv } = agedInvoice('2026-06-25') // 15 days before the 10th const { db, c, inv } = await agedInvoice('2026-06-25') // 15 days before the 10th
const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: 'd15', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' })
const { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-10') const { ctx } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-10')
expect(ctx.daysOverdue).toBe(15) expect(ctx.daysOverdue).toBe(15)
expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/15 day\(s\) past due/) expect(reminderEmail('invoice_overdue', ctx).bodyText).toMatch(/15 day\(s\) past due/)
}) })

@ -18,41 +18,41 @@ function deps(f: typeof fetch): SendReminderDeps {
return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: fakePdf, company, now: () => '2026-07-10T09:00:00Z' } return { gmail: { f, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, renderPdf: fakePdf, company, now: () => '2026-07-10T09:00:00Z' }
} }
function invoiceSetup() { async function invoiceSetup() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY)) await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id) const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
return { db, c, inv } return { db, c, inv }
} }
describe('sendReminder', () => { describe('sendReminder', () => {
it('sends an overdue reminder with the invoice PDF and marks it sent', async () => { it('sends an overdue reminder with the invoice PDF and marks it sent', async () => {
const { db, c, inv } = invoiceSetup() const { db, c, inv } = await invoiceSetup()
const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' })
const okFetch = (async (url: string) => const okFetch = (async (url: string) =>
new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch new Response(JSON.stringify(String(url).includes('/token') ? { access_token: 'at' } : { id: 'gmsg-1' }), { status: 200 })) as typeof fetch
const out = await sendReminder(db, deps(okFetch), id, 'u1') const out = await sendReminder(db, deps(okFetch), id, 'u1')
expect(out).toEqual({ ok: true }) expect(out).toEqual({ ok: true })
expect(getReminder(db, id)!.status).toBe('sent') expect((await getReminder(db, id))!.status).toBe('sent')
expect(getReminder(db, id)!.sentAt).toBe('2026-07-10T09:00:00Z') expect((await getReminder(db, id))!.sentAt).toBe('2026-07-10T09:00:00Z')
const log = db.prepare(`SELECT status, document_id FROM email_log ORDER BY id DESC`).get() as { status: string; document_id: string } const log = await db.get<{ status: string; document_id: string }>(`SELECT status, document_id FROM email_log ORDER BY id DESC`)
expect(log).toMatchObject({ status: 'sent', document_id: inv.id }) expect(log).toMatchObject({ status: 'sent', document_id: inv.id })
}) })
it('leaves the reminder failed (not sent) on token death, and flips the account dead', async () => { it('leaves the reminder failed (not sent) on token death, and flips the account dead', async () => {
const { db, c, inv } = invoiceSetup() const { db, c, inv } = await invoiceSetup()
const { id } = upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.id, now: '2026-07-10T00:00:00Z' })
const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch const deadFetch = (async () => new Response(JSON.stringify({ error: 'invalid_grant' }), { status: 400 })) as typeof fetch
const out = await sendReminder(db, deps(deadFetch), id, 'u1') const out = await sendReminder(db, deps(deadFetch), id, 'u1')
expect(out).toEqual({ ok: false, error: 'gmail-token-dead' }) expect(out).toEqual({ ok: false, error: 'gmail-token-dead' })
expect(getReminder(db, id)!.status).toBe('failed') expect((await getReminder(db, id))!.status).toBe('failed')
expect(db.prepare(`SELECT status FROM email_account`).get()).toMatchObject({ status: 'dead' }) expect(await db.get(`SELECT status FROM email_account`)).toMatchObject({ status: 'dead' })
}) })
it('refuses to email an internal follow_up reminder', async () => { it('refuses to email an internal follow_up reminder', async () => {
const { db, c } = invoiceSetup() const { db, c } = await invoiceSetup()
const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id, now: '2026-07-10T00:00:00Z' }) const { id } = await upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id, now: '2026-07-10T00:00:00Z' })
const noFetch = (async () => new Response('{}', { status: 200 })) as typeof fetch const noFetch = (async () => new Response('{}', { status: 200 })) as typeof fetch
await expect(sendReminder(db, deps(noFetch), id, 'u1')).rejects.toThrow(/sendable/i) await expect(sendReminder(db, deps(noFetch), id, 'u1')).rejects.toThrow(/sendable/i)
}) })

@ -3,16 +3,16 @@ import { openDb } from '../src/db'
import { nextDocNo, seedSeries } from '../src/series' import { nextDocNo, seedSeries } from '../src/series'
describe('hq doc series', () => { describe('hq doc series', () => {
it('starts at 0001 per type+fy and increments', () => { it('starts at 0001 per type+fy and increments', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
expect(nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0001') expect(await nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0001')
expect(nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0002') expect(await nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0002')
expect(nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0001') expect(await nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0001')
expect(nextDocNo(db, 'QUOTATION', '2027-28')).toBe('QT/27-28-0001') expect(await nextDocNo(db, 'QUOTATION', '2027-28')).toBe('QT/27-28-0001')
}) })
it('seeds from the last APEX number at cutover', () => { it('seeds from the last APEX number at cutover', async () => {
const db = openDb(':memory:') const db = openDb(':memory:')
seedSeries(db, 'INVOICE', '2026-27', 412) await seedSeries(db, 'INVOICE', '2026-27', 412)
expect(nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0413') expect(await nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0413')
}) })
}) })

@ -1,15 +1,15 @@
// apps/hq/test/settings-company.test.ts // apps/hq/test/settings-company.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth' import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -17,12 +17,13 @@ function appWith() {
} }
describe('company profile settings', () => { describe('company profile settings', () => {
const { db, server, base } = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
afterAll(() => server.close()) beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
const login = async (email: string, password: string) => const login = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token
const call = async (token: string, method: string, path: string, body?: unknown) => { const call = async (token: string, method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) const res = await fetch(ctx.base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
@ -34,7 +35,7 @@ describe('company profile settings', () => {
expect(put.status).toBe(200) expect(put.status).toBe(200)
expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd') expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd')
expect(put.json.company['company.phone']).toBe('0484-1234567') expect(put.json.company['company.phone']).toBe('0484-1234567')
const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`).get() as { n: number } const audits = (await ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`))!
expect(audits.n).toBeGreaterThanOrEqual(3) expect(audits.n).toBeGreaterThanOrEqual(3)
}) })
it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => { it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => {

@ -1,5 +1,5 @@
// apps/hq/test/settings-reminders.test.ts — Task 3: reminder settings + dated schedule inserts // apps/hq/test/settings-reminders.test.ts — Task 3: reminder settings + dated schedule inserts
import { describe, expect, it, afterAll } from 'vitest' import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -7,56 +7,57 @@ import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders' import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders'
const fresh = () => { const db = openDb(':memory:'); seedIfEmpty(db); return db } const fresh = async () => { const db = openDb(':memory:'); await seedIfEmpty(db); return db }
describe('schedule settings', () => { describe('schedule settings', () => {
it('lists the two seeded open-ended rows', () => { it('lists the two seeded open-ended rows', async () => {
const rows = listSchedules(fresh()) const rows = await listSchedules(await fresh())
expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup']) expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup'])
}) })
it('insertSchedule appends a dated row that resolveSchedule picks from its date', () => { it('insertSchedule appends a dated row that resolveSchedule picks from its date', async () => {
const db = fresh() const db = await fresh()
const row = insertSchedule(db, 'u1', { const row = await insertSchedule(db, 'u1', {
ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ', ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ',
}) })
expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted
expect(resolveSchedule(db, 'quote_followup', '2026-08-02').dayOffsets).toEqual([2, 5, 9]) expect((await resolveSchedule(db, 'quote_followup', '2026-08-02')).dayOffsets).toEqual([2, 5, 9])
expect(resolveSchedule(db, 'quote_followup', '2026-07-20').dayOffsets).toEqual([3, 7, 14]) expect((await resolveSchedule(db, 'quote_followup', '2026-07-20')).dayOffsets).toEqual([3, 7, 14])
expect(listSchedules(db)).toHaveLength(3) // append-only: old row still there expect(await listSchedules(db)).toHaveLength(3) // append-only: old row still there
}) })
it('rejects bad input', () => { it('rejects bad input', async () => {
const db = fresh() const db = await fresh()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow() await expect(insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).rejects.toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow() await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).rejects.toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow() await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).rejects.toThrow()
// Strict CSV: partial garbage must reject the WHOLE input, never save '3,7' // Strict CSV: partial garbage must reject the WHOLE input, never save '3,7'
// with the typo'd 14-day follow-up silently dropped from the cadence. // with the typo'd 14-day follow-up silently dropped from the cadence.
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: '3,7,I4' })).toThrow(/Invalid day offset/) await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: '3,7,I4' })).rejects.toThrow(/Invalid day offset/)
expect(listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt expect(await listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt
}) })
it('audits the insert', () => { it('audits the insert', async () => {
const db = fresh() const db = await fresh()
const row = insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' }) const row = await insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' })
const audit = db.prepare(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`).get(row.id) const audit = await db.get(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`, row.id)
expect(audit).toBeDefined() expect(audit).toBeDefined()
}) })
}) })
describe('settings reminders routes', () => { describe('settings reminders routes', () => {
const appWith = () => { const appWith = async () => {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, base } return { db, server, base }
} }
const { db, server, base } = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
afterAll(() => server.close()) beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
const login = async (email: string, password: string) => const login = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token
const call = async (token: string, method: string, path: string, body?: unknown) => { const call = async (token: string, method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) const res = await fetch(ctx.base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
@ -82,9 +83,9 @@ describe('settings reminders routes', () => {
expect(after.json.overdueDays).toBe(9) expect(after.json.overdueDays).toBe(9)
expect(after.json.renewalDays).toBe(21) expect(after.json.renewalDays).toBe(21)
// Both writes were audited (same-transaction rule) // Both writes were audited (same-transaction rule)
const audits = db.prepare( const audits = await ctx.db.all<{ entity_id: string }>(
`SELECT entity_id FROM audit_log WHERE entity='setting' AND entity_id IN ('reminders.overdue_days','reminders.renewal_days')`, `SELECT entity_id FROM audit_log WHERE entity='setting' AND entity_id IN ('reminders.overdue_days','reminders.renewal_days')`,
).all() as { entity_id: string }[] )
expect(new Set(audits.map((a) => a.entity_id)).size).toBe(2) expect(new Set(audits.map((a) => a.entity_id)).size).toBe(2)
}) })
}) })

@ -6,61 +6,61 @@ import { createModule, setPrice } from '../src/repos-modules'
import { createDraft } from '../src/repos-documents' import { createDraft } from '../src/repos-documents'
import { mintShare } from '../src/repos-shares' import { mintShare } from '../src/repos-shares'
function setup() { async function setup() {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
return { db, c, m, doc } return { db, c, m, doc }
} }
describe('mintShare default expiry (share.default_expiry_days setting)', () => { describe('mintShare default expiry (share.default_expiry_days setting)', () => {
it('no setting row: defaults to +30d', () => { it('no setting row: defaults to +30d', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).not.toBeNull() expect(s.expiresAt).not.toBeNull()
const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000 const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(29) expect(days).toBeGreaterThan(29)
expect(days).toBeLessThan(31) expect(days).toBeLessThan(31)
}) })
it("setting '7': defaults to +7d", () => { it("setting '7': defaults to +7d", async () => {
const { db, doc } = setup() const { db, doc } = await setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`)
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).not.toBeNull() expect(s.expiresAt).not.toBeNull()
const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000 const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(6) expect(days).toBeGreaterThan(6)
expect(days).toBeLessThan(8) expect(days).toBeLessThan(8)
}) })
it("setting 'never': expiresAt is null", () => { it("setting 'never': expiresAt is null", async () => {
const { db, doc } = setup() const { db, doc } = await setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'never')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'never')`)
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).toBeNull() expect(s.expiresAt).toBeNull()
}) })
it('explicit opts.expiresDays always wins over the setting (including explicit null)', () => { it('explicit opts.expiresDays always wins over the setting (including explicit null)', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`)
const s1 = mintShare(db, 'u1', doc.id, { expiresDays: 3 }) const s1 = await mintShare(db, 'u1', doc.id, { expiresDays: 3 })
expect(s1.expiresAt).not.toBeNull() expect(s1.expiresAt).not.toBeNull()
const days1 = (new Date(s1.expiresAt!).getTime() - Date.now()) / 86_400_000 const days1 = (new Date(s1.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days1).toBeGreaterThan(2) expect(days1).toBeGreaterThan(2)
expect(days1).toBeLessThan(4) expect(days1).toBeLessThan(4)
const s2 = mintShare(db, 'u1', doc.id, { expiresDays: null }) const s2 = await mintShare(db, 'u1', doc.id, { expiresDays: null })
expect(s2.expiresAt).toBeNull() expect(s2.expiresAt).toBeNull()
}) })
it("garbage setting 'abc': falls back to +30d", () => { it("garbage setting 'abc': falls back to +30d", async () => {
const { db, doc } = setup() const { db, doc } = await setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'abc')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'abc')`)
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).not.toBeNull() expect(s.expiresAt).not.toBeNull()
const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000 const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(29) expect(days).toBeGreaterThan(29)

@ -1,5 +1,5 @@
// apps/hq/test/settings-template.test.ts // apps/hq/test/settings-template.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -7,10 +7,10 @@ import { createStaff } from '../src/auth'
import { getSetting } from '../src/repos-reminders' import { getSetting } from '../src/repos-reminders'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) const db = openDb(':memory:'); await seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' }) await createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json({ limit: '2mb' })); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json({ limit: '2mb' })); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api` const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -18,12 +18,13 @@ function appWith() {
} }
describe('template settings', () => { describe('template settings', () => {
const { db, server, base } = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
afterAll(() => server.close()) beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
const login = async (email: string, password: string) => const login = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token (await (await fetch(`${ctx.base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token
const call = async (token: string, method: string, path: string, body?: unknown) => { const call = async (token: string, method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) }) const res = await fetch(ctx.base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
return { status: res.status, json: await res.json() as any } return { status: res.status, json: await res.json() as any }
} }
@ -36,10 +37,10 @@ describe('template settings', () => {
titles: { INVOICE: 'GST INVOICE' }, titles: { INVOICE: 'GST INVOICE' },
}) })
expect(put.status).toBe(200) expect(put.status).toBe(200)
expect(getSetting(db, 'template.footer_note')).toBe('Thank you!') expect(await getSetting(ctx.db, 'template.footer_note')).toBe('Thank you!')
expect(getSetting(db, 'template.signatory_label')).toBe('Proprietor') expect(await getSetting(ctx.db, 'template.signatory_label')).toBe('Proprietor')
expect(getSetting(db, 'template.title_INVOICE')).toBe('GST INVOICE') expect(await getSetting(ctx.db, 'template.title_INVOICE')).toBe('GST INVOICE')
const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'template.%'`).get() as { n: number } const audits = (await ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'template.%'`))!
expect(audits.n).toBeGreaterThanOrEqual(4) expect(audits.n).toBeGreaterThanOrEqual(4)
}) })
it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => { it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => {
@ -53,7 +54,7 @@ describe('template settings', () => {
const owner = await login('owner@test.in', 'owner-password') const owner = await login('owner@test.in', 'owner-password')
const tiny = 'data:image/png;base64,' + 'A'.repeat(200) const tiny = 'data:image/png;base64,' + 'A'.repeat(200)
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(200) expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(200)
expect(getSetting(db, 'template.logo')).toBe(tiny) expect(await getSetting(ctx.db, 'template.logo')).toBe(tiny)
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: 'data:text/html;base64,AAAA' })).status).toBe(400) expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: 'data:text/html;base64,AAAA' })).status).toBe(400)
const huge = 'data:image/png;base64,' + 'A'.repeat(400_000) // ~300 KB decoded > 200 KB cap const huge = 'data:image/png;base64,' + 'A'.repeat(400_000) // ~300 KB decoded > 200 KB cap
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: huge })).status).toBe(400) expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: huge })).status).toBe(400)
@ -61,6 +62,6 @@ describe('template settings', () => {
expect((await call(staff, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(403) expect((await call(staff, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(403)
// clearing is allowed // clearing is allowed
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: '' })).status).toBe(200) expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: '' })).status).toBe(200)
expect(getSetting(db, 'template.logo')).toBe('') expect(await getSetting(ctx.db, 'template.logo')).toBe('')
}) })
}) })

@ -1,5 +1,5 @@
// apps/hq/test/shares.test.ts // apps/hq/test/shares.test.ts
import { describe, it, expect, afterAll } from 'vitest' import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express' import express from 'express'
import { openDb } from '../src/db' import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed' import { seedIfEmpty } from '../src/seed'
@ -10,23 +10,23 @@ import { createDraft } from '../src/repos-documents'
import { mintShare, validateShare, revokeShare, listShares } from '../src/repos-shares' import { mintShare, validateShare, revokeShare, listShares } from '../src/repos-shares'
import { apiRouter } from '../src/api' import { apiRouter } from '../src/api'
function setup() { async function setup() {
const db = openDb(':memory:') const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
return { db, c, m, doc } return { db, c, m, doc }
} }
describe('repos-shares (mint / validate / revoke / list)', () => { describe('repos-shares (mint / validate / revoke / list)', () => {
it('mintShare produces a 64-hex token, unique per mint, with a +30d default expiry', () => { it('mintShare produces a 64-hex token, unique per mint, with a +30d default expiry', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s1 = mintShare(db, 'u1', doc.id) const s1 = await mintShare(db, 'u1', doc.id)
const s2 = mintShare(db, 'u1', doc.id) const s2 = await mintShare(db, 'u1', doc.id)
expect(s1.token).toMatch(/^[0-9a-f]{64}$/) expect(s1.token).toMatch(/^[0-9a-f]{64}$/)
expect(s2.token).toMatch(/^[0-9a-f]{64}$/) expect(s2.token).toMatch(/^[0-9a-f]{64}$/)
expect(s1.token).not.toBe(s2.token) // unique expect(s1.token).not.toBe(s2.token) // unique
@ -38,12 +38,12 @@ describe('repos-shares (mint / validate / revoke / list)', () => {
expect(days).toBeLessThan(31) expect(days).toBeLessThan(31)
}) })
it('mintShare writes an audit row that NEVER contains the token (secrets never logged)', () => { it('mintShare writes an audit row that NEVER contains the token (secrets never logged)', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
const rows = db.prepare( const rows = await db.all<{ user_id: string; entity_id: string; before_json: string | null; after_json: string | null }>(
`SELECT * FROM audit_log WHERE entity='document_share' AND action='create'`, `SELECT * FROM audit_log WHERE entity='document_share' AND action='create'`,
).all() as { user_id: string; entity_id: string; before_json: string | null; after_json: string | null }[] )
expect(rows.length).toBe(1) expect(rows.length).toBe(1)
expect(rows[0]!.user_id).toBe('u1') expect(rows[0]!.user_id).toBe('u1')
expect(rows[0]!.entity_id).toBe(s.id) expect(rows[0]!.entity_id).toBe(s.id)
@ -51,76 +51,76 @@ describe('repos-shares (mint / validate / revoke / list)', () => {
expect(JSON.stringify(rows[0])).not.toContain(s.token) expect(JSON.stringify(rows[0])).not.toContain(s.token)
}) })
it('mintShare throws for an unknown document', () => { it('mintShare throws for an unknown document', async () => {
const { db } = setup() const { db } = await setup()
expect(() => mintShare(db, 'u1', 'nope')).toThrow(/not found/i) await expect(mintShare(db, 'u1', 'nope')).rejects.toThrow(/not found/i)
}) })
it('validateShare returns the one document for a live token', () => { it('validateShare returns the one document for a live token', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
const out = validateShare(db, s.token) const out = await validateShare(db, s.token)
expect(out).not.toBeNull() expect(out).not.toBeNull()
expect(out!.id).toBe(doc.id) expect(out!.id).toBe(doc.id)
expect(out!.payablePaise).toBe(11_800_00) expect(out!.payablePaise).toBe(11_800_00)
}) })
it('validateShare returns null for an unknown token', () => { it('validateShare returns null for an unknown token', async () => {
const { db } = setup() const { db } = await setup()
expect(validateShare(db, 'deadbeef')).toBeNull() expect(await validateShare(db, 'deadbeef')).toBeNull()
}) })
it('validateShare returns null for an expired token', () => { it('validateShare returns null for an expired token', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s = mintShare(db, 'u1', doc.id, { expiresDays: -1 }) // already expired const s = await mintShare(db, 'u1', doc.id, { expiresDays: -1 }) // already expired
expect(s.expiresAt).not.toBeNull() expect(s.expiresAt).not.toBeNull()
expect(validateShare(db, s.token)).toBeNull() expect(await validateShare(db, s.token)).toBeNull()
}) })
it('validateShare returns null for a revoked token', () => { it('validateShare returns null for a revoked token', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
revokeShare(db, 'u1', s.id) await revokeShare(db, 'u1', s.id)
expect(validateShare(db, s.token)).toBeNull() expect(await validateShare(db, s.token)).toBeNull()
}) })
it('revokeShare flips revoked, writes an audit row, and is idempotent-safe', () => { it('revokeShare flips revoked, writes an audit row, and is idempotent-safe', async () => {
const { db, doc } = setup() const { db, doc } = await setup()
const s = mintShare(db, 'u1', doc.id) const s = await mintShare(db, 'u1', doc.id)
const after = revokeShare(db, 'u1', s.id) const after = await revokeShare(db, 'u1', s.id)
expect(after.revoked).toBe(true) expect(after.revoked).toBe(true)
const rows = db.prepare( const rows = await db.all(
`SELECT * FROM audit_log WHERE entity='document_share' AND action='revoke'`, `SELECT * FROM audit_log WHERE entity='document_share' AND action='revoke'`,
).all() as unknown[] )
expect(rows.length).toBe(1) expect(rows.length).toBe(1)
}) })
it('revokeShare throws for an unknown share', () => { it('revokeShare throws for an unknown share', async () => {
const { db } = setup() const { db } = await setup()
expect(() => revokeShare(db, 'u1', 'nope')).toThrow(/not found/i) await expect(revokeShare(db, 'u1', 'nope')).rejects.toThrow(/not found/i)
}) })
it('listShares returns every share for the document (scoped to that doc)', () => { it('listShares returns every share for the document (scoped to that doc)', async () => {
const { db, doc, m, c } = setup() const { db, doc, m, c } = await setup()
const s1 = mintShare(db, 'u1', doc.id) const s1 = await mintShare(db, 'u1', doc.id)
const s2 = mintShare(db, 'u1', doc.id) const s2 = await mintShare(db, 'u1', doc.id)
// A share on a different document must not leak into this list. // A share on a different document must not leak into this list.
const other = createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, const other = await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
mintShare(db, 'u1', other.id) await mintShare(db, 'u1', other.id)
const list = listShares(db, doc.id) const list = await listShares(db, doc.id)
expect(list.length).toBe(2) expect(list.length).toBe(2)
expect(new Set(list.map((s) => s.id))).toEqual(new Set([s1.id, s2.id])) expect(new Set(list.map((s) => s.id))).toEqual(new Set([s1.id, s2.id]))
}) })
}) })
function appWith() { async function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18 const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' }) const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db)) const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0) const server = app.listen(0)
@ -129,7 +129,8 @@ function appWith() {
} }
describe('share routes (requireAuth)', () => { describe('share routes (requireAuth)', () => {
const ctx = appWith() let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close()) afterAll(() => ctx.server.close())
const login = async () => const login = async () =>
(await (await fetch(`${ctx.base}/auth/login`, { (await (await fetch(`${ctx.base}/auth/login`, {
@ -172,6 +173,6 @@ describe('share routes (requireAuth)', () => {
expect(out.status).toBe(200) expect(out.status).toBe(200)
expect(out.json.ok).toBe(true) expect(out.json.ok).toBe(true)
// the token no longer resolves to a document // the token no longer resolves to a document
expect(validateShare(ctx.db, minted.json.share.token)).toBeNull() expect(await validateShare(ctx.db, minted.json.share.token)).toBeNull()
}) })
}) })

Loading…
Cancel
Save