fix(review): close all 8 findings from the D18 slice review

MAJOR
- Documents register: Convert & send no longer bubbles into row navigation
  (stopPropagation guard on the actions cell)
- Pipeline rows: convert_invoice now uses the same confirmed convert-and-send
  as the Documents page (was the old plain draft convert)
- APEX importer carries anydesk/os/district/sector: stg_client columns
  (+ migrate guard), optional CSV headers staged, commit INSERT maps them
- Clients page: district/sector filter inputs wired to the server filters
  (server side widened to LIKE for narrow-as-you-type)

MINOR
- WhatsApp/secretary typed contact roles: datalist on the contact Role input,
  labeled rows (with Copy) on the Client 360 support card
- Dashboard overdue + dues-aging now anchor on COALESCE(due_date, doc_date) —
  an unpaid invoice not yet due no longer shows as overdue (test added)
- PATCH /clients/:id is atomic: dbPassword + field patch in one transaction
- Audit same-txn: setClientDbPassword, stageCsv, and the import stage/commit
  route audits all wrapped in db.transaction

Suite: 345 tests / 66 files, typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 8064c49465
commit 94018b9ae4

@ -113,10 +113,15 @@ export function ClientFormDialog(props: {
<input className="wf" placeholder="Name" value={c.name} onChange={setContact(i, 'name')} />
<input className="wf" placeholder="Email" type="email" value={c.email} onChange={setContact(i, 'email')} />
<input className="wf" placeholder="Phone" value={c.phone} onChange={setContact(i, 'phone')} />
<input className="wf" placeholder="Role" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
{/* Typed roles (D18 WS-F): whatsapp/secretary get labeled rows on the support card; free text stays allowed. */}
<input className="wf" placeholder="Role" list="hq-contact-roles" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
<Button onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}></Button>
</div>
))}
<datalist id="hq-contact-roles">
<option value="whatsapp" />
<option value="secretary" />
</datalist>
<Button onClick={() => setContacts((prev) => [...prev, { ...EMPTY_CONTACT }])}>+ Add contact</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>

@ -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 (
<span key={i} style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6, marginRight: 18 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>
{ct.role === 'whatsapp' ? 'WhatsApp' : 'Secretary'}
</span>
<span>{[ct.name, num].filter((x) => x !== undefined && x !== '').join(' · ')}</span>
{num !== undefined && num !== '' && (
<Button onClick={() => copy(num, ct.role === 'whatsapp' ? 'WhatsApp number' : 'Secretary contact')}>Copy</Button>
)}
</span>
)
})}
<span style={{ display: 'inline-flex', alignItems: 'baseline', gap: 6 }}>
<span style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5, opacity: 0.6 }}>DB password</span>
{revealed !== undefined

@ -38,7 +38,12 @@ export const DOC_TONE: Record<DocStatus, Tone> = {
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)}
/>
<input
className="wf" style={{ maxWidth: 140 }} placeholder="District" aria-label="District"
value={district} onChange={(e) => setDistrict(e.target.value)}
/>
<input
className="wf" style={{ maxWidth: 120 }} placeholder="Sector" aria-label="Sector"
value={sector} onChange={(e) => setSector(e.target.value)}
/>
<FilterChips
active={statusFilter}
onChange={setStatusFilter}

@ -104,12 +104,16 @@ export function Documents() {
actions: d.docType === 'PROFORMA' && d.docNo !== null
&& d.status !== 'cancelled' && d.status !== 'invoiced'
? (
<Button
tone="primary"
onClick={() => setConverting({ id: d.id, docNo: d.docNo! })}
>
Convert & send
</Button>
// The whole <tr> navigates on click — without this guard the button
// click bubbles up, the page unmounts and the confirm never shows.
<span onClick={(e) => e.stopPropagation()}>
<Button
tone="primary"
onClick={() => setConverting({ id: d.id, docNo: d.docNo! })}
>
Convert & send
</Button>
</span>
)
: null,
}))}

@ -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<PipelineFilter>('all')
const [owner, setOwner] = useState('')
const [page, setPage] = useState(1)
const [error, setError] = useState<string | undefined>()
// 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 (
<Button tone="primary" onClick={() => act(documentAction(r.docId!, 'convert', { to: 'INVOICE' }))}>
Convert invoice
<Button tone="primary" onClick={() => setConverting({ docId: r.docId!, clientName: r.clientName })}>
Convert & send
</Button>
)
}
@ -153,6 +156,18 @@ export function Pipeline() {
actions: actions(r),
}))}
/>
{converting !== undefined && (
<ConfirmDialog
open
onClose={() => 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"
/>
)}
<Toolbar>
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />

@ -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) })

@ -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)
}

@ -51,8 +51,9 @@ function stageClients(db: DB, records: Record<string, string>[]): number {
)
const seen = new Set<string>()
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<string, string>[]): 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<string, string>[]): 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' })
}

@ -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. */

@ -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)),
})
}

@ -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<string, DuesAgingRow>()
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

@ -380,6 +380,7 @@ export function documentHtmlSample(company: Record<string, string>, 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',

@ -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', () => {

@ -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' })
})
})

@ -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
}

Loading…
Cancel
Save