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 { 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).
createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
seedIfEmpty(db)
const U = 'demo'
// Known owner so we can log in for screenshots (before seedIfEmpty, so it skips its own).
await createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
await seedIfEmpty(db)
const U = 'demo'
// A simple monogram logo as an SVG data URI (renders in <img> and puppeteer).
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>`,
).toString('base64')
// A simple monogram logo as an SVG data URI (renders in <img> and puppeteer).
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>`,
).toString('base64')
// Company + template letterhead data (what the Document Template page edits).
const settings: Record<string, string> = {
'company.name': 'Tecnostac Solutions Pvt Ltd',
'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030',
'company.gstin': '32ABCDE1234F1Z9',
'company.state_code': '32',
'company.phone': '+91 98470 12345',
'company.email': 'accounts@tecnostac.com',
'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad',
'template.accent': '#1e3a5f',
'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.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.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345',
'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd',
}
for (const [k, v] of Object.entries(settings)) setSetting(db, U, k, v)
// Company + template letterhead data (what the Document Template page edits).
const settings: Record<string, string> = {
'company.name': 'Tecnostac Solutions Pvt Ltd',
'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030',
'company.gstin': '32ABCDE1234F1Z9',
'company.state_code': '32',
'company.phone': '+91 98470 12345',
'company.email': 'accounts@tecnostac.com',
'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad',
'template.accent': '#1e3a5f',
'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.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.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345',
'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd',
}
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.
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 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 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
createClient(db, U, { name: 'Green Valley Stores', stateCode: '32', address: 'Thrissur, Kerala', status: 'lead', contacts: [{ name: 'Suresh', phone: '9847044444' }] })
// Clients — one out-of-state (Karnataka, 29) to show IGST; one lead.
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 = (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 = (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
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%).
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 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 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 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
// Modules + dated prices (GST-exclusive; the engine adds 18%).
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 = (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 = (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 = (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'
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 })
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 })
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 })
const FROM = '2026-04-01'
await setPrice(db, U, { moduleId: pos, kind: 'yearly', pricePaise: 18_000_00, effectiveFrom: FROM })
await setPrice(db, U, { moduleId: pos, kind: 'one_time', pricePaise: 25_000_00, effectiveFrom: FROM })
await setPrice(db, U, { moduleId: inv, kind: 'yearly', pricePaise: 12_000_00, effectiveFrom: FROM })
await setPrice(db, U, { moduleId: cloud, kind: 'monthly', pricePaise: 1_500_00, effectiveFrom: FROM })
await 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-1L', kind: 'one_time', pricePaise: 7_000_00, effectiveFrom: FROM })
// Client 360 modules
assignModule(db, U, { clientId: malabar, moduleId: pos, kind: 'yearly' })
assignModule(db, U, { clientId: malabar, moduleId: inv, kind: 'yearly' })
// Client 360 modules
await assignModule(db, U, { clientId: malabar, moduleId: pos, 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.
const qt = createDraft(db, U, {
docType: 'QUOTATION', clientId: malabar,
lines: [
{ moduleId: pos, qty: 1, kind: 'yearly' },
{ moduleId: inv, qty: 1, kind: 'yearly' },
{ moduleId: sms, qty: 1, kind: 'one_time', edition: 'SMS-1L' },
],
terms: 'Prices valid for 15 days. 50% advance on confirmation.',
})
const qtIssued = issueDocument(db, U, qt.id)
markStatus(db, U, qtIssued.id, 'sent')
mintShare(db, U, qtIssued.id, { expiresDays: 30 })
// A quotation for Malabar (POS yearly + Inventory + SMS 1-Lakh pack) → issued + share link.
const qt = await createDraft(db, U, {
docType: 'QUOTATION', clientId: malabar,
lines: [
{ moduleId: pos, qty: 1, kind: 'yearly' },
{ moduleId: inv, qty: 1, kind: 'yearly' },
{ moduleId: sms, qty: 1, kind: 'one_time', edition: 'SMS-1L' },
],
terms: 'Prices valid for 15 days. 50% advance on confirmation.',
})
const qtIssued = await issueDocument(db, U, qt.id)
await markStatus(db, U, qtIssued.id, 'sent')
await mintShare(db, U, qtIssued.id, { expiresDays: 30 })
// 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
setPrice(db, U, { moduleId: amcMod, kind: 'yearly', pricePaise: 6_000_00, effectiveFrom: FROM })
const inv1 = issueDocument(db, U, createDraft(db, U, {
docType: 'INVOICE', clientId: lulu,
lines: [{ moduleId: pos, qty: 1, kind: 'one_time' }, { moduleId: amcMod, qty: 1, kind: 'yearly' }],
}).id)
recordPayment(db, U, { clientId: lulu, receivedOn: '2026-07-05', mode: 'bank', reference: 'NEFT-88213', amountPaise: 20_000_00 })
// An invoice for Lulu (POS one-time + AMC) → issued, part-paid (drives the dashboard).
const amcMod = ((await db.get<{ id: string }>(`SELECT id FROM module WHERE code='AMC'`))!).id
await setPrice(db, U, { moduleId: amcMod, kind: 'yearly', pricePaise: 6_000_00, effectiveFrom: FROM })
const inv1 = await issueDocument(db, U, (await createDraft(db, U, {
docType: 'INVOICE', clientId: lulu,
lines: [{ moduleId: pos, qty: 1, kind: 'one_time' }, { moduleId: amcMod, qty: 1, kind: 'yearly' }],
})).id)
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.
issueDocument(db, U, createDraft(db, U, {
docType: 'QUOTATION', clientId: blr,
lines: [{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: cloud, qty: 12, kind: 'monthly' }],
}).id)
// An out-of-state quotation (IGST) for Bengaluru.
await issueDocument(db, U, (await createDraft(db, U, {
docType: 'QUOTATION', clientId: blr,
lines: [{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: cloud, qty: 12, kind: 'monthly' }],
})).id)
const shareToken = (db.prepare(`SELECT token FROM document_share LIMIT 1`).get() as { token: string }).token
console.log('DEMO_READY')
console.log('login: owner@tecnostac.com / demo-owner-2026')
console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo)
console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo)
console.log('share_token:', shareToken)
const shareToken = ((await db.get<{ token: string }>(`SELECT token FROM document_share LIMIT 1`))!).token
console.log('DEMO_READY')
console.log('login: owner@tecnostac.com / demo-owner-2026')
console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo)
console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo)
console.log('share_token:', shareToken)
})()

@ -105,7 +105,7 @@ async function main(): Promise<void> {
const code = await waitForCode()
const { accessToken, refreshToken } = await exchangeCode(code, clientId, clientSecret, redirectUri)
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.`)
}

File diff suppressed because it is too large Load Diff

@ -16,20 +16,19 @@ function nextIdMs(): number {
return lastIdMs
}
export function writeAudit(
export async function writeAudit(
db: DB, userId: string, action: string, entity: string, entityId: string,
before?: unknown, after?: unknown,
): void {
db.prepare(
): Promise<void> {
await db.run(
`INSERT INTO audit_log (id, at_wall, user_id, action, entity, entity_id, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
uuidv7(nextIdMs()), new Date().toISOString(), userId, action, entity, entityId,
before === undefined ? null : JSON.stringify(before),
after === undefined ? null : JSON.stringify(after),
)
}
export function listAudit(db: DB, limit = 200): AuditRow[] {
return db.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`).all(limit) as AuditRow[]
export async function listAudit(db: DB, limit = 200): Promise<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
export function createStaff(db: DB, input: {
export async function createStaff(db: DB, input: {
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')
const id = uuidv7()
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 (?, ?, ?, ?, ?, ?)`,
).run(id, input.email.toLowerCase(), input.displayName, input.role, salt, hash)
writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role })
id, input.email.toLowerCase(), input.displayName, input.role, salt, hash,
)
await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role })
return { id }
}
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):
{ 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
export async function login(db: DB, email: string, password: string):
Promise<{ token: string; staff: { id: string; displayName: string; role: string } } | null> {
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
const token = randomBytes(32).toString('hex')
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)
writeAudit(db, row.id, 'login', 'staff_user', row.id)
await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires)
await writeAudit(db, row.id, 'login', 'staff_user', row.id)
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.
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
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
}
export const requireAuth: RequestHandler = (req, res, next) => {
const db = req.app.locals['db'] as DB
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
const staff = token ? verifySession(db, token) : null
if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return }
res.locals['staff'] = staff
next()
void (async () => {
const db = req.app.locals['db'] as DB
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
const staff = token ? await verifySession(db, token) : null
if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return }
res.locals['staff'] = staff
next()
})()
}
export const requireOwner: RequestHandler = (_req, res, next) => {

@ -106,9 +106,9 @@ export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string)
totalPaise += costPaise
const client = tag === ''
? 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 }
upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' })
await upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' })
upserted += 1
}
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. */
export async function maybePullAwsCosts(db: DB, deps: AwsCostDeps, today: string): Promise<PullResult | null> {
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)
setSetting(db, 'system', 'aws.last_pull_month', target)
await setSetting(db, 'system', 'aws.last_pull_month', target)
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. */
export function markBounce(db: DB, recipient: string, at: string): number {
const row = db.prepare(
export async function markBounce(db: DB, recipient: string, at: string): Promise<number> {
const row = await db.get<{ id: string; document_id: string | null }>(
`SELECT id, document_id FROM email_log
WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0
ORDER BY id DESC LIMIT 1`,
).get(recipient) as { id: string; document_id: string | null } | undefined
ORDER BY id DESC LIMIT 1`, recipient,
)
if (row === undefined) return 0
db.prepare(`UPDATE email_log SET bounced=1 WHERE id=?`).run(row.id)
const clientId = row.document_id !== null ? (getDocument(db, row.document_id)?.clientId ?? '') : ''
upsertReminder(db, {
await db.run(`UPDATE email_log SET bounced=1 WHERE id=?`, row.id)
const clientId = row.document_id !== null ? ((await getDocument(db, row.document_id))?.clientId ?? '') : ''
await upsertReminder(db, {
ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId,
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
}
export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned: number; bounced: number }> {
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 }
let accessToken: string
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,
)
} catch (err) {
if (err instanceof TokenDeadError) markAccountDead(db)
if (err instanceof TokenDeadError) await markAccountDead(db)
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()
const q = `from:mailer-daemon OR from:postmaster after:${Math.floor(Date.parse(since) / 1000)}`
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 },
)
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 {
// Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window.
return { scanned, bounced }
}
setSetting(db, 'system', 'reminders.bounce_last_poll', now)
await setSetting(db, 'system', 'reminders.bounce_last_poll', now)
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 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 = `
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 {
let db: DB
let raw: SqliteRaw
if (dataDir === ':memory:') {
db = new Database(':memory:')
raw = new Database(':memory:')
} else {
const dir = dataDir ?? path.resolve(process.cwd(), 'data')
fs.mkdirSync(dir, { recursive: true })
db = new Database(path.join(dir, 'hq.db'))
db.pragma('journal_mode = WAL')
raw = new Database(path.join(dir, 'hq.db'))
raw.pragma('journal_mode = WAL')
}
db.exec(SCHEMA)
migrate(db)
return db
raw.exec(SCHEMA)
migrate(raw)
return new SqliteDb(raw)
}
/** Additive, idempotent column adds for DBs created before a schema change. */
function migrate(db: DB): void {
/** Additive, idempotent column adds for DBs created before a schema change.
* 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 }[]
if (!moduleCols.some((c) => c.name === 'quote_content')) {
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.
*/
export function rebuildTable(
db: DB, table: string, newDdl: string, guardToken: string, columns: string[],
db: SqliteRaw, table: string, newDdl: string, guardToken: string, columns: string[],
): void {
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.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. */
export function rebuildStaffUserRoleCheck(db: DB): void {
export function rebuildStaffUserRoleCheck(db: SqliteRaw): void {
rebuildTable(
db,
'staff_user',
@ -272,7 +338,7 @@ export function rebuildStaffUserRoleCheck(db: DB): void {
* Guard token: the old list ends "...'email_bounced')" the new DDL continues
* ",'quote_followup')" instead, so the fragment vanishes once rebuilt (idempotent).
*/
export function rebuildReminderRuleKindCheck(db: DB): void {
export function rebuildReminderRuleKindCheck(db: SqliteRaw): void {
rebuildTable(
db,
'reminder',

@ -94,15 +94,15 @@ export type SendResult = { ok: true } | { ok: false; error: string }
export async function sendDocumentEmail(
db: DB, deps: GmailDeps, args: SendDocumentInput,
): Promise<SendResult> {
const doc = getDocument(db, args.documentId)
const doc = await getDocument(db, args.documentId)
if (doc === null) throw new Error('Document not found')
// 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')
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')
const fail = (error: string): SendResult => {
logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error })
const fail = async (error: string): Promise<SendResult> => {
await logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error })
return { ok: false, error }
}
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)
} catch (err) {
if (err instanceof TokenDeadError) {
markAccountDead(db)
await markAccountDead(db)
return fail('gmail-token-dead')
}
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))
}
logEmail(db, {
await logEmail(db, {
documentId: args.documentId, to: args.to, subject: args.subject, status: 'sent',
...(gmailMessageId !== undefined ? { gmailMessageId } : {}),
})
if (doc.status === 'draft') {
// 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 {
// 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 (?, ?, ?, ?, ?)`,
).run(uuidv7(), args.documentId, new Date().toISOString(), 'sent',
uuidv7(), args.documentId, new Date().toISOString(), 'sent',
JSON.stringify({ to: args.to, resend: true }))
}
return { ok: true }
@ -171,14 +171,14 @@ export interface ReminderMailInput {
export async function sendReminderEmail(
db: DB, deps: GmailDeps, args: ReminderMailInput,
): Promise<SendResult> {
const logFail = (error: string): SendResult => {
logEmail(db, {
const logFail = async (error: string): Promise<SendResult> => {
await logEmail(db, {
...(args.documentId !== undefined ? { documentId: args.documentId } : {}),
to: args.to, subject: args.subject, status: 'failed', error,
})
return { ok: false, error }
}
const account = getAccount(db)
const account = await getAccount(db)
if (account === null) return logFail('gmail-not-connected')
if (account.status === 'dead') return logFail('gmail-token-dead')
@ -187,7 +187,7 @@ export async function sendReminderEmail(
const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex)
accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f)
} 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))
}
@ -211,7 +211,7 @@ export async function sendReminderEmail(
return logFail(err instanceof Error ? err.message : String(err))
}
logEmail(db, {
await logEmail(db, {
...(args.documentId !== undefined ? { documentId: args.documentId } : {}),
to: args.to, subject: args.subject, status: 'sent',
...(gmailMessageId !== undefined ? { gmailMessageId } : {}),

@ -44,18 +44,18 @@ function parseCsv(text: string): Record<string, string>[] {
const CLIENT_STATUSES = new Set(['lead', 'active', 'dormant', 'lost'])
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
function stageClients(db: DB, records: Record<string, string>[]): number {
db.prepare(`DELETE FROM stg_client`).run()
async function stageClients(db: DB, records: Record<string, string>[]): Promise<number> {
await db.run(`DELETE FROM stg_client`)
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 insert = db.prepare(
const INSERT_SQL =
`INSERT INTO stg_client (row_no, code, name, gstin, state_code, address, phone, email, status,
anydesk, os, district, sector, problems)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
records.forEach((r, i) => {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (let i = 0; i < records.length; i++) {
const r = records[i]!
const problems: string[] = []
const code = r['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}`)
const status = r['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'] ?? '',
r['address'] ?? '', r['phone'] ?? '', r['email'] ?? '', status,
// WS-F support-access fields — optional headers; absent columns stage as ''.
r['anydesk'] ?? '', r['os'] ?? '', r['district'] ?? '', r['sector'] ?? '',
JSON.stringify(problems),
)
})
}
return records.length
}
@ -83,18 +84,18 @@ function paiseOrNull(raw: string): number | null {
return Number.isFinite(n) ? fromRupees(n) : null
}
function stageInvoices(db: DB, records: Record<string, string>[]): number {
db.prepare(`DELETE FROM stg_invoice`).run()
async function stageInvoices(db: DB, records: Record<string, string>[]): Promise<number> {
await db.run(`DELETE FROM stg_invoice`)
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),
)
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
records.forEach((r, i) => {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
for (let i = 0; i < records.length; i++) {
const r = records[i]!
const problems: string[] = []
if ((r['client_code'] ?? '') === '') problems.push('missing client_code')
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 (tax === null) problems.push(`bad tax: ${r['tax'] ?? ''}`)
if (total === null) problems.push(`bad total: ${r['total'] ?? ''}`)
insert.run(
await db.run(
INSERT_SQL,
i + 1, r['client_code'] ?? '', docNo, docDate,
taxable, tax, total, r['paid'] === '1' ? 1 : 0,
JSON.stringify(problems),
)
})
}
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)
// One transaction: the DELETE + re-insert of the staging area and its audit row
// land (or roll back) together — same-txn audit rule.
return db.transaction(() => {
const staged = kind === 'clients' ? stageClients(db, records) : stageInvoices(db, records)
writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged })
return db.transaction(async () => {
const staged = kind === 'clients' ? await stageClients(db, records) : await stageInvoices(db, records)
await writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged })
return { staged }
})()
})
}
// ---------- verification report ----------
@ -137,24 +139,24 @@ export interface VerificationReport {
samples: { firstClients: string[]; lastInvoices: string[] }
}
export function verificationReport(db: DB): VerificationReport {
const c = db.prepare(
export async function verificationReport(db: DB): Promise<VerificationReport> {
const c = (await db.get<{ staged: number; problems: number }>(
`SELECT COUNT(*) AS staged,
COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems
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,
COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems,
COALESCE(SUM(total_paise), 0) AS totalPaise
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`,
).all() as { code: string; name: string }[]).map((r) => `${r.code} ${r.name}`.trim())
const lastInvoices = (db.prepare(
)).map((r) => `${r.code} ${r.name}`.trim())
const lastInvoices = (await db.all<{ doc_no: string }>(
`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 {
clients: { staged: c.staged, problems: c.problems },
invoices: { staged: i.staged, problems: i.problems, totalPaise: i.totalPaise },
@ -181,32 +183,30 @@ export interface CommitResult {
seeded: { fy: string; lastSeq: number } | null
}
function companyStateCode(db: DB): string {
const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`)
.get() as { value: string } | undefined
async function companyStateCode(db: DB): Promise<string> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='company.state_code'`)
// 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`)
return row.value
}
export function commitImport(db: DB, userId: string): CommitResult {
return db.transaction((): CommitResult => {
const bad = (db.prepare(
export async function commitImport(db: DB, userId: string): Promise<CommitResult> {
return db.transaction(async (): Promise<CommitResult> => {
const bad = (await db.get<{ n: number }>(
`SELECT (SELECT COUNT(*) FROM stg_client WHERE problems <> '[]')
+ (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`)
const ourState = companyStateCode(db)
const ourState = await companyStateCode(db)
const now = new Date().toISOString()
// Clients — source 'apex' marks the cutover rows.
const stgClients = db.prepare(`SELECT * FROM stg_client ORDER BY row_no`).all() as StgClientRow[]
const insertClient = db.prepare(
const stgClients = await db.all<StgClientRow>(`SELECT * FROM stg_client ORDER BY row_no`)
const INSERT_CLIENT_SQL =
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`,
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`
const orNull = (v: string): string | null => (v !== '' ? v : null)
for (const c of stgClients) {
const id = uuidv7()
@ -217,28 +217,29 @@ export function commitImport(db: DB, userId: string): CommitResult {
...(c.email !== '' ? { email: c.email } : {}),
}]
: []
insertClient.run(
await db.run(
INSERT_CLIENT_SQL,
id, c.code, c.name, c.gstin !== '' ? c.gstin : null,
c.state_code !== '' ? c.state_code : ourState, c.address,
JSON.stringify(contacts), c.status !== '' ? c.status : 'active',
// 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,
)
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
// 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 insertDoc = db.prepare(
const stgInvoices = await db.all<StgInvoiceRow>(`SELECT * FROM stg_invoice ORDER BY row_no`)
const INSERT_DOC_SQL =
`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,
payload, source, created_by, created_at)
VALUES (?, 'INVOICE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`,
)
VALUES (?, 'INVOICE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`
for (const inv of stgInvoices) {
const client = db.prepare(`SELECT id, state_code FROM client WHERE code=?`)
.get(inv.client_code) as { id: string; state_code: string } | undefined
const client = await db.get<{ id: string; state_code: string }>(
`SELECT id, state_code FROM client WHERE code=?`, inv.client_code,
)
if (client === undefined) {
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 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,
inv.taxable_paise, cgst, sgst, igst, roundOff, inv.total_paise,
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' })
}
@ -275,40 +277,42 @@ export function commitImport(db: DB, userId: string): CommitResult {
.map((t) => Number(t))
if (tails.length > 0) {
const lastSeq = Math.max(...tails)
seedSeries(db, 'INVOICE', currentFy, lastSeq)
await seedSeries(db, 'INVOICE', 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.
db.prepare(`DELETE FROM stg_client`).run()
db.prepare(`DELETE FROM stg_invoice`).run()
await db.run(`DELETE FROM stg_client`)
await db.run(`DELETE FROM stg_invoice`)
return { clients: stgClients.length, invoices: stgInvoices.length, seeded }
})()
})
}
// ---------- CLI: npm run import -- --dir <folder> [--commit] ----------
if (process.argv[1] !== undefined && /import-apex\.(ts|cjs|js)$/.test(process.argv[1])) {
const args = process.argv.slice(2)
const dirIdx = args.indexOf('--dir')
const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined
if (dir === undefined) {
console.error('Usage: npm run import -- --dir <folder> [--commit]')
process.exit(1)
}
const db = openDb(process.env['HQ_DATA_DIR'])
seedIfEmpty(db)
const clients = stageCsv(db, 'clients', fs.readFileSync(path.join(dir, 'clients.csv'), 'utf8'))
const invoices = stageCsv(db, 'invoices', fs.readFileSync(path.join(dir, 'invoices.csv'), 'utf8'))
console.log(`Staged ${clients.staged} clients, ${invoices.staged} invoices from ${dir}`)
console.log(JSON.stringify(verificationReport(db), null, 2))
if (args.includes('--commit')) {
const out = commitImport(db, 'system')
console.log(`Committed: ${out.clients} clients, ${out.invoices} invoices; series seeded:`,
out.seeded ?? 'no current-FY invoices')
} else {
console.log('Dry run — verify the report above, then re-run with --commit to apply.')
}
void (async () => {
const args = process.argv.slice(2)
const dirIdx = args.indexOf('--dir')
const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined
if (dir === undefined) {
console.error('Usage: npm run import -- --dir <folder> [--commit]')
process.exit(1)
}
const db = openDb(process.env['HQ_DATA_DIR'])
await seedIfEmpty(db)
const clients = await stageCsv(db, 'clients', fs.readFileSync(path.join(dir, 'clients.csv'), 'utf8'))
const invoices = await stageCsv(db, 'invoices', fs.readFileSync(path.join(dir, 'invoices.csv'), 'utf8'))
console.log(`Staged ${clients.staged} clients, ${invoices.staged} invoices from ${dir}`)
console.log(JSON.stringify(await verificationReport(db), null, 2))
if (args.includes('--commit')) {
const out = await commitImport(db, 'system')
console.log(`Committed: ${out.clients} clients, ${out.invoices} invoices; series seeded:`,
out.seeded ?? 'no current-FY invoices')
} 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 {
const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined
export async function getAmc(db: DB, id: string): Promise<AmcContract | null> {
const row = await db.get<AmcRow>(`SELECT * FROM amc_contract WHERE id=?`, id)
return row === undefined ? null : toAmc(row)
}
export function listAmc(db: DB, clientId: string): AmcContract[] {
const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[]
export async function listAmc(db: DB, clientId: string): Promise<AmcContract[]> {
const rows = await db.all<AmcRow>(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`, clientId)
return rows.map(toAmc)
}
@ -45,22 +45,21 @@ export interface CreateAmcInput {
amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean
}
export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
export async function createAmc(db: DB, userId: string, input: CreateAmcInput): Promise<AmcContract> {
if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) {
throw new Error('amountPaise must be a non-negative integer (paise)')
}
const id = uuidv7()
db.prepare(
await db.run(
`INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise,
renewal_reminder_days, legacy_paid, invoice_doc_id, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`,
).run(
id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise,
input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0,
)
const amc = getAmc(db, id)!
writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
const amc = (await getAmc(db, id))!
await writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
return amc
}
@ -69,8 +68,8 @@ export interface AmcPatch {
renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean
}
export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract {
const before = getAmc(db, id)
export async function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): Promise<AmcContract> {
const before = await getAmc(db, id)
if (before === null) throw new Error('AMC contract not found')
const sets: string[] = []
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 (sets.length > 0) {
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)!
writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
const after = (await getAmc(db, id))!
await writeAudit(db, userId, 'update', 'amc_contract', id, before, 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 })
}
/** 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. */
export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc {
const amc = getAmc(db, amcId)
export async function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Promise<Doc> {
const amc = await getAmc(db, amcId)
if (amc === null) throw new Error('AMC contract not found')
if (amc.invoiceDocId !== null) {
const existing = getDocument(db, amc.invoiceDocId)
if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) {
const existing = await getDocument(db, amc.invoiceDocId)
if (existing !== null && existing.status !== 'cancelled' && (await outstandingOf(db, existing.id)) > 0) {
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')
return db.transaction(() => {
const draft = createDraft(db, userId, {
return db.transaction(async () => {
const draft = await createDraft(db, userId, {
docType: 'INVOICE', clientId: amc.clientId,
lines: [{
moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance',
qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise,
}],
})
const inv = issueDocument(db, userId, draft.id)
db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId)
writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
const inv = await issueDocument(db, userId, draft.id)
await db.run(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`, inv.id, amcId)
await writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
return inv
})()
})
}
/** Local outstanding read (payable allocations non-cancelled credit notes)
* mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can
* switch to it then, but does not depend on Task 7 to compile. */
function outstandingOf(db: DB, docId: string): number {
const doc = getDocument(db, docId)
async function outstandingOf(db: DB, docId: string): Promise<number> {
const doc = await getDocument(db, docId)
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=?`,
).get(docId) as { total: number }).total
const credited = (db.prepare(
docId,
))!.total
const credited = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
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
}
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.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 {
const row = db.prepare(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`).get(clientId, month) as AwsUsageRow | undefined
export async function getAwsUsage(db: DB, clientId: string, month: string): Promise<AwsUsage | null> {
const row = await db.get<AwsUsageRow>(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`, clientId, month)
return row === undefined ? null : toUsage(row)
}
export function listAwsUsage(db: DB, clientId: string, limit = 12): AwsUsage[] {
const rows = db.prepare(
export async function listAwsUsage(db: DB, clientId: string, limit = 12): Promise<AwsUsage[]> {
const rows = await db.all<AwsUsageRow>(
`SELECT * FROM aws_usage WHERE client_id=? ORDER BY month DESC LIMIT ?`,
).all(clientId, limit) as AwsUsageRow[]
clientId, limit,
)
return rows.map(toUsage)
}
@ -44,8 +45,8 @@ export interface UpsertAwsUsageInput {
costPaise?: number; source?: 'auto' | 'manual'
}
export function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): AwsUsage {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
export async function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): Promise<AwsUsage> {
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 (input.costPaise !== undefined && (!Number.isInteger(input.costPaise) || input.costPaise < 0)) {
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) {
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 storageGb = input.storageGb ?? before?.storageGb ?? 0
const transferGb = input.transferGb ?? before?.transferGb ?? 0
const costPaise = input.costPaise ?? before?.costPaise ?? 0
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).
`INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (client_id, month) DO UPDATE SET
storage_gb=excluded.storage_gb, transfer_gb=excluded.transfer_gb,
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())
const after = getAwsUsage(db, input.clientId, input.month)!
writeAudit(db, userId, before === null ? 'create' : 'update', 'aws_usage', after.id, before ?? undefined, after)
id, input.clientId, input.month, storageGb, transferGb, costPaise, source, new Date().toISOString(),
)
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
}
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. */
export function costRanking(db: DB, month: string): { total: number; rows: CostRankRow[] } {
const rows = db.prepare(
export async function costRanking(db: DB, month: string): Promise<{ total: number; rows: CostRankRow[] }> {
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
FROM aws_usage a JOIN client c ON c.id = a.client_id
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)
return {
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. */
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=?`
const args: unknown[] = [clientId]
if (from !== undefined) { sql += ` AND month >= ?`; args.push(from.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). */

@ -49,9 +49,9 @@ function assertGstin(gstin: string): void {
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 } = {},
): Client[] {
): Promise<Client[]> {
let where = ' WHERE 1=1'
const args: unknown[] = []
if (q !== undefined && q !== '') {
@ -67,12 +67,12 @@ export function listClients(
if (filters.sector !== undefined && 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)
}
export function getClient(db: DB, id: string): Client | null {
const row = db.prepare(`SELECT * FROM client WHERE id=?`).get(id) as ClientRow | undefined
export async function getClient(db: DB, id: string): Promise<Client | null> {
const row = await db.get<ClientRow>(`SELECT * FROM client WHERE id=?`, id)
return row === undefined ? null : toClient(row)
}
@ -82,24 +82,23 @@ export interface ClientInput {
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)
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')}`
db.prepare(
await db.run(
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, code, input.name, input.gstin ?? null, input.stateCode,
input.address ?? '', JSON.stringify(input.contacts ?? []),
input.status ?? 'active',
input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null,
input.notes ?? '', new Date().toISOString(),
)
const client = getClient(db, id)!
writeAudit(db, userId, 'create', 'client', id, undefined, client)
const client = (await getClient(db, id))!
await writeAudit(db, userId, 'create', 'client', id, undefined, client)
return client
}
@ -110,8 +109,8 @@ export interface ClientPatch {
anydesk?: string; os?: string; district?: string; sector?: string
}
export function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Client {
const before = getClient(db, id)
export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise<Client> {
const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (patch.gstin !== undefined && patch.gstin !== null) assertGstin(patch.gstin)
const sets: string[] = []
@ -132,10 +131,10 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
optText('sector', patch.sector)
if (sets.length > 0) {
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)!
writeAudit(db, userId, 'update', 'client', id, before, after)
const after = (await getClient(db, id))!
await writeAudit(db, userId, 'update', 'client', id, before, 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.
* 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,
): Client {
return db.transaction(() => {
const before = getClient(db, id)
): Promise<Client> {
return db.transaction(async () => {
const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (plain !== '' && keyHex === '') {
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`)
}
const enc = plain === '' ? null : encrypt(plain, keyHex)
db.prepare(`UPDATE client SET db_password_enc=? WHERE id=?`).run(enc, id)
writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null })
return getClient(db, id)!
})()
await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, enc, id)
await writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null })
return (await getClient(db, id))!
})
}
/** 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 {
const row = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`)
.get(id) as { db_password_enc: string | null } | undefined
export async function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): Promise<string> {
const row = await db.get<{ db_password_enc: string | null }>(
`SELECT db_password_enc FROM client WHERE id=?`, id)
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 (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`)
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
}
@ -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
* 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 {
return db.transaction(() => {
const before = getClient(db, id)
export async function setClientOwner(db: DB, userId: string, id: string, ownerId: string | null): Promise<Client> {
return db.transaction(async () => {
const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (ownerId !== null) {
const emp = db.prepare(`SELECT active FROM staff_user WHERE id=?`)
.get(ownerId) as { active: number } | undefined
const emp = await db.get<{ active: number }>(`SELECT active FROM staff_user WHERE id=?`, ownerId)
if (emp === undefined) throw new Error('Employee not found')
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)
const after = getClient(db, id)!
writeAudit(db, userId, 'set_owner', 'client', id, before, after)
await db.run(`UPDATE client SET owner_id=? WHERE id=?`, ownerId, id)
const after = (await getClient(db, id))!
await writeAudit(db, userId, 'set_owner', 'client', id, before, 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
}
export function dashboardView(
export async function dashboardView(
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
// it); owner/manager opt in via mine=true. "Mine" = clients I own, documents and
// 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
// 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
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') AND COALESCE(d.due_date, d.doc_date) <= ?${mineDoc}
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'] = []
let overduePaise = 0
for (const r of overdueRows) {
const outstanding = outstandingPaise(db, r.id)
const outstanding = await outstandingPaise(db, r.id)
if (outstanding <= 0) continue
overduePaise += outstanding
overdue.push({
@ -63,37 +64,40 @@ export function dashboardView(
}
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
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`,
).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 }
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
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`,
).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 }
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
.map((f) => ({
id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate,
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
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=?)' : ''}
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 }
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);
// a managerial My Day narrows the queue to their own rows via ownerId.
const queuePage = viewer !== undefined
? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) })
: listQueue(db)
const counts = queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined)
? await listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) })
: await listQueue(db)
const counts = await queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined)
return {
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments,

@ -63,8 +63,8 @@ function toDoc(r: DocRow): Doc {
}
}
export function getDocument(db: DB, id: string): Doc | null {
const row = db.prepare(`SELECT * FROM document WHERE id=?`).get(id) as DocRow | undefined
export async function getDocument(db: DB, id: string): Promise<Doc | null> {
const row = await db.get<DocRow>(`SELECT * FROM document WHERE id=?`, id)
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 }
/** 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 pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 50))
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.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) }
if (filter.clientId !== undefined) { where += ` AND d.client_id=?`; args.push(filter.clientId) }
const total = (db.prepare(
`SELECT COUNT(*) AS n FROM document d${where}`,
).get(...args) as { n: number }).n
const rows = db.prepare(
const total = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM document d${where}`, ...args,
))!.n
const rows = await db.all<DocRow & { client_name: string }>(
`SELECT d.*, c.name AS client_name
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
).all(...args, pageSize, (page - 1) * pageSize) as (DocRow & { client_name: string })[]
...args, pageSize, (page - 1) * pageSize)
return {
documents: rows.map((r) => ({ ...toDoc(r), clientName: r.client_name })),
total, page, pageSize,
@ -112,16 +112,15 @@ export interface DocumentEvent {
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 {
db.prepare(
async function addEvent(db: DB, documentId: string, kind: string, meta: Record<string, unknown> = {}): Promise<void> {
await db.run(
`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[] {
const rows = db.prepare(
`SELECT * FROM document_event WHERE document_id=? ORDER BY id`,
).all(documentId) as EventRow[]
export async function listDocumentEvents(db: DB, documentId: string): Promise<DocumentEvent[]> {
const rows = await db.all<EventRow>(
`SELECT * FROM document_event WHERE document_id=? ORDER BY id`, documentId)
return rows.map((r) => ({
id: r.id, documentId: r.document_id, atWall: r.at_wall, kind: r.kind,
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)
}
function supplyStateCode(db: DB): string {
const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`)
.get() as { value: string } | undefined
async function supplyStateCode(db: DB): Promise<string> {
const row = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key='company.state_code'`)
// 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`)
return row.value
}
function taxRates(db: DB): TaxClassRow[] {
const rows = db.prepare(`SELECT * FROM tax_class`).all() as {
async function taxRates(db: DB): Promise<TaxClassRow[]> {
const rows = await db.all<{
class_code: string; rate_pct_bp: number; cess_pct_bp: number
effective_from: string; effective_to: string | null
}[]
}>(`SELECT * FROM tax_class`)
return rows.map((r) => ({
classCode: r.class_code, ratePctBp: r.rate_pct_bp, cessPctBp: r.cess_pct_bp,
effectiveFrom: r.effective_from,
@ -174,12 +173,14 @@ function taxRates(db: DB): TaxClassRow[] {
}))
}
function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): LineInput[] {
return inputs.map((line) => {
const mod = getModule(db, line.moduleId)
async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): Promise<LineInput[]> {
const out: LineInput[] = []
for (const line of inputs) {
const mod = await getModule(db, line.moduleId)
if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
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) {
// Strict (no warnings sink): refuse — same message save has always thrown.
if (warnings === undefined) {
@ -190,7 +191,7 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?:
unitPricePaise = 0
}
const packSuffix = edition !== 'standard' ? `${edition}` : '' // pack name prints on the line
return {
out.push({
itemId: mod.id,
name: mod.name
+ (line.description !== undefined && line.description !== '' ? `${line.description}` : '')
@ -201,30 +202,29 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?:
unitPricePaise,
priceIncludesTax: false, // HQ B2B convention: prices are GST-exclusive
taxClassCode: TAX_CLASS,
}
})
})
}
return out
}
/** 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
totals: BillTotals; payload: DocPayload; dueDate?: string | null
}): Doc {
}): Promise<Doc> {
const id = uuidv7()
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,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
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,
JSON.stringify(a.payload), userId, new Date().toISOString(),
)
addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
const doc = getDocument(db, id)!
writeAudit(db, userId, 'create', 'document', id, undefined, {
JSON.stringify(a.payload), userId, new Date().toISOString())
await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
const doc = (await getDocument(db, id))!
await writeAudit(db, userId, 'create', 'document', id, undefined, {
docType: doc.docType, clientId: doc.clientId,
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);
* 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
if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(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 (!/^\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')
const today = todayIso()
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,
}
}
const supply = supplyStateCode(db)
const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
const supply = await supplyStateCode(db)
const computed = computeBill(await buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
businessDate: today,
supplyStateCode: supply,
placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default
roundToRupee: true,
}, taxRates(db))
}, await taxRates(db))
// Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists).
const lineContents = lineInputs.map((line) =>
line.contentLines ?? getModule(db, line.moduleId)!.quoteContent)
const lineContents: string[][] = []
for (const line of lineInputs) {
lineContents.push(line.contentLines ?? (await getModule(db, line.moduleId))!.quoteContent)
}
const payload: DocPayload = {
lines: computed.lines, totals: computed.totals, lineContents,
...(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 {
const prepared = prepareDraft(db, input) // strict — save refuses a bad document
export async function createDraft(db: DB, userId: string, input: DraftInput): Promise<Doc> {
const prepared = await prepareDraft(db, input) // strict — save refuses a bad document
return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null,
}))()
}))
}
// ---------- lifecycle ----------
/** Issue = number assignment only; status stays draft until sent/marked. */
export function issueDocument(db: DB, userId: string, docId: string): Doc {
return db.transaction(() => {
const before = getDocument(db, docId)
export async function issueDocument(db: DB, userId: string, docId: string): Promise<Doc> {
return db.transaction(async () => {
const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found')
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')
const docNo = nextDocNo(db, before.docType, before.fy)
db.prepare(`UPDATE document SET doc_no=? WHERE id=?`).run(docNo, docId)
const docNo = await nextDocNo(db, before.docType, before.fy)
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
// stamped NOW — the last moment it is still a draft (issued paper never changes).
let dueDate = before.dueDate
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)
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 })
writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) })
return getDocument(db, docId)!
})()
await addEvent(db, docId, 'issued', { docNo })
await writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) })
return (await getDocument(db, docId))!
})
}
const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = {
@ -336,23 +338,23 @@ const LEGAL_MARKS: Partial<Record<DocStatus, DocStatus[]>> = {
sent: ['accepted', 'lost'],
}
export function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Doc {
return db.transaction(() => {
const before = getDocument(db, docId)
export async function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Promise<Doc> {
return db.transaction(async () => {
const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found')
if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) {
throw new Error(`Illegal status transition: ${before.status}${status}`)
}
db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId)
addEvent(db, docId, status)
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
await addEvent(db, docId, status)
await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
if (status === 'accepted' || status === 'lost') {
// STOP cleanup (spec §7): a decided quote stops chasing — dismiss its open
// 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 }
@ -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
* 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 {
return db.transaction(() => {
const src = getDocument(db, docId)
export async function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Promise<Doc> {
return db.transaction(async () => {
const src = await getDocument(db, docId)
if (src === null) throw new Error('Document not found')
const from = CHAIN[src.docType]
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)
// 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.
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`,
).get(src.id) as { doc_type: string } | undefined
src.id)
if (child !== undefined) {
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
// 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.
const client = getClient(db, src.clientId)
const client = await getClient(db, src.clientId)
if (client === null) throw new Error('Client not found')
const computed = computeBill(
src.payload.lines.map((l): LineInput => ({
@ -397,52 +399,51 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
})),
{
businessDate: docDate,
supplyStateCode: supplyStateCode(db),
supplyStateCode: await supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode,
roundToRupee: true,
}, taxRates(db))
}, await taxRates(db))
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,
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
// 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)
writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
await db.run(`UPDATE document SET status='invoiced' WHERE id=?`, src.id)
await writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
if (src.docType === 'QUOTATION') {
// STOP cleanup (spec §7): a converted quote has moved forward — dismiss its
// 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
})()
})
}
/** Only issued, unpaid documents; the consumed number is never reused. */
export function cancelDocument(db: DB, userId: string, docId: string): Doc {
return db.transaction(() => {
const before = getDocument(db, docId)
export async function cancelDocument(db: DB, userId: string, docId: string): Promise<Doc> {
return db.transaction(async () => {
const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found')
if (before.docNo === null) throw new Error('Only issued documents can be cancelled')
if (before.status === 'cancelled') throw new Error('Document is already cancelled')
const alloc = db.prepare(
`SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`,
).get(docId) as { n: number }
const alloc = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, docId))!
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)
addEvent(db, docId, 'cancelled')
writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
await addEvent(db, docId, 'cancelled')
await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
if (before.docType === 'QUOTATION') {
// STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges
// 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
* a QUOTATION re-drafts through the normal composer.
*/
export function supersedeProforma(db: DB, userId: string, docId: string): Doc {
return db.transaction(() => {
const src = getDocument(db, docId)
export async function supersedeProforma(db: DB, userId: string, docId: string): Promise<Doc> {
return db.transaction(async () => {
const src = await getDocument(db, docId)
if (src === null) throw new Error('Document not found')
if (src.docType === 'INVOICE') {
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.status === 'cancelled') throw new Error('Document is already cancelled')
// 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`,
).get(src.id) as { doc_type: string } | undefined
src.id)
if (child !== undefined) {
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
}
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 {
// Unissued draft: no number to preserve; retire it the same audited way.
db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId)
addEvent(db, docId, 'cancelled')
writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' })
await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
await addEvent(db, docId, '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,
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
})()
})
}
/**
* Credit note against an issued invoice; defaults to full value. Amounts are
* positive here CN semantics are "negative" only in settlement math (Task 9).
*/
export function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Doc {
const inv = getDocument(db, invoiceId)
export async function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Promise<Doc> {
const inv = await getDocument(db, invoiceId)
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.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')
const client = getClient(db, inv.clientId)
const client = await getClient(db, inv.clientId)
if (client === null) throw new Error('Client not found')
// 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.
const lineInputs = lines !== undefined
? buildLines(db, lines, inv.docDate)
? await buildLines(db, lines, inv.docDate)
: inv.payload.lines.map((l): LineInput => ({
itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode,
unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS,
}))
const computed = computeBill(lineInputs, {
businessDate: inv.docDate,
supplyStateCode: supplyStateCode(db),
supplyStateCode: await supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode,
roundToRupee: true,
}, taxRates(db))
return db.transaction(() => {
const doc = insertDocRow(db, userId, {
}, await taxRates(db))
return db.transaction(async () => {
const doc = await insertDocRow(db, userId, {
docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id,
docDate: todayIso(), 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
})()
})
}
export interface GenerateReceiptInput {
@ -533,8 +534,8 @@ export interface GenerateReceiptInput {
* acknowledge money, they do not levy tax: zero GST, no line items, payable =
* amount received. Issued (numbered) immediately and never edited thereafter.
*/
export function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Doc {
const client = getClient(db, input.clientId)
export async function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Promise<Doc> {
const client = await getClient(db, input.clientId)
if (client === null) throw new Error('Client not found')
const totals: BillTotals = {
grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0,
@ -549,11 +550,11 @@ export function generateReceipt(db: DB, userId: string, input: GenerateReceiptIn
allocations: input.allocations,
},
}
return db.transaction(() => {
const draft = insertDocRow(db, userId, {
return db.transaction(async () => {
const draft = await insertDocRow(db, userId, {
docType: 'RECEIPT', clientId: input.clientId, refDocId: null,
docDate: todayIso(), totals, payload,
})
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 {
const row = db.prepare(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`)
.get() as AccountRow | undefined
export async function getAccount(db: DB): Promise<EmailAccount | null> {
const row = await db.get<AccountRow>(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`)
return row === undefined ? null : toAccount(row)
}
export function saveAccount(db: DB, address: string, refreshTokenEnc: string): EmailAccount {
return db.transaction(() => {
db.prepare(`DELETE FROM email_account`).run() // single sending identity
export async function saveAccount(db: DB, address: string, refreshTokenEnc: string): Promise<EmailAccount> {
return db.transaction(async () => {
await db.run(`DELETE FROM email_account`) // single sending identity
const id = uuidv7()
db.prepare(
await db.run(
`INSERT INTO email_account (id, address, refresh_token_enc, status, updated_at)
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.
writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' })
return getAccount(db)!
})()
await writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' })
return (await getAccount(db))!
})
}
/** Refresh token revoked/expired (invalid_grant) — flips the dashboard banner. */
export function markAccountDead(db: DB): void {
const account = getAccount(db)
export async function markAccountDead(db: DB): Promise<void> {
const account = await getAccount(db)
if (account === null || account.status === 'dead') return
db.prepare(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`)
.run(new Date().toISOString(), account.id)
writeAudit(db, 'system', 'update', 'email_account', account.id,
await db.run(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`,
new Date().toISOString(), account.id)
await writeAudit(db, 'system', 'update', 'email_account', account.id,
{ status: account.status }, { status: 'dead' })
}
@ -61,11 +61,10 @@ export interface EmailLogInput {
}
/** Append-only send trace — the email_log table is itself the record. */
export function logEmail(db: DB, e: EmailLogInput): void {
db.prepare(
export async function logEmail(db: DB, e: EmailLogInput): Promise<void> {
await db.run(
`INSERT INTO email_log (id, document_id, to_addr, subject, status, gmail_message_id, error, at_wall)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
uuidv7(), e.documentId ?? null, e.to, e.subject, e.status,
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 function emailStatus(db: DB): EmailStatus {
const account = getAccount(db)
export async function emailStatus(db: DB): Promise<EmailStatus> {
const account = await getAccount(db)
if (account === null) return { connected: false, dead: false }
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. */
export function listEmployees(db: DB): Employee[] {
const rows = db.prepare(
export async function listEmployees(db: DB): Promise<Employee[]> {
const rows = await db.all<EmployeeRow>(
`SELECT ${COLS} FROM staff_user ORDER BY display_name`,
).all() as EmployeeRow[]
)
return rows.map(toEmployee)
}
export function getEmployee(db: DB, id: string): Employee | null {
const row = db.prepare(`SELECT ${COLS} FROM staff_user WHERE id=?`).get(id) as EmployeeRow | undefined
export async function getEmployee(db: DB, id: string): Promise<Employee | null> {
const row = await db.get<EmployeeRow>(`SELECT ${COLS} FROM staff_user WHERE id=?`, id)
return row ? toEmployee(row) : null
}
/** True when at least one ACTIVE owner other than `excludeId` exists. */
function otherActiveOwnerExists(db: DB, excludeId: string): boolean {
const row = db.prepare(
async function otherActiveOwnerExists(db: DB, excludeId: string): Promise<boolean> {
const row = (await db.get<{ n: number }>(
`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
}
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
}): Employee {
}): Promise<Employee> {
assertRole(input.role)
if (input.password.length < 8) throw new Error('Password must be at least 8 characters')
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')
const id = uuidv7()
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
// 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')
}
db.prepare(
await db.run(
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
).run(id, email, input.displayName, input.role, salt, hash)
writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role })
return getEmployee(db, id)!
})()
id, email, input.displayName, input.role, salt, hash,
)
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
}): Employee {
return db.transaction(() => {
const before = getEmployee(db, id)
}): Promise<Employee> {
return db.transaction(async () => {
const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
if (patch.role !== undefined) {
assertRole(patch.role)
// 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')
}
}
@ -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.
if (sets.length === 0) throw new Error('Nothing to update')
args.push(id)
db.prepare(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`).run(...args)
const after = getEmployee(db, id)!
writeAudit(db, userId, 'update', 'staff_user', id, before, after)
await db.run(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`, ...args)
const after = (await getEmployee(db, id))!
await writeAudit(db, userId, 'update', 'staff_user', id, before, 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.
* 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,
): void {
): Promise<void> {
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`)
.get(userId) as { pw_salt: string; pw_hash: string } | undefined
const row = await db.get<{ pw_salt: string; pw_hash: string }>(
`SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`, userId)
if (row === undefined) throw new Error('Employee not found')
if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) {
throw new Error('Current password is incorrect')
}
const { salt, hash } = hashPin(newPassword)
db.transaction(() => {
db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, userId)
db.prepare(`DELETE FROM session WHERE staff_id=? AND token != ?`).run(userId, currentToken)
writeAudit(db, userId, 'change_own_password', 'staff_user', userId)
})()
await db.transaction(async () => {
await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, userId)
await db.run(`DELETE FROM session WHERE staff_id=? AND token != ?`, userId, currentToken)
await writeAudit(db, userId, 'change_own_password', 'staff_user', userId)
})
}
/** 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
* "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')
const { salt, hash } = hashPin(password)
db.transaction(() => {
const before = getEmployee(db, id)
await db.transaction(async () => {
const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, id)
db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id)
writeAudit(db, userId, 'reset_password', 'staff_user', id)
})()
await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, id)
await db.run(`DELETE FROM session WHERE staff_id=?`, id)
await writeAudit(db, userId, 'reset_password', 'staff_user', id)
})
}
/** 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')
return db.transaction(() => {
const before = getEmployee(db, id)
return db.transaction(async () => {
const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
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')
}
db.prepare(`UPDATE staff_user SET active=0 WHERE id=?`).run(id)
db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id)
const after = getEmployee(db, id)!
writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, id)
await db.run(`DELETE FROM session WHERE staff_id=?`, id)
const after = (await getEmployee(db, id))!
await writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
return after
})()
})
}
export function reactivateEmployee(db: DB, userId: string, id: string): Employee {
return db.transaction(() => {
const before = getEmployee(db, id)
export async function reactivateEmployee(db: DB, userId: string, id: string): Promise<Employee> {
return db.transaction(async () => {
const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
if (before.active) return before // already active — idempotent
db.prepare(`UPDATE staff_user SET active=1 WHERE id=?`).run(id)
const after = getEmployee(db, id)!
writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
await db.run(`UPDATE staff_user SET active=1 WHERE id=?`, id)
const after = (await getEmployee(db, id))!
await writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
return after
})()
})
}

@ -7,14 +7,14 @@ import { getClient } from './repos-clients'
export interface InteractionType { code: string; label: string }
export function listInteractionTypes(db: DB): InteractionType[] {
return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[]
export async function listInteractionTypes(db: DB): Promise<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')
db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim())
writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
await db.run(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`, input.code.trim(), input.label.trim())
await writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
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 {
const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined
export async function getInteraction(db: DB, id: string): Promise<Interaction | null> {
const row = await db.get<InteractionRow>(`SELECT * FROM interaction WHERE id=?`, id)
return row === undefined ? null : toInteraction(row)
}
export function listInteractions(db: DB, clientId: string): Interaction[] {
const rows = db.prepare(
export async function listInteractions(db: DB, clientId: string): Promise<Interaction[]> {
const rows = await db.all<InteractionRow>(
`SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`,
).all(clientId) as InteractionRow[]
clientId,
)
return rows.map(toInteraction)
}
@ -56,28 +57,27 @@ export interface CreateInteractionInput {
const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative']
export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction {
if (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)
export async function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Promise<Interaction> {
if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
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 (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`)
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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '',
input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(),
)
const interaction = getInteraction(db, id)!
writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
const interaction = (await getInteraction(db, id))!
await writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
return interaction
}
export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null }
export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction {
const before = getInteraction(db, id)
export async function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Promise<Interaction> {
const before = await getInteraction(db, id)
if (before === null) throw new Error('Interaction not found')
if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(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 (sets.length > 0) {
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)!
writeAudit(db, userId, 'update', 'interaction', id, before, after)
const after = (await getInteraction(db, id))!
await writeAudit(db, userId, 'update', 'interaction', id, before, after)
return after
}
/** 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 })[] {
const rows = db.prepare(
export async function listOpenFollowUps(db: DB, onOrBefore: string): Promise<(Interaction & { clientName: string })[]> {
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
WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ?
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 }))
}

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

@ -39,67 +39,69 @@ function toPayment(r: PaymentRow): Payment {
}
}
export function getPayment(db: DB, id: string): Payment | null {
const row = db.prepare(`SELECT * FROM payment WHERE id=?`).get(id) as PaymentRow | undefined
export async function getPayment(db: DB, id: string): Promise<Payment | null> {
const row = await db.get<PaymentRow>(`SELECT * FROM payment WHERE id=?`, id)
return row === undefined ? null : toPayment(row)
}
export function listPayments(db: DB, clientId: string): Payment[] {
const rows = db.prepare(
export async function listPayments(db: DB, clientId: string): Promise<Payment[]> {
const rows = await db.all<PaymentRow>(
`SELECT * FROM payment WHERE client_id=? ORDER BY id`, // uuidv7 ids sort by creation time
).all(clientId) as PaymentRow[]
clientId)
return rows.map(toPayment)
}
// ---------- settlement math ----------
function allocatedTo(db: DB, documentId: string): number {
const row = db.prepare(
async function allocatedTo(db: DB, documentId: string): Promise<number> {
const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
).get(documentId) as { total: number }
documentId))!
return row.total
}
/** Credit notes are "negative" only here, in settlement math (Task 8 stores them positive). */
function creditedTo(db: DB, invoiceId: string): number {
const row = db.prepare(
async function creditedTo(db: DB, invoiceId: string): Promise<number> {
const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
).get(invoiceId) as { total: number }
invoiceId))!
return row.total
}
function outstandingOf(db: DB, doc: Doc): number {
return doc.payablePaise - allocatedTo(db, doc.id) - creditedTo(db, doc.id)
async function outstandingOf(db: DB, doc: Doc): Promise<number> {
return doc.payablePaise - await allocatedTo(db, doc.id) - await creditedTo(db, doc.id)
}
/** Exported outstanding read for the scheduler and dashboard. */
export function outstandingPaise(db: DB, docId: string): number {
const doc = getDocument(db, docId)
export async function outstandingPaise(db: DB, docId: string): Promise<number> {
const doc = await getDocument(db, docId)
return doc === null ? 0 : outstandingOf(db, doc)
}
/** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */
function refreshSettlementStatus(db: DB, userId: string, docId: string): void {
const doc = getDocument(db, docId)
async function refreshSettlementStatus(db: DB, userId: string, docId: string): Promise<void> {
const doc = await getDocument(db, docId)
if (doc === null || doc.status === 'cancelled') return
const allocated = allocatedTo(db, docId)
const outstanding = doc.payablePaise - allocated - creditedTo(db, docId)
const allocated = await allocatedTo(db, docId)
const outstanding = doc.payablePaise - allocated - await creditedTo(db, docId)
const status = outstanding <= 0 ? 'paid' : allocated > 0 ? 'part_paid' : doc.status
if (status === doc.status) return
db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId)
writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status })
await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
await writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status })
}
/** Issued, unsettled invoices for the client, oldest first. */
function unsettledInvoices(db: DB, clientId: string): Doc[] {
const ids = db.prepare(
async function unsettledInvoices(db: DB, clientId: string): Promise<Doc[]> {
const ids = await db.all<{ id: string }>(
`SELECT id FROM document
WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('cancelled', 'paid')
ORDER BY doc_date, doc_no`,
).all(clientId) as { id: string }[]
return ids.map((r) => getDocument(db, r.id)!)
clientId)
const docs: Doc[] = []
for (const r of ids) docs.push((await getDocument(db, r.id))!)
return docs
}
// ---------- recording ----------
@ -118,8 +120,8 @@ export interface RecordPaymentResult {
receipt?: Doc
}
export function recordPayment(db: DB, userId: string, input: RecordPaymentInput): RecordPaymentResult {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
export async function recordPayment(db: DB, userId: string, input: RecordPaymentInput): Promise<RecordPaymentResult> {
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 (!Number.isInteger(input.amountPaise) || input.amountPaise <= 0) {
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) {
throw new Error('tdsPaise must be a non-negative integer (paise)')
}
return db.transaction(() => {
return db.transaction(async () => {
const id = uuidv7()
db.prepare(
await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise,
created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
id, input.clientId, input.receivedOn, input.mode, input.reference ?? '',
input.amountPaise, tdsPaise, userId, new Date().toISOString(),
)
writeAudit(db, userId, 'create', 'payment', id, undefined, {
input.amountPaise, tdsPaise, userId, new Date().toISOString())
await writeAudit(db, userId, 'create', 'payment', id, undefined, {
clientId: input.clientId, receivedOn: input.receivedOn, mode: input.mode,
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.
let pool = input.amountPaise + tdsPaise
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()
db.prepare(
await db.run(
`INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`,
).run(allocId, id, documentId, amountPaise)
writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, {
allocId, id, documentId, amountPaise)
await writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, {
paymentId: id, documentId, amountPaise,
})
allocated.push({ documentId, amountPaise })
refreshSettlementStatus(db, userId, documentId)
await refreshSettlementStatus(db, userId, documentId)
}
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) {
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.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.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`)
const outstanding = outstandingOf(db, doc)
const outstanding = await outstandingOf(db, doc)
if (a.amountPaise > outstanding) {
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)`)
}
pool -= a.amountPaise
insertAllocation(doc.id, a.amountPaise)
await insertAllocation(doc.id, a.amountPaise)
}
} else {
// Default: oldest-invoice-first, each up to its outstanding; leftover
// 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
const take = Math.min(pool, outstandingOf(db, doc))
const take = Math.min(pool, await outstandingOf(db, doc))
if (take <= 0) continue
pool -= take
insertAllocation(doc.id, take)
await insertAllocation(doc.id, take)
}
}
const receipt = input.issueReceipt === true ? receiptForPayment(db, userId, id) : undefined
return { payment: getPayment(db, id)!, allocated, ...(receipt !== undefined ? { receipt } : {}) }
})()
const receipt = input.issueReceipt === true ? await receiptForPayment(db, userId, id) : undefined
return { payment: (await getPayment(db, id))!, allocated, ...(receipt !== undefined ? { receipt } : {}) }
})
}
/** Build a RECEIPT for an already-recorded payment from its stored allocations. */
export function receiptForPayment(db: DB, userId: string, paymentId: string): Doc {
const p = getPayment(db, paymentId)
export async function receiptForPayment(db: DB, userId: string, paymentId: string): Promise<Doc> {
const p = await getPayment(db, paymentId)
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
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, {
clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode,
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 function clientLedger(db: DB, clientId: string): ClientLedger {
const payments = listPayments(db, clientId)
export async function clientLedger(db: DB, clientId: string): Promise<ClientLedger> {
const payments = await listPayments(db, clientId)
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
FROM payment_allocation a JOIN payment p ON p.id = a.payment_id
WHERE p.client_id=?`,
).get(clientId) as { total: number }
clientId))!
// The ledger must show EVERY document for the client (rule 8): walk the
// paginated repo in max-size batches instead of trusting one page.
const documents: Doc[] = []
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)
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
* split sums exactly).
*/
export function modulePaidView(db: DB, clientId: string): ModulePaidRow[] {
const invoices = db.prepare(
export async function modulePaidView(db: DB, clientId: string): Promise<ModulePaidRow[]> {
const invoices = await db.all<{ id: string }>(
`SELECT id FROM document
WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'
ORDER BY doc_date, doc_no`,
).all(clientId) as { id: string }[]
clientId)
const acc = new Map<string, { billedPaise: number; settledPaise: number }>()
for (const { id } of invoices) {
const inv = getDocument(db, id)!
const inv = (await getDocument(db, id))!
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))
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
}
export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
export async function listPipeline(db: DB, opts: ListPipelineOpts): Promise<PipelinePage> {
const filter = opts.filter ?? 'all'
const today = opts.today ?? new Date().toISOString().slice(0, 10)
const page = Math.max(opts.page ?? 1, 1)
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]!
// 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
// 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 MIN(e.at_wall) FROM document_event e
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
WHERE d2.client_id = d.client_id AND d2.doc_type = 'QUOTATION'
AND d2.status != 'cancelled')`,
).all() as QuoteRow[]
)
// 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
// 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,
c.status AS client_status, c.owner_id
FROM client c
@ -110,10 +110,10 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
AND NOT EXISTS (SELECT 1 FROM document d
WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION'
AND d.status != 'cancelled')`,
).all() as LeadRow[]
)
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]),
)
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 {
const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined
export async function getRecurringPlan(db: DB, id: string): Promise<RecurringPlan | null> {
const row = await db.get<RecurringRow>(`SELECT * FROM recurring_plan WHERE id=?`, id)
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
? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[]
: 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 ORDER BY id DESC`)
: await db.all<RecurringRow>(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`, clientId)
return rows.map(toPlan)
}
@ -44,9 +44,9 @@ export interface CreateRecurringInput {
amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual'
}
export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan {
if (getClient(db, input.clientId) === null) throw new Error('Client not found')
const cm = getClientModule(db, input.clientModuleId)
export async function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): Promise<RecurringPlan> {
if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
const cm = await getClientModule(db, input.clientModuleId)
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 (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.
if (input.amountPaise === undefined) {
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`)
}
}
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)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
).run(
id, input.clientId, input.clientModuleId, input.cadence,
input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual',
)
const plan = getRecurringPlan(db, id)!
writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
const plan = (await getRecurringPlan(db, id))!
await writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
return plan
}
@ -77,8 +76,8 @@ export interface RecurringPatch {
policy?: 'auto' | 'manual'; active?: boolean
}
export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan {
const before = getRecurringPlan(db, id)
export async function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): Promise<RecurringPlan> {
const before = await getRecurringPlan(db, id)
if (before === null) throw new Error('Recurring plan not found')
const sets: string[] = []
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 (sets.length > 0) {
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)!
writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
const after = (await getRecurringPlan(db, id))!
await writeAudit(db, userId, 'update', 'recurring_plan', id, before, 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 })
}

@ -8,7 +8,7 @@ import {
/** Reminder rows + settings helpers (D12 plain-repo pattern). The UNIQUE key
* (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'
@ -33,8 +33,8 @@ function toReminder(r: ReminderRow): Reminder {
}
}
export function getReminder(db: DB, id: string): Reminder | null {
const row = db.prepare(`SELECT * FROM reminder WHERE id=?`).get(id) as ReminderRow | undefined
export async function getReminder(db: DB, id: string): Promise<Reminder | null> {
const row = await db.get<ReminderRow>(`SELECT * FROM reminder WHERE id=?`, id)
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
* 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 now = input.now ?? new Date().toISOString()
const res = db.prepare(
// Portability quirk: INSERT OR IGNORE is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT OR IGNORE INTO reminder
const res = await db.run(
// Portability quirk: ON CONFLICT DO NOTHING is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO reminder
(id, rule_kind, subject_id, due_period, client_id, doc_id, status, policy_applied, error, created_at, sent_at)
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)`,
).run(
VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)
ON CONFLICT (rule_kind, subject_id, due_period) DO NOTHING`,
id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId,
input.docId ?? null, input.policyApplied ?? 'manual', now,
)
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,
})
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=?`,
).get(input.ruleKind, input.subjectId, input.duePeriod) as { id: string }
input.ruleKind, input.subjectId, input.duePeriod,
))!
return { id: existing.id, created: false }
}
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`
const args: unknown[] = []
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.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) }
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. */
@ -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.
* Paginated with an honest total (rule 8); owner scope derives via
* 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 pageSize = Math.min(Math.max(opts.pageSize ?? QUEUE_PAGE_SIZE, 1), QUEUE_MAX_PAGE_SIZE)
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 —
// 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) }
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}`,
).get(...args) as { n: number }).n
const rows = db.prepare(
...args,
))!.n
const rows = await db.all<QueueRow>(
`SELECT r.*, c.name AS client_name, d.doc_no, d.created_by AS owner_id
FROM reminder r
LEFT JOIN client c ON c.id = r.client_id
@ -141,7 +143,8 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
WHERE ${where}
ORDER BY r.id DESC
LIMIT ? OFFSET ?`,
).all(...args, pageSize, (page - 1) * pageSize) as QueueRow[]
...args, pageSize, (page - 1) * pageSize,
)
return {
rows: rows.map((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. */
export function queueCounts(db: DB, ownerId?: string): { queued: number; failed: number } {
const rows = db.prepare(
export async function queueCounts(db: DB, ownerId?: string): Promise<{ queued: number; failed: number }> {
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
WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND (d.created_by = ? OR r.doc_id IS NULL)` : ''}
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]))
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,
opts: { error?: string | null; sentAt?: string | null } = {},
): Reminder {
const before = getReminder(db, id)
): Promise<Reminder> {
const before = await getReminder(db, id)
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,
opts.error !== undefined ? opts.error : before.error,
opts.sentAt !== undefined ? opts.sentAt : before.sentAt,
id,
)
const after = getReminder(db, id)!
writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error })
const after = (await getReminder(db, id))!
await writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error })
return after
}
export function dismissReminder(db: DB, userId: string, id: string): Reminder {
const before = getReminder(db, id)
export async function dismissReminder(db: DB, userId: string, id: string): Promise<Reminder> {
const before = await getReminder(db, id)
if (before === null) throw new Error('Reminder not found')
if (before.status === 'sent') throw new Error('Cannot dismiss a reminder that was already sent')
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.
* Callers (markStatus / convertDocument) run this inside their own transaction.
*/
export function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): number {
const rows = db.prepare(
export async function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): Promise<number> {
const rows = await db.all<{ id: string }>(
`SELECT id FROM reminder
WHERE rule_kind='quote_followup' AND subject_id=? AND status IN ('queued','failed')`,
).all(quoteId) as { id: string }[]
for (const r of rows) setReminderStatus(db, userId, r.id, 'dismissed')
quoteId,
)
for (const r of rows) await setReminderStatus(db, userId, r.id, 'dismissed')
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),
* 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. */
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 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
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)
).get(ruleKind, today, today) as { day_offsets: string; subject: string | null; body: string | null } | undefined
ruleKind, today, today,
)
if (row !== undefined) {
const dayOffsets = parseDayOffsets(row.day_offsets)
if (dayOffsets.length > 0) {
@ -290,11 +297,11 @@ export interface ScheduleRow {
const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS)
/** All dated schedule rows, newest first within each rule kind (bounded set). */
export function listSchedules(db: DB): ScheduleRow[] {
const rows = db.prepare(
export async function listSchedules(db: DB): Promise<ScheduleRow[]> {
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
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) => ({
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,
@ -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). */
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
}): ScheduleRow {
}): Promise<ScheduleRow> {
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')
const offsets = parseDayOffsetsStrict(input.dayOffsets)
const id = uuidv7()
const csv = offsets.join(',')
db.transaction(() => {
db.prepare(
await db.transaction(async () => {
await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null)
writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null,
)
await writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
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) ----------
export function getSetting(db: DB, key: string): string | null {
const row = db.prepare(`SELECT value FROM setting WHERE key=?`).get(key) as { value: string } | undefined
export async function getSetting(db: DB, key: string): Promise<string | null> {
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key=?`, key)
return row === undefined ? null : row.value
}
export function setSetting(db: DB, userId: string, key: string, value: string): void {
const before = getSetting(db, key)
db.prepare(
export async function setSetting(db: DB, userId: string, key: string, value: string): Promise<void> {
const before = await getSetting(db, key)
await db.run(
// 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`,
).run(key, value)
writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value })
key, 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 {
const raw = getSetting(db, key)
export async function getNumberSetting(db: DB, key: string, fallback: number): Promise<number> {
const raw = await getSetting(db, key)
if (raw === null) return fallback
const n = Number(raw)
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. */
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'`
const args: unknown[] = []
if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) }
if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) }
if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) }
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]. */
function settledOf(db: DB, docId: string, payablePaise: number): number {
return Math.max(0, Math.min(payablePaise, payablePaise - outstandingPaise(db, docId)))
async function settledOf(db: DB, docId: string, payablePaise: number): Promise<number> {
return Math.max(0, Math.min(payablePaise, payablePaise - await outstandingPaise(db, docId)))
}
export interface DuesAgingRow {
@ -37,16 +37,16 @@ export interface DuesAgingRow {
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.
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
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')`,
).all() as { id: string; client_id: string; anchor: string; client_name: string }[]
)
const acc = new Map<string, DuesAgingRow>()
for (const inv of invoices) {
const out = outstandingPaise(db, inv.id)
const out = await outstandingPaise(db, inv.id)
if (out <= 0) continue
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 }
@ -65,11 +65,11 @@ export interface ModuleRevenueRow {
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 }>()
for (const id of invoiceIds(db, undefined, range)) {
const inv = getDocument(db, id)!
const settledTotal = settledOf(db, inv.id, inv.payablePaise)
for (const id of await invoiceIds(db, undefined, range)) {
const inv = (await getDocument(db, id))!
const settledTotal = await settledOf(db, inv.id, inv.payablePaise)
const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise))
inv.payload.lines.forEach((line, i) => {
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)
})
}
return [...acc.entries()].map(([moduleId, v]) => {
const mod = getModule(db, moduleId)
return {
const out: ModuleRevenueRow[] = []
for (const [moduleId, v] of acc.entries()) {
const mod = await getModule(db, moduleId)
out.push({
moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId,
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 {
@ -92,18 +94,18 @@ export interface ProfitabilityRow {
billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number
}
export function clientProfitability(db: DB, range?: DateRange): ProfitabilityRow[] {
const clients = db.prepare(`SELECT id, name FROM client ORDER BY name`).all() as { id: string; name: string }[]
export async function clientProfitability(db: DB, range?: DateRange): Promise<ProfitabilityRow[]> {
const clients = await db.all<{ id: string; name: string }>(`SELECT id, name FROM client ORDER BY name`)
const out: ProfitabilityRow[] = []
for (const c of clients) {
let billed = 0
let settled = 0
for (const id of invoiceIds(db, c.id, range)) {
const inv = getDocument(db, id)!
for (const id of await invoiceIds(db, c.id, range)) {
const inv = (await getDocument(db, id))!
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
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 {
const row = db.prepare(`SELECT * FROM document_share WHERE id=?`).get(id) as ShareRow | undefined
export async function getShare(db: DB, id: string): Promise<Share | null> {
const row = await db.get<ShareRow>(`SELECT * FROM document_share WHERE id=?`, id)
return row === undefined ? null : toShare(row)
}
export function listShares(db: DB, documentId: string): Share[] {
const rows = db.prepare(
export async function listShares(db: DB, documentId: string): Promise<Share[]> {
const rows = await db.all<ShareRow>(
`SELECT * FROM document_share WHERE document_id=? ORDER BY id DESC`, // uuidv7 ids: newest first
).all(documentId) as ShareRow[]
documentId)
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
* path uses it to reuse an existing link instead of minting a duplicate.
*/
export function resolveLiveShare(db: DB, documentId: string): Share | null {
const row = db.prepare(
export async function resolveLiveShare(db: DB, documentId: string): Promise<Share | null> {
const row = await db.get<ShareRow>(
// ISO-8601 UTC strings compare lexicographically === chronologically.
`SELECT * FROM document_share
WHERE document_id=? AND revoked=0 AND (expires_at IS NULL OR expires_at > ?)
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)
}
@ -72,32 +72,32 @@ export interface MintShareOpts {
}
/** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */
function defaultExpiryDays(db: DB): number | null {
const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
.get() as { value: string } | undefined
async function defaultExpiryDays(db: DB): Promise<number | null> {
const row = await db.get<{ value: string }>(
`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
if (row === undefined) return 30
if (row.value === 'never') return null
const n = Number(row.value)
return Number.isInteger(n) && n > 0 ? n : 30
}
export function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Share {
if (getDocument(db, documentId) === null) throw new Error('Document not found')
export async function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Promise<Share> {
if (await getDocument(db, documentId) === null) throw new Error('Document not found')
const id = uuidv7()
const token = randomBytes(32).toString('hex') // 256-bit, unguessable; 64 hex chars
const now = new Date()
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
? null
: 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)
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).
writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt })
return getShare(db, id)!
await writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt })
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
* returns nothing but the single document the live token points at.
*/
export function validateShare(db: DB, token: string): Doc | null {
const row = db.prepare(`SELECT * FROM document_share WHERE token=?`).get(token) as ShareRow | undefined
export async function validateShare(db: DB, token: string): Promise<Doc | null> {
const row = await db.get<ShareRow>(`SELECT * FROM document_share WHERE token=?`, token)
if (row === undefined) return null // unknown
if (row.revoked !== 0) return null // revoked
// 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)
}
export function revokeShare(db: DB, userId: string, shareId: string): Share {
const before = getShare(db, shareId)
export async function revokeShare(db: DB, userId: string, shareId: string): Promise<Share> {
const before = await getShare(db, shareId)
if (before === null) throw new Error('Share not found')
db.prepare(`UPDATE document_share SET revoked=1 WHERE id=?`).run(shareId)
const after = getShare(db, shareId)!
writeAudit(db, userId, 'revoke', 'document_share', shareId,
await db.run(`UPDATE document_share SET revoked=1 WHERE id=?`, shareId)
const after = (await getShare(db, shareId))!
await writeAudit(db, userId, 'revoke', 'document_share', shareId,
{ revoked: before.revoked }, { revoked: after.revoked })
return after
}

@ -54,11 +54,11 @@ function bump(created: Record<string, number>, kind: string): void {
type ClaimResult = 'done' | { created: boolean; auto: boolean; reminderId: string }
/** One period for one plan, atomically. Callers wrap in db.transaction. */
function claimAndGenerate(db: DB, planId: string, today: string, now: string): ClaimResult {
const plan = getRecurringPlan(db, planId)
async function claimAndGenerate(db: DB, planId: string, today: string, now: string): Promise<ClaimResult> {
const plan = await getRecurringPlan(db, planId)
if (plan === null || !plan.active || plan.nextRun > today) return 'done'
const duePeriod = plan.nextRun
const up = upsertReminder(db, {
const up = await upsertReminder(db, {
ruleKind: 'recurring_generated', subjectId: plan.id, duePeriod,
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) {
// Defensive: the period's reminder already exists but next_run wasn't advanced.
// 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 }
}
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`)
const kind = CADENCE_KIND[plan.cadence]
const draft = createDraft(db, 'system', {
const draft = await createDraft(db, 'system', {
docType: 'INVOICE', clientId: plan.clientId,
lines: [{
moduleId: cm.moduleId, qty: 1, kind, edition: cm.edition,
...(plan.amountPaise !== null ? { unitPricePaise: plan.amountPaise } : {}),
}],
})
const inv = issueDocument(db, 'system', draft.id)
db.prepare(`UPDATE reminder SET doc_id=? WHERE id=?`).run(inv.id, up.id)
db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId)
writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id })
const inv = await issueDocument(db, 'system', draft.id)
await db.run(`UPDATE reminder SET doc_id=? WHERE id=?`, inv.id, up.id)
await db.run(`UPDATE recurring_plan SET next_run=? WHERE id=?`, nextRun, planId)
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 }
}
/** 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[] {
const plans = db.prepare(
`SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`,
).all(today) as { id: string }[]
async function generateRecurring(db: DB, today: string, now: string, created: Record<string, number>): Promise<string[]> {
const plans = await db.all<{ id: string }>(
`SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`, today,
)
const autoQueue: string[] = []
for (const { id } of plans) {
let guard = 0
for (;;) {
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.created) {
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
// 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.
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 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
WHERE doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('paid','cancelled','lost')
AND COALESCE(due_date, doc_date) <= ?`,
).all(overdueCutoff) as { id: string; client_id: string; doc_date: string; due_date: string | null }[]
AND COALESCE(due_date, doc_date) <= ?`, overdueCutoff,
)
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 crossed = invOffsets.filter((d) => age >= d)
if (crossed.length === 0) continue
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')
}
}
// --- 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 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
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) {
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')
}
}
// --- 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`,
).all() as { id: string; client_id: string; period_to: string; renewal_reminder_days: number }[]
)
for (const a of amcs) {
const horizon = addDaysIso(today, a.renewal_reminder_days)
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')
}
}
// --- 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
WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`,
).all(today) as { id: string; client_id: string; follow_up_on: string }[]
WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`, today,
)
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')
}
}
@ -176,13 +177,13 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// --- quote_followup (escalating chase on sent quotations — spec §7) ---
// 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.
const followupSchedule = resolveSchedule(db, 'quote_followup', today)
const followupSchedule = await resolveSchedule(db, 'quote_followup', today)
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
// 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.
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 MIN(e.at_wall) FROM document_event e
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 NOT EXISTS (SELECT 1 FROM document ch
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[] = []
for (const q of sentQuotes) {
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
// unique key), so daily operation still fires each interval exactly once.
const highest = crossed[crossed.length - 1]!
const up = upsertReminder(db, {
const up = await upsertReminder(db, {
ruleKind: 'quote_followup', subjectId: q.id, duePeriod: `d${highest}`,
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) ---
const autoQueue = [...followupAuto, ...generateRecurring(db, today, now, created)]
const autoQueue = [...followupAuto, ...await generateRecurring(db, today, now, created)]
let autoSent = 0
let autoFailed = 0
for (const reminderId of autoQueue) {
@ -227,7 +228,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
} catch (err) {
// 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.
setReminderStatus(db, 'system', reminderId, 'failed', {
await setReminderStatus(db, 'system', reminderId, 'failed', {
error: err instanceof Error ? err.message : String(err),
})
autoFailed += 1

@ -31,77 +31,83 @@ const REMINDER_SETTINGS: Record<string, string> = {
'reminders.renewal_days': '15',
}
export function seedIfEmpty(db: DB): void {
const staff = db.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number }
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
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) {
// 9 random bytes → 12 base64url chars; printed exactly once, never stored in clear.
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)`)
}
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
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) {
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) {
db.prepare(
await db.run(
`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
for (const [code, label] of INTERACTION_TYPES) seededTypes += typeInsert.run(code, label).changes
if (seededTypes > 0) writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes })
for (const [code, label] of INTERACTION_TYPES) {
// 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
for (const [key, value] of Object.entries(REMINDER_SETTINGS)) seededReminderSettings += insert.run(key, value).changes
if (seededReminderSettings > 0) writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS)
for (const [key, value] of Object.entries(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
// due_date = doc_date + terms on invoices that don't carry one.
const BILLING_SETTINGS: Record<string, string> = { 'billing.payment_terms_days': '15' }
let seededBilling = 0
for (const [key, value] of Object.entries(BILLING_SETTINGS)) seededBilling += insert.run(key, value).changes
if (seededBilling > 0) writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS)
for (const [key, value] of Object.entries(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,
// matching the code-constant fallback. A cadence change is a NEW dated row, not an edit.
const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue']
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
const d = SCHEDULE_DEFAULTS[ruleKind]
const id = uuidv7()
const effectiveFrom = '2020-01-01' // predates all HQ data — active for every business date
db.transaction(() => {
db.prepare(
await db.transaction(async () => {
await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
).run(id, ruleKind, effectiveFrom, d.dayOffsets.join(','), d.subject, d.body)
writeAudit(db, 'system', 'seed', 'reminder_schedule', id, undefined, {
id, ruleKind, effectiveFrom, d.dayOffsets.join(','), d.subject, d.body,
)
await writeAudit(db, 'system', 'seed', 'reminder_schedule', id, undefined, {
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) {
// The AMC/renewal invoice line hangs off a real module so it flows through
// 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',
allowedKinds: ['yearly', 'one_time'],
})

@ -18,7 +18,7 @@ import { documentHtml } from './templates'
export interface SendReminderDeps {
gmail: GmailDeps
renderPdf: (html: string) => Promise<Buffer>
company: () => Record<string, string>
company: () => Record<string, string> | Promise<Record<string, string>>
now?: () => string
}
@ -27,18 +27,18 @@ export interface SendReminderDeps {
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). */
function shareUrlFor(db: DB, token: string): string {
const base = getSetting(db, 'share.base_url')
async function shareUrlFor(db: DB, token: string): Promise<string> {
const base = await getSetting(db, 'share.base_url')
return `${base !== null ? base.replace(/\/+$/, '') : ''}/share/${token}`
}
export function reminderContext(db: DB, reminder: Reminder, companyName: string, today?: string): ReminderRender {
const client = getClient(db, reminder.clientId)
export async function reminderContext(db: DB, reminder: Reminder, companyName: string, today?: string): Promise<ReminderRender> {
const client = await getClient(db, reminder.clientId)
if (client === null) throw new Error('Client not found')
const ctx: ReminderContext = { clientName: client.name, companyName }
let doc: Doc | null = 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')
ctx.docNo = doc.docNo ?? undefined
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
// 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 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.bodyTemplate = schedule.body ?? undefined
// Quotes reach 'sent' without issuance (F10) — never render "quotation null".
ctx.ref = doc.docNo ?? `dated ${doc.docDate}`
// Resolve-only (F5): this builder is shared with GET /preview and must write
// nothing. Minting happens exclusively in sendReminder below.
const live = resolveLiveShare(db, doc.id)
const live = await resolveLiveShare(db, doc.id)
ctx.shareUrl = live !== null
? shareUrlFor(db, live.token)
? await shareUrlFor(db, live.token)
: '(a view link is generated when this reminder is sent)'
}
} 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
} 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 }
}
return { ctx, doc, client }
@ -86,33 +86,33 @@ export function reminderContext(db: DB, reminder: Reminder, companyName: string,
export async function sendReminder(
db: DB, deps: SendReminderDeps, reminderId: string, userId: string,
): Promise<SendResult> {
const reminder = getReminder(db, reminderId)
const reminder = await getReminder(db, reminderId)
if (reminder === null) throw new Error('Reminder not found')
if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
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 now = deps.now?.() ?? new Date().toISOString()
// 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).
const recipient = getClient(db, reminder.clientId)
const recipient = await getClient(db, reminder.clientId)
if (recipient === null) throw new Error('Client not found')
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 (reminder.ruleKind === 'quote_followup' && reminder.docId !== null) {
// 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.
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`)
}
// 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).
if (resolveLiveShare(db, reminder.docId) === null) {
mintShare(db, userId, reminder.docId, { expiresDays: 60 })
if (await resolveLiveShare(db, reminder.docId) === null) {
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 documentId: string | undefined
@ -128,7 +128,7 @@ export async function sendReminder(
...(attachment !== undefined ? { attachment } : {}),
...(documentId !== undefined ? { documentId } : {}),
})
if (out.ok) setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null })
else setReminderStatus(db, userId, reminderId, 'failed', { error: out.error })
if (out.ok) await setReminderStatus(db, userId, reminderId, 'sent', { sentAt: now, error: null })
else await setReminderStatus(db, userId, reminderId, 'failed', { error: out.error })
return out
}

@ -5,25 +5,28 @@ export const TYPE_PREFIX: Record<string, string> = {
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)}`
db.prepare(
await db.run(
// 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)
ON CONFLICT (doc_type, fy) DO NOTHING`,
).run(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 }
db.prepare(`UPDATE doc_series SET next_seq = next_seq + 1 WHERE doc_type=? AND fy=?`).run(docType, fy)
docType, fy, prefix,
)
const row = (await db.get<{ prefix: string; next_seq: number }>(
`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)
}
/** 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)}`
db.prepare(
await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO doc_series (doc_type, fy, prefix, next_seq) VALUES (?, ?, ?, ?)
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
function schedulerDeps(db: DB) {
const company = (): Record<string, string> =>
Object.fromEntries((db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`)
.all() as { key: string; value: string }[]).map((r) => [r.key, r.value]))
const company = async (): Promise<Record<string, string>> =>
Object.fromEntries((await db.all<{ key: string; value: string }>(
`SELECT key, value FROM setting WHERE key LIKE 'company.%'`,
)).map((r) => [r.key, r.value]))
const gmail = {
f: fetch,
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. */
function companySettings(db: DB): Record<string, string> {
const rows = db.prepare(
async function companySettings(db: DB): Promise<Record<string, string>> {
const rows = await db.all<{ key: string; value: string }>(
`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]))
}
@ -128,12 +129,12 @@ export function mountPublicShare(app: express.Express, db: DB, deps: PublicShare
const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown'
if (!allow(ip)) { gone(res, 429, 'Too many requests. Please try again in a minute.'); return }
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 () => {
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 {
const pdf = await deps.renderPdf(documentHtml(doc, client, company))
res.setHeader('Content-Type', 'application/pdf')
@ -144,14 +145,17 @@ export function mountPublicShare(app: express.Express, db: DB, deps: PublicShare
} catch {
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 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.use(express.json({ limit: '2mb' }))
// 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')) {
const PORT = Number(process.env['HQ_PORT'] ?? 5182)
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,
} from '../src/repos-amc'
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) // seeds company.state_code, GST18, AMC module
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
async function setup() {
const db = openDb(':memory:'); await seedIfEmpty(db) // seeds company.state_code, GST18, AMC module
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
return { db, c }
}
describe('amc contracts', () => {
it('creates, generates a renewal invoice, and derives paid from settlement', () => {
const { db, c } = setup()
const amc = createAmc(db, 'u1', {
it('creates, generates a renewal invoice, and derives paid from settlement', async () => {
const { db, c } = await setup()
const amc = await createAmc(db, 'u1', {
clientId: c.id, coverage: 'On-site support', periodFrom: '2026-04-01', periodTo: '2027-03-31',
amountPaise: 20_000_00,
})
expect(listAmc(db, c.id)).toHaveLength(1)
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unbilled')
const inv = generateAmcRenewalInvoice(db, 'u1', amc.id)
expect(await listAmc(db, c.id)).toHaveLength(1)
expect(await amcPaidStatus(db, (await getAmc(db, amc.id))!)).toBe('unbilled')
const inv = await generateAmcRenewalInvoice(db, 'u1', amc.id)
expect(inv.docType).toBe('INVOICE')
expect(inv.payablePaise).toBe(23_600_00) // 20,000 + 18% GST intra-state
expect(amcPaidStatus(db, getAmc(db, amc.id)!)).toBe('unpaid')
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('unpaid')
await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 23_600_00 })
expect(await amcPaidStatus(db, (await getAmc(db, amc.id))!)).toBe('paid')
})
it('honours the legacy_paid flag for imported contracts without an invoice', () => {
const { db, c } = setup()
const amc = createAmc(db, 'u1', {
it('honours the legacy_paid flag for imported contracts without an invoice', async () => {
const { db, c } = await setup()
const amc = await createAmc(db, 'u1', {
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'
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:')
createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(login(db, 'admin@tecnostac.com', 'wrong')).toBeNull()
const ok = login(db, 'admin@tecnostac.com', 'let-me-in-9')
await createStaff(db, { email: 'admin@tecnostac.com', displayName: 'Owner', role: 'owner', password: 'let-me-in-9' })
expect(await login(db, 'admin@tecnostac.com', 'wrong')).toBeNull()
const ok = await login(db, 'admin@tecnostac.com', 'let-me-in-9')
expect(ok).not.toBeNull()
const staff = verifySession(db, ok!.token)
const staff = await verifySession(db, ok!.token)
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:')
expect(() =>
await expect(
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', () => {
it('maps client tags to clients, upserts cost rows, and collects unknown tags', async () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const acme = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const db = openDb(':memory:'); await seedIfEmpty(db)
const acme = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
let sentUrl = ''
const out = await pullMonthlyCosts(db, deps(CE_RESPONSE, (u) => { sentUrl = u }), '2026-06')
expect(sentUrl).toBe('https://ce.us-east-1.amazonaws.com/')
expect(out.upserted).toBe(1) // only ACME matched
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)
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' })
})
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' } } }] }] }
await expect(pullMonthlyCosts(db, deps(usd), '2026-06')).rejects.toThrow(/INR/i)
})
@ -69,12 +69,12 @@ describe('pullMonthlyCosts', () => {
describe('maybePullAwsCosts', () => {
it('pulls the previous month once per calendar month', async () => {
const db = openDb(':memory:'); seedIfEmpty(db)
createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const db = openDb(':memory:'); await seedIfEmpty(db)
await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
expect(previousMonth('2026-07-10')).toBe('2026-06')
const first = await maybePullAwsCosts(db, deps(CE_RESPONSE), '2026-07-10')
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')
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
})
const db = openDb(':memory:'); seedIfEmpty(db)
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' })
const db = openDb(':memory:')
beforeAll(async () => {
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 server = app.listen(0)
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 { upsertAwsUsage, getAwsUsage, listAwsUsage, costRanking, awsCostForClient, previousMonth } from '../src/repos-aws'
function setup() {
async function setup() {
const db = openDb(':memory:')
const a = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const b = createClient(db, 'u1', { name: 'Bolt', code: 'BOLT', stateCode: '29' })
const a = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const b = await createClient(db, 'u1', { name: 'Bolt', code: 'BOLT', stateCode: '29' })
return { db, a, b }
}
describe('aws usage repo', () => {
it('upserts and merges a (client, month) row without clobbering the other source', () => {
const { db, a } = setup()
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
const row = getAwsUsage(db, a.id, '2026-06')!
it('upserts and merges a (client, month) row without clobbering the other source', async () => {
const { db, a } = await setup()
await 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', storageGb: 42.5, source: 'manual' }) // merges
const row = (await getAwsUsage(db, a.id, '2026-06'))!
expect(row.costPaise).toBe(5_000_00) // auto cost preserved
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 })
const audits = db.prepare(`SELECT action FROM audit_log WHERE entity='aws_usage'`).all()
expect(await db.get(`SELECT COUNT(*) AS n FROM aws_usage WHERE client_id=?`, a.id)).toMatchObject({ n: 1 })
const audits = await db.all(`SELECT action FROM audit_log WHERE entity='aws_usage'`)
expect(audits.length).toBe(2) // create + update
})
it('lists newest month first, capped', () => {
const { db, a } = setup()
for (const m of ['2026-04', '2026-05', '2026-06']) 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'])
it('lists newest month first, capped', async () => {
const { db, a } = await setup()
for (const m of ['2026-04', '2026-05', '2026-06']) await upsertAwsUsage(db, 'u1', { clientId: a.id, month: m, costPaise: 100 })
expect((await listAwsUsage(db, a.id, 2)).map((r) => r.month)).toEqual(['2026-06', '2026-05'])
})
it('rejects a bad month', () => {
const { db, a } = setup()
expect(() => upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026/06', costPaise: 1 })).toThrow(/month/i)
it('rejects a bad month', async () => {
const { db, a } = await setup()
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', () => {
const { db, a, b } = setup()
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 })
const r = costRanking(db, '2026-06')
it('ranks clients by cost with share in basis points', async () => {
const { db, a, b } = await setup()
await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 7_500_00 })
await upsertAwsUsage(db, 'u1', { clientId: b.id, month: '2026-06', costPaise: 2_500_00 })
const r = await costRanking(db, '2026-06')
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[0]!.sharePctBp).toBe(7500) // 75.00%
expect(r.rows[1]!.sharePctBp).toBe(2500)
})
it('sums a client cost over a month range and derives the previous month', () => {
const { db, a } = setup()
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-05', costPaise: 100 })
upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 200 })
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(awsCostForClient(db, a.id)).toBe(700)
it('sums a client cost over a month range and derives the previous month', async () => {
const { db, a } = await setup()
await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-05', costPaise: 100 })
await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-06', costPaise: 200 })
await upsertAwsUsage(db, 'u1', { clientId: a.id, month: '2026-07', costPaise: 400 })
expect(await awsCostForClient(db, a.id, '2026-05-01', '2026-06-30')).toBe(300)
expect(await awsCostForClient(db, a.id)).toBe(700)
expect(previousMonth('2026-07-10')).toBe('2026-06')
expect(previousMonth('2026-01-05')).toBe('2025-12') // year rollover
})

@ -25,24 +25,24 @@ function bounceFetch(recipient: string): typeof fetch {
describe('pollBounces', () => {
it('flips the sent email_log to bounced and raises an email_bounced reminder, idempotently', async () => {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' })
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
await logEmail(db, { to: 'ravi@acme.in', subject: 'INV/26-27-0001', status: 'sent', gmailMessageId: 'g1' })
const deps: BounceDeps = {
gmail: { f: bounceFetch('ravi@acme.in'), clientId: 'cid', clientSecret: 'sec', keyHex: KEY },
now: () => '2026-07-10T00:00:00Z',
}
const out = await pollBounces(db, deps)
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(listReminders(db, { ruleKind: 'email_bounced' })).toHaveLength(1)
expect(await db.get(`SELECT bounced FROM email_log WHERE to_addr='ravi@acme.in'`)).toMatchObject({ bounced: 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.
const again = await pollBounces(db, deps)
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 () => {
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' }
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'
describe('client modules', () => {
it('enforces allowed kinds and single-subscription', () => {
it('enforces allowed kinds and single-subscription', async () => {
const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = 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/)
const cm = 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/)
const up = updateClientModule(db, 'u1', cm.id, { status: 'installed', installedOn: '2026-07-01' })
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['one_time','yearly'] })
await expect(assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })).rejects.toThrow(/allow/)
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await expect(assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })).rejects.toThrow(/already/)
const up = await updateClientModule(db, 'u1', cm.id, { status: 'installed', installedOn: '2026-07-01' })
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 c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', multiSubscription: true })
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()
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', multiSubscription: true })
await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'usage' })
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 os from 'node:os'
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 { createStaff, ownerScope } from '../src/auth'
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 { apiRouter } from '../src/api'
function withOwner(): { db: DB; ownerId: string } {
async function withOwner(): Promise<{ db: DB; ownerId: string }> {
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',
})
return { db, ownerId: id }
}
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 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')
const c = createClient(db, 'u1', { name: 'Fresh Co', stateCode: '32' })
expect(getClient(db, c.id)!.ownerId).toBeUndefined()
const c = await createClient(db, 'u1', { name: 'Fresh Co', stateCode: '32' })
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.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hq-owner-'))
const raw = new Database(path.join(dir, 'hq.db'))
@ -47,55 +47,55 @@ describe('client.owner_id column', () => {
).run()
raw.close()
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')
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 })
db.close()
await db.close()
const again = openDb(dir) // idempotent re-run loses nothing
expect(again.prepare(`SELECT COUNT(*) AS n FROM client`).get()).toMatchObject({ n: 1 })
again.close()
expect(await again.get(`SELECT COUNT(*) AS n FROM client`)).toMatchObject({ n: 1 })
await again.close()
fs.rmSync(dir, { recursive: true, force: true })
})
})
describe('setClientOwner', () => {
it('gives a lead a routable owner and writes an audit row in the same transaction', () => {
const { db, ownerId } = withOwner()
const emp = 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 after = setClientOwner(db, ownerId, lead.id, emp.id)
it('gives a lead a routable owner and writes an audit row in the same transaction', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const lead = await createClient(db, ownerId, { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
const after = await setClientOwner(db, ownerId, lead.id, emp.id)
expect(after.ownerId).toBe(emp.id)
expect(getClient(db, lead.id)!.ownerId).toBe(emp.id)
expect(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)
expect((await getClient(db, lead.id))!.ownerId).toBe(emp.id)
expect((await listClients(db)).find((c) => c.id === lead.id)!.ownerId).toBe(emp.id)
const audit = (await listAudit(db)).find((a) => a.action === 'set_owner' && a.entity === 'client' && a.entity_id === lead.id)
expect(audit).toBeDefined()
expect(JSON.parse(audit!.before_json!)).not.toHaveProperty('ownerId')
expect(JSON.parse(audit!.after_json!)).toMatchObject({ ownerId: emp.id })
})
it('clears the owner with null, audited', () => {
const { db, ownerId } = withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const c = createClient(db, ownerId, { name: 'Clearable', stateCode: '32' })
setClientOwner(db, ownerId, c.id, emp.id)
const cleared = setClientOwner(db, ownerId, c.id, null)
it('clears the owner with null, audited', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const c = await createClient(db, ownerId, { name: 'Clearable', stateCode: '32' })
await setClientOwner(db, ownerId, c.id, emp.id)
const cleared = await setClientOwner(db, ownerId, c.id, null)
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)
})
it('rejects an unknown employee and an inactive employee; unknown client 404s', () => {
const { db, ownerId } = withOwner()
const c = createClient(db, ownerId, { name: 'Guarded', stateCode: '32' })
expect(() => setClientOwner(db, ownerId, c.id, 'nope')).toThrow(/employee/i)
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
deactivateEmployee(db, ownerId, emp.id)
expect(() => setClientOwner(db, ownerId, c.id, emp.id)).toThrow(/inactive/i)
expect(() => setClientOwner(db, ownerId, 'missing', null)).toThrow(/client/i)
it('rejects an unknown employee and an inactive employee; unknown client 404s', async () => {
const { db, ownerId } = await withOwner()
const c = await createClient(db, ownerId, { name: 'Guarded', stateCode: '32' })
await expect(setClientOwner(db, ownerId, c.id, 'nope')).rejects.toThrow(/employee/i)
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
await deactivateEmployee(db, ownerId, emp.id)
await expect(setClientOwner(db, ownerId, c.id, emp.id)).rejects.toThrow(/inactive/i)
await expect(setClientOwner(db, ownerId, 'missing', null)).rejects.toThrow(/client/i)
// failed sets leave no owner and no audit row behind
expect(getClient(db, c.id)!.ownerId).toBeUndefined()
expect(listAudit(db).filter((a) => a.action === 'set_owner')).toHaveLength(0)
expect((await getClient(db, c.id))!.ownerId).toBeUndefined()
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', () => {
const db = openDb(':memory:')
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'manager@test.in', displayName: 'Mgr', role: 'manager', password: 'manager-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id
let staffDbId = ''
beforeAll(async () => {
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
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 server = app.listen(0)
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 () => {
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 })
expect(set.status).toBe(200)
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()
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 denied = await patchOwner(staff, c.id, { ownerId: staffDbId })
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 () => {
const owner = await tokenOf('owner@test.in', 'owner-password')
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, {})).status).toBe(400)
})

@ -13,62 +13,62 @@ import { apiRouter } from '../src/api'
const KEY = '22'.repeat(32)
describe('client support data (repo)', () => {
it('anydesk/os/district/sector round-trip on create and patch; emptying clears to absent', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', {
it('anydesk/os/district/sector round-trip on create and patch; emptying clears to absent', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', {
name: 'Acme Bank', stateCode: '32',
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.district).toBe('Satara')
})
it('district/sector filter the client book', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' })
createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' })
createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' })
expect(listClients(db, undefined, { district: 'Pune' })).toHaveLength(2)
expect(listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1)
expect(listClients(db)).toHaveLength(3) // no filter = everything, unchanged
it('district/sector filter the client book', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
await createClient(db, 'u1', { name: 'A', stateCode: '32', district: 'Pune', sector: 'Urban Coop' })
await createClient(db, 'u1', { name: 'B', stateCode: '32', district: 'Sangli', sector: 'Urban Coop' })
await createClient(db, 'u1', { name: 'C', stateCode: '32', district: 'Pune', sector: 'Credit Society' })
expect(await listClients(db, undefined, { district: 'Pune' })).toHaveLength(2)
expect(await listClients(db, undefined, { district: 'Pune', sector: 'Urban Coop' })).toHaveLength(1)
expect(await listClients(db)).toHaveLength(3) // no filter = everything, unchanged
})
it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
it('DB password: encrypted at rest, never in payloads, reveal decrypts + audits', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(c.hasDbPassword).toBe(false)
setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY)
const after = getClient(db, c.id)!
await setClientDbPassword(db, 'u1', c.id, 'oracle#secret', KEY)
const after = (await getClient(db, c.id))!
expect(after.hasDbPassword).toBe(true)
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(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)
expect(await revealClientDbPassword(db, 'u1', c.id, KEY)).toBe('oracle#secret')
const reveals = (await listAudit(db)).filter((a) => a.action === 'reveal_db_password' && a.entity_id === c.id)
expect(reveals).toHaveLength(1)
// 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[0]!.after_json).not.toContain('oracle')
})
it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
expect(() => setClientDbPassword(db, 'u1', c.id, 'pw', '')).toThrow(/HQ_SECRET_KEY/)
setClientDbPassword(db, 'u1', c.id, 'pw', KEY)
expect(() => revealClientDbPassword(db, 'u1', c.id, '')).toThrow(/HQ_SECRET_KEY/)
it('refuses to store or reveal without HQ_SECRET_KEY (loud, never silent)', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
await expect(setClientDbPassword(db, 'u1', c.id, 'pw', '')).rejects.toThrow(/HQ_SECRET_KEY/)
await setClientDbPassword(db, 'u1', c.id, 'pw', KEY)
await expect(revealClientDbPassword(db, 'u1', c.id, '')).rejects.toThrow(/HQ_SECRET_KEY/)
})
})
describe('client support data (routes)', () => {
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
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' })
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-password' })
await createStaff(db, { email: 'staff@test.in', displayName: 'S', role: 'staff', password: 'staff-password' })
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const app = express(); app.use(express.json()); app.locals['db'] = db
app.use('/api', apiRouter(db, { keyHex: KEY }))
const server = app.listen(0)
@ -84,7 +84,7 @@ describe('client support data (routes)', () => {
})).json()) as { token: string }).token
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 ownerTok = await login(ctx.baseUrl, 'owner@test.in', 'owner-password')
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'
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 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' }] })
expect(c.code).toBe('CL0001')
expect(listClients(db, 'malabar')).toHaveLength(1)
const up = updateClient(db, 'u1', c.id, { status: 'active', notes: 'AMC due Oct' })
expect(await listClients(db, 'malabar')).toHaveLength(1)
const up = await updateClient(db, 'u1', c.id, { status: 'active', notes: 'AMC due Oct' })
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)
})
it('rejects a bad GSTIN checksum', () => {
it('rejects a bad GSTIN checksum', async () => {
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 fakePdf = async () => Buffer.from('%PDF-fake')
function base(db: DB) {
seedIfEmpty(db)
const c = createClient(db, 'u1', {
async function base(db: DB) {
await seedIfEmpty(db)
const c = await createClient(db, 'u1', {
name: 'Acme Bank', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }],
})
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { c, m }
}
// ---------- supersedeProforma (repo) ----------
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 { c, m } = base(db)
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', {
const { c, m } = await base(db)
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }],
}).id)
const fresh = supersedeProforma(db, 'u1', pi.id)
const old = getDocument(db, pi.id)!
})).id)
const fresh = await supersedeProforma(db, 'u1', pi.id)
const old = (await getDocument(db, pi.id))!
expect(old.status).toBe('cancelled')
expect(old.docNo).toBe(pi.docNo) // consumed number stays on the corpse
expect(fresh.docType).toBe('PROFORMA')
@ -47,52 +47,52 @@ describe('supersedeProforma', () => {
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 { c, m } = base(db)
const draft = createDraft(db, 'u1', {
const { c, m } = await base(db)
const draft = await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
const fresh = supersedeProforma(db, 'u1', draft.id)
expect(getDocument(db, draft.id)!.status).toBe('cancelled')
const fresh = await supersedeProforma(db, 'u1', draft.id)
expect((await getDocument(db, draft.id))!.status).toBe('cancelled')
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 { c, m } = base(db)
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', {
const { c, m } = await base(db)
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id)
expect(() => supersedeProforma(db, 'u1', inv.id))
.toThrow(/immutable.*credit note/i)
const qt = createDraft(db, 'u1', {
})).id)
await expect(supersedeProforma(db, 'u1', inv.id))
.rejects.toThrow(/immutable.*credit note/i)
const qt = await createDraft(db, 'u1', {
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 { c, m } = base(db)
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', {
const { c, m } = await base(db)
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id)
supersedeProforma(db, 'u1', pi.id)
expect(() => supersedeProforma(db, 'u1', pi.id)).toThrow(/already cancelled/i)
})).id)
await supersedeProforma(db, 'u1', pi.id)
await expect(supersedeProforma(db, 'u1', pi.id)).rejects.toThrow(/already cancelled/i)
})
})
// ---------- POST /documents/:id/convert-and-send (route) ----------
function appWith(fetchImpl: typeof fetch) {
async function appWith(fetchImpl: typeof fetch) {
const db = openDb(':memory:')
const { c, m } = base(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', {
const { c, m } = await base(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
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
app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
const server = app.listen(0)
@ -126,67 +126,68 @@ describe('POST /documents/:id/convert-and-send', () => {
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 () => {
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`)
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.docNo).toMatch(/^INV\//)
expect(invoice.status).toBe('sent') // sendDocumentEmail flips draft→sent
expect(invoice.refDocId).toBe(ctx.pi.id)
expect(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`)
.get() as { status: string; document_id: string; to_addr: string }
expect((await getDocument(ctx.db, ctx.pi.id))!.status).toBe('invoiced')
const log = (await ctx.db.get<{ 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' })
})
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`)
const again = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
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)
})
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`)
expect(out.status).toBe(200)
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.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')
})
it('guards fire BEFORE any write: gmail disconnected → 409 and no invoice created', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
ctx.db.prepare(`DELETE FROM email_account`).run() // disconnect
const ctx = await appWith(okFetch); servers.push(ctx.server)
await ctx.db.run(`DELETE FROM email_account`) // disconnect
const out = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(out.status).toBe(409)
expect(listDocuments(ctx.db, { type: 'INVOICE' }).documents).toHaveLength(0)
expect(getDocument(ctx.db, ctx.pi.id)!.status).not.toBe('invoiced')
expect((await listDocuments(ctx.db, { type: 'INVOICE' })).documents).toHaveLength(0)
expect((await getDocument(ctx.db, ctx.pi.id))!.status).not.toBe('invoiced')
})
it('rejects non-proformas up front', async () => {
const ctx = appWith(okFetch); servers.push(ctx.server)
const qt = createDraft(ctx.db, 'u1', {
const ctx = await appWith(okFetch); servers.push(ctx.server)
const qt = await createDraft(ctx.db, 'u1', {
docType: 'QUOTATION',
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`)
expect(out.status).toBe(400)
})
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`)
expect(out.status).toBe(200)
expect(getDocument(ctx.db, ctx.pi.id)!.status).toBe('cancelled')
const fresh = getDocument(ctx.db, out.json.document!.id)!
expect((await getDocument(ctx.db, ctx.pi.id))!.status).toBe('cancelled')
const fresh = (await getDocument(ctx.db, out.json.document!.id))!
expect(fresh.docType).toBe('PROFORMA')
expect(fresh.refDocId).toBe(ctx.pi.id)
})

@ -1,5 +1,5 @@
// 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 { openDb } from '../src/db'
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',
}
function seeded() {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
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)
db.prepare(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`).run(inv.id)
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
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' })
async function seeded() {
const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
await db.run(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`, inv.id)
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
await createInteraction(db, 'u1', { clientId: c.id, typeCode: 'call', onDate: '2026-07-01', followUpOn: '2026-07-09' })
return { db, c, inv }
}
describe('dashboardView', () => {
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
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[0]!.outstandingPaise).toBe(11_800_00)
expect(view.renewalsThisMonth).toHaveLength(1)
@ -43,34 +43,39 @@ describe('dashboardView', () => {
expect(view.totals.overduePaise).toBe(11_800_00)
})
it('anchors overdue on the stamped due date, not the issue date (D18 WS-A)', () => {
const { db, inv } = seeded()
it('anchors overdue on the stamped due date, not the issue date (D18 WS-A)', async () => {
const { db, inv } = await seeded()
// 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)
expect(dashboardView(db, '2026-07-10').overdue).toHaveLength(0)
await db.run(`UPDATE document SET due_date='2026-07-20' WHERE id=?`, inv.id)
expect((await dashboardView(db, '2026-07-10')).overdue).toHaveLength(0)
// 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)
const view = dashboardView(db, '2026-07-10')
await db.run(`UPDATE document SET due_date='2026-07-05' WHERE id=?`, inv.id)
const view = await dashboardView(db, '2026-07-10')
expect(view.overdue.map((o) => o.docId)).toContain(inv.id)
expect(view.overdue[0]!.daysOverdue).toBe(5)
})
})
describe('GET /api/dashboard + reminder queue routes', () => {
const { db } = seeded()
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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
async function routeCtx() {
const { db } = await seeded()
await 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 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 = ''
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 }
}
afterAll(() => server.close())
afterAll(() => ctx.server.close())
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
await runDailyScan(db, scanDeps, '2026-07-10')
await runDailyScan(ctx.db, scanDeps, '2026-07-10')
const dash = (await call('GET', '/dashboard')).json
expect(dash.ok).toBe(true)
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 { dashboardView } = await import('../src/repos-dashboard')
const db = openDb(':memory:'); seedIfEmpty(db)
const { id: staffId } = 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 mine = createClient(db, 'u1', { name: 'Mine Bank', stateCode: '32' })
const other = createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
setClientOwner(db, mgrId, mine.id, staffId)
const m = createModule(db, 'u1', { code: 'POS2', name: 'POS2' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_000_00, effectiveFrom: '2026-01-01' })
const db = openDb(':memory:'); await seedIfEmpty(db)
const { id: staffId } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'password-9' })
const { id: mgrId } = await createStaff(db, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password-9' })
const mine = await createClient(db, 'u1', { name: 'Mine Bank', stateCode: '32' })
const other = await createClient(db, 'u1', { name: 'Other Bank', stateCode: '32' })
await setClientOwner(db, mgrId, mine.id, staffId)
const m = await createModule(db, 'u1', { code: 'POS2', name: 'POS2' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_000_00, effectiveFrom: '2026-01-01' })
const today = '2026-07-17'
for (const c of [mine, other]) {
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-25' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-25' })
}
// 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'])
// 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)
// 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)
})
})

@ -3,19 +3,19 @@ import { openDb } from '../src/db'
import { writeAudit, listAudit } from '../src/audit'
describe('hq db', () => {
it('creates every HQ-1 table', () => {
it('creates every HQ-1 table', async () => {
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',
'tax_class','doc_series','document','payment','payment_allocation','document_event',
'email_account','email_log','setting','audit_log','stg_client','stg_invoice'])
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:')
writeAudit(db, 'u1', 'create', 'client', 'c1', undefined, { name: 'Acme' })
writeAudit(db, 'u1', 'update', 'client', 'c1', { name: 'Acme' }, { name: 'Acme Ltd' })
const rows = listAudit(db)
await writeAudit(db, 'u1', 'create', 'client', 'c1', undefined, { name: 'Acme' })
await writeAudit(db, 'u1', 'update', 'client', 'c1', { name: 'Acme' }, { name: 'Acme Ltd' })
const rows = await listAudit(db)
expect(rows).toHaveLength(2)
expect(rows[0]!.action).toBe('update')
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 fakePdf = async () => Buffer.from('%PDF-fake')
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
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 mkDraft = (clientId: string, docType: 'QUOTATION' | 'PROFORMA' | 'INVOICE' = 'QUOTATION') =>
createDraft(db, 'u1', { docType, clientId, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
@ -25,19 +25,19 @@ function world() {
}
describe('listDocuments — pagination (rule 8)', () => {
it('returns { documents, total, page, pageSize } with defaults page=1 pageSize=50, newest first', () => {
const { db, mkClient, mkDraft } = world()
const c = mkClient('Acme Bank')
it('returns { documents, total, page, pageSize } with defaults page=1 pageSize=50, newest first', async () => {
const { db, mkClient, mkDraft } = await world()
const c = await mkClient('Acme Bank')
const ids: string[] = []
for (let i = 0; i < 55; i++) ids.push(mkDraft(c.id).id)
const p1 = listDocuments(db)
for (let i = 0; i < 55; i++) ids.push((await mkDraft(c.id)).id)
const p1 = await listDocuments(db)
expect(p1.total).toBe(55)
expect(p1.page).toBe(1)
expect(p1.pageSize).toBe(50)
expect(p1.documents).toHaveLength(50)
// newest first — the last-created draft leads the list
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.documents).toHaveLength(5)
// no overlap between pages
@ -45,55 +45,55 @@ describe('listDocuments — pagination (rule 8)', () => {
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)', () => {
const { db, mkClient, mkDraft } = world()
const a = mkClient('Acme Bank'); const b = mkClient('Beta Coop')
mkDraft(a.id); mkDraft(b.id)
const page = listDocuments(db)
it('joins the client name onto every row (no N+1 for the list view)', async () => {
const { db, mkClient, mkDraft } = await world()
const a = await mkClient('Acme Bank'); const b = await mkClient('Beta Coop')
await mkDraft(a.id); await mkDraft(b.id)
const page = await listDocuments(db)
const names = new Map(page.documents.map((d) => [d.clientId, d.clientName]))
expect(names.get(a.id)).toBe('Acme Bank')
expect(names.get(b.id)).toBe('Beta Coop')
})
it('type/status/clientId filters compose, with an honest filtered total', () => {
const { db, mkClient, mkDraft } = world()
const a = mkClient('Acme Bank'); const b = mkClient('Beta Coop')
mkDraft(a.id, 'QUOTATION')
const invA = mkDraft(a.id, 'INVOICE')
issueDocument(db, 'u1', invA.id)
markStatus(db, 'u1', invA.id, 'sent')
mkDraft(b.id, 'INVOICE') // stays draft
const filtered = listDocuments(db, { type: 'INVOICE', clientId: a.id, status: 'sent', pageSize: 1 })
it('type/status/clientId filters compose, with an honest filtered total', async () => {
const { db, mkClient, mkDraft } = await world()
const a = await mkClient('Acme Bank'); const b = await mkClient('Beta Coop')
await mkDraft(a.id, 'QUOTATION')
const invA = await mkDraft(a.id, 'INVOICE')
await issueDocument(db, 'u1', invA.id)
await markStatus(db, 'u1', invA.id, 'sent')
await mkDraft(b.id, 'INVOICE') // stays draft
const filtered = await listDocuments(db, { type: 'INVOICE', clientId: a.id, status: 'sent', pageSize: 1 })
expect(filtered.total).toBe(1)
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.documents[0]!.clientId).toBe(b.id)
})
it('clamps pageSize to 200 and floors page/pageSize at 1', () => {
const { db, mkClient, mkDraft } = world()
const c = mkClient('Acme Bank')
for (let i = 0; i < 3; i++) mkDraft(c.id)
expect(listDocuments(db, { pageSize: 9999 }).pageSize).toBe(200)
expect(listDocuments(db, { pageSize: 0 }).pageSize).toBe(1)
expect(listDocuments(db, { page: 0 }).page).toBe(1)
it('clamps pageSize to 200 and floors page/pageSize at 1', async () => {
const { db, mkClient, mkDraft } = await world()
const c = await mkClient('Acme Bank')
for (let i = 0; i < 3; i++) await mkDraft(c.id)
expect((await listDocuments(db, { pageSize: 9999 })).pageSize).toBe(200)
expect((await listDocuments(db, { pageSize: 0 })).pageSize).toBe(1)
expect((await listDocuments(db, { page: 0 })).page).toBe(1)
})
it('clientLedger still surfaces EVERY document — the page cap never truncates the ledger', () => {
const { db, mkClient, mkDraft } = world()
const c = mkClient('Acme Bank')
for (let i = 0; i < 55; i++) mkDraft(c.id)
const ledger = clientLedger(db, c.id)
it('clientLedger still surfaces EVERY document — the page cap never truncates the ledger', async () => {
const { db, mkClient, mkDraft } = await world()
const c = await mkClient('Acme Bank')
for (let i = 0; i < 55; i++) await mkDraft(c.id)
const ledger = await clientLedger(db, c.id)
expect(ledger.documents).toHaveLength(55)
})
})
// ---------- GET /documents pass-through ----------
function appWith() {
const { db, mkClient, mkDraft } = world()
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
async function appWith() {
const { db, mkClient, mkDraft } = await world()
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 nullFetch = (async () => new Response('{}', { status: 500 })) as typeof fetch
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() })
it('passes page/pageSize through and reports the honest total + clientName rows', async () => {
const ctx = appWith(); servers.push(ctx.server)
const c = ctx.mkClient('Acme Bank')
for (let i = 0; i < 5; i++) ctx.mkDraft(c.id)
const ctx = await appWith(); servers.push(ctx.server)
const c = await ctx.mkClient('Acme Bank')
for (let i = 0; i < 5; i++) await ctx.mkDraft(c.id)
const token = await login(ctx.baseUrl)
const res = await fetch(`${ctx.baseUrl}/documents?page=2&pageSize=2`, {
headers: { authorization: `Bearer ${token}` },
@ -134,11 +134,11 @@ describe('GET /documents — paginated route', () => {
})
it('composes type + status filters with pagination', async () => {
const ctx = appWith(); servers.push(ctx.server)
const c = ctx.mkClient('Acme Bank')
ctx.mkDraft(c.id, 'QUOTATION')
ctx.mkDraft(c.id, 'INVOICE')
ctx.mkDraft(c.id, 'INVOICE')
const ctx = await appWith(); servers.push(ctx.server)
const c = await ctx.mkClient('Acme Bank')
await ctx.mkDraft(c.id, 'QUOTATION')
await ctx.mkDraft(c.id, 'INVOICE')
await ctx.mkDraft(c.id, 'INVOICE')
const token = await login(ctx.baseUrl)
const res = await fetch(`${ctx.baseUrl}/documents?type=INVOICE&status=draft&page=1&pageSize=1`, {
headers: { authorization: `Bearer ${token}` },

@ -7,20 +7,20 @@ import {
createDraft, issueDocument, convertDocument, createCreditNote, cancelDocument, getDocument,
} from '../src/repos-documents'
function setup() {
async function setup() {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = 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 db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { db, c, m }
}
describe('documents', () => {
it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', () => {
const { db, c, m } = setup()
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('quotation: ₹10,000 + 18% intra-state = CGST 900 + SGST 900, payable ₹11,800', async () => {
const { db, c, m } = await setup()
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.taxablePaise).toBe(10_000_00)
expect(d.cgstPaise).toBe(900_00)
@ -30,110 +30,111 @@ describe('documents', () => {
expect(d.status).toBe('draft')
expect(d.docNo).toBeNull()
})
it('inter-state client gets IGST', () => {
const { db, m } = setup()
it('inter-state client gets IGST', async () => {
const { db, m } = await setup()
const db2 = db
const kar = createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' })
const d = createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id,
const kar = await createClient(db2, 'u1', { name: 'BLR Co', stateCode: '29' })
const d = await createDraft(db2, 'u1', { docType: 'INVOICE', clientId: kar.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.igstPaise).toBe(1_800_00)
expect(d.cgstPaise).toBe(0)
})
it('issue assigns a series number; convert QT→INV carries lines and links back', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('issue assigns a series number; convert QT→INV carries lines and links back', async () => {
const { db, c, m } = await setup()
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
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}$/)
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.payablePaise).toBe(qt.payablePaise)
})
it('line contents default to the module quote content, and can be overridden per line', () => {
const { db, c } = setup()
const sms = createModule(db, 'u1', {
it('line contents default to the module quote content, and can be overridden per line', async () => {
const { db, c } = await setup()
const sms = await createModule(db, 'u1', {
code: 'SMS', name: 'SMS Gateway', quoteContent: ['Bulk SMS gateway', 'DLT registration'],
})
setPrice(db, 'u1', { moduleId: sms.id, kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
await setPrice(db, 'u1', { moduleId: sms.id, kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly' }] })
expect(d.payload.lineContents).toEqual([['Bulk SMS gateway', 'DLT registration']])
const d2 = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
const d2 = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: sms.id, qty: 1, kind: 'yearly', contentLines: ['Custom scope only'] }] })
expect(d2.payload.lineContents).toEqual([['Custom scope only']])
})
it('a module without quote content contributes an empty content array', () => {
const { db, c, m } = setup()
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('a module without quote content contributes an empty content array', async () => {
const { db, c, m } = await setup()
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(d.payload.lineContents).toEqual([[]])
})
it('a pack (non-standard edition) prices from that edition and prints the pack name on the line', () => {
const { db, c, m } = setup()
setPrice(db, 'u1', { moduleId: m.id, edition: 'SMS-50K', kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
const d = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('a pack (non-standard edition) prices from that edition and prints the pack name on the line', async () => {
const { db, c, m } = await setup()
await setPrice(db, 'u1', { moduleId: m.id, edition: 'SMS-50K', kind: 'yearly', pricePaise: 5_000_00, effectiveFrom: '2026-04-01' })
const d = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly', edition: 'SMS-50K' }] })
expect(d.taxablePaise).toBe(5_000_00)
expect(d.payload.lines[0]!.name).toContain('— SMS-50K')
const std = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
const std = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
expect(std.payload.lines[0]!.name).not.toContain('—')
})
it('convert is one-shot: a live forward child blocks re-convert; cancelling it re-opens (rule 4 / F4)', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('convert is one-shot: a live forward child blocks re-convert; cancelling it re-opens (rule 4 / F4)', async () => {
const { db, c, m } = await setup()
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
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
expect(() => convertDocument(db, 'u1', qt.id, 'INVOICE')).toThrow(/already converted/i)
expect(() => convertDocument(db, 'u1', qt.id, 'PROFORMA')).toThrow(/already converted/i)
await expect(convertDocument(db, 'u1', qt.id, 'INVOICE')).rejects.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
issueDocument(db, 'u1', inv.id)
cancelDocument(db, 'u1', inv.id)
const inv2 = convertDocument(db, 'u1', qt.id, 'INVOICE')
await issueDocument(db, 'u1', inv.id)
await cancelDocument(db, 'u1', inv.id)
const inv2 = await convertDocument(db, 'u1', qt.id, 'INVOICE')
expect(inv2.refDocId).toBe(qt.id)
})
it('QT→PROFORMA moves the quotation out of the sent set (status → invoiced)', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('QT→PROFORMA moves the quotation out of the sent set (status → invoiced)', async () => {
const { db, c, m } = await setup()
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
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.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', () => {
const { db, c, m } = setup()
const qt = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
it('→INVOICE recomputes GST on the invoice date (rule 2 / F3); QT→PI copies verbatim', async () => {
const { db, c, m } = await setup()
const qt = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
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
// The rate changes (new dated tax_class row) before the invoice is drawn.
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, ?)`,
).run(today)
const inv = convertDocument(db, 'u1', pi.id, 'INVOICE')
today,
)
const inv = await convertDocument(db, 'u1', pi.id, 'INVOICE')
expect(inv.taxablePaise).toBe(10_000_00)
expect(inv.cgstPaise).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(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', () => {
const { db, c, m } = setup()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).id)
const cn = createCreditNote(db, 'u1', inv.id)
it('credit note defaults to full value against the invoice; cancel keeps the number', async () => {
const { db, c, m } = await setup()
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 cn = await createCreditNote(db, 'u1', inv.id)
expect(cn.docType).toBe('CREDIT_NOTE')
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.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)
}
function world() {
const db = openDb(':memory:'); 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 m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db) // seeds billing.payment_terms_days=15
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
const invoiceDraft = () => createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
@ -35,105 +35,105 @@ function world() {
}
describe('due dates (D18)', () => {
it('issue stamps doc_date + terms on an invoice without one (manual issue path)', () => {
const { db, invoiceDraft } = world()
const draft = invoiceDraft()
it('issue stamps doc_date + terms on an invoice without one (manual issue path)', async () => {
const { db, invoiceDraft } = await world()
const draft = await invoiceDraft()
expect(draft.dueDate).toBeNull()
db.prepare(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`).run(draft.id)
const inv = issueDocument(db, 'u1', draft.id)
await db.run(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`, draft.id)
const inv = await issueDocument(db, 'u1', draft.id)
expect(inv.dueDate).toBe('2026-07-16') // 2026-07-01 + 15
})
it('a caller-provided due date survives issue untouched', () => {
const { db, c, m } = world()
const draft = createDraft(db, 'u1', {
it('a caller-provided due date survives issue untouched', async () => {
const { db, c, m } = await world()
const draft = await createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, dueDate: '2026-09-30',
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})
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)', () => {
const { db, invoiceDraft } = world()
setSetting(db, 'u1', 'billing.payment_terms_days', '30')
const draft = invoiceDraft()
db.prepare(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`).run(draft.id)
expect(issueDocument(db, 'u1', draft.id).dueDate).toBe('2026-07-31')
it('the terms setting is honoured when changed (dated behavior lives in the stamp)', async () => {
const { db, invoiceDraft } = await world()
await setSetting(db, 'u1', 'billing.payment_terms_days', '30')
const draft = await invoiceDraft()
await db.run(`UPDATE document SET doc_date='2026-07-01' WHERE id=?`, draft.id)
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', () => {
const { db, c, m } = world()
expect(() => createDraft(db, 'u1', {
it('quotes and proformas never get a due date — at draft or at issue', async () => {
const { db, c, m } = await world()
await expect(createDraft(db, 'u1', {
docType: 'QUOTATION', clientId: c.id, dueDate: '2026-08-01',
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
})).toThrow(/INVOICE/)
const pi = createDraft(db, 'u1', {
})).rejects.toThrow(/INVOICE/)
const pi = await createDraft(db, 'u1', {
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)', () => {
const { db, c, m } = world()
const pi = issueDocument(db, 'u1', createDraft(db, 'u1', {
it('proforma → invoice conversion gets stamped at issue (convert-and-send path)', async () => {
const { db, c, m } = await world()
const pi = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
docType: 'PROFORMA', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }],
}).id)
const invDraft = convertDocument(db, 'u1', pi.id, 'INVOICE')
const inv = issueDocument(db, 'u1', invDraft.id)
})).id)
const invDraft = await convertDocument(db, 'u1', pi.id, 'INVOICE')
const inv = await issueDocument(db, 'u1', invDraft.id)
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 () => {
const { db, c, m } = world()
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' })
createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-17', policy: 'manual' })
const { db, c, m } = await world()
await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
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')
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!.dueDate).toBe(addDaysIso(inv!.docDate, 15)) // doc_date + seeded 15-day terms
})
it('bad dueDate format is rejected at draft', () => {
const { db, c, m } = world()
expect(() => createDraft(db, 'u1', {
it('bad dueDate format is rejected at draft', async () => {
const { db, c, m } = await world()
await expect(createDraft(db, 'u1', {
docType: 'INVOICE', clientId: c.id, dueDate: '17-07-2026',
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 () => {
const { db, invoiceDraft } = world()
const { db, invoiceDraft } = await world()
// Old issue date but due date in the future → NOT overdue.
const a = issueDocument(db, 'u1', invoiceDraft().id)
db.prepare(`UPDATE document SET doc_date='2026-05-01', due_date='2026-08-01' WHERE id=?`).run(a.id)
const a = await issueDocument(db, 'u1', (await invoiceDraft()).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.
const b = issueDocument(db, 'u1', invoiceDraft().id)
db.prepare(`UPDATE document SET doc_date='2026-05-01', due_date='2026-07-01' WHERE id=?`).run(b.id)
const b = await issueDocument(db, 'u1', (await invoiceDraft()).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')
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[0]).toMatchObject({ subjectId: b.id, duePeriod: 'd15' })
})
it('the reminder email says "was due on X and is now N day(s) overdue"', () => {
const { db, c, invoiceDraft } = world()
const inv = issueDocument(db, 'u1', invoiceDraft().id)
db.prepare(`UPDATE document SET due_date='2026-07-02' WHERE id=?`).run(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 { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-17')
it('the reminder email says "was due on X and is now N day(s) overdue"', async () => {
const { db, c, invoiceDraft } = await world()
const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
await db.run(`UPDATE document SET due_date='2026-07-02' WHERE id=?`, inv.id)
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 } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
expect(ctx.dueDate).toBe('2026-07-02')
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/)
})
it('legacy invoices without a due date keep the past-due-since-issue wording and doc_date anchor', () => {
const { db, c, invoiceDraft } = world()
const inv = issueDocument(db, 'u1', invoiceDraft().id)
db.prepare(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`).run(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 { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-17')
it('legacy invoices without a due date keep the past-due-since-issue wording and doc_date anchor', async () => {
const { db, c, invoiceDraft } = await world()
const inv = await issueDocument(db, 'u1', (await invoiceDraft()).id)
await db.run(`UPDATE document SET doc_date='2026-07-07', due_date=NULL WHERE id=?`, inv.id)
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 } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-17')
expect(ctx.dueDate).toBeUndefined()
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
import { describe, it, expect, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -7,11 +7,15 @@ import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api'
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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
let server: ReturnType<typeof app.listen>
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 = ''
const call = async (method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method,

@ -1,16 +1,16 @@
// 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).
import { describe, it, expect, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff, login } from '../src/auth'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
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' })
async function appWith() {
const db = openDb(':memory:'); 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: 'Staffer', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -18,7 +18,8 @@ function appWith() {
}
describe('/employees routes', () => {
const ctx = appWith()
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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' })
expect(pw.status).toBe(200)
expect(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', 'password-9')).toBeNull()
expect(await login(ctx.db, 'new@test.in', 'brand-new-secret')).not.toBeNull()
})
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',
})
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 row = ctx.db.prepare(
const ownerId = (await ctx.db.get<{ id: string }>(`SELECT id FROM staff_user WHERE email='owner@test.in'`))!.id
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'`,
).get(id) as { user_id: string }
id,
))!
expect(row.user_id).toBe(ownerId)
})
})

@ -9,20 +9,20 @@ import {
} from '../src/repos-employees'
import { listAudit } from '../src/audit'
function withOwner(): { db: DB; ownerId: string } {
async function withOwner(): Promise<{ db: DB; ownerId: string }> {
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',
})
return { db, ownerId: id }
}
describe('employee foundation', () => {
it('creates manager and staff; list never exposes password columns', () => {
const { db, ownerId } = withOwner()
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' })
const all = listEmployees(db)
it('creates manager and staff; list never exposes password columns', async () => {
const { db, ownerId } = await withOwner()
await createEmployee(db, ownerId, { email: 'M@x.co', displayName: 'Mgr', role: 'manager', password: 'password1' })
await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const all = await listEmployees(db)
expect(all).toHaveLength(3)
expect(all.map((e) => e.role).sort()).toEqual(['manager', 'owner', 'staff'])
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', () => {
const { db, ownerId } = withOwner()
expect(() =>
it('rejects a bad role and a short password in the repo', async () => {
const { db, ownerId } = await withOwner()
await expect(
createEmployee(db, ownerId, { email: 'x@x.co', displayName: 'X', role: 'admin' as never, password: 'password1' }),
).toThrow(/role/i)
expect(() =>
).rejects.toThrow(/role/i)
await expect(
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', () => {
const { db, ownerId } = withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const session = login(db, 's@x.co', 'password2')!
expect(verifySession(db, session.token)).toMatchObject({ id: emp.id })
deactivateEmployee(db, ownerId, emp.id)
expect(verifySession(db, session.token)).toBeNull()
const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number }
it('deactivation kills the live session immediately and purges its rows', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const session = (await login(db, 's@x.co', 'password2'))!
expect(await verifySession(db, session.token)).toMatchObject({ id: emp.id })
await deactivateEmployee(db, ownerId, emp.id)
expect(await verifySession(db, session.token)).toBeNull()
const rows = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`, emp.id))!
expect(rows.n).toBe(0)
})
it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', () => {
const { db, ownerId } = withOwner()
expect(() => updateEmployee(db, ownerId, ownerId, { role: 'staff' })).toThrow(/last active owner/)
expect(() => deactivateEmployee(db, ownerId, ownerId)).toThrow(/yourself/)
it('guards: last active owner cannot be demoted or deactivated; self-deactivation rejected', async () => {
const { db, ownerId } = await withOwner()
await expect(updateEmployee(db, ownerId, ownerId, { role: 'staff' })).rejects.toThrow(/last active owner/)
await expect(deactivateEmployee(db, ownerId, ownerId)).rejects.toThrow(/yourself/)
// second owner unlocks the demotion of the first
const second = createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' })
expect(updateEmployee(db, second.id, ownerId, { role: 'manager' }).role).toBe('manager')
const second = await createEmployee(db, ownerId, { email: 'o2@x.co', displayName: 'O2', role: 'owner', password: 'password3' })
expect((await updateEmployee(db, second.id, ownerId, { role: 'manager' })).role).toBe('manager')
})
it('deactivate/reactivate round-trips and login respects active', () => {
const { db, ownerId } = withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
deactivateEmployee(db, ownerId, emp.id)
expect(login(db, 's@x.co', 'password2')).toBeNull()
expect(getEmployee(db, emp.id)!.active).toBe(false)
reactivateEmployee(db, ownerId, emp.id)
expect(login(db, 's@x.co', 'password2')).not.toBeNull()
it('deactivate/reactivate round-trips and login respects active', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
await deactivateEmployee(db, ownerId, emp.id)
expect(await login(db, 's@x.co', 'password2')).toBeNull()
expect((await getEmployee(db, emp.id))!.active).toBe(false)
await reactivateEmployee(db, ownerId, emp.id)
expect(await login(db, 's@x.co', 'password2')).not.toBeNull()
})
it('password reset invalidates existing sessions immediately (compromised-credential lockout)', () => {
const { db, ownerId } = withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const session = login(db, 's@x.co', 'password2')!
expect(verifySession(db, session.token)).toMatchObject({ id: emp.id })
setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
expect(verifySession(db, session.token)).toBeNull()
const rows = db.prepare(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`).get(emp.id) as { n: number }
it('password reset invalidates existing sessions immediately (compromised-credential lockout)', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const session = (await login(db, 's@x.co', 'password2'))!
expect(await verifySession(db, session.token)).toMatchObject({ id: emp.id })
await setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
expect(await verifySession(db, session.token)).toBeNull()
const rows = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM session WHERE staff_id=?`, emp.id))!
expect(rows.n).toBe(0)
})
it('rejects a duplicate email with a friendly, engine-neutral message', () => {
const { db, ownerId } = withOwner()
createEmployee(db, ownerId, { email: 'dup@x.co', displayName: 'A', role: 'staff', password: 'password1' })
expect(() =>
it('rejects a duplicate email with a friendly, engine-neutral message', async () => {
const { db, ownerId } = await withOwner()
await createEmployee(db, ownerId, { email: 'dup@x.co', displayName: 'A', role: 'staff', password: 'password1' })
await expect(
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', () => {
const { db, ownerId } = withOwner()
const emp = 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
expect(() => updateEmployee(db, ownerId, emp.id, {})).toThrow(/nothing to update/i)
const auditAfter = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length
it('rejects an empty patch instead of writing a no-op audit row', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
const auditBefore = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length
await expect(updateEmployee(db, ownerId, emp.id, {})).rejects.toThrow(/nothing to update/i)
const auditAfter = (await listAudit(db)).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).length
expect(auditAfter).toBe(auditBefore)
})
it('password reset works and is audited without the hash', () => {
const { db, ownerId } = withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
expect(login(db, 's@x.co', 'password2')).toBeNull()
expect(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)
it('password reset works and is audited without the hash', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
await setEmployeePassword(db, ownerId, emp.id, 'new-secret-9')
expect(await login(db, 's@x.co', 'password2')).toBeNull()
expect(await login(db, 's@x.co', 'new-secret-9')).not.toBeNull()
const audit = (await listAudit(db)).find((a) => a.action === 'reset_password' && a.entity_id === emp.id)
expect(audit).toBeDefined()
expect(audit!.before_json ?? '').not.toMatch(/hash/)
expect(audit!.after_json ?? '').not.toMatch(/hash/)
})
it('every employee mutation writes an audit row', () => {
const { db, ownerId } = withOwner()
const emp = createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' })
deactivateEmployee(db, ownerId, emp.id)
reactivateEmployee(db, ownerId, emp.id)
const actions = listAudit(db).filter((a) => a.entity === 'staff_user' && a.entity_id === emp.id).map((a) => a.action)
it('every employee mutation writes an audit row', async () => {
const { db, ownerId } = await withOwner()
const emp = await createEmployee(db, ownerId, { email: 's@x.co', displayName: 'Stf', role: 'staff', password: 'password2' })
await updateEmployee(db, ownerId, emp.id, { displayName: 'Renamed' })
await deactivateEmployee(db, ownerId, emp.id)
await reactivateEmployee(db, ownerId, emp.id)
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']))
})
it('fresh DBs accept manager directly (SCHEMA born correct)', () => {
const { db, ownerId } = withOwner()
const m = createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' })
expect(getEmployee(db, m.id)!.role).toBe('manager')
it('fresh DBs accept manager directly (SCHEMA born correct)', async () => {
const { db, ownerId } = await withOwner()
const m = await createEmployee(db, ownerId, { email: 'm@x.co', displayName: 'M', role: 'manager', password: 'password1' })
expect((await getEmployee(db, m.id))!.role).toBe('manager')
})
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.
const raw = new Database(':memory:') as DB
const raw = new Database(':memory:')
raw.exec(`CREATE TABLE staff_user (
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL,
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'
const server = startServer(0)
let server: Awaited<ReturnType<typeof startServer>>
const base = () => `http://localhost:${(server.address() as { port: number }).port}`
describe('hq health', () => {
beforeAll(async () => { server = await startServer(0) })
afterAll(() => server.close())
it('answers /api/health', async () => {
const res = await fetch(`${base()}/api/health`)

@ -3,32 +3,30 @@ import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
describe('hq2 schema', () => {
it('creates every HQ-2 table', () => {
it('creates every HQ-2 table', async () => {
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'])
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 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')
})
it('enforces the reminder idempotency key', () => {
it('enforces the reminder idempotency key', async () => {
const db = openDb(':memory:')
const ins = db.prepare(
`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')`,
)
expect(ins.run('r1').changes).toBe(1)
expect(ins.run('r2').changes).toBe(0) // same (rule_kind, subject_id, due_period) → ignored
const ins = `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')`
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
})
it('seeds interaction types, the AMC module and reminder settings', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const types = (db.prepare(`SELECT code FROM interaction_type`).all() as { code: string }[]).map((r) => r.code)
it('seeds interaction types, the AMC module and reminder settings', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
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(db.prepare(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`).get()).toMatchObject({ n: 1 })
const s = (db.prepare(`SELECT value FROM setting WHERE key='reminders.overdue_days'`).get() as { value: string } | undefined)
expect(await db.get(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`)).toMatchObject({ n: 1 })
const s = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='reminders.overdue_days'`)
expect(s?.value).toBe('7')
})
})

@ -2,24 +2,22 @@ import { describe, it, expect } from 'vitest'
import { openDb } from '../src/db'
describe('hq3 schema', () => {
it('creates the aws_usage table', () => {
it('creates the aws_usage table', async () => {
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')
})
it('has the expected columns', () => {
it('has the expected columns', async () => {
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'])
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 ins = db.prepare(
`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')`,
)
expect(ins.run('a1').changes).toBe(1)
expect(() => ins.run('a2')).toThrow() // UNIQUE (client_id, month)
const ins = `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')`
expect((await db.run(ins, 'a1')).changes).toBe(1)
await expect(db.run(ins, 'a2')).rejects.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.
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() {
const db = openDb(':memory:'); seedIfEmpty(db)
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' })
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'O', role: 'owner', password: 'owner-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
app.use('/api', apiRouter(db))
const server = app.listen(0)
@ -36,7 +36,7 @@ describe('APEX import routes (owner-only)', () => {
afterAll(() => { for (const s of servers) s.close() })
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')
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: '{}' } : {}) })
@ -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 () => {
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')
// Stage a BAD clients file → problems reported, commit refused.
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.seeded).not.toBeNull() // INVOICE series seeded past the legacy number
// 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' })
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' })
})
})

@ -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`
describe('apex import', () => {
it('stages, reports problems, refuses commit until clean', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
stageCsv(db, 'clients', CLIENTS)
stageCsv(db, 'invoices', INVOICES)
const rep = verificationReport(db)
it('stages, reports problems, refuses commit until clean', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
await stageCsv(db, 'clients', CLIENTS)
await stageCsv(db, 'invoices', INVOICES)
const rep = await verificationReport(db)
expect(rep.clients.staged).toBe(2)
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', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
stageCsv(db, 'clients', CLIENTS.split('\n').slice(0, 2).join('\n'))
stageCsv(db, 'invoices', INVOICES)
const out = commitImport(db, 'u1')
it('commits clean data and seeds the invoice series from the last APEX number', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
await stageCsv(db, 'clients', CLIENTS.split('\n').slice(0, 2).join('\n'))
await stageCsv(db, 'invoices', INVOICES)
const out = await commitImport(db, 'u1')
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')
})
})

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

@ -16,10 +16,10 @@ import { apiRouter } from '../src/api'
const KEY = '11'.repeat(32)
const fakePdf = async () => Buffer.from('%PDF-fake')
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
const m = createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 9_000_00, effectiveFrom: '2026-01-01' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
const m = await createModule(db, 'u1', { code: 'CORE', name: 'Core Banking' })
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', {
name, stateCode: '32',
contacts: email !== undefined ? [{ name: 'C', email }] : [],
@ -28,77 +28,77 @@ function world() {
}
describe('listClientsByModule', () => {
it('lists active links with price + renewal, ordered by client name, with a true total', () => {
const { db, m, mkClient } = world()
const a = mkClient('Acme Bank'); const z = mkClient('Zeta CCS'); const b = mkClient('Beta Coop')
const cmA = assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' })
assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' })
const page = listClientsByModule(db, m.id, { onDate: '2026-07-17' })
it('lists active links with price + renewal, ordered by client name, with a true total', async () => {
const { db, m, mkClient } = await world()
const a = await mkClient('Acme Bank'); const z = await mkClient('Zeta CCS'); const b = await mkClient('Beta Coop')
const cmA = await assignModule(db, 'u1', { clientId: a.id, moduleId: m.id, kind: 'yearly' })
await updateClientModule(db, 'u1', cmA.id, { nextRenewal: '2026-09-01' })
await assignModule(db, 'u1', { clientId: z.id, moduleId: m.id, kind: 'yearly' })
await assignModule(db, 'u1', { clientId: b.id, moduleId: m.id, kind: 'yearly' })
const page = await listClientsByModule(db, m.id, { onDate: '2026-07-17' })
expect(page.total).toBe(3)
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.totalPricePaise).toBe(27_000_00) // spans all links
})
it('excludes inactive links (historical assignments) and paginates with a stable total', () => {
const { db, m, mkClient } = world()
it('excludes inactive links (historical assignments) and paginates with a stable total', async () => {
const { db, m, mkClient } = await world()
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' })
db.prepare(`UPDATE client_module SET active=0 WHERE id=?`).run(dead.id)
const p1 = listClientsByModule(db, m.id, { page: 1, pageSize: 2 })
const p3 = listClientsByModule(db, m.id, { page: 3, pageSize: 2 })
const dead = await assignModule(db, 'u1', { clientId: (await mkClient('Gone Bank')).id, moduleId: m.id, kind: 'yearly' })
await db.run(`UPDATE client_module SET active=0 WHERE id=?`, dead.id)
const p1 = await listClientsByModule(db, m.id, { page: 1, pageSize: 2 })
const p3 = await listClientsByModule(db, m.id, { page: 3, pageSize: 2 })
expect(p1.total).toBe(5) // Gone Bank excluded
expect(p1.clients).toHaveLength(2)
expect(p3.clients).toHaveLength(1)
expect([...p1.clients, ...p3.clients].some((c) => c.clientName === 'Gone Bank')).toBe(false)
})
it('a module nobody uses returns an honest empty page', () => {
const { db, m } = world()
const page = listClientsByModule(db, m.id)
it('a module nobody uses returns an honest empty page', async () => {
const { db, m } = await world()
const page = await listClientsByModule(db, m.id)
expect(page).toMatchObject({ total: 0, clients: [], totalPricePaise: 0 })
})
it('rows carry cmId, and roster edits round-trip through updateClientModule (WS-G full-edit)', () => {
const { db, m, mkClient } = world()
const c = mkClient('Acme Bank')
assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = listClientsByModule(db, m.id).clients[0]!
it('rows carry cmId, and roster edits round-trip through updateClientModule (WS-G full-edit)', async () => {
const { db, m, mkClient } = await world()
const c = await mkClient('Acme Bank')
await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
const row = (await listClientsByModule(db, m.id)).clients[0]!
expect(row.cmId).toBeTruthy()
// 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' })
const after = listClientsByModule(db, m.id).clients[0]!
await updateClientModule(db, 'u1', row.cmId, { kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
const after = (await listClientsByModule(db, m.id)).clients[0]!
expect(after).toMatchObject({ kind: 'monthly', edition: 'gold', nextRenewal: '2026-12-01', status: 'live' })
// Unassign = deactivate: the row leaves the roster and the total shrinks.
updateClientModule(db, 'u1', row.cmId, { active: false })
expect(listClientsByModule(db, m.id).total).toBe(0)
await updateClientModule(db, 'u1', row.cmId, { active: false })
expect((await listClientsByModule(db, m.id)).total).toBe(0)
})
it('roster kind edit rejects a kind the module does not allow', () => {
const { db, mkClient } = world()
const restricted = createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] })
const c = mkClient('Beta Coop')
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' })
expect(() => updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).toThrow(/does not allow/)
expect(() => updateClientModule(db, 'u1', cm.id, { edition: ' ' })).toThrow(/edition/i)
it('roster kind edit rejects a kind the module does not allow', async () => {
const { db, mkClient } = await world()
const restricted = await createModule(db, 'u1', { code: 'AMC2', name: 'AMC Only', allowedKinds: ['yearly'] })
const c = await mkClient('Beta Coop')
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: restricted.id, kind: 'yearly' })
await expect(updateClientModule(db, 'u1', cm.id, { kind: 'usage' })).rejects.toThrow(/does not allow/)
await expect(updateClientModule(db, 'u1', cm.id, { edition: ' ' })).rejects.toThrow(/edition/i)
})
})
// ---------- routes ----------
function appWith(fetchImpl: typeof fetch) {
const { db, m, mkClient } = world()
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' })
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const withMail = mkClient('Acme Bank', 'ravi@acme.in')
const noMail = mkClient('Beta Coop') // no contact email — must be reported, not skipped silently
assignModule(db, 'u1', { clientId: withMail.id, moduleId: m.id, kind: 'yearly' })
assignModule(db, 'u1', { clientId: noMail.id, moduleId: m.id, kind: 'yearly' })
async function appWith(fetchImpl: typeof fetch) {
const { db, m, mkClient } = await world()
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' })
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const withMail = await mkClient('Acme Bank', 'ravi@acme.in')
const noMail = await mkClient('Beta Coop') // no contact email — must be reported, not skipped silently
await assignModule(db, 'u1', { clientId: withMail.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
app.use('/api', apiRouter(db, { f: fetchImpl, clientId: 'cid', clientSecret: 'sec', keyHex: KEY }, fakePdf))
const server = app.listen(0)
@ -122,7 +122,7 @@ describe('module roster routes', () => {
afterAll(() => { for (const s of servers) s.close() })
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 res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients`, {
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 () => {
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 res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/clients.csv`, {
headers: { authorization: `Bearer ${token}` },
@ -151,7 +151,7 @@ describe('module roster routes', () => {
})
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 res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
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 () => {
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 res = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST',
@ -174,18 +174,18 @@ describe('module roster routes', () => {
expect(json.sent).toBe(1) // Acme has a contact email
expect(json.total).toBe(2)
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`)
.get() as { to_addr: string; status: string; subject: string }
const log = await ctx.db.get(`SELECT to_addr, status, subject FROM email_log ORDER BY id DESC LIMIT 1`) as
{ to_addr: string; status: string; subject: string }
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
// 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(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 () => {
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 bad = await fetch(`${ctx.baseUrl}/modules/${ctx.m.id}/notify`, {
method: 'POST',
@ -193,13 +193,13 @@ describe('module roster routes', () => {
body: JSON.stringify({ subject: '', body: '' }),
})
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`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ subject: 'S', body: 'B' }),
})
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'
describe('module catalog', () => {
it('resolves the dated price row', () => {
it('resolves the dated price row', async () => {
const db = openDb(':memory:')
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_20_000_00, effectiveFrom: '2026-04-01' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 1_50_000_00, effectiveFrom: '2026-08-01' })
expect(priceOn(db, m.id, 'yearly', 'standard', '2026-07-10')).toBe(1_20_000_00)
expect(priceOn(db, m.id, 'yearly', 'standard', '2026-09-01')).toBe(1_50_000_00)
expect(priceOn(db, m.id, 'monthly', 'standard', '2026-09-01')).toBeNull()
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await 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_50_000_00, effectiveFrom: '2026-08-01' })
expect(await priceOn(db, m.id, 'yearly', 'standard', '2026-07-10')).toBe(1_20_000_00)
expect(await priceOn(db, m.id, 'yearly', 'standard', '2026-09-01')).toBe(1_50_000_00)
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 plain = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
const plain = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
expect(plain.quoteContent).toEqual([])
const sms = createModule(db, 'u1', {
const sms = await createModule(db, 'u1', {
code: 'SMS', name: 'SMS Gateway',
quoteContent: ['Bulk SMS gateway', 'DLT template registration'],
})
expect(sms.quoteContent).toEqual(['Bulk SMS gateway', 'DLT template registration'])
expect(getModule(db, sms.id)!.quoteContent).toEqual(['Bulk SMS gateway', 'DLT template registration'])
expect(listModules(db).find((m) => m.id === sms.id)!.quoteContent).toEqual([
expect((await getModule(db, sms.id))!.quoteContent).toEqual(['Bulk SMS gateway', 'DLT template registration'])
expect((await listModules(db)).find((m) => m.id === sms.id)!.quoteContent).toEqual([
'Bulk SMS gateway', 'DLT template registration',
])
})
it('updateModule edits quote content and writes an audit row', () => {
it('updateModule edits quote content and writes an audit row', async () => {
const db = openDb(':memory:')
const m = createModule(db, 'u1', { code: 'SMS', name: 'SMS Gateway' })
const updated = updateModule(db, 'u1', m.id, { quoteContent: ['Two-way SMS', 'Delivery reports'] })
const m = await createModule(db, 'u1', { code: 'SMS', name: 'SMS Gateway' })
const updated = await updateModule(db, 'u1', m.id, { quoteContent: ['Two-way SMS', 'Delivery reports'] })
expect(updated.quoteContent).toEqual(['Two-way SMS', 'Delivery reports'])
expect(getModule(db, m.id)!.quoteContent).toEqual(['Two-way SMS', 'Delivery reports'])
const audit = listAudit(db).find((a) => a.action === 'update' && a.entity === 'module' && a.entity_id === m.id)
expect((await getModule(db, m.id))!.quoteContent).toEqual(['Two-way SMS', 'Delivery reports'])
const audit = (await listAudit(db)).find((a) => a.action === 'update' && a.entity === 'module' && a.entity_id === m.id)
expect(audit).toBeDefined()
})
})

@ -6,40 +6,40 @@ import { createModule, setPrice } from '../src/repos-modules'
import { createDraft, issueDocument, getDocument } from '../src/repos-documents'
import { recordPayment, clientLedger, modulePaidView } from '../src/repos-payments'
function invoiceFor(db: any, clientId: string, moduleId: string) {
return issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId,
lines: [{ moduleId, qty: 1, kind: 'yearly' }] }).id)
async function invoiceFor(db: any, clientId: string, moduleId: string) {
return issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId,
lines: [{ moduleId, qty: 1, kind: 'yearly' }] })).id)
}
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:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
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 inv2 = invoiceFor(db, c.id, m.id) // ₹11,800
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(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 })
expect(getDocument(db, inv2.id)!.status).toBe('paid')
expect(clientLedger(db, c.id).advancePaise).toBe(1_400_00) // 25,000 23,600
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv1 = await invoiceFor(db, c.id, m.id) // ₹11,800
const inv2 = await invoiceFor(db, c.id, m.id) // ₹11,800
await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank', amountPaise: 15_000_00 })
expect((await getDocument(db, inv1.id))!.status).toBe('paid') // 11,800 settled
expect((await getDocument(db, inv2.id))!.status).toBe('part_paid') // 3,200 of 11,800
await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-11', mode: 'upi', amountPaise: 10_000_00 })
expect((await getDocument(db, inv2.id))!.status).toBe('paid')
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:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
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
recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank',
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const inv = await invoiceFor(db, c.id, m.id) // ₹11,800; client pays minus 10% TDS on ₹10,000
await recordPayment(db, 'u1', { clientId: c.id, receivedOn: '2026-07-10', mode: 'bank',
amountPaise: 10_800_00, tdsPaise: 1_000_00 })
expect(getDocument(db, inv.id)!.status).toBe('paid')
const view = modulePaidView(db, c.id)
expect((await getDocument(db, inv.id))!.status).toBe('paid')
const view = await modulePaidView(db, c.id)
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
import { describe, it, expect, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
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
* exercised without launching Chrome the house pattern for PDF-touching tests.
*/
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db)
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 m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
async function setup() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
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)…
const issued = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }).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)
// …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' }] })
const fakePdf = async (): Promise<Buffer> => Buffer.from('%PDF-1.4 fake')
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)', () => {
const ctx = setup()
let ctx: Awaited<ReturnType<typeof setup>>
beforeAll(async () => { ctx = await setup() })
afterAll(() => ctx.server.close())
const login = async (): Promise<string> =>
(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;
// actionable-oldest-first sort; LIMIT/OFFSET pagination with total.
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 { createStaff } from '../src/auth'
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()
}
function setup(): { db: DB; moduleId: string } {
async function setup(): Promise<{ db: DB; moduleId: string }> {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const m = 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 db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
return { db, moduleId: m.id }
}
/** 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 d = createDraft(db, by, {
const d = await createDraft(db, by, {
docType: 'QUOTATION', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }],
})
if (opts.sentAgeDays !== undefined) {
markStatus(db, by, d.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`)
.run(daysAgo(opts.sentAgeDays), d.id)
await markStatus(db, by, d.id, 'sent')
await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`,
daysAgo(opts.sentAgeDays), d.id)
}
return d
}
describe('listPipeline — stage derivation (spec §9)', () => {
it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', () => {
const { db, moduleId } = setup()
const emp = createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' })
const lead = createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(emp.id, lead.id)
it('a bare lead with no quotation appears as Enquiry owned by client.owner_id', async () => {
const { db, moduleId } = await setup()
const emp = await createStaff(db, { email: 'v@x.co', displayName: 'Vikram', role: 'staff', password: 'password2' })
const lead = await createClient(db, 'u1', { name: 'Nagari Sah.', stateCode: '32', status: 'lead' })
await db.run(`UPDATE client SET owner_id=? WHERE id=?`, emp.id, lead.id)
// 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
const quotedLead = createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' })
quote(db, quotedLead.id, moduleId)
const quotedLead = await createClient(db, 'u1', { name: 'Quoted Lead', stateCode: '32', status: 'lead' })
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)
expect(enquiry).toMatchObject({
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)
})
it('derives New Project / Quoted-Waiting / Won from the latest quotation status', () => {
const { db, moduleId } = setup()
const draft = createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' })
quote(db, draft.id, moduleId)
const sent = createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' })
quote(db, sent.id, moduleId, { sentAgeDays: 1 })
const accepted = createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' })
const qa = quote(db, accepted.id, moduleId, { sentAgeDays: 2 })
markStatus(db, 'u1', qa.id, 'accepted')
const invoiced = createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' })
const qi = quote(db, invoiced.id, moduleId, { sentAgeDays: 2 })
markStatus(db, 'u1', qi.id, 'accepted')
convertDocument(db, 'u1', qi.id, 'INVOICE')
it('derives New Project / Quoted-Waiting / Won from the latest quotation status', async () => {
const { db, moduleId } = await setup()
const draft = await createClient(db, 'u1', { name: 'Draft Co', stateCode: '32', status: 'lead' })
await quote(db, draft.id, moduleId)
const sent = await createClient(db, 'u1', { name: 'Sent Co', stateCode: '32', status: 'lead' })
await quote(db, sent.id, moduleId, { sentAgeDays: 1 })
const accepted = await createClient(db, 'u1', { name: 'Accepted Co', stateCode: '32' })
const qa = await quote(db, accepted.id, moduleId, { sentAgeDays: 2 })
await markStatus(db, 'u1', qa.id, 'accepted')
const invoiced = await createClient(db, 'u1', { name: 'Invoiced Co', stateCode: '32' })
const qi = await quote(db, invoiced.id, moduleId, { sentAgeDays: 2 })
await markStatus(db, 'u1', qi.id, 'accepted')
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('Sent Co')).toMatchObject({ stage: 'quoted_waiting', ageDays: 1 })
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' })
})
it('the LATEST quotation wins when a client has several', () => {
const { db, moduleId } = setup()
const c = createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' })
const old = quote(db, c.id, moduleId, { sentAgeDays: 40 })
markStatus(db, 'u1', old.id, 'lost')
quote(db, c.id, moduleId, { sentAgeDays: 2 })
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id)
it('the LATEST quotation wins when a client has several', async () => {
const { db, moduleId } = await setup()
const c = await createClient(db, 'u1', { name: 'Two Quotes', stateCode: '32' })
const old = await quote(db, c.id, moduleId, { sentAgeDays: 40 })
await markStatus(db, 'u1', old.id, 'lost')
await quote(db, c.id, moduleId, { sentAgeDays: 2 })
const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ stage: 'quoted_waiting', ageDays: 2 })
})
it('Lost (quote lost or client lost) is hidden unless filtered', () => {
const { db, moduleId } = setup()
const lostQuote = createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' })
const q = quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 })
markStatus(db, 'u1', q.id, 'lost')
const lostClient = createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' })
quote(db, lostClient.id, moduleId, { sentAgeDays: 5 })
const alive = createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' })
quote(db, alive.id, moduleId, { sentAgeDays: 1 })
it('Lost (quote lost or client lost) is hidden unless filtered', async () => {
const { db, moduleId } = await setup()
const lostQuote = await createClient(db, 'u1', { name: 'Lost Quote Co', stateCode: '32' })
const q = await quote(db, lostQuote.id, moduleId, { sentAgeDays: 5 })
await markStatus(db, 'u1', q.id, 'lost')
const lostClient = await createClient(db, 'u1', { name: 'Lost Client Co', stateCode: '32', status: 'lost' })
await quote(db, lostClient.id, moduleId, { sentAgeDays: 5 })
const alive = await createClient(db, 'u1', { name: 'Alive Co', stateCode: '32' })
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.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.total).toBe(2)
})
it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', () => {
const { db } = setup()
createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' })
expect(listPipeline(db, { ...OWNER_VIEW }).total).toBe(0)
const lost = listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
it('a quote-less LOST client is reviewable under filter=lost (spec §9), hidden otherwise', async () => {
const { db } = await setup()
await createClient(db, 'u1', { name: 'Quote-less Lost Co', stateCode: '32', status: 'lost' })
expect((await listPipeline(db, { ...OWNER_VIEW })).total).toBe(0)
const lost = await listPipeline(db, { ...OWNER_VIEW, filter: 'lost' })
expect(lost.total).toBe(1)
expect(lost.rows[0]).toMatchObject({
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', () => {
const { db, moduleId } = setup()
const c = createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' })
const q = quote(db, c.id, moduleId, { sentAgeDays: 10 })
convertDocument(db, 'u1', q.id, 'PROFORMA')
const rows = listPipeline(db, { ...OWNER_VIEW }).rows.filter((r) => r.clientId === c.id)
it('QT→PI conversion moves the client out of Quoted/Waiting — no stuck chase row', async () => {
const { db, moduleId } = await setup()
const c = await createClient(db, 'u1', { name: 'Converted Co', stateCode: '32' })
const q = await quote(db, c.id, moduleId, { sentAgeDays: 10 })
await convertDocument(db, 'u1', q.id, 'PROFORMA')
const rows = (await listPipeline(db, { ...OWNER_VIEW })).rows.filter((r) => r.clientId === c.id)
expect(rows).toHaveLength(1)
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)', () => {
it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', () => {
const { db, moduleId } = setup()
const mk = (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: age })
it('default offsets 3/7/14: waiting<3, chase>=3, nudge>=7, final nudge>=14; bands green/amber/red', async () => {
const { db, moduleId } = await setup()
const mk = async (name: string, age: number) => {
const c = await createClient(db, 'u1', { name, stateCode: '32' })
await quote(db, c.id, moduleId, { sentAgeDays: age })
}
mk('Age2', 2); mk('Age5', 5); mk('Age8', 8); mk('Age15', 15)
const out = listPipeline(db, { ...OWNER_VIEW })
await mk('Age2', 2); await mk('Age5', 5); await mk('Age8', 8); await mk('Age15', 15)
const out = await listPipeline(db, { ...OWNER_VIEW })
expect(out.dayOffsets).toEqual([3, 7, 14])
const by = new Map(out.rows.map((r) => [r.clientName, r]))
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 })
})
it('a dated reminder_schedule row changes the bands without a code edit', () => {
const { db, moduleId } = setup()
db.prepare(
it('a dated reminder_schedule row changes the bands without a code edit', async () => {
const { db, moduleId } = await setup()
await db.run(
`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)`,
).run()
const c = createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: 5 })
const out = listPipeline(db, { ...OWNER_VIEW })
)
const c = await createClient(db, 'u1', { name: 'Fast Lane', stateCode: '32' })
await quote(db, c.id, moduleId, { sentAgeDays: 5 })
const out = await listPipeline(db, { ...OWNER_VIEW })
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)
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)', () => {
function team(db: DB) {
const a = 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' })
async function team(db: DB) {
const a = await createStaff(db, { email: 'a@x.co', displayName: 'Asha', 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 }
}
it('staff see only quotes they created and leads they own; widening params are ignored', () => {
const { db, moduleId } = setup()
const { a, b } = team(db)
const c1 = createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' })
quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 })
const c2 = createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' })
quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 })
const leadA = createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' })
db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(a, leadA.id)
createClient(db, 'u1', { name: 'Unassigned Lead', stateCode: '32', status: 'lead' })
it('staff see only quotes they created and leads they own; widening params are ignored', async () => {
const { db, moduleId } = await setup()
const { a, b } = await team(db)
const c1 = await createClient(db, 'u1', { name: 'Asha Quote Co', stateCode: '32' })
await quote(db, c1.id, moduleId, { by: a, sentAgeDays: 4 })
const c2 = await createClient(db, 'u1', { name: 'Priya Quote Co', stateCode: '32' })
await quote(db, c2.id, moduleId, { by: b, sentAgeDays: 4 })
const leadA = await createClient(db, 'u1', { name: 'Asha Lead', stateCode: '32', status: 'lead' })
await db.run(`UPDATE client SET owner_id=? WHERE id=?`, a, leadA.id)
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'])
// 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'])
// 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)
// ...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'])
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)
})
})
describe('listPipeline — sort, overdue filter, pagination', () => {
it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', () => {
const { db, moduleId } = setup()
const mkSent = (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' })
it('sorts actionable oldest first: overdue by age desc, then enquiry/draft, then won', async () => {
const { db, moduleId } = await setup()
const mkSent = async (name: string, age: number) => {
const c = await createClient(db, 'u1', { name, stateCode: '32' })
return quote(db, c.id, moduleId, { sentAgeDays: age })
}
mkSent('Mid 5d', 5)
mkSent('Oldest 15d', 15)
mkSent('Waiting 2d', 2)
createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' })
const won = createClient(db, 'u1', { name: 'Won Co', stateCode: '32' })
markStatus(db, 'u1', quote(db, won.id, moduleId, { sentAgeDays: 30 }).id, 'accepted')
await mkSent('Mid 5d', 5)
await mkSent('Oldest 15d', 15)
await mkSent('Waiting 2d', 2)
await createClient(db, 'u1', { name: 'A Lead', stateCode: '32', status: 'lead' })
const won = await createClient(db, 'u1', { name: 'Won Co', stateCode: '32' })
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'])
})
it('filter=overdue keeps only sent quotes past the first offset', () => {
const { db, moduleId } = setup()
const mkSent = (name: string, age: number) => {
const c = createClient(db, 'u1', { name, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: age })
it('filter=overdue keeps only sent quotes past the first offset', async () => {
const { db, moduleId } = await setup()
const mkSent = async (name: string, age: number) => {
const c = await createClient(db, 'u1', { name, stateCode: '32' })
await quote(db, c.id, moduleId, { sentAgeDays: age })
}
mkSent('Fresh 1d', 1)
mkSent('Over 9d', 9)
createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' })
const out = listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' })
await mkSent('Fresh 1d', 1)
await mkSent('Over 9d', 9)
await createClient(db, 'u1', { name: 'Some Lead', stateCode: '32', status: 'lead' })
const out = await listPipeline(db, { ...OWNER_VIEW, filter: 'overdue' })
expect(out.rows.map((r) => r.clientName)).toEqual(['Over 9d'])
expect(out.total).toBe(1)
})
it('paginates with LIMIT/OFFSET semantics and an honest total', () => {
const { db, moduleId } = setup()
it('paginates with LIMIT/OFFSET semantics and an honest total', async () => {
const { db, moduleId } = await setup()
for (let i = 0; i < 5; i += 1) {
const c = createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' })
quote(db, c.id, moduleId, { sentAgeDays: 10 + i })
const c = await createClient(db, 'u1', { name: `Client ${i}`, stateCode: '32' })
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.rows).toHaveLength(2)
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.page).toBe(3)
expect(p3.pageSize).toBe(2)
// no overlap, nothing dropped
const all = [
...p1.rows,
...listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 }).rows,
...(await listPipeline(db, { ...OWNER_VIEW, page: 2, pageSize: 2 })).rows,
...p3.rows,
]
expect(new Set(all.map((r) => r.clientId)).size).toBe(5)
@ -261,17 +261,21 @@ describe('listPipeline — sort, overdue filter, pagination', () => {
})
describe('GET /pipeline', () => {
const { db, moduleId } = setup()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const staffDbId = (db.prepare(`SELECT id FROM staff_user WHERE email='staff@test.in'`).get() as { id: string }).id
const mineClient = createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' })
quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 })
const otherClient = createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' })
quote(db, otherClient.id, moduleId, { sentAgeDays: 4 })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
let server: ReturnType<ReturnType<typeof express>['listen']>
let base: string
beforeAll(async () => {
const { db, moduleId } = await setup()
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const staffDbId = (await db.get(`SELECT id FROM staff_user WHERE email='staff@test.in'`) as { id: string }).id
const mineClient = await createClient(db, 'seed', { name: 'Staff Own Co', stateCode: '32' })
await quote(db, mineClient.id, moduleId, { by: staffDbId, sentAgeDays: 4 })
const otherClient = await createClient(db, 'seed', { name: 'Someone Else Co', stateCode: '32' })
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())
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 { prepareDraft, createDraft, type DraftInput } from '../src/repos-documents'
function setup() {
async function setup() {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const kar = createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' })
const m = 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' })
const noPrice = createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const kar = await createClient(db, 'u1', { name: 'BLR Co', stateCode: '29' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS'] })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const noPrice = await createModule(db, 'u1', { code: 'ANALYTICS', name: 'Analytics' }) // no price row
return { db, c, kar, m, noPrice }
}
describe('prepareDraft ↔ createDraft parity', () => {
it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', () => {
const { db, c, m } = setup()
it('createDraft persists exactly what prepareDraft computes (totals + payload deep-equal)', async () => {
const { db, c, m } = await setup()
const input: DraftInput = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
const prepared = prepareDraft(db, input)
const saved = createDraft(db, 'u1', input)
const prepared = await prepareDraft(db, input)
const saved = await createDraft(db, 'u1', input)
expect(saved.payload).toEqual(prepared.payload)
expect({
taxable: saved.taxablePaise, cgst: saved.cgstPaise, sgst: saved.sgstPaise,
@ -34,36 +34,36 @@ describe('prepareDraft ↔ createDraft parity', () => {
})
expect(prepared.warnings).toEqual([])
})
it('resolves IGST for an out-of-state client, exactly as createDraft would', () => {
const { db, kar, m } = setup()
it('resolves IGST for an out-of-state client, exactly as createDraft would', async () => {
const { db, kar, m } = await setup()
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', () => {
it('STRICT: a line with no price throws (save must refuse the same document)', () => {
const { db, c, noPrice } = setup()
expect(() => prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
it('STRICT: a line with no price throws (save must refuse the same document)', async () => {
const { db, c, noPrice } = await setup()
await expect(prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
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', () => {
const { db, c, noPrice } = setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
it('PERMISSIVE: the same line computes at ₹0 and is reported in warnings', async () => {
const { db, c, noPrice } = await setup()
const out = await prepareDraft(db, { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: noPrice.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
expect(out.totals.payablePaise).toBe(0)
expect(out.warnings.some((w) => /ANALYTICS/.test(w))).toBe(true)
})
it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', () => {
const { db, c } = setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true })
it('PERMISSIVE: zero lines → empty payload, ₹0, no computeBill throw', async () => {
const { db, c } = await setup()
const out = await prepareDraft(db, { docType: 'QUOTATION', clientId: c.id, lines: [] }, { permissive: true })
expect(out.payload.lines).toEqual([])
expect(out.totals.payablePaise).toBe(0)
})
it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', () => {
const { db, m } = setup()
const out = prepareDraft(db, { docType: 'QUOTATION', clientId: '', lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }, { permissive: true })
it('PERMISSIVE: unknown client does not throw; split defaults intra-state with a pending warning', async () => {
const { db, m } = await setup()
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.igstPaise).toBe(0)
expect(out.warnings.some((w) => /client/i.test(w))).toBe(true)

@ -1,5 +1,5 @@
// 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 { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -10,28 +10,35 @@ import { createDraft } from '../src/repos-documents'
import { documentHtml } from '../src/templates'
import { apiRouter } from '../src/api'
function companyMap(db: any): Record<string, string> {
const rows = db.prepare(`SELECT key, value FROM setting WHERE key LIKE 'company.%'`).all() as { key: string; value: string }[]
async function companyMap(db: any): Promise<Record<string, 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]))
}
describe('preview fidelity (string identity with the PDF path)', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
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 m = 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' })
async function setup() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', address: 'Kochi', gstin: '32ABCDE1234F1Z9' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing', quoteContent: ['Cloud POS', 'GST filing'] })
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 server = app.listen(0)
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 () => {
const { db, c, m, base } = ctx
const input = { docType: 'INVOICE', clientId: c.id, terms: 'Net 15',
lines: [{ moduleId: m.id, qty: 2, kind: 'yearly' }] }
// The fixture draft: exactly what /documents/:id/pdf renders (un-issued draft, docNo=null).
const fixture = createDraft(db, 'u1', input)
const expected = documentHtml(fixture, getClient(db, c.id)!, companyMap(db))
const fixture = await createDraft(db, 'u1', input)
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 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
import { describe, it, expect, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -8,14 +8,14 @@ import { createClient } from '../src/repos-clients'
import { createModule, setPrice } from '../src/repos-modules'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18
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 kar = createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
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' })
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const kar = await createClient(db, 'u1', { name: 'BLR Co', code: 'BLR', stateCode: '29' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -23,7 +23,8 @@ function appWith() {
}
describe('POST /documents/preview', () => {
const ctx = appWith()
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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
@ -56,8 +57,8 @@ describe('POST /documents/preview', () => {
expect(out.json.totals.cgstPaise).toBe(900_00)
expect(out.json.warnings).toEqual([])
// Persisted nothing:
expect((ctx.db.prepare(`SELECT COUNT(*) AS n FROM document`).get() 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 document`) 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 () => {

@ -1,5 +1,5 @@
// 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 { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -31,9 +31,9 @@ describe('templates: screen paper styles + documentHtmlSample', () => {
})
})
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db)
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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -41,12 +41,13 @@ function appWith() {
}
describe('POST /previews/sample', () => {
const { server, base } = appWith()
afterAll(() => server.close())
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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 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 }
}
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 { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'staff@test.in', displayName: 'Priya', role: 'staff', password: 'first-pass-9' })
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db)
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
app.use('/api', apiRouter(db))
const server = app.listen(0)
@ -34,7 +34,7 @@ describe('self-service profile (/me)', () => {
afterAll(() => { for (const s of servers) s.close() })
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 res = await fetch(`${ctx.baseUrl}/me`, { headers: H(token) })
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 () => {
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 res = await fetch(`${ctx.baseUrl}/me`, {
method: 'PATCH', headers: H(token),
@ -57,31 +57,31 @@ describe('self-service profile (/me)', () => {
displayName: 'Priya N', phone: '+91 98765',
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()
})
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 res = await fetch(`${ctx.baseUrl}/me`, { method: 'PATCH', headers: H(token), body: '{}' })
expect(res.status).toBe(400)
})
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 res = await fetch(`${ctx.baseUrl}/me/password`, {
method: 'POST', headers: H(token),
body: JSON.stringify({ currentPassword: 'wrong-guess', newPassword: 'brand-new-pw-9' }),
})
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
})
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 current = (await login(ctx.baseUrl, 'staff@test.in', 'first-pass-9'))! // the one changing it
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' }),
})
expect(res.status).toBe(200)
expect(verifySession(ctx.db, current)).not.toBeNull() // survives
expect(verifySession(ctx.db, other)).toBeNull() // dead with the old credential
expect(await verifySession(ctx.db, current)).not.toBeNull() // survives
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', '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!.after_json ?? '').not.toMatch(/hash/)
})
it('short new passwords are rejected in the repo', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const { id } = 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/)
it('short new passwords are rejected in the repo', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const { id } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
await expect(changeOwnPassword(db, id, 'first-pass-9', 'short', 'tok')).rejects.toThrow(/8/)
})
it('phone/title round-trip through updateEmployee, and emptying stores NULL', () => {
const db = openDb(':memory:'); seedIfEmpty(db)
const { id } = createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' })
expect(getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' })
updateEmployee(db, id, id, { phone: '', title: ' ' })
expect(getEmployee(db, id)).toMatchObject({ phone: null, title: null })
it('phone/title round-trip through updateEmployee, and emptying stores NULL', async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
const { id } = await createStaff(db, { email: 's@x.co', displayName: 'S', role: 'staff', password: 'first-pass-9' })
await updateEmployee(db, id, id, { phone: '+91 12345', title: 'Field Engineer' })
expect(await getEmployee(db, id)).toMatchObject({ phone: '+91 12345', title: 'Field Engineer' })
await updateEmployee(db, id, id, { phone: '', title: ' ' })
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
* launching Chrome the house pattern for every PDF-touching test in this suite.
*/
function setup(rateLimit?: { limit: number; windowMs: number }) {
const db = openDb(':memory:'); seedIfEmpty(db)
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 m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
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,
async function setup(rateLimit?: { limit: number; windowMs: number }) {
const db = openDb(':memory:'); await seedIfEmpty(db)
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const rendered: string[] = []
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', () => {
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 {
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
expect(res.status).toBe(200)
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 () => {
const ctx = setup()
const ctx = await setup()
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}`)
expect(res.headers.get('content-disposition')).toMatch(/^inline/)
} finally { ctx.server.close() }
})
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 {
const res = await fetch(`${ctx.base}/share/deadbeefdeadbeef`)
expect(res.status).toBe(404)
@ -76,9 +76,9 @@ describe('public GET /share/:token', () => {
})
it('404s for an expired token', async () => {
const ctx = setup()
const ctx = await setup()
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}`)
expect(res.status).toBe(404)
expect(ctx.rendered.length).toBe(0)
@ -86,10 +86,10 @@ describe('public GET /share/:token', () => {
})
it('404s for a revoked token', async () => {
const ctx = setup()
const ctx = await setup()
try {
const s = mintShare(ctx.db, 'u1', ctx.doc.id)
revokeShare(ctx.db, 'u1', s.id)
const s = await mintShare(ctx.db, 'u1', ctx.doc.id)
await revokeShare(ctx.db, 'u1', s.id)
const res = await fetch(`${ctx.base}/share/${s.token}`)
expect(res.status).toBe(404)
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 () => {
const ctx = setup({ limit: 3, windowMs: 60_000 })
const ctx = await setup({ limit: 3, windowMs: 60_000 })
try {
for (let i = 0; i < 3; 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)
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 { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
@ -29,27 +30,27 @@ const deps: ScanDeps & SendReminderDeps = {
now: () => '2026-07-10T09:00:00Z',
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
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
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in') // sends refuse a dead relative link without it
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
/** 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 {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id)
async function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Promise<Doc> {
const q = await createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
await markStatus(db, by, q.id, 'sent')
await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, sentAt, q.id)
return q
}
const followups = (db: DB) => listReminders(db, { ruleKind: 'quote_followup' })
const shareCount = (db: DB): number =>
(db.prepare(`SELECT COUNT(*) AS n FROM document_share`).get() as { n: number }).n
const shareCount = async (db: DB): Promise<number> =>
((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM document_share`))!).n
// ---------- schema migration (rebuild-once via the shared helper) ----------
@ -67,14 +68,15 @@ CREATE TABLE reminder (
)`
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 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)
})
it('rebuilds an old-CHECK table preserving rows, UNIQUE key and status CHECK; idempotent', () => {
const db = openDb(':memory:')
db.exec(`DROP TABLE reminder`)
// Raw better-sqlite3 handle: the rebuild helper is a SQLite-only migration that
// operates below the async DB interface (it takes SqliteRaw).
const db = new Database(':memory:')
db.exec(OLD_REMINDER_DDL)
db.prepare(
`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', () => {
it('fires each interval at most once per quote, day by day', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
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-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-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-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.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 () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-06-01T09:00:00Z') // age 39 on first scan — past 3, 7 and 14
const { db, c, m } = await world()
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')
expect(res.created['quote_followup']).toBe(1)
const rows = followups(db)
const rows = await followups(db)
expect(rows).toHaveLength(1)
expect(rows[0]!.duePeriod).toBe('d14')
})
it('ignores quotes that are not sent (draft / accepted / lost / cancelled leave the set)', async () => {
const { db, c, m } = world()
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')
markStatus(db, 'u1', q.id, 'accepted')
const { db, c, m } = await world()
await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
const q = await sentQuote(db, c, m, '2026-06-01T09:00:00Z')
await markStatus(db, 'u1', q.id, 'accepted')
const res = await runDailyScan(db, deps, '2026-07-10')
expect(res.created['quote_followup'] ?? 0).toBe(0)
})
it('a converted quote is NEVER chased again — the next scan creates nothing', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04') // d3 queued
convertDocument(db, 'u1', q.id, 'PROFORMA')
expect(getDocument(db, q.id)!.status).toBe('invoiced') // left the 'sent' set for good
await convertDocument(db, 'u1', q.id, 'PROFORMA')
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
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 () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
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
const { db, c, m } = await world()
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await convertDocument(db, 'u1', q.id, 'PROFORMA')
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')
expect(res.created['quote_followup'] ?? 0).toBe(0)
})
it('a lost client is never chased, whatever their quote row says', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-06-01T09:00:00Z')
db.prepare(`UPDATE client SET status='lost' WHERE id=?`).run(c.id)
const { db, c, m } = await world()
await sentQuote(db, c, m, '2026-06-01T09:00:00Z')
await db.run(`UPDATE client SET status='lost' WHERE id=?`, c.id)
const res = await runDailyScan(db, deps, '2026-07-10')
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 () => {
const { db, c, m } = world()
setSetting(db, 'u1', 'quote.followup.policy', 'auto')
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
await setSetting(db, 'u1', 'quote.followup.policy', 'auto')
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const res = await runDailyScan(db, deps, '2026-07-04') // d3
expect(res.created['quote_followup']).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.status).toBe('sent')
expect(shareCount(db)).toBe(1)
const share = db.prepare(`SELECT expires_at FROM document_share`).get() as { expires_at: string | null }
expect(await shareCount(db)).toBe(1)
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)
const days = (Date.parse(share.expires_at!) - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(59); expect(days).toBeLessThan(61)
// Next milestone reuses the live share instead of duplicating it.
const res2 = await runDailyScan(db, deps, '2026-07-08') // d7
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).
const res3 = await runDailyScan(db, deps, '2026-07-08')
expect(res3.created['quote_followup'] ?? 0).toBe(0)
@ -196,22 +198,22 @@ describe('runDailyScan — quote_followup escalation', () => {
describe('quote_followup context, preview and send', () => {
it('preview writes nothing — no share minted, no audit rows', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]!
const auditBefore = (db.prepare(`SELECT COUNT(*) AS n FROM audit_log`).get() as { n: number }).n
const { ctx } = reminderContext(db, rem, 'Tecnostac')
const rem = (await followups(db))[0]!
const auditBefore = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM audit_log`))!).n
const { ctx } = await reminderContext(db, rem, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).toContain('Tecnostac')
expect(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 shareCount(db)).toBe(0) // resolve-only: preview NEVER mints
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 () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z') // never issued: docNo is null (F10)
const { db, c, m } = await world()
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')
const { ctx } = reminderContext(db, followups(db)[0]!, 'Tecnostac')
const { ctx } = await reminderContext(db, (await followups(db))[0]!, 'Tecnostac')
const mail = reminderEmail('quote_followup', ctx)
expect(mail.subject).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
})
it('uses the doc number when the quote is issued', async () => {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const issued = issueDocument(db, 'u1', q.id)
const { db, c, m } = await world()
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const issued = await issueDocument(db, 'u1', q.id)
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!)
})
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.
db.prepare(
await db.run(
`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}')`,
).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')
const rem = followups(db)[0]!
const before = reminderContext(db, rem, 'Tecnostac', '2026-07-10')
const rem = (await followups(db))[0]!
const before = await reminderContext(db, rem, 'Tecnostac', '2026-07-10')
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')
})
it('refuses to send when share.base_url is unset — loud error, NO share minted, row still queued', async () => {
const { db, c, m } = world()
db.prepare(`DELETE FROM setting WHERE key='share.base_url'`).run()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
await db.run(`DELETE FROM setting WHERE key='share.base_url'`)
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
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/)
expect(shareCount(db)).toBe(0) // nothing public left behind by the refused send
expect(getReminder(db, rem.id)!.status).toBe('queued')
expect(await shareCount(db)).toBe(0) // nothing public left behind by the refused send
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 () => {
const { db, c, m } = world()
db.prepare(`DELETE FROM setting WHERE key='share.base_url'`).run()
setSetting(db, 'u1', 'quote.followup.policy', 'auto')
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
await db.run(`DELETE FROM setting WHERE key='share.base_url'`)
await setSetting(db, 'u1', 'quote.followup.policy', 'auto')
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const res = await runDailyScan(db, deps, '2026-07-04')
expect(res.created['quote_followup']).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.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 () => {
const { db, m } = world()
const bare = createClient(db, 'u1', { name: 'NoMail Co', stateCode: '32' })
sentQuote(db, bare, m, '2026-07-01T09:00:00Z')
const { db, m } = await world()
const bare = await createClient(db, 'u1', { name: 'NoMail Co', stateCode: '32' })
await sentQuote(db, bare, m, '2026-07-01T09:00:00Z')
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)
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 () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
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')
expect(out).toEqual({ ok: true })
expect(shareCount(db)).toBe(1)
expect(await shareCount(db)).toBe(1)
// 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/')
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(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', () => {
async function withOpenNudge(status?: 'accepted' | 'lost') {
const { db, c, m } = world()
const q = sentQuote(db, c, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
const q = await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const rem = followups(db)[0]!
const rem = (await followups(db))[0]!
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 }
}
it('markStatus accepted dismisses open nudges, each audited', async () => {
const { db, remId } = await withOpenNudge('accepted')
expect(getReminder(db, remId)!.status).toBe('dismissed')
const audit = db.prepare(
expect((await getReminder(db, remId))!.status).toBe('dismissed')
const audit = (await db.get<{ n: number }>(
`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
})
it('markStatus lost dismisses open nudges', async () => {
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 () => {
const { db, q, remId } = await withOpenNudge()
convertDocument(db, 'u1', q.id, 'PROFORMA')
expect(getReminder(db, remId)!.status).toBe('dismissed')
await convertDocument(db, 'u1', q.id, 'PROFORMA')
expect((await getReminder(db, remId))!.status).toBe('dismissed')
})
it('cancelDocument dismisses the quotations open nudges — dead paper is not chased', async () => {
const { db, q, remId } = await withOpenNudge()
issueDocument(db, 'u1', q.id) // only issued documents can cancel
cancelDocument(db, 'u1', q.id)
expect(getReminder(db, remId)!.status).toBe('dismissed')
await issueDocument(db, 'u1', q.id) // only issued documents can cancel
await cancelDocument(db, 'u1', q.id)
expect((await getReminder(db, remId))!.status).toBe('dismissed')
})
it('leaves already-sent follow-ups untouched', async () => {
const { db, q, remId } = await withOpenNudge()
await sendReminder(db, deps, remId, 'u1')
markStatus(db, 'u1', q.id, 'accepted')
expect(getReminder(db, remId)!.status).toBe('sent') // history is history
await markStatus(db, 'u1', q.id, 'accepted')
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', () => {
it('labels quote follow-ups and derives the owner from document.created_by', async () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
const { db, c, m } = await world()
await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
await runDailyScan(db, deps, '2026-07-04')
const page = listQueue(db)
const page = await listQueue(db)
expect(page.total).toBe(1)
expect(page.rows[0]!.label).toBe('Quote follow-up (d3)')
expect(page.rows[0]!.ownerId).toBe('staff-a')
expect(page.rows[0]!.clientName).toBe('Acme')
})
it('staff see only their own rows; managerial viewers see everything', async () => {
const { db, c, m } = world()
const c2 = 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')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b')
const { db, c, m } = await world()
const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-a')
await sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'staff-b')
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.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)
// 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.rows[0]!.ownerId).toBe('staff-a')
})
it('paginates with an honest total', async () => {
const { db, c, m } = world()
const c2 = createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
sentQuote(db, c, m, '2026-07-01T09:00:00Z')
sentQuote(db, c2, m, '2026-07-01T09:00:00Z')
const { db, c, m } = await world()
const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
await sentQuote(db, c, m, '2026-07-01T09:00:00Z')
await sentQuote(db, c2, m, '2026-07-01T09:00:00Z')
await runDailyScan(db, deps, '2026-07-04')
const p1 = listQueue(db, { page: 1, pageSize: 1 })
const p2 = listQueue(db, { page: 2, pageSize: 1 })
const p1 = await listQueue(db, { page: 1, pageSize: 1 })
const p2 = await listQueue(db, { page: 2, pageSize: 1 })
expect(p1.total).toBe(2); expect(p2.total).toBe(2)
expect(p1.rows).toHaveLength(1); expect(p2.rows).toHaveLength(1)
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 () => {
const { db, c, m } = world()
sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-b') // someone else's quote nudge
const { db, c, m } = await world()
await sentQuote(db, c, m, '2026-07-01T09:00:00Z', 'staff-b') // someone else's quote nudge
await runDailyScan(db, deps, '2026-07-04')
upsertReminder(db, {
await upsertReminder(db, {
ruleKind: 'renewal_due', subjectId: 'cm1', duePeriod: '2026-08-01',
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.rows[0]!.ruleKind).toBe('renewal_due')
expect(queueCounts(db, 'staff-a')).toEqual({ queued: 1, failed: 0 })
const boss = listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
expect(await queueCounts(db, 'staff-a')).toEqual({ queued: 1, failed: 0 })
const boss = await listQueue(db, { viewerRole: 'owner', viewerId: 'the-owner' })
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 ----------
describe('GET /reminders?status=', () => {
const { db, c, m } = world()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const staff = createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
afterAll(() => server.close())
async function setup() {
const { db, c, m } = await world()
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const staff = await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
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) =>
(await (await fetch(`${base}/auth/login`, {
(await (await fetch(`${ctx.base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json() as { token: string }).token
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 }
}
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 mine = sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id)
sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else')
await runDailyScan(db, deps, '2026-07-04')
const c2 = await createClient(ctx.db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
const mine = await sentQuote(ctx.db, ctx.c, ctx.m, '2026-07-01T09:00:00Z', ctx.staff.id)
await sentQuote(ctx.db, c2, ctx.m, '2026-07-01T09:00:00Z', 'someone-else')
await runDailyScan(ctx.db, deps, '2026-07-04')
const staffTok = await tokenOf('staff@test.in', 'staff-password')
const own = await get(staffTok, '?status=queued')
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 { documentHtml } from '../src/templates'
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = 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'] })
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)
async function setup() {
const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
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 }
}
describe('payment receipts', () => {
it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', () => {
const { db, c, inv } = setup()
const out = recordPayment(db, 'u1', {
it('issues a zero-GST RECEIPT with its own RCT/ series on recordPayment', async () => {
const { db, c, inv } = await setup()
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,
})
@ -32,19 +32,19 @@ describe('payment receipts', () => {
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 })
})
it('generates a receipt after the fact from a stored payment', () => {
const { db, c, inv } = setup()
const out = recordPayment(db, 'u1', {
it('generates a receipt after the fact from a stored payment', async () => {
const { db, c, inv } = await setup()
const out = await recordPayment(db, 'u1', {
clientId: c.id, receivedOn: '2026-07-10', mode: 'upi', 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(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)', () => {
const { db, c, inv } = 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 })
it('renders a receipt acknowledgment (no SAC/GST table)', async () => {
const { db, c, inv } = await setup()
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' })
expect(html).toContain('RECEIPT')
expect(html).toContain('Received with thanks')

@ -6,45 +6,45 @@ import {
createRecurringPlan, listRecurringPlans, updateRecurringPlan, deactivateRecurringPlan,
} from '../src/repos-recurring'
function setup() {
async function setup() {
const db = openDb(':memory:')
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = 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' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud Hosting', allowedKinds: ['monthly', 'yearly'] })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-04-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
return { db, c, m, cm }
}
describe('recurring plans', () => {
it('creates a plan, lists it, updates and deactivates with audit', () => {
const { db, c, cm } = setup()
const p = createRecurringPlan(db, 'u1', {
it('creates a plan, lists it, updates and deactivates with audit', async () => {
const { db, c, cm } = await setup()
const p = await createRecurringPlan(db, 'u1', {
clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-08-01', policy: 'auto',
})
expect(p.policy).toBe('auto')
expect(p.amountPaise).toBeNull() // price resolves from the module at generation
expect(listRecurringPlans(db, c.id)).toHaveLength(1)
const up = updateRecurringPlan(db, 'u1', p.id, { amountPaise: 2_500_00 })
expect(await listRecurringPlans(db, c.id)).toHaveLength(1)
const up = await updateRecurringPlan(db, 'u1', p.id, { amountPaise: 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)
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
})
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 c = createClient(db, 'u1', { name: 'X', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
expect(() => createRecurringPlan(db, 'u1', {
const c = await createClient(db, 'u1', { name: 'X', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await expect(createRecurringPlan(db, 'u1', {
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', () => {
const { db, cm } = setup()
const other = createClient(db, 'u1', { name: 'Other', stateCode: '32' })
expect(() => createRecurringPlan(db, 'u1', {
it('rejects a client_module that belongs to another client', async () => {
const { db, cm } = await setup()
const other = await createClient(db, 'u1', { name: 'Other', stateCode: '32' })
await expect(createRecurringPlan(db, 'u1', {
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
import { describe, it, expect, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -10,15 +10,15 @@ import { createDraft, issueDocument } from '../src/repos-documents'
import { upsertReminder } from '../src/repos-reminders'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.name='Tecnostac'
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 m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
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 overdue = 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 })
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company.name='Tecnostac'
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
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 = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: '2026-07', clientId: c.id, docId: inv.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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -26,7 +26,8 @@ function appWith() {
}
describe('GET /reminders/:id/preview', () => {
const ctx = appWith()
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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

@ -11,82 +11,84 @@ interface Row {
}
/** Raw insert — proves a cadence change is a dated DB row, no code edit (rule 3). */
function insertSchedule(db: DB, r: Row): void {
db.prepare(
async function insertSchedule(db: DB, r: Row): Promise<void> {
await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
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', () => {
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 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.source).toBe('default')
expect(qf.subject).toContain('{ref}')
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.source).toBe('default')
expect(inv.subject).toBeNull() // invoice mail text stays in reminder-templates
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:')
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' })
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([2, 5, 9])
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').source).toBe('db')
expect(resolveSchedule(db, 'quote_followup', '2026-03-01').dayOffsets).toEqual([3, 7, 14])
await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '3,7,14' })
await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-07-01', offsets: '2,5,9' })
expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([2, 5, 9])
expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).source).toBe('db')
expect((await resolveSchedule(db, 'quote_followup', '2026-03-01')).dayOffsets).toEqual([3, 7, 14])
// 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 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)
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 —
// the tiebreaker (id DESC; UUIDv7 ids are time-ordered) must decide, not the plan.
ins('zzzz-high', '2,4')
ins('aaaa-low', '5,10')
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([2, 4])
await ins('zzzz-high', '2,4')
await ins('aaaa-low', '5,10')
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:')
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(resolveSchedule(db, 'quote_followup', '2026-07-01').source).toBe('default') // boundary day excluded
await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', to: '2026-07-01', offsets: '1,2' })
expect((await resolveSchedule(db, 'quote_followup', '2026-06-30')).dayOffsets).toEqual([1, 2])
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:')
seedIfEmpty(db)
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').dayOffsets).toEqual([3, 7, 14])
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(resolveSchedule(db, 'quote_followup', '2026-08-01').dayOffsets).toEqual([5, 10]) // new cadence after
await seedIfEmpty(db)
expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14])
await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-08-01', offsets: '5,10' })
expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14]) // unchanged before
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:')
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])
insertSchedule(db, { ruleKind: 'invoice_overdue', from: '2026-01-01', offsets: 'a,b,-3,0' })
const inv = resolveSchedule(db, 'invoice_overdue', '2026-07-17')
await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: ' 14, 3 ,7 ' })
expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).dayOffsets).toEqual([3, 7, 14])
await insertSchedule(db, { ruleKind: 'invoice_overdue', from: '2026-01-01', offsets: 'a,b,-3,0' })
const inv = await resolveSchedule(db, 'invoice_overdue', '2026-07-17')
expect(inv.dayOffsets).toEqual([7, 15, 30]) // nothing usable → code 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:')
insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '4,8' })
const r = resolveSchedule(db, 'quote_followup', '2026-07-17')
await insertSchedule(db, { ruleKind: 'quote_followup', from: '2026-01-01', offsets: '4,8' })
const r = await resolveSchedule(db, 'quote_followup', '2026-07-17')
expect(r.dayOffsets).toEqual([4, 8])
expect(r.subject).toBe(SCHEDULE_DEFAULTS.quote_followup.subject)
expect(r.body).toBe(SCHEDULE_DEFAULTS.quote_followup.body)
@ -94,22 +96,22 @@ describe('resolveSchedule', () => {
})
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:')
seedIfEmpty(db)
seedIfEmpty(db) // idempotent — no duplicates
const rows = db.prepare(
await seedIfEmpty(db)
await seedIfEmpty(db) // idempotent — no duplicates
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`,
).all() as { rule_kind: string; n: number }[]
)
expect(rows).toEqual([
{ rule_kind: 'invoice_overdue', n: 1 },
{ rule_kind: 'quote_followup', n: 1 },
])
expect(resolveSchedule(db, 'quote_followup', '2026-07-17').source).toBe('db')
expect(resolveSchedule(db, 'invoice_overdue', '2026-07-17').source).toBe('db')
const audits = db.prepare(
expect((await resolveSchedule(db, 'quote_followup', '2026-07-17')).source).toBe('db')
expect((await resolveSchedule(db, 'invoice_overdue', '2026-07-17')).source).toBe('db')
const audits = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder_schedule'`,
).get() as { n: number }
))!
expect(audits.n).toBe(2)
})
})

@ -3,7 +3,7 @@
// 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.
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 { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
@ -27,57 +27,63 @@ const deps: ScanDeps & SendReminderDeps = {
now: () => '2026-07-10T09:00:00Z',
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
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 m = createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
await setSetting(db, 'u1', 'share.base_url', 'https://hq.example.in')
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'CBS', name: 'Core Banking' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
/** 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 {
const q = createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
markStatus(db, by, q.id, 'sent')
db.prepare(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`).run(sentAt, q.id)
async function sentQuote(db: DB, c: Client, m: Module, sentAt: string, by = 'u1'): Promise<Doc> {
const q = await createDraft(db, by, { docType: 'QUOTATION', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
await markStatus(db, by, q.id, 'sent')
await db.run(`UPDATE document_event SET at_wall=? WHERE document_id=? AND kind='sent'`, sentAt, q.id)
return q
}
describe('GET /reminders — Reminders page contract (status chips, Mine/All, staff scope)', () => {
const { db, c, m } = world()
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const staff = createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
afterAll(() => server.close())
async function setup() {
const { db, c, m } = await world()
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const staff = await createStaff(db, { email: 'staff@test.in', displayName: 'Stf', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json()); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
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) =>
(await (await fetch(`${base}/auth/login`, {
(await (await fetch(`${ctx.base}/auth/login`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email, password }),
})).json() as { token: string }).token
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 }
}
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 mine = sentQuote(db, c, m, '2026-07-01T09:00:00Z', staff.id) // → queued nudge owned by staff
const theirs = sentQuote(db, c2, m, '2026-07-01T09:00:00Z', 'someone-else') // → queued nudge owned by someone else
const { db, c, m, staff } = ctx
const c2 = await createClient(db, 'u1', { name: 'Beta', stateCode: '32', contacts: [{ name: 'S', email: 's@beta.in' }] })
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')
// 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)!
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',
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')
// Each chip narrows to exactly its status.

@ -19,31 +19,31 @@ describe('reminder templates', () => {
})
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 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)
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.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(listQueue(db).total).toBe(1)
expect(db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='reminder'`).get()).toMatchObject({ n: 1 })
expect((await listQueue(db)).rows).toHaveLength(1) // Phase 6: listQueue paginates ({ rows, total, page, pageSize })
expect((await listQueue(db)).total).toBe(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 { id } = 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 { id } = await upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: 'c1', now: '2026-07-10T00:00:00Z' })
const dismissed = await dismissReminder(db, 'u1', id)
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' })
setReminderStatus(db, 'u1', id2, 'sent', { sentAt: '2026-07-10T09:00:00Z' })
expect(getReminder(db, id2)!.status).toBe('sent')
expect(() => dismissReminder(db, 'u1', id2)).toThrow(/sent/i)
const { id: id2 } = await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: 'inv2', duePeriod: '2026-07', clientId: 'c1', docId: 'inv2', now: '2026-07-10T00:00:00Z' })
await setReminderStatus(db, 'u1', id2, 'sent', { sentAt: '2026-07-10T09:00:00Z' })
expect((await getReminder(db, id2))!.status).toBe('sent')
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:')
expect(getNumberSetting(db, 'reminders.overdue_days', 7)).toBe(7)
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(7)
await setSetting(db, 'u1', 'reminders.overdue_days', '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 { duesAging, moduleRevenue, clientProfitability } from '../src/repos-reports'
function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: string) {
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', {
async function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: string) {
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', {
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,
// 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
}
function setup() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18
const c = createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const pos = 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' })
assignModule(db, 'u1', { clientId: c.id, moduleId: pos.id, kind: 'yearly' })
async function setup() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const pos = await createModule(db, 'u1', { code: 'POS', name: 'POS', allowedKinds: ['yearly'] })
await setPrice(db, 'u1', { moduleId: pos.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
await assignModule(db, 'u1', { clientId: c.id, moduleId: pos.id, kind: 'yearly' })
return { db, c, pos }
}
describe('duesAging', () => {
it('buckets each client outstanding by invoice age', () => {
const { db, c, pos } = setup()
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
issuedInvoice(db, c.id, pos.id, '2026-01-01') // >90 days → 90+
const rows = duesAging(db, '2026-07-10')
it('buckets each client outstanding by invoice age', async () => {
const { db, c, pos } = await setup()
await 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-05-20') // ~51 days → 31-60
await issuedInvoice(db, c.id, pos.id, '2026-01-01') // >90 days → 90+
const rows = await duesAging(db, '2026-07-10')
expect(rows).toHaveLength(1)
const r = rows[0]!
expect(r.clientId).toBe(c.id)
@ -42,40 +42,40 @@ describe('duesAging', () => {
expect(r.b90p).toBe(11_800_00)
expect(r.totalPaise).toBe(35_400_00)
})
it('drops fully-settled invoices', () => {
const { db, c, pos } = setup()
const inv = 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 }] })
expect(duesAging(db, '2026-07-10')).toHaveLength(0)
it('drops fully-settled invoices', async () => {
const { db, c, pos } = await setup()
const inv = await issuedInvoice(db, c.id, pos.id, '2026-07-01')
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(await duesAging(db, '2026-07-10')).toHaveLength(0)
})
})
describe('moduleRevenue', () => {
it('reports billed and settled per module, settlement split pro-rata', () => {
const { db, c, pos } = setup()
const inv = 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 }] })
const rows = moduleRevenue(db)
it('reports billed and settled per module, settlement split pro-rata', async () => {
const { db, c, pos } = await setup()
const inv = await issuedInvoice(db, c.id, pos.id, '2026-07-01')
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 = await moduleRevenue(db)
expect(rows).toHaveLength(1)
expect(rows[0]!.moduleCode).toBe('POS')
expect(rows[0]!.billedPaise).toBe(11_800_00)
expect(rows[0]!.settledPaise).toBe(5_900_00)
})
it('honours a date range', () => {
const { db, c, pos } = setup()
issuedInvoice(db, c.id, pos.id, '2026-04-01')
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)
it('honours a date range', async () => {
const { db, c, pos } = await setup()
await issuedInvoice(db, c.id, pos.id, '2026-04-01')
await issuedInvoice(db, c.id, pos.id, '2026-07-01')
expect((await moduleRevenue(db, { from: '2026-07-01', to: '2026-07-31' }))[0]!.billedPaise).toBe(11_800_00)
})
})
describe('clientProfitability', () => {
it('reports billed, settled, aws cost and margin per client', () => {
const { db, c, pos } = setup()
const inv = 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 }] })
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' })
it('reports billed, settled, aws cost and margin per client', async () => {
const { db, c, pos } = await setup()
const inv = await issuedInvoice(db, c.id, pos.id, '2026-07-01')
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 }] })
await upsertAwsUsage(db, 'u1', { clientId: c.id, month: '2026-07', costPaise: 1_000_00 })
const rows = await clientProfitability(db, { from: '2026-07-01', to: '2026-07-31' })
expect(rows).toHaveLength(1)
expect(rows[0]!).toMatchObject({
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' }
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
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 m = 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' })
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('rt', KEY))
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'CLOUD', name: 'Cloud', allowedKinds: ['monthly'] })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'monthly', pricePaise: 2_000_00, effectiveFrom: '2026-01-01' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'monthly' })
return { db, c, m, cm }
}
describe('runDailyScan — recurring generation', () => {
it('auto plan: generates+issues an invoice, advances next_run one cadence, sends, marks sent', async () => {
const { db, c, cm } = world()
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' })
const { db, c, cm } = await world()
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')
expect(res.created['recurring_generated']).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[0]!.docNo).toMatch(/^INV\//)
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10') // advanced exactly one month
const rem = listReminders(db, { ruleKind: 'recurring_generated' })[0]!
expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') // advanced exactly one month
const rem = (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]!
expect(rem.status).toBe('sent')
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 () => {
const { db, c, cm } = world()
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'auto' })
const { db, c, cm } = await world()
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')
expect(res.created['recurring_generated']).toBe(1)
expect(res.autoFailed).toBe(1)
expect(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
const rem = listReminders(db, { ruleKind: 'recurring_generated' })[0]!
expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(1) // invoice still issued
expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10') // schedule advanced on generation
const rem = (await listReminders(db, { ruleKind: 'recurring_generated' }))[0]!
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.
const again = await runDailyScan(db, deps(deadFetch), '2026-07-10')
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 () => {
const { db, c, cm } = world()
createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-07-10', policy: 'manual' })
const { db, c, cm } = await world()
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')
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 () => {
const { db, c, cm } = world()
const plan = createRecurringPlan(db, 'u1', { clientId: c.id, clientModuleId: cm.id, cadence: 'monthly', nextRun: '2026-05-10', policy: 'manual' })
const { db, c, cm } = await world()
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')
expect(res.created['recurring_generated']).toBe(3) // May, Jun, Jul
expect(listDocuments(db, { clientId: c.id, type: 'INVOICE' }).documents).toHaveLength(3)
expect(getRecurringPlan(db, plan.id)!.nextRun).toBe('2026-08-10')
const periods = listReminders(db, { ruleKind: 'recurring_generated' }).map((r) => r.duePeriod).sort()
expect((await listDocuments(db, { clientId: c.id, type: 'INVOICE' })).documents).toHaveLength(3)
expect((await getRecurringPlan(db, plan.id))!.nextRun).toBe('2026-08-10')
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'])
})
})

@ -18,100 +18,101 @@ const deps: ScanDeps = {
now: () => '2026-07-10T00:00:00Z',
}
function world() {
const db = openDb(':memory:'); seedIfEmpty(db)
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
async function world() {
const db = openDb(':memory:'); await seedIfEmpty(db)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'R', email: 'r@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-01-01' })
return { db, c, m }
}
describe('runDailyScan — detection rules', () => {
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.
const inv = issueDocument(db, 'u1', 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)
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
await db.run(`UPDATE document SET doc_date='2026-06-01', due_date=NULL WHERE id=?`, inv.id)
// Renewal within 15 days.
const cm = assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
const cm = await assignModule(db, 'u1', { clientId: c.id, moduleId: m.id, kind: 'yearly' })
await updateClientModule(db, 'u1', cm.id, { nextRenewal: '2026-07-20' })
// 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.
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')
expect(res.created['invoice_overdue']).toBe(1)
expect(res.created['renewal_due']).toBe(1)
expect(res.created['amc_expiring']).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.
const again = await runDailyScan(db, deps, '2026-07-10')
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 () => {
const { db, c, m } = world()
const inv = issueDocument(db, 'u1', 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
const { db, c, m } = await world()
const inv = await issueDocument(db, 'u1', (await createDraft(db, 'u1', { docType: 'INVOICE', clientId: c.id, lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })).id)
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')
expect(res.created['invoice_overdue'] ?? 0).toBe(0)
})
})
describe('invoice_overdue — dN milestone escalation (Phase 9, spec §8)', () => {
function agedInvoice(docDate: string) {
const { db, c, m } = world()
const inv = issueDocument(db, 'u1', createDraft(db, 'u1', {
async function agedInvoice(docDate: string) {
const { db, c, m } = await world()
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=?, due_date=NULL WHERE id=?`).run(docDate, inv.id)
})).id)
await db.run(`UPDATE document SET doc_date=?, due_date=NULL WHERE id=?`, docDate, inv.id)
return { db, c, inv }
}
const invoiceRows = (db: ReturnType<typeof openDb>) =>
listReminders(db, {}).filter((r) => r.ruleKind === 'invoice_overdue')
const invoiceRows = async (db: ReturnType<typeof openDb>) =>
(await listReminders(db, {})).filter((r) => r.ruleKind === 'invoice_overdue')
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-10') // age 9 → d7 already consumed
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-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 () => {
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
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 () => {
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.
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
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
})
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
db.prepare(
const { db } = await agedInvoice('2026-07-07') // age 3 on the 10th — below the 7/15/30 default
await db.run(
`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')
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 () => {
const { db, c, inv } = 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 { ctx } = reminderContext(db, getReminder(db, id)!, 'Tecnostac', '2026-07-10')
const { db, c, inv } = await agedInvoice('2026-06-25') // 15 days before the 10th
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 } = await reminderContext(db, (await getReminder(db, id))!, 'Tecnostac', '2026-07-10')
expect(ctx.daysOverdue).toBe(15)
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' }
}
function invoiceSetup() {
const db = openDb(':memory:'); seedIfEmpty(db)
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 m = createModule(db, 'u1', { code: 'POS', name: 'POS' })
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)
async function invoiceSetup() {
const db = openDb(':memory:'); await seedIfEmpty(db)
await saveAccount(db, 'us@tecnostac.com', encrypt('refresh-token', KEY))
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32', contacts: [{ name: 'Ravi', email: 'ravi@acme.in' }] })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
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 }
}
describe('sendReminder', () => {
it('sends an overdue reminder with the invoice PDF and marks it sent', async () => {
const { db, c, inv } = 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 { db, c, inv } = await invoiceSetup()
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) =>
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')
expect(out).toEqual({ ok: true })
expect(getReminder(db, id)!.status).toBe('sent')
expect(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 }
expect((await getReminder(db, id))!.status).toBe('sent')
expect((await getReminder(db, id))!.sentAt).toBe('2026-07-10T09:00:00Z')
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 })
})
it('leaves the reminder failed (not sent) on token death, and flips the account dead', async () => {
const { db, c, inv } = 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 { db, c, inv } = await invoiceSetup()
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 out = await sendReminder(db, deps(deadFetch), id, 'u1')
expect(out).toEqual({ ok: false, error: 'gmail-token-dead' })
expect(getReminder(db, id)!.status).toBe('failed')
expect(db.prepare(`SELECT status FROM email_account`).get()).toMatchObject({ status: 'dead' })
expect((await getReminder(db, id))!.status).toBe('failed')
expect(await db.get(`SELECT status FROM email_account`)).toMatchObject({ status: 'dead' })
})
it('refuses to email an internal follow_up reminder', async () => {
const { db, c } = invoiceSetup()
const { id } = upsertReminder(db, { ruleKind: 'follow_up', subjectId: 'i1', duePeriod: '2026-07-08', clientId: c.id, now: '2026-07-10T00:00:00Z' })
const { db, c } = await invoiceSetup()
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
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'
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:')
expect(nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0001')
expect(nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0002')
expect(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', '2026-27')).toBe('QT/26-27-0001')
expect(await nextDocNo(db, 'QUOTATION', '2026-27')).toBe('QT/26-27-0002')
expect(await nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-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:')
seedSeries(db, 'INVOICE', '2026-27', 412)
expect(nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0413')
await seedSeries(db, 'INVOICE', '2026-27', 412)
expect(await nextDocNo(db, 'INVOICE', '2026-27')).toBe('INV/26-27-0413')
})
})

@ -1,15 +1,15 @@
// 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 { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
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' })
async function appWith() {
const db = openDb(':memory:'); 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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -17,12 +17,13 @@ function appWith() {
}
describe('company profile settings', () => {
const { db, server, base } = appWith()
afterAll(() => server.close())
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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 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 }
}
@ -34,7 +35,7 @@ describe('company profile settings', () => {
expect(put.status).toBe(200)
expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd')
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)
})
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
import { describe, expect, it, afterAll } from 'vitest'
import { describe, expect, it, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -7,56 +7,57 @@ import { createStaff } from '../src/auth'
import { apiRouter } from '../src/api'
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', () => {
it('lists the two seeded open-ended rows', () => {
const rows = listSchedules(fresh())
it('lists the two seeded open-ended rows', async () => {
const rows = await listSchedules(await fresh())
expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup'])
})
it('insertSchedule appends a dated row that resolveSchedule picks from its date', () => {
const db = fresh()
const row = insertSchedule(db, 'u1', {
it('insertSchedule appends a dated row that resolveSchedule picks from its date', async () => {
const db = await fresh()
const row = await insertSchedule(db, 'u1', {
ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ',
})
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(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 resolveSchedule(db, 'quote_followup', '2026-08-02')).dayOffsets).toEqual([2, 5, 9])
expect((await resolveSchedule(db, 'quote_followup', '2026-07-20')).dayOffsets).toEqual([3, 7, 14])
expect(await listSchedules(db)).toHaveLength(3) // append-only: old row still there
})
it('rejects bad input', () => {
const db = fresh()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow()
it('rejects bad input', async () => {
const db = await fresh()
await expect(insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).rejects.toThrow()
await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).rejects.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'
// 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/)
expect(listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt
await expect(insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: '3,7,I4' })).rejects.toThrow(/Invalid day offset/)
expect(await listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt
})
it('audits the insert', () => {
const db = fresh()
const row = 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)
it('audits the insert', async () => {
const db = await fresh()
const row = await insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' })
const audit = await db.get(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`, row.id)
expect(audit).toBeDefined()
})
})
describe('settings reminders routes', () => {
const appWith = () => {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const appWith = async () => {
const db = openDb(':memory:'); await seedIfEmpty(db)
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 server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, base }
}
const { db, server, base } = appWith()
afterAll(() => server.close())
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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 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 }
}
@ -82,9 +83,9 @@ describe('settings reminders routes', () => {
expect(after.json.overdueDays).toBe(9)
expect(after.json.renewalDays).toBe(21)
// 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')`,
).all() as { entity_id: string }[]
)
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 { mintShare } from '../src/repos-shares'
function setup() {
async function setup() {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
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,
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
return { db, c, m, doc }
}
describe('mintShare default expiry (share.default_expiry_days setting)', () => {
it('no setting row: defaults to +30d', () => {
const { db, doc } = setup()
const s = mintShare(db, 'u1', doc.id)
it('no setting row: defaults to +30d', async () => {
const { db, doc } = await setup()
const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).not.toBeNull()
const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(29)
expect(days).toBeLessThan(31)
})
it("setting '7': defaults to +7d", () => {
const { db, doc } = setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`).run()
const s = mintShare(db, 'u1', doc.id)
it("setting '7': defaults to +7d", async () => {
const { db, doc } = await setup()
await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`)
const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).not.toBeNull()
const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(6)
expect(days).toBeLessThan(8)
})
it("setting 'never': expiresAt is null", () => {
const { db, doc } = setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'never')`).run()
const s = mintShare(db, 'u1', doc.id)
it("setting 'never': expiresAt is null", async () => {
const { db, doc } = await setup()
await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'never')`)
const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).toBeNull()
})
it('explicit opts.expiresDays always wins over the setting (including explicit null)', () => {
const { db, doc } = setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`).run()
const s1 = mintShare(db, 'u1', doc.id, { expiresDays: 3 })
it('explicit opts.expiresDays always wins over the setting (including explicit null)', async () => {
const { db, doc } = await setup()
await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`)
const s1 = await mintShare(db, 'u1', doc.id, { expiresDays: 3 })
expect(s1.expiresAt).not.toBeNull()
const days1 = (new Date(s1.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days1).toBeGreaterThan(2)
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()
})
it("garbage setting 'abc': falls back to +30d", () => {
const { db, doc } = setup()
db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'abc')`).run()
const s = mintShare(db, 'u1', doc.id)
it("garbage setting 'abc': falls back to +30d", async () => {
const { db, doc } = await setup()
await db.run(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'abc')`)
const s = await mintShare(db, 'u1', doc.id)
expect(s.expiresAt).not.toBeNull()
const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000
expect(days).toBeGreaterThan(29)

@ -1,5 +1,5 @@
// 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 { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
@ -7,10 +7,10 @@ import { createStaff } from '../src/auth'
import { getSetting } from '../src/repos-reminders'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
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' })
async function appWith() {
const db = openDb(':memory:'); 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({ limit: '2mb' })); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
@ -18,12 +18,13 @@ function appWith() {
}
describe('template settings', () => {
const { db, server, base } = appWith()
afterAll(() => server.close())
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
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 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 }
}
@ -36,10 +37,10 @@ describe('template settings', () => {
titles: { INVOICE: 'GST INVOICE' },
})
expect(put.status).toBe(200)
expect(getSetting(db, 'template.footer_note')).toBe('Thank you!')
expect(getSetting(db, 'template.signatory_label')).toBe('Proprietor')
expect(getSetting(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 }
expect(await getSetting(ctx.db, 'template.footer_note')).toBe('Thank you!')
expect(await getSetting(ctx.db, 'template.signatory_label')).toBe('Proprietor')
expect(await getSetting(ctx.db, 'template.title_INVOICE')).toBe('GST INVOICE')
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)
})
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 tiny = 'data:image/png;base64,' + 'A'.repeat(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)
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)
@ -61,6 +62,6 @@ describe('template settings', () => {
expect((await call(staff, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(403)
// clearing is allowed
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
import { describe, it, expect, afterAll } from 'vitest'
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
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 { apiRouter } from '../src/api'
function setup() {
async function setup() {
const db = openDb(':memory:')
db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run()
db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run()
const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
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,
await db.run(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`)
await db.run(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`)
const c = await createClient(db, 'u1', { name: 'Acme', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] })
return { db, c, m, doc }
}
describe('repos-shares (mint / validate / revoke / list)', () => {
it('mintShare produces a 64-hex token, unique per mint, with a +30d default expiry', () => {
const { db, doc } = setup()
const s1 = mintShare(db, 'u1', doc.id)
const s2 = mintShare(db, 'u1', doc.id)
it('mintShare produces a 64-hex token, unique per mint, with a +30d default expiry', async () => {
const { db, doc } = await setup()
const s1 = await mintShare(db, 'u1', doc.id)
const s2 = await mintShare(db, 'u1', doc.id)
expect(s1.token).toMatch(/^[0-9a-f]{64}$/)
expect(s2.token).toMatch(/^[0-9a-f]{64}$/)
expect(s1.token).not.toBe(s2.token) // unique
@ -38,12 +38,12 @@ describe('repos-shares (mint / validate / revoke / list)', () => {
expect(days).toBeLessThan(31)
})
it('mintShare writes an audit row that NEVER contains the token (secrets never logged)', () => {
const { db, doc } = setup()
const s = mintShare(db, 'u1', doc.id)
const rows = db.prepare(
it('mintShare writes an audit row that NEVER contains the token (secrets never logged)', async () => {
const { db, doc } = await setup()
const s = await mintShare(db, 'u1', doc.id)
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'`,
).all() as { user_id: string; entity_id: string; before_json: string | null; after_json: string | null }[]
)
expect(rows.length).toBe(1)
expect(rows[0]!.user_id).toBe('u1')
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)
})
it('mintShare throws for an unknown document', () => {
const { db } = setup()
expect(() => mintShare(db, 'u1', 'nope')).toThrow(/not found/i)
it('mintShare throws for an unknown document', async () => {
const { db } = await setup()
await expect(mintShare(db, 'u1', 'nope')).rejects.toThrow(/not found/i)
})
it('validateShare returns the one document for a live token', () => {
const { db, doc } = setup()
const s = mintShare(db, 'u1', doc.id)
const out = validateShare(db, s.token)
it('validateShare returns the one document for a live token', async () => {
const { db, doc } = await setup()
const s = await mintShare(db, 'u1', doc.id)
const out = await validateShare(db, s.token)
expect(out).not.toBeNull()
expect(out!.id).toBe(doc.id)
expect(out!.payablePaise).toBe(11_800_00)
})
it('validateShare returns null for an unknown token', () => {
const { db } = setup()
expect(validateShare(db, 'deadbeef')).toBeNull()
it('validateShare returns null for an unknown token', async () => {
const { db } = await setup()
expect(await validateShare(db, 'deadbeef')).toBeNull()
})
it('validateShare returns null for an expired token', () => {
const { db, doc } = setup()
const s = mintShare(db, 'u1', doc.id, { expiresDays: -1 }) // already expired
it('validateShare returns null for an expired token', async () => {
const { db, doc } = await setup()
const s = await mintShare(db, 'u1', doc.id, { expiresDays: -1 }) // already expired
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', () => {
const { db, doc } = setup()
const s = mintShare(db, 'u1', doc.id)
revokeShare(db, 'u1', s.id)
expect(validateShare(db, s.token)).toBeNull()
it('validateShare returns null for a revoked token', async () => {
const { db, doc } = await setup()
const s = await mintShare(db, 'u1', doc.id)
await revokeShare(db, 'u1', s.id)
expect(await validateShare(db, s.token)).toBeNull()
})
it('revokeShare flips revoked, writes an audit row, and is idempotent-safe', () => {
const { db, doc } = setup()
const s = mintShare(db, 'u1', doc.id)
const after = revokeShare(db, 'u1', s.id)
it('revokeShare flips revoked, writes an audit row, and is idempotent-safe', async () => {
const { db, doc } = await setup()
const s = await mintShare(db, 'u1', doc.id)
const after = await revokeShare(db, 'u1', s.id)
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'`,
).all() as unknown[]
)
expect(rows.length).toBe(1)
})
it('revokeShare throws for an unknown share', () => {
const { db } = setup()
expect(() => revokeShare(db, 'u1', 'nope')).toThrow(/not found/i)
it('revokeShare throws for an unknown share', async () => {
const { db } = await setup()
await expect(revokeShare(db, 'u1', 'nope')).rejects.toThrow(/not found/i)
})
it('listShares returns every share for the document (scoped to that doc)', () => {
const { db, doc, m, c } = setup()
const s1 = mintShare(db, 'u1', doc.id)
const s2 = mintShare(db, 'u1', doc.id)
it('listShares returns every share for the document (scoped to that doc)', async () => {
const { db, doc, m, c } = await setup()
const s1 = await 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.
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' }] })
mintShare(db, 'u1', other.id)
const list = listShares(db, doc.id)
await mintShare(db, 'u1', other.id)
const list = await listShares(db, doc.id)
expect(list.length).toBe(2)
expect(new Set(list.map((s) => s.id))).toEqual(new Set([s1.id, s2.id]))
})
})
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db) // company.state_code=32, GST18
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 m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
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,
async function appWith() {
const db = openDb(':memory:'); await seedIfEmpty(db) // company.state_code=32, GST18
await createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
const c = await createClient(db, 'u1', { name: 'Acme', code: 'ACME', stateCode: '32' })
const m = await createModule(db, 'u1', { code: 'POS', name: 'POS Billing' })
await setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' })
const doc = await createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id,
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 server = app.listen(0)
@ -129,7 +129,8 @@ function appWith() {
}
describe('share routes (requireAuth)', () => {
const ctx = appWith()
let ctx: Awaited<ReturnType<typeof appWith>>
beforeAll(async () => { ctx = await appWith() })
afterAll(() => ctx.server.close())
const login = async () =>
(await (await fetch(`${ctx.base}/auth/login`, {
@ -172,6 +173,6 @@ describe('share routes (requireAuth)', () => {
expect(out.status).toBe(200)
expect(out.json.ok).toBe(true)
// 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