Units of account
Money is never stored as a floating-point number. Every monetary column is an integer count of cents, and the conversion to dollars happens once, at the boundary where a value is rendered or handed to the model.
Two integer widths are in use, and the split is not arbitrary. Bank-side amounts are
bigint; payroll-side amounts are 32-bit integer. The bank
columns are the ones that accumulate an account balance and must tolerate an arbitrary
transaction size; a single payroll line item has a natural ceiling.
| Column | Type | Note |
|---|---|---|
| Bank side — 64-bit | ||
accounts.balance | bigint | Nullable. A freshly created account has no balance until first sync. |
transactions.amount | bigint | Not null. Always stored positive; sign lives in direction. |
| Payroll side — 32-bit | ||
payroll_runs.total_gross | integer | Not null |
payroll_runs.total_taxes | integer | Not null |
payroll_runs.total_deductions | integer | Not null |
payroll_runs.total_net | integer | Not null |
payroll_line_items.gross_pay | integer | Not null |
payroll_line_items.taxes | integer | Not null |
payroll_line_items.deductions | integer | Not null |
payroll_line_items.net_pay | integer | Not null |
employees.hourly_rate | integer | Nullable; added by migration 0002 alongside employment_type |
Direction, not sign
The upstream bank API signs its amounts: a payment out arrives as a negative number. The
sync layer splits that single signed value into an unsigned magnitude and a
direction discriminator, so that summing outflows never requires an
ABS() and a category rollup cannot silently cancel itself out.
// calculateDirection() — exported from src/server/sync/transactions.ts
calculateDirection(100) → 'inflow'
calculateDirection(50000) → 'inflow'
calculateDirection(-100) → 'outflow'
calculateDirection(-50000) → 'outflow'
calculateDirection(0) → 'inflow' // zero is classified as inflow
// and the magnitude is stored unsigned:
// upstream amount -15000 → amount = 15000, direction = 'outflow'
The zero case is deliberate and carries its own test, titled “should return ‘inflow’ for zero (edge case)”. A fee reversal of exactly zero therefore lands on the inflow side of every aggregate.
Formatting at the boundary
src/lib/format.ts holds the presentation rules. Its behaviour is fixed by
exact-equality assertions, which makes it one of the few modules whose output can be
quoted verbatim.
| Call | Result | Rule |
|---|---|---|
| formatCurrency(100000) | $1,000.00 | Cents in, two decimals out |
| formatCurrency(-50000) | -$500.00 | Minus precedes the symbol |
| formatCurrency(0) | $0.00 | — |
| formatCurrency(100000, true) | +$1,000.00 | Second argument forces an explicit sign |
| formatCompactCurrency(150000000) | $1.5M | Millions to one decimal |
| formatCompactCurrency(45000000) | $450.0K | Thousands keep the decimal |
| formatCompactCurrency(50000) | $500 | Below 1K, no suffix and no decimals |
| formatMonths(1) | 1 month | Singular is special-cased, and unlike the others carries no decimal |
| formatMonths(12) | 12.0 months | — |
| formatMonths(3.567) | 3.6 months | Rounded to one place |
| formatPercentage(12.567, 2) | 12.57% | Optional precision, default 1 |
Source: __tests__/lib/metrics/index.test.ts, “Format Functions for KPIs”.
Asymmetry worth knowing. formatCurrency takes cents;
formatMonths and formatPercentage take ordinary numbers. The
agent's tool handlers convert to dollars before serialising a response, so the
model receives 10000 meaning ten thousand dollars, never
1000000 meaning the same amount in cents. The two conventions meet only
inside the handler layer.
Schema in full
Every table and every column as at migration 0002, read from
drizzle/meta/0002_snapshot.json. Primary keys are all
uuid defaulting to gen_random_uuid(); that default is omitted
from the drawing for legibility.
← Scroll to see the full diagram →
transactions and payroll_line_items — that carry
the atomic financial rows; everything else is dimension, configuration or history.
ON DELETE CASCADE appears exactly once in the schema, on
chat_messages.conversation_id, which is why deleting a conversation is a
single statement rather than a transaction.
chat_conversations.user_id and chat_messages.user_id are
uuid columns with real foreign keys into users.id.
quickbooks_connections.user_id is a plain text column with no
constraint — it holds the Clerk identifier directly. The two clusters were built
in different migrations and never reconciled.
Schedule of tables
| Table | Cols | FK | UQ | Added | Holds |
|---|---|---|---|---|---|
| Cluster A — transaction ledger | |||||
accounts | 6 | 0 | 1 | 0000 | One row per bank account, with its last-known balance |
transactions | 12 | 2 | 1 | 0000 | Every synced bank movement; the primary fact table |
categories | 6 | 0 | 0 | 0000 | Expense, revenue and transfer buckets |
category_patterns | 5 | 1 | 0 | 0000 | Learned vendor → category rules with a hit counter |
settings | 4 | 0 | 0 | 0000 | Singleton row: encrypted bank key, last full sync, sync toggle |
| Cluster B — payroll journal | |||||
quickbooks_connections | 9 | 0 | 0 | 0001 | Encrypted OAuth tokens, realm id, both expiry timestamps |
payroll_runs | 12 | 0 | 0 | 0001 | One row per parsed journal entry; period and four totals |
payroll_line_items | 10 | 2 | 0 | 0001 | Per-employee split of a run; the payroll fact table |
employees | 9 | 0 | 0 | 0001 | Employee dimension; employment_type and hourly_rate added in 0002 |
| Cluster C — identity & conversation | |||||
users | 9 | 0 | 1 | 0000 | Mirror of the Clerk directory, keyed by clerk_user_id |
chat_conversations | 5 | 1 | 0 | 0000 | Named threads, ordered by updated_at |
chat_messages | 7 | 2 | 0 | 0000 | Turn-by-turn history plus a jsonb tool-call trace |
| Total | 94 | 8 | 3 | ||
Column types in aggregate
| Type | Columns | Share |
|---|---|---|
text | 33 | 35.1% |
uuid | 20 | 21.3% |
timestamp | 17 | 18.1% |
integer | 11 | 11.7% |
boolean | 5 | 5.3% |
date | 4 | 4.3% |
bigint | 2 | 2.1% |
jsonb | 1 | 1.1% |
numeric(10, 2) | 1 | 1.1% |
| Total | 94 | 100.0% |
Computed from drizzle/meta/0002_snapshot.json. The single numeric column is payroll_line_items.hours_worked — the one quantity in the schema that is genuinely fractional rather than a count of cents.
The twelve categories
Categories are seeded, not configured. The seed script writes twelve rows with
is_default = true, and the seed test asserts each name and colour, that every
type falls in {revenue, expense, transfer}, that every colour
matches /^#[0-9a-fA-F]{6}$/, and that every default row carries a non-empty
icon.
The same twelve names are the closed vocabulary handed to the classifier. A model reply
naming anything outside this list falls back to Other.
| # | Name | Type | Colour |
|---|---|---|---|
| 1 | Revenue | revenue | #22c55e |
| 2 | Payroll | expense | #8b5cf6 |
| 3 | Software | expense | #3b82f6 |
| 4 | Contractors | expense | #f97316 |
| 5 | Marketing | expense | #ec4899 |
| 6 | Office | expense | #6366f1 |
| 7 | Travel | expense | #14b8a6 |
| 8 | Professional Services | expense | #a855f7 |
| 9 | Banking Fees | expense | #64748b |
| 10 | Taxes | expense | #ef4444 |
| 11 | Transfer | transfer | #94a3b8 |
| 12 | Other | expense | #78716c |
| Of which expense / revenue / transfer | 10 / 1 / 1 |
Source: __tests__/server/db/seed.test.ts, which reads the live table and compares against a hard-coded expectation array.
Seeds are a floor, not a ceiling. Every assertion in the seed test is
toBeGreaterThanOrEqual, never an exact count. Categories created by hand
through the settings page coexist with the seeded twelve, and only the seeded ones carry
is_default = true — which is also what makes them undeletable in the
UI, since the delete control is disabled for default rows.
Creating a category
createCategory is a server action, not an API route. It validates before it
writes and returns a discriminated result rather than throwing.
createCategory({ name: '', type: 'expense', color: '#6366f1' })
→ { success: false, message: 'Category name is required' }
createCategory({ name: ' ', type: 'expense', color: '#6366f1' })
→ { success: false, message: 'Category name is required' } // trimmed first
createCategory({ name: 'X', type: 'invalid', color: '#6366f1' })
→ { success: false, message: 'Invalid category type' }
// accepted types: 'expense' | 'revenue' | 'transfer'
// the Add Category form defaults to type 'expense', colour '#6366f1'
Sources: __tests__/server/actions/settings.test.ts for the messages; __tests__/components/settings/category-settings.test.tsx for the form defaults.
Migration ledger
Three migrations, all applied within a single eighteen-hour window. The journal records each with a millisecond timestamp, which makes the sequence in which the system's scope widened unusually legible.
← Scroll to see the full diagram →
when
field of each entry in drizzle/meta/_journal.json; the horizontal axis is
proportional to elapsed time on 22 December 2025. The hollow marker on 0002 records that
it widened an existing table rather than adding one — the point at which payroll
stopped being a single flat concept and grew an employment-type distinction.
1ALTER TABLE "employees" ADD COLUMN "employment_type" text DEFAULT 'w2_fulltime';
--> statement-breakpoint
2ALTER TABLE "employees" ADD COLUMN "hourly_rate" integer;
The default value 'w2_fulltime' is the only place in the schema that encodes a US employment classification. Contractor rows are distinguished from it by taking a different value in the same column.
How migrations are generated
drizzle.config.ts points the generator at a single schema module and emits
into ./drizzle. Credentials come from .env.local, loaded by
dotenv inside the config file itself rather than by the shell.
1import { config } from 'dotenv';
2import { defineConfig } from 'drizzle-kit';
3
4// Load environment variables from .env.local
5config({ path: '.env.local' });
6
7export default defineConfig({
8 schema: './src/server/db/schema.ts',
9 out: './drizzle',
10 dialect: 'postgresql',
11 dbCredentials: {
12 url: process.env.DATABASE_URL!,
13 },
14});
A single schema file, src/server/db/schema.ts, is the sole source of truth for all twelve tables. Every table and type name quoted on this sheet is exported from it.
Query layer
Reads go through src/lib/queries/ rather than being written inline in page
components. Two modules exist: transactions.ts and chat.ts.
getTransactions
The pagination contract is defensive on both ends. Page numbers below one are clamped up;
page sizes above one hundred are clamped down. The clamped value — not the requested
one — is what reaches the LIMIT clause.
| Aspect | Behaviour |
|---|---|
| Default page | 1 |
| Default page size | 20 |
| Page clamp | 0 → 1, -5 → 1 |
| Page-size clamp | 200 → 100; the query issues limit(100) |
| Offset | (page - 1) × pageSize. Page 2 at size 10 issues limit(10) offset(10) |
| Count | A second query; totalPages = Math.ceil(totalCount / pageSize) |
| Join | leftJoin onto categories, so an uncategorised row still returns |
| Return shape | { transactions, totalCount, totalPages, currentPage, pageSize } |
TransactionFilters
- search?: string
- Free text. Empty and whitespace-only values are accepted without special-casing.
- categoryId?: string
- Resolved from a human category name by the agent layer before it reaches this point.
- direction?
- Exactly
'inflow' | 'outflow'. - startDate?: Date
endDate?: Date Dateobjects, not strings. Callers passing ISO strings — the agent tool handler, for instance — parse them first.
A where() clause is always issued, even for an empty filter object. There is
no branch that skips it.
Conversation queries
| Function | Contract |
|---|---|
createConversation(userId, title?) | Title defaults to 'New Conversation' |
getConversations(userId) | Ordered by updated_at descending. No pagination |
getConversation(id) | Takes the conversation id alone; returns null if absent. Ownership is checked by the caller |
updateConversationTitle(id, title) | Returns the updated row, or null |
deleteConversation(id) | Returns a boolean. Messages disappear with it by cascade |
createMessage(conversationId, userId, role, content, toolCalls?) | Positional. Omitted toolCalls stores SQL null, not []. Bumps the parent's updated_at |
getMessages(conversationId) | Ordered by created_at ascending — the opposite direction to getConversations |
generateConversationTitle(message) | Synchronous and pure. Collapses whitespace, trims, then caps at 50 characters — longer strings become slice(0, 47) + '...', may cut mid-word |
Source: __tests__/lib/queries/chat.test.ts, an integration suite that runs against a real database and creates and tears down its own fixtures.