Module topology
149 TypeScript files under src/, 22,437 lines, in four layers over one
database. The path alias @/* maps to ./src/* in both
tsconfig.json and jest.config.ts.
← Scroll to see the full diagram →
src/lib imports from src/components, and only
src/server/db opens a connection. Two boxes are highlighted because they are
where the layering breaks down. lib/chat/tool-handlers.ts imports
@/server/db directly and writes its own Drizzle aggregations rather than
going through lib/queries — at 2,127 lines it is by some margin the
largest file in the project. And server/sync.ts shadows the
server/sync/ directory beside it, as set out on
Sheet 3.
Register of routes
Twenty-seven route files: eighteen production, nine debug. Authentication is layered
— src/middleware.ts protects everything by default and names eight
public exceptions, and most protected routes then re-check the session for themselves.
Production routes
| Route | Verbs | Guard | Note |
|---|---|---|---|
| Conversation & agent | |||
/api/chat | POST | auth() | SSE stream; 1 MB body cap; 20 tools attached |
/api/conversations | GET, POST | currentUser() | Just-in-time users row creation |
/api/conversations/[id] | GET, PATCH, DELETE | + ownership | 404 then 403 checks |
/api/conversations/[id]/messages | GET, POST | + ownership | No route test of its own |
| Scheduled & public | |||
/api/cron/sync | GET | Bearer | Public in middleware; strict secret comparison in-route |
/api/categorize | POST | Bearer, optional | Public; accepts a request with no Authorization header at all |
/api/cron/health | GET | none | Public by design |
/api/health | GET | none | Public by design |
/api/webhooks/clerk | POST | Svix | Signature-verified; handles user created / updated / deleted |
| Data & integrations | |||
/api/sync | GET, POST | auth() | Manual trigger; body { type: 'full' | 'incremental' } |
/api/metrics/expenses | GET | middleware | ?days; omitted means all time. No in-route check |
/api/metrics/net-change | GET | middleware | ?days. No in-route check |
/api/transactions/[id]/category | PATCH | auth() | { categoryId, rememberPattern? } |
/api/auth/quickbooks | GET | middleware | Sets a 10-minute httpOnly qb_oauth_state cookie, then redirects |
/api/auth/quickbooks/callback | GET | auth() + state | CSRF state comparison; eight distinct error codes |
/api/quickbooks/status | GET | auth() | Returns the realm identifier |
/api/quickbooks/sync | GET, POST | auth() | Zod-validated date range; default 90 days, maximum 365 |
/api/quickbooks/disconnect | DELETE | auth() | 404 when no connection exists |
Debug routes
src/middleware.ts lists /api/debug(.*) among its public
matchers, with the comment // Debug endpoints (REMOVE IN PRODUCTION). No
debug route file performs a check of its own. Four of the nine mutate data; one deletes
every payroll row it can reach. Several exist specifically to print payroll internals, so
their responses carry exactly the material this record is written to keep out.
| Route | Verbs | Writes | Purpose |
|---|---|---|---|
/api/debug/quickbooks-entries | GET | tokens only | Decrypts the stored tokens and dumps journal entries, chart of accounts and company info |
/api/debug/quickbooks-purchases | GET | tokens only | Contractor payment rollup by vendor |
/api/debug/test-parser | GET | tokens only | Runs parseGustoJournalEntry over live entries and prints the parse |
/api/debug/payroll-line-items | GET | no | Most recent 50 line items, joined to employees |
/api/debug/test-compensation | GET | no | Per-person compensation totals |
/api/debug/setup-team | GET, POST | yes | Upserts employee rows from a hard-coded roster |
/api/debug/cleanup-employees | POST | yes | Deactivates the four parser-artefact names |
/api/debug/update-from-gusto | GET, POST | yes | Overwrites period and headcount fields on matched runs |
/api/debug/sync-all-payroll | POST | destructive | ?clear=true deletes all payroll line items and all payroll runs, unconfirmed, then re-syncs |
Specified but never built
PRD.md carries a route table written before implementation. Seven of its
entries have no corresponding file. In most cases the capability exists — it simply
arrived as a server action instead.
| Specified | What happened instead |
|---|---|
/api/mercury/connect | updateMercuryApiKey() server action |
/api/mercury/sync | triggerManualSync() action, and POST /api/sync |
/api/categories | getCategories / createCategory actions |
/api/patterns | Folded into updateTransactionCategory(…, rememberPattern) |
/api/dashboard/metrics | Server components call lib/metrics directly |
/api/transactions | The page calls getTransactions() directly |
/api/auth/webhook | Built as /api/webhooks/clerk; DEPLOY.md uses the second name |
Public route matchers
const isPublicRoute = createRouteMatcher([
'/sign-in(.*)',
'/sign-up(.*)',
'/api/webhooks(.*)',
'/api/cron(.*)', // Cron jobs use CRON_SECRET auth
'/api/health(.*)', // Health checks should be public
'/api/categorize(.*)', // AI categorization endpoint
'/api/debug(.*)', // Debug endpoints (REMOVE IN PRODUCTION)
'/privacy', // Privacy policy (public for QuickBooks)
'/terms', // Terms of service (public for QuickBooks)
]);
Comments are the source's own. The privacy and terms pages are public because Intuit requires a published policy before granting production OAuth access — which is also why those two pages exist in src/app/ at all.
Common response conventions
| Code | Body | Raised when |
|---|---|---|
| 200 | varies | Success |
| 201 | the created object | Conversation and message creation |
| 400 | { error: 'Invalid request body' }, { error: 'Title is required' } | Malformed JSON or a failed field check |
| 401 | { error: 'Unauthorized' } | No Clerk session, or a failed bearer check |
| 403 | { error: 'Forbidden' } | The row belongs to another user |
| 404 | { error: '… not found' } | No such row |
| 413 | { error: 'Request too large' } | /api/chat only, above 1 MB |
| 500 | { error: '<verb> failed', message } | Any throw; a non-Error reports 'Unknown error' |
Distribution of tests
1,088 test blocks across 50 files and 16,649 lines — 0.74 lines of test for every line of source. The distribution is heavily weighted toward the agent boundary and the markdown renderer, the two places where behaviour is defined by a long list of discrete cases rather than by a single control flow.
← Scroll to see the full diagram →
lib/chat files carry 189 blocks
between them, 17.4% of the suite — a defensible allocation given that they define
the entire surface a language model is permitted to touch. Counts are of it
and test declarations, including those inside conditionally skipped describe
blocks.
Where the assurance is not
| Area | Covered | Note |
|---|---|---|
| API routes | 5 of 27 | Untested: the Clerk webhook, both OAuth legs, all QuickBooks routes, both metrics routes, the category PATCH, /api/sync, and all nine debug routes |
| Server actions | 1 of 4 files | Only settings.ts; no tests for chat.ts, transactions.ts or categorize.ts |
| Payroll components | 0 of 6 | No __tests__/components/payroll/ directory |
| Payroll agent tools | 0 of 8 | Handlers exist; none is invoked by a test |
| The live sync | 0 | src/server/sync.ts has no test at all; the sync tests target the shadowed directory |
The pattern is consistent: almost everything built in Phases 1 to 9 is covered, and almost nothing added in Phase 10 is. That matches the four unticked assurance items in TASKS.md.
Reconciliation of counts
CLAUDE.md requires that completed tasks record their test count in
TASKS.md. That makes the file auditable: the recorded figures can be compared
against the blocks actually present.
| TASKS.md entry | Recorded | Counted | Result |
|---|---|---|---|
| 2.2 Mercury API client | 27 | 33 | agrees — 6 are conditionally skipped |
| 2.3 Transaction sync | 13 | 13 | agrees |
| 1.2 Auth flow | 7 | 7 | agrees |
| 2.1 DB operations | 7 | 7 | agrees |
| 8.1 Sync → categorize | 10 | 10 | agrees — on a module that never runs |
| 8.3 /api/categorize endpoint | 8 | 8 | agrees |
| 10.3 QuickBooks sync | 21 | 21 | agrees |
| 10.1 QuickBooks OAuth + client | 74 | 74 | agrees — client 33 + encryption 19 + types 22 |
| Summary — chat tools | 29 | 78 | differs — the file grew across Phases 9 and 10 |
| Summary — chat tool handlers | 53 | 111 | differs — same cause |
| 3.2 + 8.2 Pattern learning | 27 | 23 | differs — the two entries overlap in one file |
Eight of eleven agree exactly. The three that differ are the entries whose files were later extended without the earlier line being revised — the same drift that left the tool count assertion at nineteen. Every figure recorded against a component that was finished and not revisited still agrees, which is why the figures on Sheet 1 are drawn from mechanical counts rather than from the summary table.
Environment
Fourteen variables are referenced. DEPLOY.md lists seven; the other seven are
discoverable only by reading code. Names only appear below — no values.
| Variable | Required | Consumed by, and behaviour when absent |
|---|---|---|
| Listed in DEPLOY.md | ||
DATABASE_URL | yes | src/server/db/index.ts asserts it non-null at module scope, so an unset value fails at import |
MERCURY_API_KEY | fallback | The sync prefers the encrypted key in settings and falls back to this; also gates the Mercury integration suite |
ANTHROPIC_API_KEY | yes | getAnthropicClient() throws 'ANTHROPIC_API_KEY not configured' |
CRON_SECRET | yes | verifyCronAuth returns false — scheduled endpoints close rather than open |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | yes | Clerk browser SDK |
CLERK_SECRET_KEY | yes | Clerk server SDK and clerkMiddleware |
CLERK_WEBHOOK_SECRET | yes | Svix verification; the webhook route throws without it |
| Discoverable only from code | ||
ENCRYPTION_KEY | yes | src/lib/crypto.ts throws 'ENCRYPTION_KEY environment variable is not set'. Both the QuickBooks tokens and the stored Mercury key depend on it |
QUICKBOOKS_CLIENT_ID | yes | Named error from getQuickBooksConfig() |
QUICKBOOKS_CLIENT_SECRET | yes | Named error |
QUICKBOOKS_REDIRECT_URI | yes | Named error |
QUICKBOOKS_ENVIRONMENT | no | Defaults to sandbox — a live deployment that omits it talks to the sandbox host |
SKIP_MERCURY_INTEGRATION | no | Set to 'true' to skip live Mercury calls even when a key is present |
NODE_ENV | no | The error boundary renders the underlying message only when it is 'development' |
Local development reads .env.local, loaded by dotenv from jest.env.ts, drizzle.config.ts, check-qb.js and the three files in scripts/. .gitignore excludes .env* wholesale.
DEPLOY.md was written at Phase 7 and lists seven variables. Phase 10 added
five more — ENCRYPTION_KEY and the four QUICKBOOKS_*
entries — without the checklist being updated. A deployment following it exactly
would start, authenticate, and run the agent; QuickBooks would fail at connect time, and
saving a Mercury key through the settings page would fail on encryption.
Scripts & the completion gate
Thirteen scripts. Five wrap drizzle-kit; the rest are the standard Next.js and
Jest entry points.
$ npm run Scripts available in gogenticcfo@0.1.0 via `npm run-script`: dev next dev build next build start next start lint eslint test jest test:watch jest --watch test:coverage jest --coverage typecheck tsc --noEmit db:generate drizzle-kit generate db:migrate drizzle-kit migrate db:push drizzle-kit push db:studio drizzle-kit studio db:seed npx tsx src/server/db/seed.ts
Script bodies are quoted verbatim from package.json. Because test is bare jest, npm test -- <path> forwards the path straight through, which is the form used in the terminal renderings on Sheets 3 and 5.
The Definition of Done
| Condition | Standing |
|---|---|
| Feature implemented | — |
| Unit tests, 100% of new code | Enforced at 80% by jest.config.ts across branches, functions, lines and statements. Phase 10 did not meet it |
npm test passes | Recorded once, at Phase 8.3. It does not pass on the code as committed — see exception 1 |
| Real implementations, not mocks | Partly honoured. Four suites run against a live Postgres and clean up after themselves; the component and handler suites mock at the module boundary |
npm run typecheck | tsc --noEmit under strict: true |
npm run build | Verified at Phase 8.3, before the Phase 10 work landed |
The four suites that talk to a real database are
__tests__/server/db/seed.test.ts,
__tests__/server/patterns/index.test.ts,
__tests__/server/sync/transactions.test.ts and
__tests__/lib/queries/chat.test.ts. Each carries a
@jest-environment node docblock, opens its own Neon connection, and tears down
its fixtures — the pattern tests delete every row whose pattern begins
TEST_ in an afterAll.
“No skipped tests” is not quite true. Two describe blocks are skipped:
the Mercury integration suite, conditionally, and an “API Integration Tests”
block in __tests__/lib/ai/categorize.test.ts, unconditionally via
describe.skip. The latter carries its own instructions for running it by
hand —
npm test -- --testEnvironment=node __tests__/lib/ai/categorize.test.ts
— because the Anthropic SDK will not run under jsdom without an explicit browser
flag. Both are documented rather than hidden.
Open items
Nine items in TASKS.md are unticked. They cluster in two places: the tail of
the polish phase, and the tests for the most recent feature.
| Ref | Item | Kind |
|---|---|---|
| Phase 6 — polish | ||
| 6.2 | Performance optimization | Engineering |
| 6.2 | Mobile responsiveness QA | Engineering |
| Phase 7 — deployment | ||
| 7.1 | Set up production Neon database | Operations |
| 7.1 | Configure Clerk production keys | Operations |
| 7.1 | Deploy and smoke test | Operations |
| Phase 10 — QuickBooks and payroll | ||
| 10.2 | Write schema tests | Assurance |
| 10.4 | Write payroll page tests | Assurance |
| 10.5 | Write settings tests | Assurance |
| 10.6 | Write agent tool tests | Assurance |
All four assurance items are corroborated by the file listing rather than taken on trust.
A fifth, unlisted, belongs with them: the debug routes carry
DELETE THIS ENDPOINT BEFORE PRODUCTION! comments and a matching note in
middleware.ts, but no task tracks their removal.
Register of exceptions
Points where the code contradicts the specification, the tests contradict the code, or the code contradicts itself. They are listed because a technical record that reports only agreement is not much of a record. Several are simply the ordinary residue of a system built quickly in phases; a few are worth acting on.
| # | Exception | Effect |
|---|---|---|
| Correctness | ||
| 1 | tools.test.ts asserts toHaveLength(19); financialTools holds 20 |
The suite fails on the committed code. get_team_compensation was added without the assertion being updated |
| 2 | src/server/sync.ts shadows src/server/sync/index.ts under bundler resolution |
The directory version — the only one that triggers categorisation after a sync — is unreachable from production. Ten tests certify it anyway |
| 3 | The Runway card's subtitle reads “Based on net burn rate”, but getRunwayMonths() is called with no argument and its parameter defaults to false |
The figure shown is gross-burn runway, labelled as net |
| 4 | Burn rate is documented as “average monthly expenses over the last 3 months” in both the code comments and the agent tool description; the implementation takes a daily average over the data range and multiplies by 30 | The two agree only when the data range happens to be three even months |
| 5 | The get_metrics cache key omits months |
Within a 60-second window, cash_flow_by_month for 3 months and for 12 months share one entry |
| Exposure | ||
| 6 | All nine /api/debug/* routes are public in middleware.ts and perform no check of their own |
Four mutate; one deletes all payroll rows on a query parameter; several print payroll and vendor detail. The source flags this itself in two places |
| 7 | /api/categorize accepts a request with no Authorization header |
Deliberate, per its comment — “allow either cron secret or no auth for manual triggers”. A wrong secret is still rejected |
| 8 | verifyCronAuth compares the bearer token with === |
Not a constant-time comparison |
| Schema | ||
| 9 | schema.ts declares no indexes at all |
Only primary keys and the three unique constraints are indexed. transactions.date, transactions.category_id, transactions.counterparty_name and payroll_runs.pay_date are all filtered or sorted on without one |
| 10 | seed.ts relies on onConflictDoNothing(), but categories.name has no unique constraint |
Re-running npm run db:seed duplicates all twelve rows |
| 11 | payroll_runs.total_deductions is assigned a literal 0 by the parser |
The column exists and is NOT NULL, and is always zero. Deductions are computed only per employee |
| 12 | The employment_type comment lists three values; five are in use |
Plain text, no check constraint, no enum — nothing enforces either set |
| 13 | quickbooks_connections.user_id is text with no foreign key and no unique constraint, though the code upserts one row per user |
Two identity conventions in one schema, and no referential integrity on the QuickBooks side |
| Documentation & duplication | ||
| 14 | PRD.md specifies cursor pagination; the client and the sync both use limit/offset. The cursor types survive, unreferenced |
Dead type surface; the specification is wrong about a shipped behaviour |
| 15 | Three totals for the test suite — 870 recorded as run, 1,056 in the summary table, 1,088 countable — and TASKS.md describes migration 0000 as creating 7 tables where the SQL creates 8 |
Documentation drift; the mechanical counts are used throughout this record |
| 16 | Currency formatting exists twice — src/lib/format.ts and a private formatCurrency inside tool-handlers.ts; the time-range option list exists three times |
The two formatters differ on sign handling. lib/time-range.ts is duplicated verbatim into two dashboard components |
| 17 | cronErrorResponse's 'Operation failed' label is never used; findOrCreateEmployee in quickbooks/sync.ts is defined but never called, its logic inlined at the call site |
Dead branches in otherwise live modules |
Accessibility, as evidenced
TASKS.md 6.2 marks an accessibility audit complete. The component tests
corroborate it — not as a claim, but as assertions that would fail if the attributes
were removed.
| Affordance | Where |
|---|---|
aria-label | Send message, Chat message input, Toggle theme, Toggle AI Chat, Open navigation menu, New chat, Delete conversation, previous/next page, Go to page N, Clear all filters, Try again, Start date, End date |
aria-current | "page" on the current pagination button; "true" on the active conversation |
role="status" | Tool status indicator and every success message in settings |
role="alert" | Every error message in settings |
aria-live="polite" | Tool status indicator |
aria-hidden="true" | Decorative icons throughout, and the streaming cursor in the markdown renderer |
| Screen-reader text | “Thinking…” inside the typing indicator |
| Focus styling | A focus-visible: variant asserted on conversation items |
Two related items remain open — performance optimisation and mobile responsiveness QA — so the audit covered semantics rather than layout.
Basis of preparation
This record was prepared by reading the repository: 149 source files, three migrations, the committed configuration, the specification set, and a fifty-file test suite. Where a claim on these sheets could be checked against more than one of those, it was.
The basis markers exist because those sources do not always agree. A behaviour read
from src/ is what the application does. A behaviour pinned by a test is what
it was expected to do when the test was written — usually the same thing, and
occasionally, as the register above records, not. A behaviour stated only in
PRD.md is an intention, and at least seven of those intentions were met a
different way or not at all. Where the sources disagree, this record follows the code.
| Artefact | Extent | What it establishes |
|---|---|---|
src/ | 149 files | 22,437 lines. Behaviour, constants, limits, prompts, route contracts, schema |
__tests__/ | 50 files | 16,649 lines. Expected behaviour, literal strings, formatting rules, error messages |
drizzle/ | 7 files | The schema as applied, and the timestamp of each migration |
package.json | 1 file | 13 scripts, 29 dependencies, 19 devDependencies, exact versions |
vercel.json | 1 file | Both cron schedules |
PRD.md, TASKS.md | 629 lines | Intended scope, completion state, recorded test counts, open items |
CLAUDE.md, DEPLOY.md | 83 lines | Definition of Done, environment checklist, webhook configuration |
| Config & scripts | 11 files | Jest, Drizzle, Next.js, TypeScript, ESLint, PostCSS, shadcn, plus four diagnostic scripts |
Deliberately excluded
This is a private business repository, and parts of it hold live financial and personal data. The following were read only far enough to establish their shape, and are not reproduced anywhere in this record — not in summary, not in aggregate, and not as illustrative figures.
| Location | Nature of the content |
|---|---|
payrollexport/ | Two exported payroll reports, one originating from Gusto and one from QuickBooks, held as a spreadsheet and a CSV. Real payroll records |
src/server/db/seed-employees.ts | A hard-coded roster of real people with departments and employment classifications, plus a headcount summary |
src/server/db/seed-contractors.ts | Named contractors with individual payment dates and amounts, per-person totals, and an aggregate spend figure |
src/server/db/seed-payroll.ts | Fourteen payroll periods with gross, tax and net figures and per-employee splits |
src/app/api/debug/setup-team/route.ts | The same roster again, in a module constant and in the file's own documentation comment |
src/app/api/debug/update-from-gusto/route.ts | Fourteen pay dates with headcounts and gross totals, and two named employees in comments |
src/app/api/chat/route.ts | The system prompt's opening section, describing the company, its product, its customer segments and its commercial model; and the worked figures in its example-response block |
src/app/(dashboard)/payroll/page.tsx | A mock-data fallback containing invented personal names and placeholder totals — excluded on the same principle even though it is fictional |
check-qb.js output | Realm identifier, journal entries, account names and amounts printed when run against the live connection |
Where a name does appear on these sheets it is one of three things: a package name, a
well-known brand written into a test as a string-handling fixture, or a payroll line-item
label such as Debit net pay that the parser mistakes for a person. No balance,
revenue figure, payroll amount, hourly rate, headcount, client name, employee name, bank
account identifier or QuickBooks realm identifier from the source system appears anywhere
in this record.
SYSTEM_PROMPT in src/app/api/chat/route.ts runs to roughly
eighty lines and is the most business-revealing artefact in the repository. Its structure
can be described without quoting it: a role line, a section describing the company and
its commercial model, a role definition, a tool-selection guide mapping six question
shapes onto specific tools, a response-style section, a startup-metrics section, a rules
section, a missing-data section, a worked example, and a list of prohibitions. Only the
structure and the tool-selection behaviour are used on these sheets.