Welcome to the Jose Madrid Salsa developer docs — explore features, APIs, and deployment guides.
Jose Madrid SalsaJMS Docs

QuickBooks Online

OAuth connection, chart-of-accounts mapping, and the queue that pushes orders, refunds and ledger entries into QuickBooks.

QuickBooks Online

QuickBooks Online is the source of truth for accounting. The website is where money is earned; QBO is where it is booked. This integration is the one-way bridge between them, plus a read-back of live reports into the admin financials dashboard.

Code lives in lib/quickbooks/:

FileResponsibility
config.tsOAuth endpoints, environment selection, app credential resolution
oauth.tsAuthorization-code exchange and token refresh
connection.tsThe single live connection, token storage, validity
sync.tsThe push queue — customers, items, receipts, journal entries
mappers.tsOrder/refund → QBO payload shapes
journal.tsLedger entries → double-entry JournalEntry
ledger-accounts.ts, ledger-account-settings.tsChart-of-accounts mapping
reports.tsRead-back: P&L, expenses, bills, vendors
client.tsAuthenticated v3 API calls

Connecting

Settings → Integrations → QuickBooks (/admin/settings/integrations/quickbooks).

The Intuit Client ID and Secret can be entered in the admin panel — they are stored encrypted in QuickBooksAppCredential — or supplied as environment variables. Admin-entered values win:

VariablePurpose
QUICKBOOKS_CLIENT_IDIntuit app client id (fallback if not set in admin)
QUICKBOOKS_CLIENT_SECRETIntuit app client secret (fallback)
QUICKBOOKS_ENVIRONMENTsandbox (default) or production — only the default for a new connect flow; a saved connection carries its own

The redirect URI registered in the Intuit app must exactly match ${NEXTAUTH_URL}/api/integrations/quickbooks/callback. The only scope requested is com.intuit.quickbooks.accounting.

OAuth routes

RoutePurpose
/api/integrations/quickbooks/connectStarts the authorize flow
/api/integrations/quickbooks/callbackExchanges the code, saves the connection
/api/integrations/quickbooks/disconnectedIntuit's disconnect notification

Tokens

QuickBooksConnection holds one row per connected company (realm). Access and refresh tokens are encrypted at rest with AES-256-GCM using the same MASTER_KEY machinery as the credential vault.

QBO access tokens last about an hour; refresh tokens about 100 days on a rolling basis. Both expiries are persisted and getValidAccessToken() refreshes proactively — so a company that goes untouched for over 100 days must be reconnected by hand.

Mapping the chart of accounts

QuickBooksSettings (one row per realm) is where the website learns which QBO accounts to post into. autoSyncEnabled stays off until these are set.

SettingWhat it controls
incomeAccountIdIncome account for Items the sync auto-creates. Without it an Item cannot be created at all.
depositAccountIdWhere SalesReceipt proceeds land — typically Undeposited Funds
shippingItemIdThe QBO Item used for the shipping line
discountAccountIdWhere a checkout discount is booked
giftCertificateAccountIdThe liability account certificates were sold into; redemptions draw it down
refundItemIdItem a partial refund lands on (our refunds carry an amount but no line detail)
ledgerAccountMapPer-LedgerCategory account + offset mapping for journal entries
syncStartDateOrders before this date are left out of the books entirely

Two traps worth knowing about, both learned the hard way:

  • QBO permits duplicate account names. Map by account id, and when two rows read the same in a dropdown, confirm the id before saving.
  • Saving the mapping screen while a dropdown has not loaded its options writes null over the whole mapping. Confirm every field is populated before saving, and re-check the page after a save.

How records reach QuickBooks

Checkout never calls QuickBooks inline. A completed payment writes a row to QuickBooksSyncRecord and returns; a cron drains the queue.

paid Order ─┐
Refund ─────┼─▶ QuickBooksSyncRecord (PENDING) ─▶ /api/cron/quickbooks-sync ─▶ QBO
LedgerEntry ┘        (durable queue + audit trail)      (hourly, :15)

QuickBooksSyncRecord is unique on (entityType, entityId) — one lineage per record, which is what makes the sweeper idempotent and a replay harmless.

Statuses

StatusMeaning
PENDINGQueued, waiting for the next drain
PROCESSINGBeing posted right now
SYNCEDPosted; quickbooksId holds the QBO object id
FAILEDTransient failure — will retry after nextAttemptAt
BLOCKEDNeeds a human decision (e.g. an unmapped account). Retrying will never fix it — deliberately distinct from FAILED
SKIPPEDDeliberately excluded — predates syncStartDate, or skipped by hand

Retries use exponential backoff (backoffFor), gated by nextAttemptAt.

Entity types

TypeSourceBecomes
CUSTOMEROrder's user or emailQBO Customer
ITEMProductQBO Item
SALES_RECEIPTPaid OrderSalesReceipt
REFUND_RECEIPTRefundRefundReceipt
JOURNAL_ENTRYLedgerEntryDouble-entry JournalEntry

JOURNAL_ENTRY exists for money that never became an order — cash and show takings, hand-entered expenses, imported statement rows. There is no customer or line detail to build a SalesReceipt from, so it posts as a journal entry against the accounts in ledgerAccountMap. See Bookkeeping Ledger.

Never a second customer

QuickBooksEntityMap keeps a stable local-id → QBO-id mapping, scoped by realm, so a re-sync can never create a duplicate Customer or Item. Scoping by realm matters: sandbox ids are meaningless in production, so switching companies starts from a clean map rather than silently reusing ids that point at nothing.

Reading back

reports.ts pulls live figures into /admin/financials:

  • getProfitAndLoss(start, end) — the P&L card
  • listRecentExpenses() / listRecentBills() / listAllExpenses() — expense feed
  • listVendors() — vendor balances

These are read-only queries against QBO's Reports API; nothing is cached in Postgres.

Receipt capture

Receipt capture is QuickBooks' own feature — the QBO mobile app and the receipt-forwarding email address. Nothing is built here for it, and nothing should be: photographing a receipt into QBO puts it exactly where the books already are.

Troubleshooting

SymptomCauseFix
Sync rows stuck BLOCKEDAn account in QuickBooksSettings is unmappedMap it in Settings → Integrations → QuickBooks, then requeue
Nothing syncs at allautoSyncEnabled is falseFinish the account mapping; the switch stays off until it is complete
"Unauthorized" after months idleRefresh token expired (~100 days)Reconnect from the settings page
Amounts post against the wrong accountDuplicate account names selected by nameVerify account ids, re-save, and check the page after saving
Old orders appear in the bookssyncStartDate unset or too earlySet it, and mark the pre-cutoff rows SKIPPED

How is this guide?

Edit on GitHub

Last updated on

On this page