diff --git a/apps/hq/scripts/demo-seed.ts b/apps/hq/scripts/demo-seed.ts index bcec437..9998471 100644 --- a/apps/hq/scripts/demo-seed.ts +++ b/apps/hq/scripts/demo-seed.ts @@ -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 and puppeteer). -const LOGO = 'data:image/svg+xml;base64,' + Buffer.from( - `TS`, -).toString('base64') + // A simple monogram logo as an SVG data URI (renders in and puppeteer). + const LOGO = 'data:image/svg+xml;base64,' + Buffer.from( + `TS`, + ).toString('base64') -// Company + template letterhead data (what the Document Template page edits). -const settings: Record = { - '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 = { + '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) +})() diff --git a/apps/hq/scripts/gmail-connect.ts b/apps/hq/scripts/gmail-connect.ts index f41be55..169190e 100644 --- a/apps/hq/scripts/gmail-connect.ts +++ b/apps/hq/scripts/gmail-connect.ts @@ -105,7 +105,7 @@ async function main(): Promise { 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.`) } diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 4691c6e..5207bd4 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -67,11 +67,11 @@ export function apiRouter( renderPdfImpl: (html: string) => Promise = renderPdf, ): Router { const r = Router() - const companySettings = (): Record => { + const companySettings = async (): Promise> => { // The full letterhead settings map documentHtml reads — company.* identity + template.* text. - const rows = db.prepare( + const rows = await db.all<{ key: string; value: string }>( `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`, - ).all() as { key: string; value: string }[] + ) return Object.fromEntries(rows.map((row) => [row.key, row.value])) } // Shared letterhead field maps — the one definition every route (company profile, @@ -95,26 +95,34 @@ export function apiRouter( r.get('/health', (_req, res) => { res.json({ ok: true, service: 'sims-hq', version: '0.1.0' }) }) - r.post('/auth/login', (req, res) => { - const { email, password } = req.body as { email?: string; password?: string } - const out = typeof email === 'string' && typeof password === 'string' ? login(db, email, password) : null - if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return } - res.json({ ok: true, ...out }) + r.post('/auth/login', async (req, res) => { + try { + const { email, password } = req.body as { email?: string; password?: string } + const out = typeof email === 'string' && typeof password === 'string' ? await login(db, email, password) : null + if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return } + res.json({ ok: true, ...out }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- employees (staff_user; D16) ---------- // GET is open to any signed-in user — the pipeline/owner pickers need names. // Console users are a small bounded set: returned whole, `total` echoes the // full count so the UI can display it (no silent truncation). - r.get('/employees', requireAuth, (_req, res) => { - const employees = listEmployees(db) - res.json({ ok: true, employees, total: employees.length }) + r.get('/employees', requireAuth, async (_req, res) => { + try { + const employees = await listEmployees(db) + res.json({ ok: true, employees, total: employees.length }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // Mutations are owner-only; the signed-in user is the audited actor. - r.post('/employees', requireAuth, requireOwner, (req, res) => { + r.post('/employees', requireAuth, requireOwner, async (req, res) => { try { const body = req.body as { email?: unknown; displayName?: unknown; role?: unknown; password?: unknown } - const employee = createEmployee(db, staffId(res), { + const employee = await createEmployee(db, staffId(res), { email: typeof body.email === 'string' ? body.email : '', displayName: typeof body.displayName === 'string' ? body.displayName : '', role: (typeof body.role === 'string' ? body.role : '') as EmployeeRole, // repo validates the enum @@ -125,14 +133,14 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - const employeeAction = (fn: (id: string, res: Response, req: Request) => unknown): RequestHandler => - (req, res) => { + const employeeAction = (fn: (id: string, res: Response, req: Request) => unknown | Promise): RequestHandler => + async (req, res) => { const id = String(req.params['id'] ?? '') - if (getEmployee(db, id) === null) { + if ((await getEmployee(db, id)) === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return } try { - res.json({ ok: true, employee: fn(id, res, req) }) + res.json({ ok: true, employee: await fn(id, res, req) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } @@ -150,27 +158,31 @@ export function apiRouter( // ---------- self-service profile (D18 WS-E) ---------- // /me is strictly self-scoped: the id comes from the session, never the request, // and the whitelist below cannot touch role, email or active. - r.get('/me', requireAuth, (_req, res) => { - const me = getEmployee(db, staffId(res)) - if (me === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return } - res.json({ ok: true, employee: me }) + r.get('/me', requireAuth, async (_req, res) => { + try { + const me = await getEmployee(db, staffId(res)) + if (me === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return } + res.json({ ok: true, employee: me }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.patch('/me', requireAuth, (req, res) => { + r.patch('/me', requireAuth, async (req, res) => { try { const body = req.body as { displayName?: unknown; phone?: unknown } const patch: { displayName?: string; phone?: string } = {} if (typeof body.displayName === 'string') patch.displayName = body.displayName if (typeof body.phone === 'string') patch.phone = body.phone - res.json({ ok: true, employee: updateEmployee(db, staffId(res), staffId(res), patch) }) + res.json({ ok: true, employee: await updateEmployee(db, staffId(res), staffId(res), patch) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/me/password', requireAuth, (req, res) => { + r.post('/me/password', requireAuth, async (req, res) => { try { const body = req.body as { currentPassword?: unknown; newPassword?: unknown } const token = (req.headers.authorization ?? '').replace(/^Bearer /, '') - changeOwnPassword( + await changeOwnPassword( db, staffId(res), typeof body.currentPassword === 'string' ? body.currentPassword : '', typeof body.newPassword === 'string' ? body.newPassword : '', @@ -185,53 +197,61 @@ export function apiRouter( deactivateEmployee(db, staffId(res), id))) r.post('/employees/:id/reactivate', requireAuth, requireOwner, employeeAction((id, res) => reactivateEmployee(db, staffId(res), id))) - r.post('/employees/:id/password', requireAuth, requireOwner, employeeAction((id, res, req) => { + r.post('/employees/:id/password', requireAuth, requireOwner, employeeAction(async (id, res, req) => { const { password } = req.body as { password?: unknown } - setEmployeePassword(db, staffId(res), id, typeof password === 'string' ? password : '') + await setEmployeePassword(db, staffId(res), id, typeof password === 'string' ? password : '') return getEmployee(db, id) })) // ---------- clients ---------- - r.get('/clients', requireAuth, (req, res) => { - const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined - const filters = { - ...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}), - ...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}), + r.get('/clients', requireAuth, async (req, res) => { + try { + const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined + const filters = { + ...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}), + ...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}), + } + res.json({ ok: true, clients: await listClients(db, q, filters) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - res.json({ ok: true, clients: listClients(db, q, filters) }) }) - r.post('/clients', requireAuth, (req, res) => { + r.post('/clients', requireAuth, async (req, res) => { try { - const client = createClient(db, staffId(res), req.body as ClientInput) + const client = await createClient(db, staffId(res), req.body as ClientInput) res.json({ ok: true, client }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // D18 WS-F: decrypt-and-return the support DB password — every reveal audited. - r.post('/clients/:id/reveal-db-password', requireAuth, (req, res) => { + r.post('/clients/:id/reveal-db-password', requireAuth, async (req, res) => { const viewer = res.locals['staff'] as { id: string; role: string } if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return } const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { - res.json({ ok: true, password: revealClientDbPassword(db, viewer.id, id, gmail().keyHex) }) + res.json({ ok: true, password: await revealClientDbPassword(db, viewer.id, id, gmail().keyHex) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.get('/clients/:id', requireAuth, (req, res) => { - const client = getClient(db, String(req.params['id'] ?? '')) - if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - res.json({ ok: true, client }) + r.get('/clients/:id', requireAuth, async (req, res) => { + try { + const client = await getClient(db, String(req.params['id'] ?? '')) + if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, client }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.patch('/clients/:id', requireAuth, (req, res) => { + r.patch('/clients/:id', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { @@ -247,14 +267,14 @@ export function apiRouter( } // One transaction: a bad field elsewhere in the patch must not leave a // half-applied (already audited) password change behind. - const client = db.transaction(() => { + const client = await db.transaction(async () => { if (typeof dbPassword === 'string') { - setClientDbPassword(db, staffId(res), id, dbPassword, gmail().keyHex) + await setClientDbPassword(db, staffId(res), id, dbPassword, gmail().keyHex) } return Object.keys(rest).length > 0 - ? updateClient(db, staffId(res), id, rest) - : getClient(db, id)! - })() + ? await updateClient(db, staffId(res), id, rest) + : (await getClient(db, id))! + }) res.json({ ok: true, client }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -263,13 +283,13 @@ export function apiRouter( // Account-owner routing — owner/manager only (inline check per spec: no requireManager // middleware). Separate from the staff-open generic PATCH above so staff cannot set or // widen ownership; the repo audits the write as its own 'set_owner' action. - r.patch('/clients/:id/owner', requireAuth, (req, res) => { + r.patch('/clients/:id/owner', requireAuth, async (req, res) => { const viewer = res.locals['staff'] as { id: string; role: string } if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return } const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { @@ -277,7 +297,7 @@ export function apiRouter( if (raw !== null && typeof raw !== 'string') { throw new Error('ownerId is required — an employee id, or null to clear') } - const client = setClientOwner(db, viewer.id, id, raw === null || raw === '' ? null : raw) + const client = await setClientOwner(db, viewer.id, id, raw === null || raw === '' ? null : raw) res.json({ ok: true, client }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -288,177 +308,205 @@ export function apiRouter( // Role gating lives inside the query: staff are server-forced to their own rows // (viewer identity comes from res.locals.staff, never the request), owner/manager // may narrow via ?owner= or filter=mine. Paginated with an honest total (rule 8). - r.get('/pipeline', requireAuth, (req, res) => { - const viewer = res.locals['staff'] as { id: string; role: string } - const rawFilter = typeof req.query['filter'] === 'string' ? req.query['filter'] : 'all' - if (!(PIPELINE_FILTERS as string[]).includes(rawFilter)) { - res.status(400).json({ ok: false, error: `filter must be one of: ${PIPELINE_FILTERS.join(', ')}` }) - return - } - const opts: ListPipelineOpts = { - filter: rawFilter as PipelineFilter, viewerRole: viewer.role, viewerId: viewer.id, + r.get('/pipeline', requireAuth, async (req, res) => { + try { + const viewer = res.locals['staff'] as { id: string; role: string } + const rawFilter = typeof req.query['filter'] === 'string' ? req.query['filter'] : 'all' + if (!(PIPELINE_FILTERS as string[]).includes(rawFilter)) { + res.status(400).json({ ok: false, error: `filter must be one of: ${PIPELINE_FILTERS.join(', ')}` }) + return + } + const opts: ListPipelineOpts = { + filter: rawFilter as PipelineFilter, viewerRole: viewer.role, viewerId: viewer.id, + } + if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner'] + const page = Number(req.query['page']) + if (Number.isInteger(page) && page >= 1) opts.page = page + const pageSize = Number(req.query['pageSize']) + if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize + res.json({ ok: true, ...await listPipeline(db, opts) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner'] - const page = Number(req.query['page']) - if (Number.isInteger(page) && page >= 1) opts.page = page - const pageSize = Number(req.query['pageSize']) - if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize - res.json({ ok: true, ...listPipeline(db, opts) }) }) // ---------- modules ---------- - r.get('/modules', requireAuth, (_req, res) => { - res.json({ ok: true, modules: listModules(db) }) + r.get('/modules', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, modules: await listModules(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/modules', requireAuth, requireOwner, (req, res) => { + r.post('/modules', requireAuth, requireOwner, async (req, res) => { try { - const mod = createModule(db, staffId(res), req.body as ModuleInput) + const mod = await createModule(db, staffId(res), req.body as ModuleInput) res.json({ ok: true, module: mod }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.patch('/modules/:id', requireAuth, requireOwner, (req, res) => { + r.patch('/modules/:id', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getModule(db, id) === null) { + if ((await getModule(db, id)) === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return } try { - const mod = updateModule(db, staffId(res), id, req.body as ModulePatch) + const mod = await updateModule(db, staffId(res), id, req.body as ModulePatch) res.json({ ok: true, module: mod }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.get('/modules/:id/prices', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getModule(db, id) === null) { - res.status(404).json({ ok: false, error: 'Module not found' }); return + r.get('/modules/:id/prices', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Module not found' }); return + } + res.json({ ok: true, prices: await listPrices(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - res.json({ ok: true, prices: listPrices(db, id) }) }) - r.post('/modules/:id/prices', requireAuth, requireOwner, (req, res) => { + r.post('/modules/:id/prices', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getModule(db, id) === null) { + if ((await getModule(db, id)) === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return } try { - setPrice(db, staffId(res), { ...(req.body as Omit), moduleId: id }) - res.json({ ok: true, prices: listPrices(db, id) }) + await setPrice(db, staffId(res), { ...(req.body as Omit), moduleId: id }) + res.json({ ok: true, prices: await listPrices(db, id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- module → client roster (spec §10, D-ROSTER) ---------- - r.get('/modules/:id/clients', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getModule(db, id) === null) { - res.status(404).json({ ok: false, error: 'Module not found' }); return + r.get('/modules/:id/clients', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getModule(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Module not found' }); return + } + const num = (v: unknown): number | undefined => + typeof v === 'string' && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined + const page = num(req.query['page']) + const pageSize = num(req.query['pageSize']) + res.json({ + ok: true, + ...await listClientsByModule(db, id, { + ...(page !== undefined ? { page } : {}), ...(pageSize !== undefined ? { pageSize } : {}), + }), + }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - const num = (v: unknown): number | undefined => - typeof v === 'string' && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined - const page = num(req.query['page']) - const pageSize = num(req.query['pageSize']) - res.json({ - ok: true, - ...listClientsByModule(db, id, { - ...(page !== undefined ? { page } : {}), ...(pageSize !== undefined ? { pageSize } : {}), - }), - }) }) // CSV export streams EVERY row — an export never silently caps (rule 8). - r.get('/modules/:id/clients.csv', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - const mod = getModule(db, id) - if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return } - const esc = (v: string): string => /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v - const lines = ['client_code,client_name,status,kind,edition,price_paise,next_renewal'] - for (let page = 1; ; page++) { - const batch = listClientsByModule(db, id, { page, pageSize: 200 }) - for (const c of batch.clients) { - lines.push([ - esc(c.clientCode), esc(c.clientName), c.status, c.kind, esc(c.edition), - c.pricePaise === null ? '' : String(c.pricePaise), c.nextRenewal ?? '', - ].join(',')) + r.get('/modules/:id/clients.csv', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + const mod = await getModule(db, id) + if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return } + const esc = (v: string): string => /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v + const lines = ['client_code,client_name,status,kind,edition,price_paise,next_renewal'] + for (let page = 1; ; page++) { + const batch = await listClientsByModule(db, id, { page, pageSize: 200 }) + for (const c of batch.clients) { + lines.push([ + esc(c.clientCode), esc(c.clientName), c.status, c.kind, esc(c.edition), + c.pricePaise === null ? '' : String(c.pricePaise), c.nextRenewal ?? '', + ].join(',')) + } + if (page * batch.pageSize >= batch.total) break } - if (page * batch.pageSize >= batch.total) break + res.setHeader('Content-Type', 'text/csv; charset=utf-8') + res.setHeader('Content-Disposition', `attachment; filename="module-${mod.code}-clients.csv"`) + res.send(lines.join('\n') + '\n') + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - res.setHeader('Content-Type', 'text/csv; charset=utf-8') - res.setHeader('Content-Disposition', `attachment; filename="module-${mod.code}-clients.csv"`) - res.send(lines.join('\n') + '\n') }) // Notify every client on the module (owner/manager — bulk client email is privileged, A6). // Ad-hoc human broadcast (A5): subject/body typed by the sender, not dated config. - r.post('/modules/:id/notify', requireAuth, (req, res) => { - const viewer = res.locals['staff'] as { id: string; role: string } - if (!isManagerial(viewer.role)) { - res.status(403).json({ ok: false, error: 'Owner or manager only' }); return - } - const id = String(req.params['id'] ?? '') - const mod = getModule(db, id) - if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return } - const body = req.body as { subject?: unknown; body?: unknown } - const subject = typeof body.subject === 'string' ? body.subject.trim() : '' - const bodyText = typeof body.body === 'string' ? body.body.trim() : '' - if (subject === '' || bodyText === '') { - res.status(400).json({ ok: false, error: 'subject and body are required' }); return - } - const status = emailStatus(db) - if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } - if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } - // Resolve the FULL roster (internally paginated) before sending anything. - const targets: { clientId: string; clientName: string; email: string | undefined }[] = [] - for (let page = 1; ; page++) { - const batch = listClientsByModule(db, id, { page, pageSize: 200 }) - for (const c of batch.clients) { - const client = getClient(db, c.clientId) - targets.push({ - clientId: c.clientId, clientName: c.clientName, - email: client?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email, - }) + r.post('/modules/:id/notify', requireAuth, async (req, res) => { + try { + const viewer = res.locals['staff'] as { id: string; role: string } + if (!isManagerial(viewer.role)) { + res.status(403).json({ ok: false, error: 'Owner or manager only' }); return } - if (page * batch.pageSize >= batch.total) break - } - void (async () => { - let sent = 0 - const failed: { client: string; error: string }[] = [] - for (const t of targets) { - if (t.email === undefined) { - failed.push({ client: t.clientName, error: 'no contact email' }) - continue + const id = String(req.params['id'] ?? '') + const mod = await getModule(db, id) + if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return } + const body = req.body as { subject?: unknown; body?: unknown } + const subject = typeof body.subject === 'string' ? body.subject.trim() : '' + const bodyText = typeof body.body === 'string' ? body.body.trim() : '' + if (subject === '' || bodyText === '') { + res.status(400).json({ ok: false, error: 'subject and body are required' }); return + } + const status = await emailStatus(db) + if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } + if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } + // Resolve the FULL roster (internally paginated) before sending anything. + const targets: { clientId: string; clientName: string; email: string | undefined }[] = [] + for (let page = 1; ; page++) { + const batch = await listClientsByModule(db, id, { page, pageSize: 200 }) + for (const c of batch.clients) { + const client = await getClient(db, c.clientId) + targets.push({ + clientId: c.clientId, clientName: c.clientName, + email: client?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email, + }) } - // sendReminderEmail logs every attempt to email_log; one audit row per - // recipient records who broadcast what to whom (rule 6). - const out = await sendReminderEmail(db, gmail(), { to: t.email, subject, bodyText }) - writeAudit(db, viewer.id, 'notify', 'client', t.clientId, undefined, { - moduleId: id, subject, status: out.ok ? 'sent' : 'failed', - ...(out.ok ? {} : { error: out.error }), - }) - if (out.ok) sent += 1 - else failed.push({ client: t.clientName, error: out.error }) - if (!out.ok && out.error === 'gmail-token-dead') break // account died mid-run: stop, report the rest honestly + if (page * batch.pageSize >= batch.total) break } - // Warn, never truncate: every unreached client is named in the response. - res.json({ ok: true, sent, failed, total: targets.length }) - })() + void (async () => { + let sent = 0 + const failed: { client: string; error: string }[] = [] + for (const t of targets) { + if (t.email === undefined) { + failed.push({ client: t.clientName, error: 'no contact email' }) + continue + } + // sendReminderEmail logs every attempt to email_log; one audit row per + // recipient records who broadcast what to whom (rule 6). + const out = await sendReminderEmail(db, gmail(), { to: t.email, subject, bodyText }) + await writeAudit(db, viewer.id, 'notify', 'client', t.clientId, undefined, { + moduleId: id, subject, status: out.ok ? 'sent' : 'failed', + ...(out.ok ? {} : { error: out.error }), + }) + if (out.ok) sent += 1 + else failed.push({ client: t.clientName, error: out.error }) + if (!out.ok && out.error === 'gmail-token-dead') break // account died mid-run: stop, report the rest honestly + } + // Warn, never truncate: every unreached client is named in the response. + res.json({ ok: true, sent, failed, total: targets.length }) + })() + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- client modules ---------- - r.get('/clients/:id/modules', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { - res.status(404).json({ ok: false, error: 'Client not found' }); return + r.get('/clients/:id/modules', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getClient(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Client not found' }); return + } + res.json({ ok: true, clientModules: await listClientModules(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - res.json({ ok: true, clientModules: listClientModules(db, id) }) }) - r.post('/clients/:id/modules', requireAuth, (req, res) => { + r.post('/clients/:id/modules', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { - const cm = assignModule(db, staffId(res), { + const cm = await assignModule(db, staffId(res), { ...(req.body as Omit), clientId: id, }) res.json({ ok: true, clientModule: cm }) @@ -466,13 +514,13 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.patch('/client-modules/:id', requireAuth, (req, res) => { + r.patch('/client-modules/:id', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getClientModule(db, id) === null) { + if ((await getClientModule(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client module not found' }); return } try { - const cm = updateClientModule(db, staffId(res), id, req.body as ClientModulePatch) + const cm = await updateClientModule(db, staffId(res), id, req.body as ClientModulePatch) res.json({ ok: true, clientModule: cm }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -480,9 +528,9 @@ export function apiRouter( }) // ---------- documents ---------- - r.post('/documents', requireAuth, (req, res) => { + r.post('/documents', requireAuth, async (req, res) => { try { - const document = createDraft(db, staffId(res), req.body as DraftInput) + const document = await createDraft(db, staffId(res), req.body as DraftInput) res.json({ ok: true, document }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -492,13 +540,13 @@ export function apiRouter( // nothing, writes no audit (it is a read). Permissive: a price gap computes at // ₹0 + a warning; no client picked renders a placeholder letterhead at our own // state. renderPdf(html) rasterizes this exact string for the actual PDF. - r.post('/documents/preview', requireAuth, (req, res) => { + r.post('/documents/preview', requireAuth, async (req, res) => { try { const input = req.body as DraftInput - const prepared = prepareDraft(db, input, { permissive: true }) - const company = companySettings() + const prepared = await prepareDraft(db, input, { permissive: true }) + const company = await companySettings() const picked = typeof input.clientId === 'string' && input.clientId !== '' - ? getClient(db, input.clientId) : null + ? await getClient(db, input.clientId) : null const client: Client = picked ?? { id: '', code: '—', name: '— pick a client —', stateCode: company['company.state_code'] ?? '32', @@ -521,89 +569,105 @@ export function apiRouter( // Sibling preview: sample letterhead / quote-content through the ONE renderer. // Body uses the same field keys as PUT /settings/template (unsaved edits), merged // over the saved settings — so the preview shows exactly what Save would print. - r.post('/previews/sample', requireAuth, (req, res) => { - const body = req.body as { - company?: Record; template?: Record - titles?: Record; logo?: unknown; contentLines?: unknown - } - const merged = companySettings() // company.* + template.* - const applyGroup = (group: Record | undefined, fields: Record): void => { - if (group === undefined || group === null) return - for (const [field, key] of Object.entries(fields)) { - const val = group[field] - if (typeof val === 'string') merged[key] = val + r.post('/previews/sample', requireAuth, async (req, res) => { + try { + const body = req.body as { + company?: Record; template?: Record + titles?: Record; logo?: unknown; contentLines?: unknown } - } - applyGroup(body.company, COMPANY_FIELDS) - applyGroup(body.template, TEMPLATE_FIELDS) - if (body.titles !== undefined && body.titles !== null) { - for (const t of DOC_TYPES) { - const val = body.titles[t] - if (typeof val === 'string') merged[`template.title_${t}`] = val + const merged = await companySettings() // company.* + template.* + const applyGroup = (group: Record | undefined, fields: Record): void => { + if (group === undefined || group === null) return + for (const [field, key] of Object.entries(fields)) { + const val = group[field] + if (typeof val === 'string') merged[key] = val + } } + applyGroup(body.company, COMPANY_FIELDS) + applyGroup(body.template, TEMPLATE_FIELDS) + if (body.titles !== undefined && body.titles !== null) { + for (const t of DOC_TYPES) { + const val = body.titles[t] + if (typeof val === 'string') merged[`template.title_${t}`] = val + } + } + if (typeof body.logo === 'string') merged['template.logo'] = body.logo + const opts = Array.isArray(body.contentLines) + ? { contentLines: body.contentLines.filter((x): x is string => typeof x === 'string') } + : {} + res.json({ ok: true, html: documentHtmlSample(merged, opts) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/documents', requireAuth, async (req, res) => { + try { + const filter: DocumentFilter = {} + if (typeof req.query['type'] === 'string') filter.type = req.query['type'] + if (typeof req.query['status'] === 'string') filter.status = req.query['status'] + if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId'] + const page = Number(req.query['page']) + if (Number.isInteger(page) && page >= 1) filter.page = page + const pageSize = Number(req.query['pageSize']) + if (Number.isInteger(pageSize) && pageSize >= 1) filter.pageSize = pageSize + res.json({ ok: true, ...await listDocuments(db, filter) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/documents/:id', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + const document = await getDocument(db, id) + if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + const emails = await db.all( + `SELECT * FROM email_log WHERE document_id=? ORDER BY id`, id, + ) + res.json({ ok: true, document, events: await listDocumentEvents(db, id), emails, shares: await listShares(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - if (typeof body.logo === 'string') merged['template.logo'] = body.logo - const opts = Array.isArray(body.contentLines) - ? { contentLines: body.contentLines.filter((x): x is string => typeof x === 'string') } - : {} - res.json({ ok: true, html: documentHtmlSample(merged, opts) }) - }) - r.get('/documents', requireAuth, (req, res) => { - const filter: DocumentFilter = {} - if (typeof req.query['type'] === 'string') filter.type = req.query['type'] - if (typeof req.query['status'] === 'string') filter.status = req.query['status'] - if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId'] - const page = Number(req.query['page']) - if (Number.isInteger(page) && page >= 1) filter.page = page - const pageSize = Number(req.query['pageSize']) - if (Number.isInteger(pageSize) && pageSize >= 1) filter.pageSize = pageSize - res.json({ ok: true, ...listDocuments(db, filter) }) - }) - r.get('/documents/:id', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - const document = getDocument(db, id) - if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } - const emails = db.prepare( - `SELECT * FROM email_log WHERE document_id=? ORDER BY id`, - ).all(id) - res.json({ ok: true, document, events: listDocumentEvents(db, id), emails, shares: listShares(db, id) }) }) - const docAction = (fn: (docId: string, res: Response, req: Request) => unknown): RequestHandler => - (req, res) => { + const docAction = (fn: (docId: string, res: Response, req: Request) => unknown | Promise): RequestHandler => + async (req, res) => { const id = String(req.params['id'] ?? '') - if (getDocument(db, id) === null) { + if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } try { - res.json({ ok: true, document: fn(id, res, req) }) + res.json({ ok: true, document: await fn(id, res, req) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } } - r.get('/documents/:id/pdf', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - const document = getDocument(db, id) - if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } - const client = getClient(db, document.clientId) - if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - const company = companySettings() - // ?download=1 forces a Save-As (attachment); the default stays inline so the - // composer iframe can preview the PDF in place. - const disposition = req.query['download'] === '1' ? 'attachment' : 'inline' - void (async () => { - try { - const pdf = await renderPdfImpl(documentHtml(document, client, company)) - res.setHeader('Content-Type', 'application/pdf') - // docNo contains '/' (QT/26-27-0001) — not legal in a filename. - const filename = (document.docNo ?? 'draft').replaceAll('/', '-') - res.setHeader('Content-Disposition', `${disposition}; filename="${filename}.pdf"`) - res.send(pdf) - } catch (err) { - res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) - } - })() + r.get('/documents/:id/pdf', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + const document = await getDocument(db, id) + if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + const client = await getClient(db, document.clientId) + if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const company = await companySettings() + // ?download=1 forces a Save-As (attachment); the default stays inline so the + // composer iframe can preview the PDF in place. + const disposition = req.query['download'] === '1' ? 'attachment' : 'inline' + void (async () => { + try { + const pdf = await renderPdfImpl(documentHtml(document, client, company)) + res.setHeader('Content-Type', 'application/pdf') + // docNo contains '/' (QT/26-27-0001) — not legal in a filename. + const filename = (document.docNo ?? 'draft').replaceAll('/', '-') + res.setHeader('Content-Disposition', `${disposition}; filename="${filename}.pdf"`) + res.send(pdf) + } catch (err) { + res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) r.post('/documents/:id/issue', requireAuth, docAction((id, res) => @@ -630,17 +694,17 @@ export function apiRouter( // One-click convert & send (spec §8): every guard runs BEFORE any write; then ONE // transaction converts + issues; the email goes strictly AFTER commit — a failed // send never rolls back an issued invoice, it surfaces as a warning instead. - r.post('/documents/:id/convert-and-send', requireAuth, (req, res) => { + r.post('/documents/:id/convert-and-send', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - const src = getDocument(db, id) + const src = await getDocument(db, id) if (src === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } if (src.docType !== 'PROFORMA') { res.status(400).json({ ok: false, error: 'Only proformas can be converted & sent' }); return } - const status = emailStatus(db) + const status = await emailStatus(db) if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } - const client = getClient(db, src.clientId) + const client = await getClient(db, src.clientId) if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } const body = req.body as { to?: string } const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email @@ -650,20 +714,20 @@ export function apiRouter( } let invoice: Doc try { - invoice = db.transaction(() => { - const draft = convertDocument(db, staffId(res), id, 'INVOICE') // F3 recompute + F4 dup-guard live here + invoice = await db.transaction(async () => { + const draft = await convertDocument(db, staffId(res), id, 'INVOICE') // F3 recompute + F4 dup-guard live here return issueDocument(db, staffId(res), draft.id) - })() + }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) return } - const company = companySettings() + const company = await companySettings() const companyName = company['company.name'] ?? '' void (async () => { - const issued = (): Doc => getDocument(db, invoice.id)! + const issued = async (): Promise => (await getDocument(db, invoice.id))! try { - const pdf = await renderPdfImpl(documentHtml(issued(), client, company)) + const pdf = await renderPdfImpl(documentHtml(await issued(), client, company)) const out = await sendDocumentEmail(db, gmail(), { documentId: invoice.id, to, subject: `INVOICE ${invoice.docNo} — ${companyName}`, @@ -671,13 +735,13 @@ export function apiRouter( pdf, userId: staffId(res), }) if (!out.ok) { - res.json({ ok: true, document: issued(), warning: `Invoice issued, email failed: ${out.error}` }) + res.json({ ok: true, document: await issued(), warning: `Invoice issued, email failed: ${out.error}` }) return } - res.json({ ok: true, document: issued() }) + res.json({ ok: true, document: await issued() }) } catch (err) { res.json({ - ok: true, document: issued(), + ok: true, document: await issued(), warning: `Invoice issued, email failed: ${err instanceof Error ? err.message : String(err)}`, }) } @@ -691,92 +755,104 @@ export function apiRouter( // ---------- shareable links ---------- // Owner mints an opaque public token for one document (default +30d expiry). The // token is returned to the authenticated owner (they build the link) but never logged. - r.post('/documents/:id/share', requireAuth, (req, res) => { + r.post('/documents/:id/share', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getDocument(db, id) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } try { const body = req.body as { expiresDays?: unknown } const opts: MintShareOpts = {} if (typeof body.expiresDays === 'number') opts.expiresDays = body.expiresDays else if (body.expiresDays === null) opts.expiresDays = null - const share = mintShare(db, staffId(res), id, opts) - res.json({ ok: true, share, shares: listShares(db, id) }) + const share = await mintShare(db, staffId(res), id, opts) + res.json({ ok: true, share, shares: await listShares(db, id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/documents/:id/share/:shareId/revoke', requireAuth, (req, res) => { + r.post('/documents/:id/share/:shareId/revoke', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getDocument(db, id) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } const shareId = String(req.params['shareId'] ?? '') - const existing = getShare(db, shareId) + const existing = await getShare(db, shareId) // Guard the pairing: a share is only revocable through the document it belongs to. if (existing === null || existing.documentId !== id) { res.status(404).json({ ok: false, error: 'Share not found' }); return } try { - const share = revokeShare(db, staffId(res), shareId) - res.json({ ok: true, share, shares: listShares(db, id) }) + const share = await revokeShare(db, staffId(res), shareId) + res.json({ ok: true, share, shares: await listShares(db, id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- email ---------- - r.get('/email/status', requireAuth, (_req, res) => { - res.json({ ok: true, ...emailStatus(db) }) - }) - r.post('/documents/:id/send', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - const document = getDocument(db, id) - if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } - // Guard before any work: a dead/missing account never attempts a send. - const status = emailStatus(db) - if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } - if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } - if (document.docNo === null) { - res.status(400).json({ ok: false, error: 'Only issued documents can be emailed' }); return - } - const client = getClient(db, document.clientId) - if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - const body = req.body as { to?: string; subject?: string; body?: string } - const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email - if (to === undefined || to === '') { - res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' }) - return + r.get('/email/status', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, ...await emailStatus(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - const company = companySettings() - const companyName = company['company.name'] ?? '' - const subject = body.subject ?? `${document.docType} ${document.docNo} — ${companyName}` - const bodyText = body.body ?? - `Dear ${client.name},\n\nPlease find attached ${document.docType} ${document.docNo}.\n\nRegards,\n${companyName}` - void (async () => { - try { - const pdf = await renderPdf(documentHtml(document, client, company)) - const out = await sendDocumentEmail(db, gmail(), { - documentId: id, to, subject, bodyText, pdf, userId: staffId(res), - }) - if (!out.ok) { - res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) - return - } - res.json({ ok: true, document: getDocument(db, id) }) - } catch (err) { - res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + }) + r.post('/documents/:id/send', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + const document = await getDocument(db, id) + if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return } + // Guard before any work: a dead/missing account never attempts a send. + const status = await emailStatus(db) + if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return } + if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return } + if (document.docNo === null) { + res.status(400).json({ ok: false, error: 'Only issued documents can be emailed' }); return } - })() + const client = await getClient(db, document.clientId) + if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const body = req.body as { to?: string; subject?: string; body?: string } + const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email + if (to === undefined || to === '') { + res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' }) + return + } + const company = await companySettings() + const companyName = company['company.name'] ?? '' + const subject = body.subject ?? `${document.docType} ${document.docNo} — ${companyName}` + const bodyText = body.body ?? + `Dear ${client.name},\n\nPlease find attached ${document.docType} ${document.docNo}.\n\nRegards,\n${companyName}` + void (async () => { + try { + const pdf = await renderPdf(documentHtml(document, client, company)) + const out = await sendDocumentEmail(db, gmail(), { + documentId: id, to, subject, bodyText, pdf, userId: staffId(res), + }) + if (!out.ok) { + res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) + return + } + res.json({ ok: true, document: await getDocument(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + })() + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- recurring plans ---------- - r.get('/recurring', requireAuth, (req, res) => { - const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined - res.json({ ok: true, plans: listRecurringPlans(db, clientId) }) + r.get('/recurring', requireAuth, async (req, res) => { + try { + const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined + res.json({ ok: true, plans: await listRecurringPlans(db, clientId) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => { + r.post('/clients/:id/recurring', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { - const plan = createRecurringPlan(db, staffId(res), { + const plan = await createRecurringPlan(db, staffId(res), { ...(req.body as Omit), clientId: id, }) res.json({ ok: true, plan }) @@ -784,86 +860,108 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => { + r.patch('/recurring/:id', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } + if ((await getRecurringPlan(db, id)) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } try { - res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) }) + res.json({ ok: true, plan: await updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } - res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) }) + r.post('/recurring/:id/deactivate', requireAuth, requireOwner, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getRecurringPlan(db, id)) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return } + res.json({ ok: true, plan: await deactivateRecurringPlan(db, staffId(res), id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- amc ---------- - r.get('/clients/:id/amc', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) })) - res.json({ ok: true, contracts }) + r.get('/clients/:id/amc', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + const contracts = await Promise.all( + (await listAmc(db, id)).map(async (a) => ({ ...a, paidStatus: await amcPaidStatus(db, a) })), + ) + res.json({ ok: true, contracts }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => { + r.post('/clients/:id/amc', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { - const amc = createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id }) - res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + const amc = await createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id }) + res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => { + r.patch('/amc/:id', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } try { - const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch) - res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + const amc = await updateAmc(db, staffId(res), id, req.body as AmcPatch) + res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } - const amc = deactivateAmc(db, staffId(res), id) - res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } }) + r.post('/amc/:id/deactivate', requireAuth, requireOwner, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + const amc = await deactivateAmc(db, staffId(res), id) + res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => { + r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } + if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return } try { - res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) }) + res.json({ ok: true, document: await generateAmcRenewalInvoice(db, staffId(res), id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- interactions ---------- - r.get('/interaction-types', requireAuth, (_req, res) => { - res.json({ ok: true, types: listInteractionTypes(db) }) + r.get('/interaction-types', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, types: await listInteractionTypes(db) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/interaction-types', requireAuth, requireOwner, (req, res) => { + r.post('/interaction-types', requireAuth, requireOwner, async (req, res) => { try { - const type = createInteractionType(db, staffId(res), req.body as { code: string; label: string }) + const type = await createInteractionType(db, staffId(res), req.body as { code: string; label: string }) res.json({ ok: true, type }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.get('/clients/:id/interactions', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - res.json({ ok: true, interactions: listInteractions(db, id) }) + r.get('/clients/:id/interactions', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, interactions: await listInteractions(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/clients/:id/interactions', requireAuth, (req, res) => { + r.post('/clients/:id/interactions', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { - const interaction = createInteraction(db, staffId(res), { + const interaction = await createInteraction(db, staffId(res), { ...(req.body as Omit), clientId: id, }) res.json({ ok: true, interaction }) @@ -871,11 +969,11 @@ export function apiRouter( res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.patch('/interactions/:id', requireAuth, (req, res) => { + r.patch('/interactions/:id', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getInteraction(db, id) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return } + if ((await getInteraction(db, id)) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return } try { - res.json({ ok: true, interaction: updateInteraction(db, staffId(res), id, req.body as InteractionPatch) }) + res.json({ ok: true, interaction: await updateInteraction(db, staffId(res), id, req.body as InteractionPatch) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } @@ -890,63 +988,71 @@ export function apiRouter( // server-forced to their own rows (derived doc_id → document.created_by, spec A3; // doc-less rows are shared work, visible to all); owner/manager see all and may // narrow via ?owner=. `?status=` narrows the SAME scoped, paginated view. - r.get('/reminders', requireAuth, (req, res) => { - const viewer = res.locals['staff'] as { id: string; role: string } - const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id } - if (typeof req.query['status'] === 'string' && req.query['status'] !== '') { - opts.status = req.query['status'] as ReminderStatus - } - if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner'] - const page = Number(req.query['page']) - if (Number.isInteger(page) && page >= 1) opts.page = page - const pageSize = Number(req.query['pageSize']) - if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize - const q = listQueue(db, opts) - res.json({ ok: true, reminders: q.rows, total: q.total, page: q.page, pageSize: q.pageSize }) - }) - r.get('/reminders/:id/preview', requireAuth, (req, res) => { + r.get('/reminders', requireAuth, async (req, res) => { + try { + const viewer = res.locals['staff'] as { id: string; role: string } + const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id } + if (typeof req.query['status'] === 'string' && req.query['status'] !== '') { + opts.status = req.query['status'] as ReminderStatus + } + if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner'] + const page = Number(req.query['page']) + if (Number.isInteger(page) && page >= 1) opts.page = page + const pageSize = Number(req.query['pageSize']) + if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize + const q = await listQueue(db, opts) + res.json({ ok: true, reminders: q.rows, total: q.total, page: q.page, pageSize: q.pageSize }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.get('/reminders/:id/preview', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - const reminder = getReminder(db, id) + const reminder = await getReminder(db, id) if (reminder === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } try { 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 { ctx } = reminderContext(db, reminder, companySettings()['company.name'] ?? '') + const { ctx } = await reminderContext(db, reminder, (await companySettings())['company.name'] ?? '') const mail = reminderEmail(reminder.ruleKind, ctx) res.json({ ok: true, subject: mail.subject, body: mail.bodyText }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/reminders/:id/send', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } - void (async () => { - try { - const out = await sendReminder(db, sendReminderDeps(), id, staffId(res)) - if (!out.ok) { - res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) - return + r.post('/reminders/:id/send', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getReminder(db, id)) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } + void (async () => { + try { + const out = await sendReminder(db, sendReminderDeps(), id, staffId(res)) + if (!out.ok) { + res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error }) + return + } + res.json({ ok: true, reminder: await getReminder(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - res.json({ ok: true, reminder: getReminder(db, id) }) - } catch (err) { - res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) - } - })() + })() + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/reminders/:id/dismiss', requireAuth, (req, res) => { + r.post('/reminders/:id/dismiss', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') - if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } + if ((await getReminder(db, id)) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return } try { - res.json({ ok: true, reminder: dismissReminder(db, staffId(res), id) }) + res.json({ ok: true, reminder: await dismissReminder(db, staffId(res), id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- APEX import (D18 WS-B; owner-only — the cutover is one-shot) ---------- - r.post('/import/stage', requireAuth, requireOwner, (req, res) => { + r.post('/import/stage', requireAuth, requireOwner, async (req, res) => { try { const body = req.body as { clientsCsv?: unknown; invoicesCsv?: unknown } const clientsCsv = typeof body.clientsCsv === 'string' && body.clientsCsv !== '' ? body.clientsCsv : undefined @@ -956,37 +1062,41 @@ export function apiRouter( return } // Staging writes and their audit rows land (or roll back) together. - const out = db.transaction(() => { + const out = await db.transaction(async () => { const o: { clientsStaged?: number; invoicesStaged?: number } = {} - if (clientsCsv !== undefined) o.clientsStaged = stageCsv(db, 'clients', clientsCsv).staged - if (invoicesCsv !== undefined) o.invoicesStaged = stageCsv(db, 'invoices', invoicesCsv).staged - writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, o) + if (clientsCsv !== undefined) o.clientsStaged = (await stageCsv(db, 'clients', clientsCsv)).staged + if (invoicesCsv !== undefined) o.invoicesStaged = (await stageCsv(db, 'invoices', invoicesCsv)).staged + await writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, o) return o - })() - res.json({ ok: true, ...out, report: verificationReport(db) }) + }) + res.json({ ok: true, ...out, report: await verificationReport(db) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.get('/import/status', requireAuth, requireOwner, (_req, res) => { - // Per-row problem lists (first 50 each, with honest totals — rule 8). - const problemClients = db.prepare( - `SELECT row_no, code, name, problems FROM stg_client WHERE problems <> '[]' ORDER BY row_no LIMIT 50`, - ).all() - const problemInvoices = db.prepare( - `SELECT row_no, client_code, doc_no, problems FROM stg_invoice WHERE problems <> '[]' ORDER BY row_no LIMIT 50`, - ).all() - res.json({ ok: true, report: verificationReport(db), problemClients, problemInvoices }) + r.get('/import/status', requireAuth, requireOwner, async (_req, res) => { + try { + // Per-row problem lists (first 50 each, with honest totals — rule 8). + const problemClients = await db.all( + `SELECT row_no, code, name, problems FROM stg_client WHERE problems <> '[]' ORDER BY row_no LIMIT 50`, + ) + const problemInvoices = await db.all( + `SELECT row_no, client_code, doc_no, problems FROM stg_invoice WHERE problems <> '[]' ORDER BY row_no LIMIT 50`, + ) + res.json({ ok: true, report: await verificationReport(db), problemClients, problemInvoices }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/import/commit', requireAuth, requireOwner, (_req, res) => { + r.post('/import/commit', requireAuth, requireOwner, async (_req, res) => { try { // commitImport is itself transactional; the outer txn folds it into a savepoint // so the cutover and its 'import_commit' audit row are one atomic unit. - const result = db.transaction(() => { - const r = commitImport(db, staffId(res)) // refuses while ANY staged row has problems - writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, r) + const result = await db.transaction(async () => { + const r = await commitImport(db, staffId(res)) // refuses while ANY staged row has problems + await writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, r) return r - })() + }) res.json({ ok: true, result }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -996,55 +1106,71 @@ export function apiRouter( // ---------- dashboard ---------- // ?mine=1 = My Day (D18 WS-E): owner/manager narrow to their own book; staff // are always scoped server-side regardless of the param. - r.get('/dashboard', requireAuth, (req, res) => { - const today = new Date().toISOString().slice(0, 10) - res.json({ - ok: true, - view: dashboardView(db, today, res.locals['staff'] as { id: string; role: string }, - { mine: req.query['mine'] === '1' }), - }) + r.get('/dashboard', requireAuth, async (req, res) => { + try { + const today = new Date().toISOString().slice(0, 10) + res.json({ + ok: true, + view: await dashboardView(db, today, res.locals['staff'] as { id: string; role: string }, + { mine: req.query['mine'] === '1' }), + }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- payments ---------- - r.post('/payments', requireAuth, (req, res) => { + r.post('/payments', requireAuth, async (req, res) => { try { - const out = recordPayment(db, staffId(res), req.body as RecordPaymentInput) + const out = await recordPayment(db, staffId(res), req.body as RecordPaymentInput) res.json({ ok: true, ...out }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.get('/clients/:id/ledger', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { - res.status(404).json({ ok: false, error: 'Client not found' }); return + r.get('/clients/:id/ledger', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getClient(db, id)) === null) { + res.status(404).json({ ok: false, error: 'Client not found' }); return + } + res.json({ ok: true, ...await clientLedger(db, id), modulePaid: await modulePaidView(db, id) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) }) }) - r.post('/payments/:id/receipt', requireAuth, (req, res) => { + r.post('/payments/:id/receipt', requireAuth, async (req, res) => { const id = String(req.params['id'] ?? '') try { - res.json({ ok: true, document: receiptForPayment(db, staffId(res), id) }) + res.json({ ok: true, document: await receiptForPayment(db, staffId(res), id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- aws usage & cost ---------- - r.get('/aws/ranking', requireAuth, (req, res) => { - const today = new Date().toISOString().slice(0, 10) - const month = typeof req.query['month'] === 'string' ? req.query['month'] : previousMonth(today) - res.json({ ok: true, month, ...costRanking(db, month) }) + r.get('/aws/ranking', requireAuth, async (req, res) => { + try { + const today = new Date().toISOString().slice(0, 10) + const month = typeof req.query['month'] === 'string' ? req.query['month'] : previousMonth(today) + res.json({ ok: true, month, ...await costRanking(db, month) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.get('/clients/:id/aws-usage', requireAuth, (req, res) => { - const id = String(req.params['id'] ?? '') - if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } - res.json({ ok: true, usage: listAwsUsage(db, id, 12) }) + r.get('/clients/:id/aws-usage', requireAuth, async (req, res) => { + try { + const id = String(req.params['id'] ?? '') + if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } + res.json({ ok: true, usage: await listAwsUsage(db, id, 12) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.post('/aws/usage', requireAuth, requireOwner, (req, res) => { + r.post('/aws/usage', requireAuth, requireOwner, async (req, res) => { try { // Manual owner entry — always 'manual' provenance regardless of the body. - const usage = upsertAwsUsage(db, staffId(res), { ...(req.body as UpsertAwsUsageInput), source: 'manual' }) + const usage = await upsertAwsUsage(db, staffId(res), { ...(req.body as UpsertAwsUsageInput), source: 'manual' }) res.json({ ok: true, usage }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -1076,42 +1202,69 @@ export function apiRouter( if (typeof req.query['to'] === 'string') range.to = req.query['to'] return range } - r.get('/reports/dues-aging', requireAuth, (_req, res) => { - res.json({ ok: true, rows: duesAging(db, new Date().toISOString().slice(0, 10)) }) + r.get('/reports/dues-aging', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, rows: await duesAging(db, new Date().toISOString().slice(0, 10)) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.get('/reports/module-revenue', requireAuth, (req, res) => { - res.json({ ok: true, rows: moduleRevenue(db, rangeOf(req)) }) + r.get('/reports/module-revenue', requireAuth, async (req, res) => { + try { + res.json({ ok: true, rows: await moduleRevenue(db, rangeOf(req)) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.get('/reports/profitability', requireAuth, (req, res) => { - res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) }) + r.get('/reports/profitability', requireAuth, async (req, res) => { + try { + res.json({ ok: true, rows: await clientProfitability(db, rangeOf(req)) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- company profile (owner-editable; PDFs read these live) ---------- // D18: the composer autofills invoice due dates from this (issue also stamps with it). - r.get('/settings/billing', requireAuth, (_req, res) => { - res.json({ ok: true, paymentTermsDays: getNumberSetting(db, 'billing.payment_terms_days', 15) }) + r.get('/settings/billing', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, paymentTermsDays: await getNumberSetting(db, 'billing.payment_terms_days', 15) }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.get('/settings/company', requireAuth, (_req, res) => { - res.json({ ok: true, company: companySettings() }) + r.get('/settings/company', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, company: await companySettings() }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) // ---------- sharing defaults (owner) ---------- - r.get('/settings/sharing', requireAuth, requireOwner, (_req, res) => { - const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) - .get() as { value: string } | undefined - const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value) - res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days }) - }) - r.put('/settings/sharing', requireAuth, requireOwner, (req, res) => { - const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays - if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) { - res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' }) - return + r.get('/settings/sharing', requireAuth, requireOwner, async (_req, res) => { + try { + const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) + const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value) + res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/settings/sharing', requireAuth, requireOwner, async (req, res) => { + try { + const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays + if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) { + res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' }) + return + } + await setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v)) + res.json({ ok: true, defaultExpiryDays: v }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } - setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v)) - res.json({ ok: true, defaultExpiryDays: v }) }) - r.put('/settings/company', requireAuth, requireOwner, (req, res) => { + r.put('/settings/company', requireAuth, requireOwner, async (req, res) => { const body = req.body as Record try { const gstin = body['gstin'] @@ -1125,25 +1278,29 @@ export function apiRouter( } for (const [field, key] of Object.entries(COMPANY_FIELDS)) { const val = body[field] - if (typeof val === 'string') setSetting(db, staffId(res), key, val) + if (typeof val === 'string') await setSetting(db, staffId(res), key, val) } - res.json({ ok: true, company: companySettings() }) + res.json({ ok: true, company: await companySettings() }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- reminder cadences (owner; dated rows are append-only — rule 3) ---------- - r.get('/settings/reminders', requireAuth, requireOwner, (_req, res) => { - const schedules = listSchedules(db) - res.json({ - ok: true, - overdueDays: getNumberSetting(db, 'reminders.overdue_days', 7), - renewalDays: getNumberSetting(db, 'reminders.renewal_days', 15), - schedules, total: schedules.length, - }) - }) - r.put('/settings/reminders', requireAuth, requireOwner, (req, res) => { + r.get('/settings/reminders', requireAuth, requireOwner, async (_req, res) => { + try { + const schedules = await listSchedules(db) + res.json({ + ok: true, + overdueDays: await getNumberSetting(db, 'reminders.overdue_days', 7), + renewalDays: await getNumberSetting(db, 'reminders.renewal_days', 15), + schedules, total: schedules.length, + }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + r.put('/settings/reminders', requireAuth, requireOwner, async (req, res) => { const body = req.body as { overdueDays?: unknown; renewalDays?: unknown } try { // First pass: validate all fields, collect tuples @@ -1157,20 +1314,20 @@ export function apiRouter( // Second pass: write all validated settings — one transaction, so a failure // mid-loop cannot persist overdueDays without renewalDays (and each setting // lands with its audit row atomically). - db.transaction(() => { + await db.transaction(async () => { for (const [key, value] of toSet) { - setSetting(db, staffId(res), key, value) + await setSetting(db, staffId(res), key, value) } - })() + }) res.json({ ok: true }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/settings/schedules', requireAuth, requireOwner, (req, res) => { + r.post('/settings/schedules', requireAuth, requireOwner, async (req, res) => { const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string } try { - const schedule = insertSchedule(db, staffId(res), { + const schedule = await insertSchedule(db, staffId(res), { ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '', ...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}), ...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}), @@ -1182,10 +1339,14 @@ export function apiRouter( }) // ---------- document template (company.* + template.* letterhead data) ---------- - r.get('/settings/template', requireAuth, (_req, res) => { - res.json({ ok: true, settings: companySettings() }) + r.get('/settings/template', requireAuth, async (_req, res) => { + try { + res.json({ ok: true, settings: await companySettings() }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } }) - r.put('/settings/template', requireAuth, requireOwner, (req, res) => { + r.put('/settings/template', requireAuth, requireOwner, async (req, res) => { const body = req.body as { company?: Record; template?: Record; titles?: Record } try { const company = body.company ?? {} @@ -1200,24 +1361,24 @@ export function apiRouter( } for (const [field, key] of Object.entries(COMPANY_FIELDS)) { const val = company[field] - if (typeof val === 'string') setSetting(db, staffId(res), key, val) + if (typeof val === 'string') await setSetting(db, staffId(res), key, val) } for (const [field, key] of Object.entries(TEMPLATE_FIELDS)) { const val = (body.template ?? {})[field] - if (typeof val === 'string') setSetting(db, staffId(res), key, val) + if (typeof val === 'string') await setSetting(db, staffId(res), key, val) } if (body.titles !== undefined && body.titles !== null) { for (const t of DOC_TYPES) { const val = body.titles[t] - if (typeof val === 'string') setSetting(db, staffId(res), `template.title_${t}`, val) + if (typeof val === 'string') await setSetting(db, staffId(res), `template.title_${t}`, val) } } - res.json({ ok: true, settings: companySettings() }) + res.json({ ok: true, settings: await companySettings() }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) - r.post('/settings/template/logo', requireAuth, requireOwner, (req, res) => { + r.post('/settings/template/logo', requireAuth, requireOwner, async (req, res) => { const { dataUri } = req.body as { dataUri?: unknown } try { if (typeof dataUri !== 'string') throw new Error('dataUri (string) is required') @@ -1227,7 +1388,7 @@ export function apiRouter( const bytes = Math.floor((m[2]!.length * 3) / 4) // approximate decoded size if (bytes > 200 * 1024) throw new Error('Logo too large — keep it under 200 KB') } - setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it + await setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it res.json({ ok: true, logo: dataUri }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/apps/hq/src/audit.ts b/apps/hq/src/audit.ts index ad77a68..5fa534b 100644 --- a/apps/hq/src/audit.ts +++ b/apps/hq/src/audit.ts @@ -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 { + 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 { + return await db.all(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`, limit) } diff --git a/apps/hq/src/auth.ts b/apps/hq/src/auth.ts index 5a7317e..7b2d1e0 100644 --- a/apps/hq/src/auth.ts +++ b/apps/hq/src/auth.ts @@ -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(`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) => { diff --git a/apps/hq/src/aws-costs.ts b/apps/hq/src/aws-costs.ts index 8d61833..8a6551b 100644 --- a/apps/hq/src/aws-costs.ts +++ b/apps/hq/src/aws-costs.ts @@ -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 { 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 } diff --git a/apps/hq/src/bounces.ts b/apps/hq/src/bounces.ts index eb0a811..2bb8b84 100644 --- a/apps/hq/src/bounces.ts +++ b/apps/hq/src/bounces.ts @@ -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 { + 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 } } diff --git a/apps/hq/src/db-pg.ts b/apps/hq/src/db-pg.ts new file mode 100644 index 0000000..7703df8 --- /dev/null +++ b/apps/hq/src/db-pg.ts @@ -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() + constructor(readonly pool: pg.Pool) {} + + /** Queries inside a transaction ride its pinned client; otherwise the pool. */ + private q(): { query: (sql: string, args?: unknown[]) => Promise } { + return this.als.getStore()?.client ?? this.pool + } + + async all(sql: string, ...args: unknown[]): Promise { + return (await this.q().query(toDollarParams(sql), args)).rows as T[] + } + + async get(sql: string, ...args: unknown[]): Promise { + 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 { + await this.q().query(sql) + } + + async transaction(fn: () => Promise | T): Promise { + 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 { + await this.pool.end() + } +} + +/** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */ +export async function runPgMigrations(db: PgDb): Promise { + 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 { + const db = new PgDb(new pg.Pool({ connectionString: url, max: 10 })) + await runPgMigrations(db) + return db +} diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index f4c911e..7ca29c1 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -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(sql: string, ...args: unknown[]): Promise + /** First row, or undefined. */ + get(sql: string, ...args: unknown[]): Promise + /** 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 + /** Atomic unit; nested calls become savepoints. (No trailing `()` — unlike better-sqlite3.) */ + transaction(fn: () => Promise | T): Promise + readonly engine: 'sqlite' | 'postgres' + close(): Promise +} + +/** 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() + 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(sql: string, ...args: unknown[]): Promise { + return this.stmt(sql).all(...args) as T[] + } + async get(sql: string, ...args: unknown[]): Promise { + 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 { this.raw.exec(sql) } + async transaction(fn: () => Promise | T): Promise { + // 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 { 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', diff --git a/apps/hq/src/gmail.ts b/apps/hq/src/gmail.ts index 4a53ffc..a8ac8a4 100644 --- a/apps/hq/src/gmail.ts +++ b/apps/hq/src/gmail.ts @@ -94,15 +94,15 @@ export type SendResult = { ok: true } | { ok: false; error: string } export async function sendDocumentEmail( db: DB, deps: GmailDeps, args: SendDocumentInput, ): Promise { - 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 => { + 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 { - const logFail = (error: string): SendResult => { - logEmail(db, { + const logFail = async (error: string): Promise => { + 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 } : {}), diff --git a/apps/hq/src/import-apex.ts b/apps/hq/src/import-apex.ts index 74e731f..5f300b1 100644 --- a/apps/hq/src/import-apex.ts +++ b/apps/hq/src/import-apex.ts @@ -44,18 +44,18 @@ function parseCsv(text: string): Record[] { const CLIENT_STATUSES = new Set(['lead', 'active', 'dormant', 'lost']) const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ -function stageClients(db: DB, records: Record[]): number { - db.prepare(`DELETE FROM stg_client`).run() +async function stageClients(db: DB, records: Record[]): Promise { + 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() - 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[]): 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[]): number { - db.prepare(`DELETE FROM stg_invoice`).run() +async function stageInvoices(db: DB, records: Record[]): Promise { + 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() - 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[]): 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 { + 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 { + 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 { + return db.transaction(async (): Promise => { + 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(`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(`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 [--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 [--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 [--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.') + } + })() } diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts new file mode 100644 index 0000000..eeb070b --- /dev/null +++ b/apps/hq/src/migrations-pg.ts @@ -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) +); +`, + }, +] diff --git a/apps/hq/src/repos-amc.ts b/apps/hq/src/repos-amc.ts index cd44953..d54d0da 100644 --- a/apps/hq/src/repos-amc.ts +++ b/apps/hq/src/repos-amc.ts @@ -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 { + const row = await db.get(`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 { + const rows = await db.all(`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 { + 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 { + 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 { 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 { + 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 { + 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' } diff --git a/apps/hq/src/repos-aws.ts b/apps/hq/src/repos-aws.ts index 34521f4..6cc98aa 100644 --- a/apps/hq/src/repos-aws.ts +++ b/apps/hq/src/repos-aws.ts @@ -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 { + const row = await db.get(`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 { + const rows = await db.all( `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 { + 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 { 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). */ diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index a6f88b9..a6d29f8 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -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 { 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(`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 { + const row = await db.get(`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 { 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 { + 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 { + 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 { + 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 { + 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 - })() + }) } diff --git a/apps/hq/src/repos-dashboard.ts b/apps/hq/src/repos-dashboard.ts index 3b090c8..1004e34 100644 --- a/apps/hq/src/repos-dashboard.ts +++ b/apps/hq/src/repos-dashboard.ts @@ -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 { // 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, diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts index 0bc0d6d..22e4692 100644 --- a/apps/hq/src/repos-documents.ts +++ b/apps/hq/src/repos-documents.ts @@ -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 { + const row = await db.get(`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 { 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( `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 = {}): void { - db.prepare( +async function addEvent(db: DB, documentId: string, kind: string, meta: Record = {}): Promise { + 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 { + const rows = await db.all( + `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, @@ -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 { + 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 { + 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 { + 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 { 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 { 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 { + 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 { + 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> = { @@ -336,23 +338,23 @@ const LEGAL_MARKS: Partial> = { 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 { + 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 = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 } @@ -362,9 +364,9 @@ const CHAIN: Record = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 } * QT→PI 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 { + 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 { + 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 { + 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 { + 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 { + 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 - })() + }) } diff --git a/apps/hq/src/repos-email.ts b/apps/hq/src/repos-email.ts index 2941e83..db4eb3c 100644 --- a/apps/hq/src/repos-email.ts +++ b/apps/hq/src/repos-email.ts @@ -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 { + const row = await db.get(`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 { + 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 { + 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 { + 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 { + const account = await getAccount(db) if (account === null) return { connected: false, dead: false } return { connected: true, address: account.address, dead: account.status === 'dead' } } diff --git a/apps/hq/src/repos-employees.ts b/apps/hq/src/repos-employees.ts index ec71020..ca90d18 100644 --- a/apps/hq/src/repos-employees.ts +++ b/apps/hq/src/repos-employees.ts @@ -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 { + const rows = await db.all( `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 { + const row = await db.get(`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 { + 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 { 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 { + 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 { 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 { 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 { 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 { + 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 - })() + }) } diff --git a/apps/hq/src/repos-interactions.ts b/apps/hq/src/repos-interactions.ts index db643f7..1ee5983 100644 --- a/apps/hq/src/repos-interactions.ts +++ b/apps/hq/src/repos-interactions.ts @@ -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 { + return db.all(`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 { 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 { + const row = await db.get(`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 { + const rows = await db.all( `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 { + 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 { + 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( `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 })) } diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index adcebbf..6207b88 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -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 { + const row = await db.get(`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 { + const rows = await db.all(`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 { 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 { + 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 { 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 { + const rows = await db.all( `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 { + 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 { + const row = await db.get(`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 { + const rows = await db.all( + `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 { 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 { + 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 { + 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 } diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts index 4f88252..52e916f 100644 --- a/apps/hq/src/repos-payments.ts +++ b/apps/hq/src/repos-payments.ts @@ -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 { + const row = await db.get(`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 { + const rows = await db.all( `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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 => { 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 { + 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 { + 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 { + 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() 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) => { diff --git a/apps/hq/src/repos-pipeline.ts b/apps/hq/src/repos-pipeline.ts index f251288..b873e26 100644 --- a/apps/hq/src/repos-pipeline.ts +++ b/apps/hq/src/repos-pipeline.ts @@ -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 { 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( `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( `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 => diff --git a/apps/hq/src/repos-recurring.ts b/apps/hq/src/repos-recurring.ts index dfecaa2..76290d5 100644 --- a/apps/hq/src/repos-recurring.ts +++ b/apps/hq/src/repos-recurring.ts @@ -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 { + const row = await db.get(`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 { 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(`SELECT * FROM recurring_plan ORDER BY id DESC`) + : await db.all(`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 { + 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 { + 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 { return updateRecurringPlan(db, userId, id, { active: false }) } diff --git a/apps/hq/src/repos-reminders.ts b/apps/hq/src/repos-reminders.ts index 59ab714..6c5aee5 100644 --- a/apps/hq/src/repos-reminders.ts +++ b/apps/hq/src/repos-reminders.ts @@ -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 { + const row = await db.get(`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 { 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(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 { 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( `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 { + 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 { + 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 { + 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 { 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 { + 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 { 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 { + 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 { + 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 { + const raw = await getSetting(db, key) if (raw === null) return fallback const n = Number(raw) return Number.isFinite(n) ? n : fallback diff --git a/apps/hq/src/repos-reports.ts b/apps/hq/src/repos-reports.ts index e513a6d..d387c50 100644 --- a/apps/hq/src/repos-reports.ts +++ b/apps/hq/src/repos-reports.ts @@ -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 { 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 { + 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 { // 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() 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 { const acc = new Map() - 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 { + 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 }) } diff --git a/apps/hq/src/repos-shares.ts b/apps/hq/src/repos-shares.ts index 7fb28de..8c130c0 100644 --- a/apps/hq/src/repos-shares.ts +++ b/apps/hq/src/repos-shares.ts @@ -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 { + const row = await db.get(`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 { + const rows = await db.all( `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 { + const row = await db.get( // 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 { + 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 { + 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 { + const row = await db.get(`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 { + 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 } diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts index 43f6c95..47ef519 100644 --- a/apps/hq/src/scheduler.ts +++ b/apps/hq/src/scheduler.ts @@ -54,11 +54,11 @@ function bump(created: Record, 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 { + 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[] { - 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): Promise { + 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 diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts index 77c1462..99e51f6 100644 --- a/apps/hq/src/seed.ts +++ b/apps/hq/src/seed.ts @@ -31,77 +31,83 @@ const REMINDER_SETTINGS: Record = { '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 { + 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 = { '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'], }) diff --git a/apps/hq/src/send-reminder.ts b/apps/hq/src/send-reminder.ts index a0ae429..891abb9 100644 --- a/apps/hq/src/send-reminder.ts +++ b/apps/hq/src/send-reminder.ts @@ -18,7 +18,7 @@ import { documentHtml } from './templates' export interface SendReminderDeps { gmail: GmailDeps renderPdf: (html: string) => Promise - company: () => Record + company: () => Record | Promise> 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/ (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 { + 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 { + 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 { - 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 } diff --git a/apps/hq/src/series.ts b/apps/hq/src/series.ts index 28572d0..3ef5425 100644 --- a/apps/hq/src/series.ts +++ b/apps/hq/src/series.ts @@ -5,25 +5,28 @@ export const TYPE_PREFIX: Record = { 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 { 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 { 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, + ) } diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index c26ee63..c96d0f2 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -21,9 +21,10 @@ const moduleDir = typeof __dirname === 'undefined' : __dirname function schedulerDeps(db: DB) { - const company = (): Record => - 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> => + 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 { - const rows = db.prepare( +async function companySettings(db: DB): Promise> { + 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 { 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) }) } diff --git a/apps/hq/test/amc.test.ts b/apps/hq/test/amc.test.ts index 9b8b805..eb3923f 100644 --- a/apps/hq/test/amc.test.ts +++ b/apps/hq/test/amc.test.ts @@ -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') }) }) diff --git a/apps/hq/test/auth.test.ts b/apps/hq/test/auth.test.ts index af20869..e491da6 100644 --- a/apps/hq/test/auth.test.ts +++ b/apps/hq/test/auth.test.ts @@ -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/) }) }) diff --git a/apps/hq/test/aws-costs.test.ts b/apps/hq/test/aws-costs.test.ts index e4557fa..4c15a95 100644 --- a/apps/hq/test/aws-costs.test.ts +++ b/apps/hq/test/aws-costs.test.ts @@ -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 }) diff --git a/apps/hq/test/aws-pull-route.test.ts b/apps/hq/test/aws-pull-route.test.ts index 7f49f75..98b4420 100644 --- a/apps/hq/test/aws-pull-route.test.ts +++ b/apps/hq/test/aws-pull-route.test.ts @@ -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` diff --git a/apps/hq/test/aws-repo.test.ts b/apps/hq/test/aws-repo.test.ts index 6824ef9..6d06ddd 100644 --- a/apps/hq/test/aws-repo.test.ts +++ b/apps/hq/test/aws-repo.test.ts @@ -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 }) diff --git a/apps/hq/test/bounces.test.ts b/apps/hq/test/bounces.test.ts index df75eb3..f97c8ad 100644 --- a/apps/hq/test/bounces.test.ts +++ b/apps/hq/test/bounces.test.ts @@ -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 }) }) diff --git a/apps/hq/test/client-modules.test.ts b/apps/hq/test/client-modules.test.ts index c0d875e..3546685 100644 --- a/apps/hq/test/client-modules.test.ts +++ b/apps/hq/test/client-modules.test.ts @@ -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() }) }) diff --git a/apps/hq/test/client-owner.test.ts b/apps/hq/test/client-owner.test.ts index 0ad1399..f772b59 100644 --- a/apps/hq/test/client-owner.test.ts +++ b/apps/hq/test/client-owner.test.ts @@ -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) }) diff --git a/apps/hq/test/client-support.test.ts b/apps/hq/test/client-support.test.ts index e1ef81c..88caf72 100644 --- a/apps/hq/test/client-support.test.ts +++ b/apps/hq/test/client-support.test.ts @@ -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}` }) diff --git a/apps/hq/test/clients.test.ts b/apps/hq/test/clients.test.ts index a6d0659..1c5b0bf 100644 --- a/apps/hq/test/clients.test.ts +++ b/apps/hq/test/clients.test.ts @@ -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() }) }) diff --git a/apps/hq/test/convert-and-send.test.ts b/apps/hq/test/convert-and-send.test.ts index 2d69d64..71cac34 100644 --- a/apps/hq/test/convert-and-send.test.ts +++ b/apps/hq/test/convert-and-send.test.ts @@ -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) }) diff --git a/apps/hq/test/dashboard.test.ts b/apps/hq/test/dashboard.test.ts index d02402f..d293ba1 100644 --- a/apps/hq/test/dashboard.test.ts +++ b/apps/hq/test/dashboard.test.ts @@ -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> + 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) }) }) diff --git a/apps/hq/test/db.test.ts b/apps/hq/test/db.test.ts index e59702a..3ff4591 100644 --- a/apps/hq/test/db.test.ts +++ b/apps/hq/test/db.test.ts @@ -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' }) diff --git a/apps/hq/test/documents-page.test.ts b/apps/hq/test/documents-page.test.ts index b5b7c5e..2f2d224 100644 --- a/apps/hq/test/documents-page.test.ts +++ b/apps/hq/test/documents-page.test.ts @@ -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}` }, diff --git a/apps/hq/test/documents.test.ts b/apps/hq/test/documents.test.ts index 4843177..d6df07f 100644 --- a/apps/hq/test/documents.test.ts +++ b/apps/hq/test/documents.test.ts @@ -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 }) diff --git a/apps/hq/test/due-dates.test.ts b/apps/hq/test/due-dates.test.ts index 9860c04..df160a6 100644 --- a/apps/hq/test/due-dates.test.ts +++ b/apps/hq/test/due-dates.test.ts @@ -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/) }) diff --git a/apps/hq/test/e2e.test.ts b/apps/hq/test/e2e.test.ts index c104ae9..64e84e0 100644 --- a/apps/hq/test/e2e.test.ts +++ b/apps/hq/test/e2e.test.ts @@ -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 +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, diff --git a/apps/hq/test/employees-routes.test.ts b/apps/hq/test/employees-routes.test.ts index d8e676b..075de67 100644 --- a/apps/hq/test/employees-routes.test.ts +++ b/apps/hq/test/employees-routes.test.ts @@ -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> + 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) }) }) diff --git a/apps/hq/test/employees.test.ts b/apps/hq/test/employees.test.ts index 29c4835..adbfb44 100644 --- a/apps/hq/test/employees.test.ts +++ b/apps/hq/test/employees.test.ts @@ -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')), diff --git a/apps/hq/test/health.test.ts b/apps/hq/test/health.test.ts index b8a34a5..a9ad071 100644 --- a/apps/hq/test/health.test.ts +++ b/apps/hq/test/health.test.ts @@ -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> 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`) diff --git a/apps/hq/test/hq2-schema.test.ts b/apps/hq/test/hq2-schema.test.ts index bc02e48..4b18d16 100644 --- a/apps/hq/test/hq2-schema.test.ts +++ b/apps/hq/test/hq2-schema.test.ts @@ -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') }) }) diff --git a/apps/hq/test/hq3-schema.test.ts b/apps/hq/test/hq3-schema.test.ts index 1c1a40b..d55a7ba 100644 --- a/apps/hq/test/hq3-schema.test.ts +++ b/apps/hq/test/hq3-schema.test.ts @@ -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) }) }) diff --git a/apps/hq/test/import-routes.test.ts b/apps/hq/test/import-routes.test.ts index 33d47b3..e35f131 100644 --- a/apps/hq/test/import-routes.test.ts +++ b/apps/hq/test/import-routes.test.ts @@ -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' }) }) }) diff --git a/apps/hq/test/import.test.ts b/apps/hq/test/import.test.ts index 4cdbb3d..39f3819 100644 --- a/apps/hq/test/import.test.ts +++ b/apps/hq/test/import.test.ts @@ -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') }) }) diff --git a/apps/hq/test/interactions.test.ts b/apps/hq/test/interactions.test.ts index cd08923..9adb0fb 100644 --- a/apps/hq/test/interactions.test.ts +++ b/apps/hq/test/interactions.test.ts @@ -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) }) }) diff --git a/apps/hq/test/module-roster.test.ts b/apps/hq/test/module-roster.test.ts index 341bc01..df1cb04 100644 --- a/apps/hq/test/module-roster.test.ts +++ b/apps/hq/test/module-roster.test.ts @@ -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 }) }) }) diff --git a/apps/hq/test/modules.test.ts b/apps/hq/test/modules.test.ts index 6461524..fbc7087 100644 --- a/apps/hq/test/modules.test.ts +++ b/apps/hq/test/modules.test.ts @@ -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() }) }) diff --git a/apps/hq/test/payments.test.ts b/apps/hq/test/payments.test.ts index ef44ee9..b683639 100644 --- a/apps/hq/test/payments.test.ts +++ b/apps/hq/test/payments.test.ts @@ -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 }) }) }) diff --git a/apps/hq/test/pdf-download.test.ts b/apps/hq/test/pdf-download.test.ts index bf504ea..2501d00 100644 --- a/apps/hq/test/pdf-download.test.ts +++ b/apps/hq/test/pdf-download.test.ts @@ -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.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> + beforeAll(async () => { ctx = await setup() }) afterAll(() => ctx.server.close()) const login = async (): Promise => (await (await fetch(`${ctx.base}/auth/login`, { diff --git a/apps/hq/test/pipeline.test.ts b/apps/hq/test/pipeline.test.ts index beddcd6..1185b75 100644 --- a/apps/hq/test/pipeline.test.ts +++ b/apps/hq/test/pipeline.test.ts @@ -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['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) => diff --git a/apps/hq/test/prepare-draft.test.ts b/apps/hq/test/prepare-draft.test.ts index a9a4a3c..3ca3a62 100644 --- a/apps/hq/test/prepare-draft.test.ts +++ b/apps/hq/test/prepare-draft.test.ts @@ -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) diff --git a/apps/hq/test/preview-fidelity.test.ts b/apps/hq/test/preview-fidelity.test.ts index 097f7c8..f76fbfe 100644 --- a/apps/hq/test/preview-fidelity.test.ts +++ b/apps/hq/test/preview-fidelity.test.ts @@ -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 { - 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> { + 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> + 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) }) diff --git a/apps/hq/test/preview-route.test.ts b/apps/hq/test/preview-route.test.ts index 9b65143..bf0dfd6 100644 --- a/apps/hq/test/preview-route.test.ts +++ b/apps/hq/test/preview-route.test.ts @@ -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> + 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 () => { diff --git a/apps/hq/test/preview-sample.test.ts b/apps/hq/test/preview-sample.test.ts index b633f78..ffbf044 100644 --- a/apps/hq/test/preview-sample.test.ts +++ b/apps/hq/test/preview-sample.test.ts @@ -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> + 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) }) diff --git a/apps/hq/test/profile-me.test.ts b/apps/hq/test/profile-me.test.ts index 0243035..62bf1ae 100644 --- a/apps/hq/test/profile-me.test.ts +++ b/apps/hq/test/profile-me.test.ts @@ -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 } @@ -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 }) }) }) diff --git a/apps/hq/test/public-share.test.ts b/apps/hq/test/public-share.test.ts index 13d2604..625ffbc 100644 --- a/apps/hq/test/public-share.test.ts +++ b/apps/hq/test/public-share.test.ts @@ -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 => { 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}`) diff --git a/apps/hq/test/quote-followup.test.ts b/apps/hq/test/quote-followup.test.ts index 144f9e5..4f273b1 100644 --- a/apps/hq/test/quote-followup.test.ts +++ b/apps/hq/test/quote-followup.test.ts @@ -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 { + 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 => + ((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 quote’s 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 quotation’s 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> + 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) diff --git a/apps/hq/test/receipt.test.ts b/apps/hq/test/receipt.test.ts index bd56e2b..cb6e289 100644 --- a/apps/hq/test/receipt.test.ts +++ b/apps/hq/test/receipt.test.ts @@ -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') diff --git a/apps/hq/test/recurring.test.ts b/apps/hq/test/recurring.test.ts index 19f753a..dc4f3f1 100644 --- a/apps/hq/test/recurring.test.ts +++ b/apps/hq/test/recurring.test.ts @@ -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) }) }) diff --git a/apps/hq/test/reminder-preview.test.ts b/apps/hq/test/reminder-preview.test.ts index 13ad555..547f23c 100644 --- a/apps/hq/test/reminder-preview.test.ts +++ b/apps/hq/test/reminder-preview.test.ts @@ -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> + 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 diff --git a/apps/hq/test/reminder-schedule.test.ts b/apps/hq/test/reminder-schedule.test.ts index 842838a..531ba7c 100644 --- a/apps/hq/test/reminder-schedule.test.ts +++ b/apps/hq/test/reminder-schedule.test.ts @@ -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 { + 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) }) }) diff --git a/apps/hq/test/reminders-page.test.ts b/apps/hq/test/reminders-page.test.ts index 9acc70a..bbec794 100644 --- a/apps/hq/test/reminders-page.test.ts +++ b/apps/hq/test/reminders-page.test.ts @@ -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 { + 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> + 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. diff --git a/apps/hq/test/reminders.test.ts b/apps/hq/test/reminders.test.ts index f18a7d3..7535bc6 100644 --- a/apps/hq/test/reminders.test.ts +++ b/apps/hq/test/reminders.test.ts @@ -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) }) }) diff --git a/apps/hq/test/reports.test.ts b/apps/hq/test/reports.test.ts index 2c69b0c..db79ac5 100644 --- a/apps/hq/test/reports.test.ts +++ b/apps/hq/test/reports.test.ts @@ -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, diff --git a/apps/hq/test/scheduler-recurring.test.ts b/apps/hq/test/scheduler-recurring.test.ts index 59edc92..59ca450 100644 --- a/apps/hq/test/scheduler-recurring.test.ts +++ b/apps/hq/test/scheduler-recurring.test.ts @@ -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']) }) }) diff --git a/apps/hq/test/scheduler-scan.test.ts b/apps/hq/test/scheduler-scan.test.ts index eb8da6f..45b6498 100644 --- a/apps/hq/test/scheduler-scan.test.ts +++ b/apps/hq/test/scheduler-scan.test.ts @@ -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) => - listReminders(db, {}).filter((r) => r.ruleKind === 'invoice_overdue') + const invoiceRows = async (db: ReturnType) => + (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/) }) diff --git a/apps/hq/test/send-reminder.test.ts b/apps/hq/test/send-reminder.test.ts index eeb7702..ef39a99 100644 --- a/apps/hq/test/send-reminder.test.ts +++ b/apps/hq/test/send-reminder.test.ts @@ -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) }) diff --git a/apps/hq/test/series.test.ts b/apps/hq/test/series.test.ts index 30c3ffb..fb04478 100644 --- a/apps/hq/test/series.test.ts +++ b/apps/hq/test/series.test.ts @@ -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') }) }) diff --git a/apps/hq/test/settings-company.test.ts b/apps/hq/test/settings-company.test.ts index d6b6836..76d3f48 100644 --- a/apps/hq/test/settings-company.test.ts +++ b/apps/hq/test/settings-company.test.ts @@ -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> + 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 () => { diff --git a/apps/hq/test/settings-reminders.test.ts b/apps/hq/test/settings-reminders.test.ts index 81c680e..af44b95 100644 --- a/apps/hq/test/settings-reminders.test.ts +++ b/apps/hq/test/settings-reminders.test.ts @@ -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> + 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) }) }) diff --git a/apps/hq/test/settings-sharing.test.ts b/apps/hq/test/settings-sharing.test.ts index 0a80e16..e995ec3 100644 --- a/apps/hq/test/settings-sharing.test.ts +++ b/apps/hq/test/settings-sharing.test.ts @@ -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) diff --git a/apps/hq/test/settings-template.test.ts b/apps/hq/test/settings-template.test.ts index 18c7c3c..4e8d642 100644 --- a/apps/hq/test/settings-template.test.ts +++ b/apps/hq/test/settings-template.test.ts @@ -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> + 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('') }) }) diff --git a/apps/hq/test/shares.test.ts b/apps/hq/test/shares.test.ts index de0c90a..8e4bb4b 100644 --- a/apps/hq/test/shares.test.ts +++ b/apps/hq/test/shares.test.ts @@ -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> + 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() }) })