From 584fb291f852ad5e780ccf3b20dbe75a3e0eed15 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 12:52:15 +0530 Subject: [PATCH] feat(documents): paginated register with honest total + Documents page (Phase 2, WS-D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listDocuments now returns { documents, total, page, pageSize } (default 50, cap 200, newest first) with the client name joined server-side — no N+1. GET /documents passes page/pageSize through; type/status/clientId filters compose against the same WHERE for both count and page (rule 8: no silent caps). clientLedger walks the paginated repo in 200-row batches so the ledger still surfaces every document. New /documents screen: type chips, status filter, mono doc numbers, amount column, pager honouring the server total; rows open /documents/:id. Nav entry under WORK (exact-match so New Document keeps its own highlight). Note: the GET /documents route hunk itself landed via 8861b46, swept into a concurrent commit from the same working tree; this commit carries the repo/ledger/web halves and the tests. Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/Layout.tsx | 2 +- apps/hq-web/src/api.ts | 20 +++ apps/hq-web/src/main.tsx | 2 + apps/hq-web/src/nav.ts | 9 +- apps/hq-web/src/pages/Documents.tsx | 120 ++++++++++++++++++ apps/hq/src/repos-documents.ts | 40 ++++-- apps/hq/src/repos-payments.ts | 10 +- apps/hq/test/convert-and-send.test.ts | 6 +- apps/hq/test/documents-page.test.ts | 151 +++++++++++++++++++++++ apps/hq/test/due-dates.test.ts | 2 +- apps/hq/test/scheduler-recurring.test.ts | 8 +- 11 files changed, 350 insertions(+), 20 deletions(-) create mode 100644 apps/hq-web/src/pages/Documents.tsx create mode 100644 apps/hq/test/documents-page.test.ts 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'])