Gogentic Finance · internal engineering record

Sheet 2 of 6

Chart of Accounts

The persistence layer. Twelve tables in three loosely coupled clusters — the transaction ledger, the payroll journal, and identity with conversation history — declared in Drizzle and applied through three migrations.

Tables
12
Columns
94
Foreign keys
8
Unique keys
3
Migrations
3
Dialect
postgresql
1

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.

Monetary columns and their storage width
Column Type Note
Bank side — 64-bit
accounts.balancebigintNullable. A freshly created account has no balance until first sync.
transactions.amountbigintNot null. Always stored positive; sign lives in direction.
Payroll side — 32-bit
payroll_runs.total_grossintegerNot null
payroll_runs.total_taxesintegerNot null
payroll_runs.total_deductionsintegerNot null
payroll_runs.total_netintegerNot null
payroll_line_items.gross_payintegerNot null
payroll_line_items.taxesintegerNot null
payroll_line_items.deductionsintegerNot null
payroll_line_items.net_payintegerNot null
employees.hourly_rateintegerNullable; 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.

Behaviour pinned by test __tests__/server/sync/transactions.test.ts
// 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.

src/lib/format.ts — asserted input/output pairs
CallResultRule
formatCurrency(100000)$1,000.00Cents in, two decimals out
formatCurrency(-50000)-$500.00Minus precedes the symbol
formatCurrency(0)$0.00
formatCurrency(100000, true)+$1,000.00Second argument forces an explicit sign
formatCompactCurrency(150000000)$1.5MMillions to one decimal
formatCompactCurrency(45000000)$450.0KThousands keep the decimal
formatCompactCurrency(50000)$500Below 1K, no suffix and no decimals
formatMonths(1)1 monthSingular is special-cased, and unlike the others carries no decimal
formatMonths(12)12.0 months
formatMonths(3.567)3.6 monthsRounded 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.

2

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.

Exhibit 2
Entity-relationship diagram of all twelve tables Twelve Postgres tables in three clusters. The transaction ledger cluster contains accounts, transactions, categories, category_patterns and settings. The payroll journal cluster contains quickbooks_connections, payroll_runs, payroll_line_items and employees. The identity cluster contains users, chat_conversations and chat_messages. Eight foreign keys connect them. CLUSTER A — TRANSACTION LEDGER accounts 6 cols id uuid PK mercury_id text UNIQUE name text NN type text balance bigint last_synced_at timestamp settings 4 cols id uuid PK mercury_api_key_encrypted last_full_sync_at ts sync_enabled bool=t transactions 12 cols id uuid PK mercury_id text UNIQUE account_id uuid FK amount bigint NN direction text NN description text counterparty_name text category_id uuid FK ai_categorized bool=f manually_overridden bool=f date date NN synced_at ts NN =now() categories 6 cols id uuid PK name text NN type text NN color text icon text is_default bool=f category_patterns 5 cols id uuid PK pattern text NN category_id uuid FK source text = 'ai_learned' match_count int = 1 learned rules point at a category CLUSTER B — PAYROLL JOURNAL quickbooks_connections id uuid PK user_id text NN realm_id text NN access_token text NN refresh_token text NN access_token_expires_at refresh_token_expires_at created_at ts NN updated_at ts NN payroll_runs 12 cols id uuid PK quickbooks_journal_id text pay_date date NN period_start date NN period_end date NN total_gross int NN total_taxes int NN total_deductions int NN total_net int NN employee_count int NN synced_at ts created_at ts NN payroll_line_items 10 cols id uuid PK payroll_run_id uuid NN FK employee_id uuid NN FK gross_pay int NN taxes int NN deductions int NN net_pay int NN hours_worked numeric(10,2) pay_type text NN created_at ts NN employees 9 cols id uuid PK quickbooks_id text name text NN department text employment_type = 'w2_fulltime' hourly_rate int is_active bool NN = t created_at ts NN updated_at ts NN one row per employee, per run no FK to users — user_id is a Clerk id, stored as text, not a uuid reference CLUSTER C — IDENTITY & CONVERSATION users 9 cols id uuid PK clerk_user_id text UNIQUE email text NN first_name text last_name text image_url text role text NN = 'viewer' created_at ts NN updated_at ts NN chat_conversations 5 cols id uuid PK user_id uuid FK title NN = 'New Conversation' created_at ts NN updated_at ts NN chat_messages 7 cols id uuid PK conversation_id uuid FK user_id uuid FK role text NN content text NN tool_calls jsonb created_at ts NN ON DELETE CASCADE chat_messages.user_id

← Scroll to see the full diagram →

The complete schema at migration 0002. Highlighted boxes are the two fact tables — 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.
Note — two kinds of user id

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.

3

Schedule of tables

All tables, with column and constraint counts
Table Cols FK UQ Added Holds
Cluster A — transaction ledger
accounts6010000One row per bank account, with its last-known balance
transactions12210000Every synced bank movement; the primary fact table
categories6000000Expense, revenue and transfer buckets
category_patterns5100000Learned vendor → category rules with a hit counter
settings4000000Singleton row: encrypted bank key, last full sync, sync toggle
Cluster B — payroll journal
quickbooks_connections9000001Encrypted OAuth tokens, realm id, both expiry timestamps
payroll_runs12000001One row per parsed journal entry; period and four totals
payroll_line_items10200001Per-employee split of a run; the payroll fact table
employees9000001Employee dimension; employment_type and hourly_rate added in 0002
Cluster C — identity & conversation
users9010000Mirror of the Clerk directory, keyed by clerk_user_id
chat_conversations5100000Named threads, ordered by updated_at
chat_messages7200000Turn-by-turn history plus a jsonb tool-call trace
Total9483

Column types in aggregate

Distribution of the 94 columns by Postgres type
TypeColumnsShare
text3335.1%
uuid2021.3%
timestamp1718.1%
integer1111.7%
boolean55.3%
date44.3%
bigint22.1%
jsonb11.1%
numeric(10, 2)11.1%
Total94100.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.

4

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.

Seeded default categories, in insertion order
# Name Type Colour
1Revenuerevenue#22c55e
2Payrollexpense#8b5cf6
3Softwareexpense#3b82f6
4Contractorsexpense#f97316
5Marketingexpense#ec4899
6Officeexpense#6366f1
7Travelexpense#14b8a6
8Professional Servicesexpense#a855f7
9Banking Feesexpense#64748b
10Taxesexpense#ef4444
11Transfertransfer#94a3b8
12Otherexpense#78716c
Of which expense / revenue / transfer10 / 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.

Validation, exact messages src/server/actions/settings.ts
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.

5

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.

Exhibit 3
Migration ledger and cumulative table count A timeline of three Drizzle migrations applied on 22 December 2025. Migration 0000 creates eight tables, 0001 adds four payroll and QuickBooks tables bringing the total to twelve, and 0002 adds two columns to the employees table without changing the table count. A step chart shows the cumulative total rising from zero to eight to twelve. CUMULATIVE TABLES IN SCHEMA 12 8 4 0 TAG APPLIED (UTC) EFFECT TOTAL 0000_round_payback 2025-12-22 01:39 creates 8 tables 8 0001_next_slyde 2025-12-22 09:24 creates 4 payroll tables 12 0002_add_employment_type 2025-12-22 19:49 2 columns on employees 12 banking, chat, identity payroll cluster arrives columns only — no new table 21:00 01:39 09:24 19:49

← Scroll to see the full diagram →

Schema growth over 18.2 hours. Timestamps are taken from the 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.
Migration 0002, in full drizzle/0002_add_employment_type.sql
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.

Config drizzle.config.ts — lines 1–14
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.

6

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.

getTransactions(filters?, page?, pageSize?)
AspectBehaviour
Default page1
Default page size20
Page clamp0 → 1, -5 → 1
Page-size clamp200 → 100; the query issues limit(100)
Offset(page - 1) × pageSize. Page 2 at size 10 issues limit(10) offset(10)
CountA second query; totalPages = Math.ceil(totalCount / pageSize)
JoinleftJoin 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
Date objects, 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

src/lib/queries/chat.ts
FunctionContract
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.