Gogentic Finance · internal engineering record

Sheet 4 of 6

Payroll Journal

Payroll is never read from the payroll provider. It is read from the general ledger the provider posts into, and reconstructed from debits and credits — which means the parser is doing double-entry bookkeeping in reverse.

Protocol
OAuth 2.0
Token cipher
AES-256-GCM
Access token life
3600 s
Refresh token life
101 days
Refresh buffer
300 s
Pay types
4
1

Why the ledger, not the provider

There is no Gusto integration in this codebase. Payroll is run in Gusto, Gusto posts a journal entry into QuickBooks Online for each run, and this application connects to QuickBooks. PRD.md records the chain plainly: “Gusto → QuickBooks → App pipeline for payroll data”.

The consequence is that the application receives accounting artefacts rather than payroll records. A journal entry does not have a field called gross pay. It has a set of lines, each with an amount, a posting type of Debit or Credit, an account reference, an optional description, and sometimes an entity reference naming an employee. Everything the payroll dashboard shows has to be inferred from those lines.

This is why the parser is the largest single piece of domain logic in the repository, and why it is tested against twenty-one hand-constructed journal entries covering benefit deductions, missing cash accounts, absent employee references and empty line arrays.

Where each payroll concept comes from
ConceptDerived from
Pay dateTxnDate on the entry — taken directly, no inference
Pay periodA date range parsed out of PrivateNote; failing that, a 14-day window ending on TxnDate
Gross payDebit lines whose account name matches /wages?|salary|payroll\s*expenses?/i
TaxesCredit lines whose description matches /^debit\s*tax$/i; failing that, a fallback of gross − net
DeductionsHard-coded 0 at run level. Only the per-employee pass computes deductions
Net payCredit lines described /^debit\s*net\s*pay$/i or /^check\s*for\s*/i
EmployeeJournalEntryLineDetail.Entity.EntityRef.name, or a name parsed from the line description
Is this payroll at all?The word Gusto in PrivateNote or DocNumber, or the shape of the account names
2

Establishing the connection

A standard authorisation-code flow, with two details worth noting: the state token is 32 bytes of randomness rendered as 64 hex characters, and the token response carries two independent lifetimes — one hour for access, 101 days for refresh.

Exhibit 8
QuickBooks OAuth 2.0 authorisation sequence A sequence diagram with five lifelines: the browser, the application, Intuit's authorisation server, Intuit's token endpoint, and the quickbooks_connections table. The application generates a state token, redirects the browser to consent, exchanges the returned code for tokens, encrypts them and stores them. A later section shows the access token being refreshed when it is within five minutes of expiry. Browser signed-in user Application /api/auth/quickbooks Intuit authorise appcenter.intuit.com Intuit token oauth.platform.intuit.com Postgres connections GET /api/auth/quickbooks generateStateToken() 32 random bytes → 64 hex chars 302 → authorisation URL client_id, response_type=code, state, redirect_uri, scope=com.intuit.quickbooks.accounting user grants access to the company file 302 → /api/auth/quickbooks/callback?code&realmId&state GET callback POST /oauth2/v1/tokens/bearer Content-Type: application/x-www-form-urlencoded { access_token, refresh_token,   expires_in: 3600, x_refresh_token_expires_in: 8726400 } encryptTokens(tokens) AES-256-GCM, fresh salt and IV upsert quickbooks_connections (realm_id, tokens, both expiries) ON EVERY SUBSEQUENT REQUEST if isAccessTokenExpired(tokens) → refreshAccessToken(), then onTokenRefresh() the 300-second buffer means a token one minute from expiry is already treated as expired if the refresh token has also expired: throw "Refresh token has expired. User must re-authenticate."

← Scroll to see the full diagram →

Authorisation code flow with proactive refresh. The refresh is not reactive — the client does not wait for a 401 and retry. It checks the stored expiry against a five-minute buffer before every call, so a request that begins with four minutes of validity left still refreshes first. When a refresh happens, an onTokenRefresh callback supplied at construction time is invoked so the caller can re-persist the new pair.

Scopes and endpoints

QUICKBOOKS_SCOPES and fixed endpoints
KeyValue
Scopes
accountingcom.intuit.quickbooks.accounting
paymentcom.intuit.quickbooks.payment
openidopenid
profileprofile
emailemail
Endpoints
Authorisehttps://appcenter.intuit.com/connect/oauth2
Token & refreshhttps://oauth.platform.intuit.com/oauth2/v1/tokens/bearer
API, sandboxhttps://sandbox-quickbooks.api.intuit.com
API, productionhttps://quickbooks.api.intuit.com

Only the accounting scope is requested by default; the others are available to generateAuthorizationUrl(state, scopes) as an override. Environment defaults to sandbox when QUICKBOOKS_ENVIRONMENT is unset, so a misconfigured deployment talks to the sandbox rather than to live company data.

Configuration and its failure modes

getQuickBooksConfig() reads four variables and throws a distinct message for each of the three that are mandatory. The messages are asserted individually, which makes a misconfiguration self-diagnosing.

Thrown messages, verbatim src/lib/quickbooks/client.ts
QUICKBOOKS_CLIENT_ID      missing → 'QUICKBOOKS_CLIENT_ID environment variable is not set'
QUICKBOOKS_CLIENT_SECRET  missing → 'QUICKBOOKS_CLIENT_SECRET environment variable is not set'
QUICKBOOKS_REDIRECT_URI   missing → 'QUICKBOOKS_REDIRECT_URI environment variable is not set'
QUICKBOOKS_ENVIRONMENT    missing → defaults to 'sandbox'  // does not throw

Client behaviour

request(path)
Prefixes /v3/company/<realmId> and sends Authorization: Bearer <access token>. Retries 5xx after a base delay; does not retry 4xx — a 404 issues exactly one fetch.
Error parsing
Intuit's Fault.Error[0].Message is lifted into the thrown QuickBooksError, so callers see 'Object Not Found' rather than a JSON blob.
getCompanyInfo()
Hits /companyinfo/<realmId>. Used to confirm a connection is live.
queryJournalEntries(start?, end?)
Builds SELECT * FROM JournalEntry, appending TxnDate >= '…' and TxnDate <= '…' when bounds are supplied, and URL-encodes the whole statement into a ?query= parameter.
revokeToken(token)
Resolves on success and on 400 — a token that is already revoked is not an error. Any other failure throws 'Failed to revoke token'.
QuickBooksError predicates
StatusisAuthErrorneedsTokenRefreshisRetryable
400falsefalsefalse
401truetruefalse
403truefalsefalse
404falsefalsefalse
429falsefalsetrue
500falsefalsetrue
502falsefalsetrue
503falsefalsetrue
504falsefalsetrue
600falsefalsefalse

The 600 row exists because the retry predicate is a bounded range check rather than status >= 500. A malformed status outside the HTTP range is not retried.

3

Token custody

A QuickBooks refresh token is a hundred-day credential to a company's accounting file. It is stored encrypted, with a key derived per-record rather than reused.

Ciphertext layout check-qb.js lines 10–44, mirroring src/lib/crypto.ts
13const SALT_LENGTH     = 16;
14const IV_LENGTH       = 12;
15const AUTH_TAG_LENGTH = 16;
16const KEY_LENGTH      = 32;

// stored value = base64( salt | iv | authTag | ciphertext )
//                        16     12      16       remainder

18function deriveKey(masterKey, salt) {
19  return crypto.scryptSync(masterKey, salt, KEY_LENGTH);
20}

// decrypt: slice the four regions, re-derive the key from the
// embedded salt, then aes-256-gcm with the embedded auth tag
39const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
40decipher.setAuthTag(authTag);

The salt is stored alongside the ciphertext, so every encryption derives a fresh key from the same master secret. That is what makes the next property possible.

Properties asserted of the token cipher

src/lib/quickbooks/encryption.ts
PropertyAssertion
ConfidentialityThe output string contains neither the access token nor the refresh token as a substring
Non-determinismEncrypting the identical token pair twice yields two different strings — the salt and IV are regenerated each time
IntegrityAltering a single character in the middle of the stored value makes decryption throw. This is the GCM auth tag doing its job
Key bindingDecrypting with a different ENCRYPTION_KEY throws
Fail closedA missing ENCRYPTION_KEY throws 'ENCRYPTION_KEY environment variable is not set' rather than falling back to a default
Type fidelityAll three timestamps survive the round trip as real Date objects with identical ISO representations, not as strings
Payload toleranceTokens containing / + = & % and tokens 1,000 characters long both round-trip byte-identically

Expiry arithmetic

isAccessTokenExpired(tokens, bufferSeconds = 300)
Token expiresBufferResult
in 1 hour300 sfalse
1 second ago300 strue
in 1 minute300 strue — inside the buffer
in 2 minutes60 sfalse
in 2 minutes180 strue

isRefreshTokenExpired takes no buffer — it is a plain comparison against the present moment. There is nothing useful to do with a nearly-expired refresh token except use it.

4

Reversing a journal entry

parseGustoJournalEntry(entry) is pure: a journal entry in, a ParsedPayrollData out, no I/O. Amounts arrive from QuickBooks in dollars and leave in cents.

Exhibit 9
Classification of journal entry lines into payroll totals Journal entry lines are split by posting type. Debit lines on payroll expense accounts accumulate into total gross. Credit lines are routed by account name into taxes, deductions or net pay. When no cash account appears, net pay is derived as gross minus taxes minus deductions. ENTRY LINES ACCOUNT-NAME CLASSIFIER TOTALS, IN CENTS QuickBooksJournalEntry Id, TxnDate, PrivateNote, DocNumber, Line[], TotalAmt PostingType: Debit Amount AccountRef.name Description, Entity? PostingType: Credit Amount AccountRef.name Description, Entity? payroll expense pattern Payroll / Wages / Salaries / Gross Pay tax liability pattern Federal / State / FICA / Social Security / Medicare benefit liability pattern 401k / Health Insurance / Benefits Payable cash account pattern Checking / Cash / Bank / Payroll Account totalGross sum of debits totalTaxes sum of credits totalDeductions sum of credits totalNet credit to cash FALLBACK — NO CASH ACCOUNT IN THE ENTRY totalNet = totalGross − totalTaxes − totalDeductions Tested with a 4,000 debit, 800 tax, 200 deduction entry whose only remaining credit is Accounts Payable: net = 3,000. When employee-attributed lines are present, totalGross and totalTaxes are summed from those instead of from the raw account totals.

← Scroll to see the full diagram →

Four buckets from two posting types. The classifier is keyed entirely on account names, matched case-insensitively against regular expressions — there is no chart-of-accounts lookup and no account-type field is consulted. Figures quoted in this exhibit are the test fixtures' own synthetic amounts, chosen to make the arithmetic checkable.
Note — detection and totalling use different rules

The three pattern arrays above answer one question only: is this journal entry payroll? isGustoPayrollEntry requires a payroll-expense account plus either a liability or a cash account, unless a Gusto marker in the note settles it outright.

The run totals are computed by a second, narrower set of rules that the tests do not reach. Gross comes from debits on accounts matching /wages?|salary|payroll\s*expenses?/i. Taxes come from credits whose description is exactly Debit tax. Net comes from credits described Debit net pay or beginning Check for. These are Gusto's own journal line labels rather than generic accounting terms — the parser has been tuned to one upstream producer.

Two consequences follow. totalDeductions is assigned a literal 0 at run level, so the payroll_runs.total_deductions column is always zero however many benefit lines an entry carries. And when no Debit tax line is found but gross and net are both positive, taxes are back-solved as gross − net — which silently absorbs any deduction into the tax figure.

Is this entry payroll?

Three tests establish the detection rule, and a fourth establishes what it must reject.

By note
The string Gusto appearing in PrivateNote is sufficient on its own — an entry noted “Payroll by Gusto — week ending …” is recognised even with only a generic Wages/Cash pair of lines.
By shape
With no Gusto marker at all, an entry debiting Salaries Expense and crediting Payroll Tax Liability and a bank account is still recognised.
Rejection
An entry debiting Office Supplies and crediting Accounts Payable returns isGustoPayroll: false.
Empty
An entry with an empty Line array returns all four totals as 0, no employee payments, and isGustoPayroll: false — it does not throw.

Pay period

The period is scavenged from the note. An ISO range such as Period: 2024-01-16 - 2024-01-31 is parsed into periodStart and periodEnd exactly. Where no range can be found, periodEnd is the TxnDate and periodStart is set fourteen days earlier — a fortnightly assumption, applied silently.

5

Account-name classifier

Each of the following account names is exercised by a test that constructs a journal entry around it and asserts the parser reaches the right conclusion. Matching is case-insensitive: PAYROLL EXPENSE and CHECKING ACCOUNT appear in the fixtures specifically to prove it.

Account names recognised by the parser
BucketPostingNames matched
Payroll expense Debit Payroll Expense, Wages Expense, Salaries Expense, Gross Pay, Employee Compensation, PAYROLL EXPENSE
Liability Credit Payroll Liability, Payroll Tax, Federal Tax, State Tax, FICA, Social Security, Medicare, 401k, Health Insurance, Benefits Payable
Cash Credit Checking, Cash, Bank, Payroll Account, Bank Account, CHECKING ACCOUNT

The liability bucket splits in two. All ten liability names satisfy the “is this payroll” test, but they do not all land in the same total. A fixture crediting Payroll Tax 600, 401k 250 and Health Insurance 150 resolves to totalTaxes 60,000 cents and totalDeductions 40,000 cents — the two benefit accounts summing into deductions, the tax account into taxes. The distinction is carried by the account name alone.

A separate fixture demonstrates the tax bucket accumulating across four distinct account names in one entry — Federal Tax Payable, State Tax Payable, Social Security Payable and Medicare Payable — summing to a single totalTaxes. Individual tax types are not preserved; the schema has one taxes column, not four.

6

Employee attribution

Some journal entries name employees; some do not. The parser handles both, and normalises the two naming conventions it encounters into one.

Name resolution, in order of preference
OrderSourceBehaviour
1 Entity reference JournalEntryLineDetail.Entity with Type: 'Employee' and an EntityRef carrying a name. Structured and unambiguous.
2 Description prefix A line described as <Name> - <pay component>. The name is taken from before the dash, and lines sharing a name are merged into one employee payment — a gross line and a tax-withholding line for the same person become a single record with both fields populated.
3 Nothing employeePayments stays empty and the run totals come from the account buckets alone. The run is still recorded.

Where a description uses the accounting convention Surname, Forename, it is rewritten into natural order before storage. The transformation is asserted directly, so the same person never appears twice under two orderings.

Pay types

payroll_line_items.pay_type is NOT NULL, so every line needs a classification. Four values are produced, matched from the description text.

Description text to pay_type
Description containspay_type
“Bonus”bonus
“Hourly Pay (40 hours)”hourly
“Commission Payout”commission
“Regular Pay” — and anything unmatchedsalary

salary is the default, not a match. An unrecognised description is recorded as salaried rather than rejected or left null.

Note — employment type

The employees table carries employment_type, defaulted by migration 0002 to 'w2_fulltime', alongside a nullable hourly_rate. Together these let the dashboard distinguish salaried staff from contractors, which the journal entries themselves do not encode. The value is set outside the parser — nothing in parseGustoJournalEntry reads or writes it.

7

Team compensation

Payroll and contractor spend arrive by two different routes and land in the same tables. src/lib/quickbooks/team-compensation.ts is the module that reads them back as one picture, and it is the newest substantial feature in the repository.

The organising idea is a five-value classification stored on employees.employment_type. Migration 0002 introduced the column with the default 'w2_fulltime'; the other four values are written by the seed and setup paths.

EmploymentType — the five classifications
Order Stored value Displayed as Source of the payments
0w2_fulltimeFull-time W2Gusto payroll, via QuickBooks journal entries
11099_fulltimeFull-time 1099QuickBooks contractor payments
21099_parttimePart-time 1099QuickBooks contractor payments
31099_onetime1-Time ContractorsQuickBooks contractor payments
41099_formerFormer ContractorsHistoric; retained for period comparisons

The numeric order is a literal sort map in team-compensation.tsx; within a class, members are ordered by total paid, descending. Only the first two classes count toward the “fulltime team” headcount on the payroll page, which filters is_active = true AND employment_type IN ('w2_fulltime','1099_fulltime').

The schema comment is out of date. schema.ts annotates employment_type with the set 'w2_fulltime' | '1099_fulltime' | '1099_parttime'. Two further values are in use. The column is plain text with no check constraint and no enum type, so nothing enforces the set in either direction.

How a total is assembled

fetchTeamCompensation(months) takes a lookback in months, or null meaning year to date — in which case the window starts at 1 January of the current year. It selects active employees, splits them by classification, and for each person sums gross_pay across payroll_line_items joined to payroll_runs where the pay date falls inside the window. Each member comes back as { id, name, employmentType, totalPaid, paymentCount, lastPayment }, with amounts in cents.

One query per person. The per-member totals are fetched in a loop rather than as a single grouped aggregate — an N+1 against a table that already has no index on pay_date. At the size of team this system was built for that is immaterial; it is the kind of thing that stops being immaterial quietly.

Filtering out the parser's own artefacts

Because employees are created automatically from names parsed out of journal entries, a mis-parse becomes a person. The module carries a literal exclusion list to undo that.

Exclusion list src/lib/quickbooks/team-compensation.ts, line 121
const INVALID_EMPLOYEE_NAMES = ['Regular Wages', 'Bonus', 'Debit tax', 'Debit net pay'];

These are Gusto journal line labels — the same four strings the totalling regexes key on. When extractEmployeePayments falls back to reading a name out of a description, a line described Debit net pay yields an “employee” of that name. The same four strings are also hard-coded into POST /api/debug/cleanup-employees, which deactivates any employee row matching them.

The presentation layer, src/components/payroll/team-compensation.tsx, is a pure function of its props: a card per classification, each with a count badge and a right-aligned total, and a row per member showing payment count and last payment date. It hard-codes no names and no figures.

8

Inspecting a live connection

check-qb.js sits at the repository root: a ninety-seven-line diagnostic that bypasses the application entirely. It opens the database directly with @neondatabase/serverless, decrypts the stored access token with a local copy of the crypto routine, and queries QuickBooks for recent journal entries so a developer can see the raw line structure the parser will receive.

Terminal — diagnostic, output redacted
$ node check-qb.js

ENCRYPTION_KEY loaded: true
Realm ID: <redacted>
Access token decrypted, length: 1024
Querying QuickBooks for journal entries...

[ entry dump withheld — the remaining output is live accounting data:
  journal ids, private notes, document numbers, totals and per-line
  account names and amounts. It is not reproduced in this record. ]

// the withheld section prints, per entry:
//   Entry <n> - ID: <id>, Date: <TxnDate>
//     PrivateNote: <note or "(none)">
//     DocNumber:   <number or "(none)">
//     Total:       $<TotalAmt>
//     Lines:       <count>
//       - $<Amount> <PostingType> "<AccountRef.name>" <Description>

Label strings are quoted verbatim from check-qb.js; the values are withheld. The token length shown is illustrative of the field's magnitude only. The script's query is SELECT * FROM JournalEntry WHERE TxnDate >= '2024-01-01' MAXRESULTS 20 against the production host, and it prints the first five lines of each entry.

The application shares one implementation; the script does not. Inside src/ there is a single primitive: src/lib/crypto.ts exports encrypt, decrypt and isEncrypted, and src/lib/quickbooks/encryption.ts imports them, adding only the QuickBooks-specific JSON serialisation. The Mercury key path uses the same primitive. check-qb.js is the exception: it carries its own copy of the decrypt routine, annotated // Decrypt function (matching src/lib/crypto.ts). The constants agree today, but a change to the wire format would have to be made twice. The script is a developer tool and is not part of the deployed application.

Coverage gaps on this cluster

Four items in TASKS.md Phase 10 remain unticked, and all four are tests. The OAuth client, the token cipher and the journal parser are covered; the parts that write to the database and render the page are not.

TASKS.md Phase 10, incomplete items
ItemStatus
10.2 — Write schema testsopen
10.4 — Write payroll page testsopen
10.5 — Write settings testsopen
10.6 — Write agent tool testsopen

Consistent with the file listing: there is no __tests__/components/payroll/ directory, and none of the eight payroll and compensation handlers is invoked anywhere in tool-handlers.test.ts — though all eight are implemented.

The debug surface

Nine routes live under src/app/api/debug/, all of them written to support this payroll work: dumping QuickBooks journal entries, exercising the parser against live data, re-running the payroll sync, reconciling against a Gusto export, and repairing employee rows the parser created in error. Four carry an explicit DELETE THIS ENDPOINT BEFORE PRODUCTION! comment.

They are unauthenticated. src/middleware.ts lists /api/debug(.*) among its public matchers — with its own comment, // Debug endpoints (REMOVE IN PRODUCTION) — and no route file performs a check of its own. Four mutate data, one of them destructively. Because their whole purpose is to print payroll internals, their responses are the most sensitive in the application. They are enumerated on Sheet 6, and no output from any of them appears in this record.