feat(documents): paginated register with honest total + Documents page (Phase 2, WS-D)

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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent 8861b46afd
commit 584fb291f8

@ -120,7 +120,7 @@ export function Layout() {
<div key={g.label} className="hq-group">
<div className="hq-eyebrow">{g.label}</div>
{g.items.map((n) => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/'}>
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/' || n.end === true}>
<n.icon size={15} strokeWidth={1.8} aria-hidden />
<span className="lbl">{n.label}</span>
{n.to === '/' && badgeCount > 0 && (

@ -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<string, unknown>): 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<DocumentsPage> => {
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<DocumentsPage>(`/documents${q === '' ? '' : `?${q}`}`)
}
export const createDocument = (body: Record<string, unknown>): Promise<Doc> =>
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[] }> =>

@ -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() {
<Route path="/reports" element={<Reports />} />
<Route path="/settings/template" element={<DocumentTemplate />} />
<Route path="/employees" element={<Employees />} />
<Route path="/documents" element={<Documents />} />
<Route path="/documents/new" element={<NewDocument />} />
<Route path="/documents/:id" element={<DocumentView />} />
<Route path="*" element={<Navigate to="/" replace />} />

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

@ -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<DocType, string> = {
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 (
<div className="wf-page">
<PageHeader
title="Documents"
desc="Every quotation, proforma, invoice, credit note and receipt — newest first. Click a row to open it."
/>
<Toolbar>
<FilterChips
chips={TYPE_CHIPS}
active={type}
onChange={(k) => { setType(k); setPage(1) }}
/>
<select
className="wf" style={{ maxWidth: 180 }} value={status} aria-label="Status"
onChange={(e) => { setStatus(e.target.value); setPage(1) }}
>
<option value="">Status: any</option>
{DOC_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<span style={{ flex: 1 }} />
{data !== undefined && <Badge>{data.total} total</Badge>}
</Toolbar>
{list.error !== undefined && <ErrorState message={list.error} onRetry={list.reload} />}
{list.error !== undefined ? null
: docs === undefined ? <Skeleton rows={6} />
: docs.length === 0 ? <EmptyState>No documents{why !== '' ? why : ' yet'}.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'no', label: 'No.', mono: true },
{ key: 'type', label: 'Type' },
{ key: 'client', label: 'Client' },
{ key: 'date', label: 'Date' },
{ key: 'status', label: 'Status' },
{ key: 'amount', label: 'Amount', numeric: true },
]}
onRowClick={(_row, i) => 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: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
amount: formatINR(d.payablePaise),
}))}
/>
<Toolbar>
<Badge>Showing {from}{to} of {data!.total}</Badge>
<span style={{ flex: 1 }} />
<button
type="button" className="wf" disabled={data!.page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Prev
</button>
<span style={{ fontSize: 12, opacity: 0.7 }}>Page {data!.page} / {totalPages}</span>
<button
type="button" className="wf" disabled={data!.page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</Toolbar>
</>
)}
</div>
)
}

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

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

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

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

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

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

Loading…
Cancel
Save