diff --git a/apps/hq-web/src/components/ClientFormDialog.tsx b/apps/hq-web/src/components/ClientFormDialog.tsx index 90483c5..f85b1bf 100644 --- a/apps/hq-web/src/components/ClientFormDialog.tsx +++ b/apps/hq-web/src/components/ClientFormDialog.tsx @@ -113,10 +113,15 @@ export function ClientFormDialog(props: { - + {/* Typed roles (D18 WS-F): whatsapp/secretary get labeled rows on the support card; free text stays allowed. */} + ))} + + {error !== undefined && {error}} diff --git a/apps/hq-web/src/pages/ClientDetail.tsx b/apps/hq-web/src/pages/ClientDetail.tsx index 54e67d0..e1bf62f 100644 --- a/apps/hq-web/src/pages/ClientDetail.tsx +++ b/apps/hq-web/src/pages/ClientDetail.tsx @@ -545,6 +545,23 @@ function SupportCard(props: { client: Client; onChanged: () => void }) { {cell('OS', c.os)} {cell('District', c.district)} {cell('Sector', c.sector)} + {/* Typed contact rows (spec §5): whatsapp/secretary contacts surface here by label. */} + {c.contacts + .filter((ct) => ct.role === 'whatsapp' || ct.role === 'secretary') + .map((ct, i) => { + const num = ct.phone ?? ct.email + return ( + + + {ct.role === 'whatsapp' ? 'WhatsApp' : 'Secretary'} + + {[ct.name, num].filter((x) => x !== undefined && x !== '').join(' · ')} + {num !== undefined && num !== '' && ( + + )} + + ) + })} DB password {revealed !== undefined diff --git a/apps/hq-web/src/pages/Clients.tsx b/apps/hq-web/src/pages/Clients.tsx index 1a589dc..1a54108 100644 --- a/apps/hq-web/src/pages/Clients.tsx +++ b/apps/hq-web/src/pages/Clients.tsx @@ -38,7 +38,12 @@ export const DOC_TONE: Record = { export function Clients() { const nav = useNavigate() const [q, setQ] = useState('') - const { data, error, reload } = useData(() => getClients(q), [q]) + // D18 WS-F: the old book's two working filters, applied server-side. + const [district, setDistrict] = useState('') + const [sector, setSector] = useState('') + const { data, error, reload } = useData( + () => getClients(q, { district, sector }), [q, district, sector], + ) const [sp, setSp] = useSearchParams() const [creating, setCreating] = useState(sp.get('new') === '1') const [statusFilter, setStatusFilter] = useState('all') @@ -65,6 +70,14 @@ export function Clients() { className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…" value={q} onChange={(e) => setQ(e.target.value)} /> + setDistrict(e.target.value)} + /> + setSector(e.target.value)} + /> setConverting({ id: d.id, docNo: d.docNo! })} - > - Convert & send - + // The whole navigates on click — without this guard the button + // click bubbles up, the page unmounts and the confirm never shows. + e.stopPropagation()}> + + ) : null, }))} diff --git a/apps/hq-web/src/pages/Pipeline.tsx b/apps/hq-web/src/pages/Pipeline.tsx index c0651b7..87c3dd2 100644 --- a/apps/hq-web/src/pages/Pipeline.tsx +++ b/apps/hq-web/src/pages/Pipeline.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { formatINR } from '@sims/domain' -import { Badge, Button, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui' +import { Badge, Button, ConfirmDialog, DataTable, EmptyState, ErrorState, FilterChips, Notice, PageHeader, Skeleton, Toolbar, useToast } from '@sims/ui' import { documentAction, getEmployees, getPipeline, isManagerial, type Employee, type PipelineFilter, type PipelineRow, type PipelineStage, @@ -32,10 +32,13 @@ const BAND_TONE = { green: 'ok', amber: 'warn', red: 'err' } as const */ export function Pipeline() { const nav = useNavigate() + const toast = useToast() const [filter, setFilter] = useState('all') const [owner, setOwner] = useState('') const [page, setPage] = useState(1) const [error, setError] = useState() + // D18 WS-G: same one-click convert-and-send as the Documents register. + const [converting, setConverting] = useState<{ docId: string; clientName: string } | undefined>() const managerial = isManagerial() const list = useData( @@ -75,8 +78,8 @@ export function Pipeline() { } if (r.nextAction === 'convert_invoice') { return ( - ) } @@ -153,6 +156,18 @@ export function Pipeline() { actions: actions(r), }))} /> + {converting !== undefined && ( + setConverting(undefined)} + onConfirm={() => documentAction(converting.docId, 'convert-and-send') + .then((doc) => { toast.ok('Invoice issued & emailed'); nav(`/documents/${doc.id}`) }) + .catch((e: Error) => { setError(e.message); setConverting(undefined) })} + title="Convert to invoice & send" + body={`Convert the accepted proforma for ${converting.clientName} to a tax invoice, assign its number and email it to the client?`} + confirmLabel="Convert & send" + /> + )} Showing {from}–{to} of {data!.total} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 2c28638..037aac8 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -244,11 +244,17 @@ export function apiRouter( if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return } - setClientDbPassword(db, viewer.id, id, dbPassword, gmail().keyHex) } - const client = Object.keys(rest).length > 0 - ? updateClient(db, staffId(res), id, rest) - : getClient(db, id)! + // One transaction: a bad field elsewhere in the patch must not leave a + // half-applied (already audited) password change behind. + const client = db.transaction(() => { + if (typeof dbPassword === 'string') { + setClientDbPassword(db, staffId(res), id, dbPassword, gmail().keyHex) + } + return Object.keys(rest).length > 0 + ? updateClient(db, staffId(res), id, rest) + : getClient(db, id)! + })() res.json({ ok: true, client }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -496,7 +502,7 @@ export function apiRouter( const client: Client = picked ?? { id: '', code: '—', name: '— pick a client —', stateCode: company['company.state_code'] ?? '32', - address: '', contacts: [], status: 'lead', notes: '', + address: '', contacts: [], status: 'lead', notes: '', hasDbPassword: false, } const t = prepared.totals const doc: Doc = { @@ -943,18 +949,20 @@ export function apiRouter( r.post('/import/stage', requireAuth, requireOwner, (req, res) => { try { const body = req.body as { clientsCsv?: unknown; invoicesCsv?: unknown } - const out: { clientsStaged?: number; invoicesStaged?: number } = {} - if (typeof body.clientsCsv === 'string' && body.clientsCsv !== '') { - out.clientsStaged = stageCsv(db, 'clients', body.clientsCsv).staged - } - if (typeof body.invoicesCsv === 'string' && body.invoicesCsv !== '') { - out.invoicesStaged = stageCsv(db, 'invoices', body.invoicesCsv).staged - } - if (out.clientsStaged === undefined && out.invoicesStaged === undefined) { + const clientsCsv = typeof body.clientsCsv === 'string' && body.clientsCsv !== '' ? body.clientsCsv : undefined + const invoicesCsv = typeof body.invoicesCsv === 'string' && body.invoicesCsv !== '' ? body.invoicesCsv : undefined + if (clientsCsv === undefined && invoicesCsv === undefined) { res.status(400).json({ ok: false, error: 'Provide clientsCsv and/or invoicesCsv as CSV text' }) return } - writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, out) + // Staging writes and their audit rows land (or roll back) together. + const out = db.transaction(() => { + 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) + return o + })() res.json({ ok: true, ...out, report: verificationReport(db) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) @@ -972,8 +980,13 @@ export function apiRouter( }) r.post('/import/commit', requireAuth, requireOwner, (_req, res) => { try { - const result = commitImport(db, staffId(res)) // refuses while ANY staged row has problems - writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, result) + // 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) + return r + })() res.json({ ok: true, result }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index e4bcbd3..f4c911e 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -106,7 +106,9 @@ CREATE TABLE IF NOT EXISTS audit_log ( ); 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, problems TEXT NOT NULL DEFAULT '[]' + 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, @@ -213,6 +215,13 @@ function migrate(db: DB): void { db.exec(`ALTER TABLE client ADD COLUMN ${col} TEXT`) } } + // …and the same fields on the import staging table so the cutover carries them. + const stgClientCols = db.prepare(`PRAGMA table_info(stg_client)`).all() as { name: string }[] + for (const col of ['anydesk', 'os', 'district', 'sector']) { + if (!stgClientCols.some((c) => c.name === col)) { + db.exec(`ALTER TABLE stg_client ADD COLUMN ${col} TEXT`) + } + } rebuildStaffUserRoleCheck(db) rebuildReminderRuleKindCheck(db) } diff --git a/apps/hq/src/import-apex.ts b/apps/hq/src/import-apex.ts index 6373c1f..74e731f 100644 --- a/apps/hq/src/import-apex.ts +++ b/apps/hq/src/import-apex.ts @@ -51,8 +51,9 @@ function stageClients(db: DB, records: Record[]): number { ) const seen = new Set() const insert = db.prepare( - `INSERT INTO stg_client (row_no, code, name, gstin, state_code, address, phone, email, status, problems) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `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) => { const problems: string[] = [] @@ -68,6 +69,8 @@ function stageClients(db: DB, records: Record[]): number { insert.run( 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), ) }) @@ -117,9 +120,13 @@ function stageInvoices(db: DB, records: Record[]): number { export function stageCsv(db: DB, kind: 'clients' | 'invoices', csvText: string): { staged: number } { const records = parseCsv(csvText) - const staged = kind === 'clients' ? stageClients(db, records) : stageInvoices(db, records) - writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged }) - return { staged } + // 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 { staged } + })() } // ---------- verification report ---------- @@ -159,7 +166,8 @@ export function verificationReport(db: DB): VerificationReport { interface StgClientRow { row_no: number; code: string; name: string; gstin: string; state_code: string - address: string; phone: string; email: string; status: string; problems: string + address: string; phone: string; email: string; status: string + anydesk: string; os: string; district: string; sector: string; problems: string } interface StgInvoiceRow { @@ -195,9 +203,11 @@ export function commitImport(db: DB, userId: string): CommitResult { // 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( - `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, source, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`, + `INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status, + anydesk, os, district, sector, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`, ) + const orNull = (v: string): string | null => (v !== '' ? v : null) for (const c of stgClients) { const id = uuidv7() const contacts = c.phone !== '' || c.email !== '' @@ -210,7 +220,9 @@ export function commitImport(db: DB, userId: string): CommitResult { insertClient.run( 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', now, + 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' }) } diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts index 01744f1..a6f88b9 100644 --- a/apps/hq/src/repos-clients.ts +++ b/apps/hq/src/repos-clients.ts @@ -59,12 +59,13 @@ export function listClients( where += ` AND (name LIKE ? OR code LIKE ? OR gstin LIKE ?)` args.push(like, like, like) } - // D18 WS-F: the old book's two working filters. + // D18 WS-F: the old book's two working filters — LIKE, so a partial "Ern" + // already narrows to Ernakulam while the user types. if (filters.district !== undefined && filters.district !== '') { - where += ` AND district=?`; args.push(filters.district) + where += ` AND district LIKE ?`; args.push(`%${filters.district}%`) } if (filters.sector !== undefined && filters.sector !== '') { - where += ` AND sector=?`; args.push(filters.sector) + where += ` AND sector LIKE ?`; args.push(`%${filters.sector}%`) } const rows = db.prepare(`SELECT * FROM client${where} ORDER BY name`).all(...args) as ClientRow[] return rows.map(toClient) @@ -147,15 +148,17 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa export function setClientDbPassword( db: DB, userId: string, id: string, plain: string, keyHex: string, ): Client { - const before = 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)! + return db.transaction(() => { + const before = 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)! + })() } /** Decrypt-and-return the stored DB password — every reveal writes an audit row. */ diff --git a/apps/hq/src/repos-dashboard.ts b/apps/hq/src/repos-dashboard.ts index 45d497a..3b090c8 100644 --- a/apps/hq/src/repos-dashboard.ts +++ b/apps/hq/src/repos-dashboard.ts @@ -41,13 +41,15 @@ export function dashboardView( const mineDoc = meId !== undefined ? ` AND (d.created_by=? OR c.owner_id=?)` : '' const mineClient = meId !== undefined ? ` AND c.owner_id=?` : '' + // 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( - `SELECT d.id, d.doc_no, d.client_id, d.doc_date, c.name AS client_name + `SELECT d.id, d.doc_no, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name FROM document d JOIN client c ON c.id = d.client_id WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL - AND d.status NOT IN ('paid','cancelled','lost') AND d.doc_date <= ?${mineDoc} - ORDER BY d.doc_date`, - ).all(...(meId !== undefined ? [today, meId, meId] : [today])) as { id: string; doc_no: string | null; client_id: string; doc_date: string; client_name: string }[] + 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 }[] const overdue: DashboardView['overdue'] = [] let overduePaise = 0 for (const r of overdueRows) { @@ -56,7 +58,7 @@ export function dashboardView( overduePaise += outstanding overdue.push({ docId: r.id, docNo: r.doc_no, clientId: r.client_id, clientName: r.client_name, - outstandingPaise: outstanding, daysOverdue: Math.max(0, daysBetween(r.doc_date, today)), + outstandingPaise: outstanding, daysOverdue: Math.max(0, daysBetween(r.anchor, today)), }) } diff --git a/apps/hq/src/repos-reports.ts b/apps/hq/src/repos-reports.ts index d20e337..e513a6d 100644 --- a/apps/hq/src/repos-reports.ts +++ b/apps/hq/src/repos-reports.ts @@ -38,18 +38,19 @@ export interface DuesAgingRow { } export function duesAging(db: DB, today: string): DuesAgingRow[] { + // Buckets age from the stamped due date when present (D18 WS-A), doc date otherwise. const invoices = db.prepare( - `SELECT d.id, d.client_id, d.doc_date, c.name AS client_name + `SELECT d.id, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name FROM document d JOIN client c ON c.id = d.client_id 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; doc_date: string; client_name: string }[] + ).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) 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 } - const age = daysBetween(inv.doc_date, today) + const age = daysBetween(inv.anchor, today) if (age <= 30) row.b0_30 += out else if (age <= 60) row.b31_60 += out else if (age <= 90) row.b61_90 += out diff --git a/apps/hq/src/templates.ts b/apps/hq/src/templates.ts index da155bc..65cd124 100644 --- a/apps/hq/src/templates.ts +++ b/apps/hq/src/templates.ts @@ -380,6 +380,7 @@ export function documentHtmlSample(company: Record, opts: { cont const client: Client = { id: 'sample', code: 'SAMPLE', name: 'Sample Client Pvt Ltd', gstin: '32ABCDE1234F1Z5', stateCode, address: '1st Floor, MG Road, Kochi', contacts: [], status: 'active', notes: '', + hasDbPassword: false, } const line: BillLine = { itemId: 'sample', name: 'POS Billing — Yearly', hsn: '998313', qty: 1, unitCode: 'NOS', diff --git a/apps/hq/test/dashboard.test.ts b/apps/hq/test/dashboard.test.ts index d89bb7a..d02402f 100644 --- a/apps/hq/test/dashboard.test.ts +++ b/apps/hq/test/dashboard.test.ts @@ -42,6 +42,18 @@ describe('dashboardView', () => { expect(view.queue.length).toBeGreaterThanOrEqual(3) // overdue + renewal + follow_up 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() + // 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) + // 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') + expect(view.overdue.map((o) => o.docId)).toContain(inv.id) + expect(view.overdue[0]!.daysOverdue).toBe(5) + }) }) describe('GET /api/dashboard + reminder queue routes', () => { diff --git a/apps/hq/test/import-routes.test.ts b/apps/hq/test/import-routes.test.ts index 3b556cb..33d47b3 100644 --- a/apps/hq/test/import-routes.test.ts +++ b/apps/hq/test/import-routes.test.ts @@ -6,7 +6,9 @@ import { seedIfEmpty } from '../src/seed' import { createStaff } from '../src/auth' import { apiRouter } from '../src/api' -const CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status\nAPX001,Acme Bank,,32,Kochi,,r@acme.in,active\n' +// Optional WS-F support columns ride the same CSV (anydesk/os/district/sector). +const CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status,anydesk,os,district,sector\n' + + 'APX001,Acme Bank,,32,Kochi,,r@acme.in,active,123456789,Windows 10,Ernakulam,Co-op\n' const BAD_CLIENTS_CSV = 'code,name,gstin,state_code,address,phone,email,status\n,No Code Bank,,32,,,,active\n' // Amounts are RUPEES in the APEX export; staging converts via fromRupees. // Current-FY doc date so the INVOICE series seeds past the legacy number. @@ -71,8 +73,10 @@ describe('APEX import routes (owner-only)', () => { expect(cj.result.clients).toBe(1) 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. + // 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() expect(imported).toMatchObject({ source: 'apex' }) + const client = ctx.db.prepare(`SELECT anydesk, os, district, sector FROM client WHERE code='APX001'`).get() + expect(client).toEqual({ anydesk: '123456789', os: 'Windows 10', district: 'Ernakulam', sector: 'Co-op' }) }) }) diff --git a/apps/hq/test/reports.test.ts b/apps/hq/test/reports.test.ts index fad5734..2c69b0c 100644 --- a/apps/hq/test/reports.test.ts +++ b/apps/hq/test/reports.test.ts @@ -12,7 +12,9 @@ function issuedInvoice(db: any, clientId: string, moduleId: string, docDate: str const inv = issueDocument(db, 'u1', createDraft(db, 'u1', { docType: 'INVOICE', clientId, lines: [{ moduleId, qty: 1, kind: 'yearly' }], }).id) - db.prepare(`UPDATE document SET doc_date=? WHERE id=?`).run(docDate, inv.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) return inv }