diff --git a/apps/hq-web/src/Layout.tsx b/apps/hq-web/src/Layout.tsx
index 3df95b4..8622382 100644
--- a/apps/hq-web/src/Layout.tsx
+++ b/apps/hq-web/src/Layout.tsx
@@ -120,7 +120,7 @@ export function Layout() {
{g.label}
{g.items.map((n) => (
-
(isActive ? 'active' : '')} end={n.to === '/'}>
+ (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
{n.label}
{n.to === '/' && badgeCount > 0 && (
diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts
index 686c78a..c2ee8e6 100644
--- a/apps/hq-web/src/api.ts
+++ b/apps/hq-web/src/api.ts
@@ -115,6 +115,9 @@ export interface ClientModule {
export type DocType = 'QUOTATION' | 'PROFORMA' | 'INVOICE' | 'RECEIPT' | 'CREDIT_NOTE'
export type DocStatus =
| 'draft' | 'sent' | 'accepted' | 'invoiced' | 'part_paid' | 'paid' | 'lost' | 'cancelled'
+export const DOC_STATUSES: DocStatus[] = [
+ 'draft', 'sent', 'accepted', 'invoiced', 'part_paid', 'paid', 'lost', 'cancelled',
+]
export interface DocLine {
itemId: string; name: string; hsn: string; qty: number; unitCode: string
@@ -198,6 +201,23 @@ export const patchClientModule = (id: string, body: Record): Pr
export const getBillingSettings = (): Promise<{ paymentTermsDays: number }> =>
apiFetch<{ paymentTermsDays: number }>('/settings/billing')
+
+/** One Documents-list row: the document plus the server-joined client name (no N+1). */
+export type DocListRow = Doc & { clientName: string }
+export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number }
+/** Server-paginated document list with an honest total (rule 8); filters compose. */
+export const getDocuments = (
+ opts: { type?: DocType; status?: DocStatus; clientId?: string; page?: number; pageSize?: number } = {},
+): Promise => {
+ const p = new URLSearchParams()
+ if (opts.type !== undefined) p.set('type', opts.type)
+ if (opts.status !== undefined) p.set('status', opts.status)
+ if (opts.clientId !== undefined && opts.clientId !== '') p.set('clientId', opts.clientId)
+ if (opts.page !== undefined) p.set('page', String(opts.page))
+ if (opts.pageSize !== undefined) p.set('pageSize', String(opts.pageSize))
+ const q = p.toString()
+ return apiFetch(`/documents${q === '' ? '' : `?${q}`}`)
+}
export const createDocument = (body: Record): Promise =>
apiFetch<{ document: Doc }>('/documents', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.document)
export const getDocumentFull = (id: string): Promise<{ document: Doc; events: DocumentEvent[]; emails: EmailLogRow[]; shares: Share[] }> =>
diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx
index 4f51de0..89bace3 100644
--- a/apps/hq-web/src/main.tsx
+++ b/apps/hq-web/src/main.tsx
@@ -17,6 +17,7 @@ import { NewDocument } from './pages/NewDocument'
import { DocumentView } from './pages/DocumentView'
import { Employees } from './pages/Employees'
import { Pipeline } from './pages/Pipeline'
+import { Documents } from './pages/Documents'
function App() {
return (
@@ -32,6 +33,7 @@ function App() {
} />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts
index 132df61..f396ef1 100644
--- a/apps/hq-web/src/nav.ts
+++ b/apps/hq-web/src/nav.ts
@@ -1,9 +1,13 @@
import {
- BarChart3, Boxes, FilePlus2, FileText, Filter, LayoutDashboard, UserCog, Users,
+ BarChart3, Boxes, FilePlus2, Files, FileText, Filter, LayoutDashboard, UserCog, Users,
type LucideIcon,
} from 'lucide-react'
-export interface NavItem { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean }
+export interface NavItem {
+ to: string; label: string; icon: LucideIcon; ownerOnly?: boolean
+ /** Match this route exactly (for parents like /documents whose children have their own items). */
+ end?: boolean
+}
export interface NavGroup { label: string; items: NavItem[] }
/** Grouped sidebar (spec §3). Pipeline sits right after Dashboard (funnel spec §6b). */
@@ -14,6 +18,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/pipeline', label: 'Pipeline', icon: Filter },
{ to: '/clients', label: 'Clients', icon: Users },
+ { to: '/documents', label: 'Documents', icon: Files, end: true },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 },
],
},
diff --git a/apps/hq-web/src/pages/Documents.tsx b/apps/hq-web/src/pages/Documents.tsx
new file mode 100644
index 0000000..2ee4678
--- /dev/null
+++ b/apps/hq-web/src/pages/Documents.tsx
@@ -0,0 +1,120 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { formatINR } from '@sims/domain'
+import { Badge, DataTable, EmptyState, ErrorState, FilterChips, PageHeader, Skeleton, Toolbar } from '@sims/ui'
+import { getDocuments, DOC_STATUSES, type DocStatus, type DocType } from '../api'
+import { useData, DOC_TONE } from './Clients'
+
+const PAGE_SIZE = 50
+
+const TYPE_LABEL: Record = {
+ QUOTATION: 'Quotation', PROFORMA: 'Proforma', INVOICE: 'Invoice',
+ CREDIT_NOTE: 'Credit note', RECEIPT: 'Receipt',
+}
+const TYPE_CHIPS: { key: string; label: string }[] = [
+ { key: 'all', label: 'All' },
+ { key: 'QUOTATION', label: 'Quotations' },
+ { key: 'PROFORMA', label: 'Proformas' },
+ { key: 'INVOICE', label: 'Invoices' },
+ { key: 'CREDIT_NOTE', label: 'Credit notes' },
+ { key: 'RECEIPT', label: 'Receipts' },
+]
+
+/**
+ * Documents register (go-live cluster WS-D): every document across all clients,
+ * newest first, server-paginated with an honest total (rule 8). Type chips and
+ * the status filter compose server-side; a row opens the document view.
+ */
+export function Documents() {
+ const nav = useNavigate()
+ const [type, setType] = useState('all')
+ const [status, setStatus] = useState('')
+ const [page, setPage] = useState(1)
+
+ const list = useData(
+ () => getDocuments({
+ ...(type !== 'all' ? { type: type as DocType } : {}),
+ ...(status !== '' ? { status: status as DocStatus } : {}),
+ page, pageSize: PAGE_SIZE,
+ }),
+ [type, status, page],
+ )
+
+ const data = list.data
+ const docs = data?.documents
+ const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1
+ const from = data !== undefined && data.total > 0 ? (data.page - 1) * data.pageSize + 1 : 0
+ const to = data !== undefined ? Math.min(data.page * data.pageSize, data.total) : 0
+ const why = [
+ type !== 'all' ? ` of type ${TYPE_LABEL[type as DocType].toLowerCase()}` : '',
+ status !== '' ? ` with status ${status}` : '',
+ ].filter((x) => x !== '').join('')
+
+ return (
+
+
+
+ { setType(k); setPage(1) }}
+ />
+
+
+ {data !== undefined && {data.total} total}
+
+ {list.error !== undefined &&
}
+ {list.error !== undefined ? null
+ : docs === undefined ?
+ : docs.length === 0 ?
No documents{why !== '' ? why : ' yet'}. : (
+ <>
+
nav(`/documents/${docs[i]!.id}`)}
+ rows={docs.map((d) => ({
+ no: d.docNo ?? '— draft —',
+ type: TYPE_LABEL[d.docType],
+ client: d.clientName,
+ date: d.docDate,
+ status: {d.status},
+ amount: formatINR(d.payablePaise),
+ }))}
+ />
+
+ Showing {from}–{to} of {data!.total}
+
+
+ Page {data!.page} / {totalPages}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts
index b186e8b..0bc0d6d 100644
--- a/apps/hq/src/repos-documents.ts
+++ b/apps/hq/src/repos-documents.ts
@@ -68,16 +68,40 @@ export function getDocument(db: DB, id: string): Doc | null {
return row === undefined ? null : toDoc(row)
}
-export interface DocumentFilter { type?: string; status?: string; clientId?: string }
+export interface DocumentFilter {
+ type?: string; status?: string; clientId?: string
+ /** 1-based page (floor 1). */
+ page?: number
+ /** Rows per page — default 50, hard cap 200. */
+ pageSize?: number
+}
+
+/** One list row: the document plus the joined client name (list views need it without N+1 fetches). */
+export type DocListRow = Doc & { clientName: string }
+
+export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number }
-export function listDocuments(db: DB, filter: DocumentFilter = {}): Doc[] {
- let sql = `SELECT * FROM document WHERE 1=1`
+/** Filtered document list, newest first, paginated with a true total (rule 8 — no silent caps). */
+export function listDocuments(db: DB, filter: DocumentFilter = {}): DocumentsPage {
+ const page = Math.max(1, filter.page ?? 1)
+ const pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 50))
+ let where = ` WHERE 1=1`
const args: unknown[] = []
- if (filter.type !== undefined) { sql += ` AND doc_type=?`; args.push(filter.type) }
- if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) }
- if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) }
- sql += ` ORDER BY id DESC` // uuidv7 ids sort by creation time
- return (db.prepare(sql).all(...args) as DocRow[]).map(toDoc)
+ 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(
+ `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 })[]
+ return {
+ documents: rows.map((r) => ({ ...toDoc(r), clientName: r.client_name })),
+ total, page, pageSize,
+ }
}
// ---------- document events ----------
diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts
index 33e608f..4f88252 100644
--- a/apps/hq/src/repos-payments.ts
+++ b/apps/hq/src/repos-payments.ts
@@ -225,8 +225,16 @@ export function clientLedger(db: DB, clientId: string): ClientLedger {
FROM payment_allocation a JOIN payment p ON p.id = a.payment_id
WHERE p.client_id=?`,
).get(clientId) as { total: number }
+ // 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 })
+ documents.push(...batch.documents)
+ if (page * batch.pageSize >= batch.total) break
+ }
return {
- documents: listDocuments(db, { clientId }),
+ documents,
payments,
advancePaise: settling - row.total,
}
diff --git a/apps/hq/test/convert-and-send.test.ts b/apps/hq/test/convert-and-send.test.ts
index bdcd89b..2d69d64 100644
--- a/apps/hq/test/convert-and-send.test.ts
+++ b/apps/hq/test/convert-and-send.test.ts
@@ -145,7 +145,7 @@ describe('POST /documents/:id/convert-and-send', () => {
await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
const again = await loginAndPost(ctx.baseUrl, `/documents/${ctx.pi.id}/convert-and-send`)
expect(again.status).toBe(400)
- const invoices = listDocuments(ctx.db, { type: 'INVOICE' }).filter((d) => d.status !== 'cancelled')
+ const invoices = listDocuments(ctx.db, { type: 'INVOICE' }).documents.filter((d) => d.status !== 'cancelled')
expect(invoices).toHaveLength(1)
})
@@ -166,7 +166,7 @@ describe('POST /documents/:id/convert-and-send', () => {
ctx.db.prepare(`DELETE FROM email_account`).run() // 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' })).toHaveLength(0)
+ expect(listDocuments(ctx.db, { type: 'INVOICE' }).documents).toHaveLength(0)
expect(getDocument(ctx.db, ctx.pi.id)!.status).not.toBe('invoiced')
})
@@ -175,7 +175,7 @@ describe('POST /documents/:id/convert-and-send', () => {
const qt = createDraft(ctx.db, 'u1', {
docType: 'QUOTATION',
clientId: ctx.c.id,
- lines: [{ moduleId: listDocuments(ctx.db, {})[0]!.payload.lines[0]!.itemId, qty: 1, kind: 'yearly' }],
+ lines: [{ moduleId: 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)
diff --git a/apps/hq/test/documents-page.test.ts b/apps/hq/test/documents-page.test.ts
new file mode 100644
index 0000000..b5b7c5e
--- /dev/null
+++ b/apps/hq/test/documents-page.test.ts
@@ -0,0 +1,151 @@
+// apps/hq/test/documents-page.test.ts — Phase 2 (go-live cluster WS-D):
+// listDocuments pagination with an honest total (rule 8) + GET /documents pass-through.
+import express from 'express'
+import { describe, it, expect, afterAll } from 'vitest'
+import { openDb } from '../src/db'
+import { seedIfEmpty } from '../src/seed'
+import { createStaff } from '../src/auth'
+import { createClient } from '../src/repos-clients'
+import { createModule, setPrice } from '../src/repos-modules'
+import { createDraft, issueDocument, listDocuments, markStatus } from '../src/repos-documents'
+import { clientLedger } from '../src/repos-payments'
+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' })
+ 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' }] })
+ return { db, m, mkClient, mkDraft }
+}
+
+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')
+ const ids: string[] = []
+ for (let i = 0; i < 55; i++) ids.push(mkDraft(c.id).id)
+ const p1 = 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 })
+ expect(p2.total).toBe(55) // total is stable across pages
+ expect(p2.documents).toHaveLength(5)
+ // no overlap between pages
+ const seen = new Set(p1.documents.map((d) => d.id))
+ 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)
+ 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 })
+ expect(filtered.total).toBe(1)
+ expect(filtered.documents.map((d) => d.id)).toEqual([invA.id])
+ const draftInvoices = 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('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)
+ 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' })
+ 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))
+ const server = app.listen(0)
+ const baseUrl = `http://localhost:${(server.address() as { port: number }).port}/api`
+ return { db, server, baseUrl, mkClient, mkDraft }
+}
+
+async function login(baseUrl: string) {
+ return (await (await fetch(`${baseUrl}/auth/login`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ email: 'staff@test.in', password: 'staff-password' }),
+ })).json() as { token: string }).token
+}
+
+interface ListJson {
+ ok: boolean; total: number; page: number; pageSize: number
+ documents: { id: string; docType: string; clientName: string }[]
+}
+
+describe('GET /documents — paginated route', () => {
+ const servers: { close: () => void }[] = []
+ 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 token = await login(ctx.baseUrl)
+ const res = await fetch(`${ctx.baseUrl}/documents?page=2&pageSize=2`, {
+ headers: { authorization: `Bearer ${token}` },
+ })
+ expect(res.status).toBe(200)
+ const json = await res.json() as ListJson
+ expect(json).toMatchObject({ ok: true, total: 5, page: 2, pageSize: 2 })
+ expect(json.documents).toHaveLength(2)
+ expect(json.documents[0]!.clientName).toBe('Acme Bank')
+ })
+
+ 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 token = await login(ctx.baseUrl)
+ const res = await fetch(`${ctx.baseUrl}/documents?type=INVOICE&status=draft&page=1&pageSize=1`, {
+ headers: { authorization: `Bearer ${token}` },
+ })
+ const json = await res.json() as ListJson
+ expect(json.total).toBe(2) // both draft invoices counted, one on the page
+ expect(json.documents).toHaveLength(1)
+ expect(json.documents[0]!.docType).toBe('INVOICE')
+ })
+})
diff --git a/apps/hq/test/due-dates.test.ts b/apps/hq/test/due-dates.test.ts
index c3c2592..9860c04 100644
--- a/apps/hq/test/due-dates.test.ts
+++ b/apps/hq/test/due-dates.test.ts
@@ -90,7 +90,7 @@ describe('due dates (D18)', () => {
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' })
await runDailyScan(db, deps, '2026-07-17')
- const [inv] = listDocuments(db, { clientId: c.id, type: 'INVOICE' })
+ const [inv] = 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
})
diff --git a/apps/hq/test/scheduler-recurring.test.ts b/apps/hq/test/scheduler-recurring.test.ts
index be1d6f7..59edc92 100644
--- a/apps/hq/test/scheduler-recurring.test.ts
+++ b/apps/hq/test/scheduler-recurring.test.ts
@@ -37,7 +37,7 @@ describe('runDailyScan — recurring generation', () => {
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' })
+ const invoices = 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
@@ -51,14 +51,14 @@ describe('runDailyScan — recurring generation', () => {
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' })).toHaveLength(1) // invoice still issued
+ 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(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' })).toHaveLength(1)
+ expect(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()
@@ -72,7 +72,7 @@ describe('runDailyScan — recurring generation', () => {
const plan = 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' })).toHaveLength(3)
+ 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(periods).toEqual(['2026-05-10', '2026-06-10', '2026-07-10'])