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.
| Concept | Derived from |
|---|---|
| Pay date | TxnDate on the entry — taken directly, no inference |
| Pay period | A date range parsed out of PrivateNote; failing that, a 14-day window ending on TxnDate |
| Gross pay | Debit lines whose account name matches /wages?|salary|payroll\s*expenses?/i |
| Taxes | Credit lines whose description matches /^debit\s*tax$/i; failing that, a fallback of gross − net |
| Deductions | Hard-coded 0 at run level. Only the per-employee pass computes deductions |
| Net pay | Credit lines described /^debit\s*net\s*pay$/i or /^check\s*for\s*/i |
| Employee | JournalEntryLineDetail.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 |
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.
← Scroll to see the full diagram →
onTokenRefresh callback supplied at construction time is invoked so the
caller can re-persist the new pair.
Scopes and endpoints
| Key | Value |
|---|---|
| Scopes | |
accounting | com.intuit.quickbooks.accounting |
payment | com.intuit.quickbooks.payment |
openid | openid |
profile | profile |
email | |
| Endpoints | |
| Authorise | https://appcenter.intuit.com/connect/oauth2 |
| Token & refresh | https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer |
| API, sandbox | https://sandbox-quickbooks.api.intuit.com |
| API, production | https://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.
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 sendsAuthorization: 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].Messageis lifted into the thrownQuickBooksError, 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, appendingTxnDate >= '…'andTxnDate <= '…'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'.
| Status | isAuthError | needsTokenRefresh | isRetryable |
|---|---|---|---|
| 400 | false | false | false |
| 401 | true | true | false |
| 403 | true | false | false |
| 404 | false | false | false |
| 429 | false | false | true |
| 500 | false | false | true |
| 502 | false | false | true |
| 503 | false | false | true |
| 504 | false | false | true |
| 600 | false | false | false |
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.
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.
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
| Property | Assertion |
|---|---|
| Confidentiality | The output string contains neither the access token nor the refresh token as a substring |
| Non-determinism | Encrypting the identical token pair twice yields two different strings — the salt and IV are regenerated each time |
| Integrity | Altering a single character in the middle of the stored value makes decryption throw. This is the GCM auth tag doing its job |
| Key binding | Decrypting with a different ENCRYPTION_KEY throws |
| Fail closed | A missing ENCRYPTION_KEY throws 'ENCRYPTION_KEY environment variable is not set' rather than falling back to a default |
| Type fidelity | All three timestamps survive the round trip as real Date objects with identical ISO representations, not as strings |
| Payload tolerance | Tokens containing / + = & % and tokens 1,000 characters long both round-trip byte-identically |
Expiry arithmetic
| Token expires | Buffer | Result |
|---|---|---|
| in 1 hour | 300 s | false |
| 1 second ago | 300 s | true |
| in 1 minute | 300 s | true — inside the buffer |
| in 2 minutes | 60 s | false |
| in 2 minutes | 180 s | true |
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.
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.
← Scroll to see the full diagram →
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
PrivateNoteis 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 Expenseand creditingPayroll Tax Liabilityand a bank account is still recognised. - Rejection
- An entry debiting
Office Suppliesand creditingAccounts PayablereturnsisGustoPayroll: false. - Empty
- An entry with an empty
Linearray returns all four totals as0, no employee payments, andisGustoPayroll: 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.
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.
| Bucket | Posting | Names 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.
Employee attribution
Some journal entries name employees; some do not. The parser handles both, and normalises the two naming conventions it encounters into one.
| Order | Source | Behaviour |
|---|---|---|
| 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 contains | pay_type |
|---|---|
| “Bonus” | bonus |
| “Hourly Pay (40 hours)” | hourly |
| “Commission Payout” | commission |
| “Regular Pay” — and anything unmatched | salary |
salary is the default, not a match. An unrecognised description is recorded as salaried rather than rejected or left null.
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.
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.
| Order | Stored value | Displayed as | Source of the payments |
|---|---|---|---|
| 0 | w2_fulltime | Full-time W2 | Gusto payroll, via QuickBooks journal entries |
| 1 | 1099_fulltime | Full-time 1099 | QuickBooks contractor payments |
| 2 | 1099_parttime | Part-time 1099 | QuickBooks contractor payments |
| 3 | 1099_onetime | 1-Time Contractors | QuickBooks contractor payments |
| 4 | 1099_former | Former Contractors | Historic; 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.
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.
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.
$ 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.
| Item | Status |
|---|---|
| 10.2 — Write schema tests | open |
| 10.4 — Write payroll page tests | open |
| 10.5 — Write settings tests | open |
| 10.6 — Write agent tool tests | open |
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.