docs(quick-260718-9qg): add self-contained QBO integration handoff document
Documents Pulse's QuickBooks Online integration (OAuth2 authorization-code flow, token storage/refresh, sandbox vs production API base URLs, scopes, minor version, and gotchas learned from the AR reconciliation/soft-delete work) so a new app's team can build their own QBO connection without access to the Pulse codebase. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
parent
672f17b7f9
commit
ea8a36b394
1 changed files with 596 additions and 0 deletions
596
QBO_INTEGRATION_HANDOFF.md
Normal file
596
QBO_INTEGRATION_HANDOFF.md
Normal file
|
|
@ -0,0 +1,596 @@
|
|||
# QuickBooks Online Integration — Handoff Document
|
||||
|
||||
This document captures everything the Pulse team learned building a QuickBooks
|
||||
Online (QBO) integration, so a separate team building a new application can
|
||||
implement their own QBO connection with a head start. It assumes zero access
|
||||
to the Pulse codebase — every detail that matters is spelled out inline.
|
||||
Where useful, a parenthetical note says "(in Pulse this lives in …)" purely as
|
||||
provenance; treat those as historical footnotes, not as things you need to go
|
||||
look up.
|
||||
|
||||
Pulse's implementation was originally built March 2026, extended with
|
||||
Payment/Deposit write support in May 2026, and had a soft-delete + AR
|
||||
reconciliation fix land in July 2026 after a real production discrepancy (see
|
||||
Gotchas below). This document reflects the accumulated, working state as of
|
||||
July 2026.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
QuickBooks Online is Intuit's hosted accounting SaaS. Pulse connects to a
|
||||
single QBO company ("realm") belonging to the business the dashboard serves,
|
||||
and uses the connection for two purposes:
|
||||
|
||||
1. **Read / sync (one-way, QBO → Pulse):** invoices, customer payments, bank
|
||||
deposits, purchases/expenses, journal entries, and pre-built financial
|
||||
reports (Profit & Loss, Balance Sheet, Cash Flow, and Aged Receivable
|
||||
reports) are pulled on a schedule and mirrored into Pulse's own database.
|
||||
This lets Pulse show finance dashboards, accounts-receivable aging, and
|
||||
engagement/finance widgets without hitting QBO's API on every page load.
|
||||
2. **Write (one-way, Pulse → QBO):** Pulse can also *create* records in QBO —
|
||||
specifically `Payment` (a "Receive Payment" applied against one or more open
|
||||
invoices) and `Deposit` (grouping several payments into a single bank
|
||||
deposit line so QBO's bank-feed reconciliation matches the actual bank
|
||||
deposit slip). This was added to support a semi-automated bank remittance
|
||||
reconciliation workflow: a CLI script parses a bank's remittance file,
|
||||
maps each check to an invoice, and posts Payments + a single Deposit to
|
||||
QBO via the API instead of a human re-keying each line into the QBO UI.
|
||||
|
||||
The integration is intentionally single-tenant: it connects to exactly one
|
||||
QBO company (one `realmId`) per deployment, configured via environment
|
||||
variables. There is no concept of "connect a QBO account per customer" — this
|
||||
was built for one business's own books, not as a multi-tenant SaaS feature.
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
Before writing any code, set up an app in Intuit's developer ecosystem:
|
||||
|
||||
1. Create an account at the Intuit Developer portal (https://developer.intuit.com)
|
||||
and create a new app under "My Apps".
|
||||
2. Choose the **QuickBooks Online and Payments** (or just **QuickBooks
|
||||
Online Accounting API**) scope for the app — this is what exposes the
|
||||
`com.intuit.quickbooks.accounting` OAuth scope.
|
||||
3. Every Intuit app ships with **two separate credential sets**: one for the
|
||||
**Sandbox** environment (a fake QBO company you can safely test writes
|
||||
against) and one for **Production** (real customer/company data). Treat
|
||||
these as two entirely different client id/secret pairs — do not assume
|
||||
sandbox credentials work against the production API base URL or vice
|
||||
versa.
|
||||
4. Register a **redirect URI** (Intuit calls this the "Redirect URI" under the
|
||||
app's Keys & OAuth section) that exactly matches the callback URL your app
|
||||
will use — e.g. `https://your-app.example.com/api/qbo/auth`. Intuit
|
||||
validates this on every authorization request; a mismatch (including
|
||||
trailing slash or http vs https) fails the authorization step outright.
|
||||
5. Intuit requires a **published EULA and Privacy Policy URL** on the app
|
||||
listing before you can move an app from Sandbox-only to Production/Go Live
|
||||
— Pulse added simple static `/legal/eula` and `/legal/privacy` pages
|
||||
specifically to satisfy Intuit's app review/assessment step. Budget time
|
||||
for this if you haven't already published one.
|
||||
6. Decide up front which QBO scope(s) you need. Pulse only ever requests
|
||||
`com.intuit.quickbooks.accounting` (the general accounting scope that
|
||||
covers invoices, payments, deposits, purchases, journal entries, and
|
||||
reports). If you also need QBO Payments (charging cards) that's a
|
||||
different, additional scope not covered here.
|
||||
|
||||
## 3. Environment variables
|
||||
|
||||
Pulse's QBO code reads the following variables directly via `process.env` —
|
||||
these are the *real* names in use, discovered by grepping the source (not
|
||||
assumed from a `.env.example`, since none of these currently appear in
|
||||
Pulse's committed `.env` — meaning this integration is present in code but
|
||||
not yet live-configured in this particular deployment).
|
||||
|
||||
| Variable | Purpose | Example / placeholder |
|
||||
|---|---|---|
|
||||
| `QBO_CLIENT_ID` | Intuit app's OAuth2 client id (Sandbox or Production, depending on `QBO_SANDBOX`) | `ABxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
|
||||
| `QBO_CLIENT_SECRET` | Intuit app's OAuth2 client secret (matching the same environment as the client id) | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
|
||||
| `QBO_REALM_ID` | The QBO company id ("realm") this deployment talks to. Received once from Intuit on the OAuth callback, then hardcoded into config for a single-tenant deployment | `123146xxxxxxxxx` |
|
||||
| `QBO_SANDBOX` | String toggle. When exactly `"true"`, the client points at the Sandbox API base URL; any other value (including unset) means Production | `true` or `false` |
|
||||
|
||||
Two things worth calling out explicitly:
|
||||
|
||||
- Pulse's health-check / integration-status layer only checks for
|
||||
`QBO_CLIENT_ID` and `QBO_CLIENT_SECRET` being present to decide whether to
|
||||
show QBO as "configured" in its admin integrations UI — it does **not**
|
||||
separately validate `QBO_REALM_ID`. A new app should validate all three
|
||||
(`CLIENT_ID`, `CLIENT_SECRET`, `REALM_ID`) before attempting any API call,
|
||||
because the actual `QboClient` constructor throws if any of the three is
|
||||
missing.
|
||||
- The OAuth redirect-URI construction (see next section) reads a **fourth**,
|
||||
unrelated env var — `NEXTAUTH_URL` — for the base URL used to build both
|
||||
the callback redirect URI sent to Intuit and the post-auth redirect back
|
||||
into the admin UI. This is a legacy holdover from an earlier auth library
|
||||
Pulse used before switching to its current auth stack; it is **not** the
|
||||
same variable as the app's primary "base URL" env var used everywhere else
|
||||
in the codebase. In Pulse's actual `.env` this variable is not set at all,
|
||||
which means in production the redirect URI silently becomes
|
||||
`undefined/api/qbo/auth` unless something else sets `NEXTAUTH_URL`. **Do
|
||||
not repeat this mistake** — in your new app, use a single canonical
|
||||
"public base URL" env var for constructing OAuth redirect URIs, and make
|
||||
sure it's actually present wherever the OAuth code runs.
|
||||
|
||||
No `client id`/`secret` values from Pulse's real `.env` are reproduced here —
|
||||
`.env` did not contain `QBO_*`/`QUICKBOOKS_*`/`INTUIT_*` keys at all at the
|
||||
time this document was written, so there was nothing to redact; the examples
|
||||
above are placeholders only.
|
||||
|
||||
## 4. OAuth2 connection flow
|
||||
|
||||
QBO uses the standard OAuth2 **authorization-code** grant. Two Intuit
|
||||
endpoints matter:
|
||||
|
||||
- **Authorize URL:** `https://appcenter.intuit.com/connect/oauth2`
|
||||
- **Token URL (exchange + refresh):** `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer`
|
||||
|
||||
Step by step, this is exactly what Pulse's implementation does:
|
||||
|
||||
1. **User clicks "Connect to QBO"** in the admin UI, which hits a single
|
||||
backend endpoint (in Pulse: `GET /api/qbo/auth`, with no query params).
|
||||
2. **Server builds the authorize redirect.** It generates a `state` value
|
||||
(Pulse currently uses `Math.random().toString(36).slice(2)` — cryptographically
|
||||
weak; a new app should use a proper random token, e.g. `crypto.randomUUID()`
|
||||
or a signed value, and *should* persist/verify it server-side before
|
||||
trusting the callback — Pulse's implementation generates `state` but does
|
||||
**not** persist or verify it on callback, which is a real CSRF gap worth
|
||||
closing in a new implementation). It then redirects the browser to:
|
||||
```
|
||||
https://appcenter.intuit.com/connect/oauth2
|
||||
?client_id=<QBO_CLIENT_ID>
|
||||
&scope=com.intuit.quickbooks.accounting
|
||||
&redirect_uri=<url-encoded redirect URI, e.g. https://your-app.example.com/api/qbo/auth>
|
||||
&response_type=code
|
||||
&state=<state>
|
||||
```
|
||||
3. **User authenticates with Intuit and approves the connection**, choosing
|
||||
which QBO company ("realm") to connect if they have access to more than
|
||||
one.
|
||||
4. **Intuit redirects back** to the exact `redirect_uri` registered on the
|
||||
Intuit app, appending query params: `code` (the authorization code),
|
||||
`realmId` (the QBO company id the user just authorized — this is how you
|
||||
discover the realm; it is not something you choose ahead of time unless
|
||||
you already know the company), and `state` (echoed back — verify it here).
|
||||
If the user declines or something goes wrong, Intuit instead appends an
|
||||
`error` param and no `code`/`realmId`.
|
||||
5. **Server exchanges the code for tokens.** POST to the token URL:
|
||||
- Header: `Authorization: Basic <base64(client_id:client_secret)>`
|
||||
- Header: `Content-Type: application/x-www-form-urlencoded`
|
||||
- Header: `Accept: application/json`
|
||||
- Body (form-encoded): `grant_type=authorization_code&code=<code>&redirect_uri=<same redirect_uri used in step 2, byte-for-byte>`
|
||||
|
||||
The redirect_uri sent here **must match** the one used in the initial
|
||||
authorize redirect exactly, or Intuit rejects the exchange.
|
||||
6. **Intuit responds with a token payload:**
|
||||
```json
|
||||
{
|
||||
"access_token": "...",
|
||||
"refresh_token": "...",
|
||||
"expires_in": 3600,
|
||||
"x_refresh_token_expires_in": 8726400,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
```
|
||||
`expires_in` is in seconds and is **short** — access tokens are valid for
|
||||
1 hour. `x_refresh_token_expires_in` is also in seconds and is **long**
|
||||
— the refresh token is valid for 100 days (8,726,400 seconds) from
|
||||
issuance, but note: **every time you use a refresh token to get a new
|
||||
access token, Intuit issues a brand-new refresh token as well**, and the
|
||||
100-day clock resets. You must persist the new refresh token every single
|
||||
time or the old one on file will eventually go stale and you'll be forced
|
||||
back through the full user-consent flow (step 1).
|
||||
7. **Persist the tokens** (see section 5) keyed by `realmId`, computing
|
||||
absolute expiry timestamps from `expires_in` / `x_refresh_token_expires_in`
|
||||
at the moment of exchange (`now + expires_in seconds`, etc.) rather than
|
||||
storing the raw relative seconds.
|
||||
8. **Redirect the user back into your own app's UI** (not back to Intuit) —
|
||||
e.g. an admin settings page — with a success indicator.
|
||||
9. **On every subsequent API call**, before hitting QBO, check whether the
|
||||
stored access token's expiry has passed. If it has, and the refresh token
|
||||
is still valid, silently call the **refresh** grant (same token URL, same
|
||||
Basic-auth header) with `grant_type=refresh_token&refresh_token=<stored
|
||||
refresh token>`, persist the new access + refresh token pair, and proceed.
|
||||
If the *refresh* token itself has also expired, there is no way to
|
||||
recover programmatically — you must re-run the full authorize flow (step
|
||||
1) with a human present.
|
||||
|
||||
Pulse's implementation checks and refreshes **proactively** (before each API
|
||||
call, based on stored expiry timestamps) rather than reactively (catching a
|
||||
401 from the API and retrying) — this is simpler and avoids ever sending a
|
||||
guaranteed-to-fail request, at the cost of trusting your stored clock/expiry
|
||||
bookkeeping to be accurate.
|
||||
|
||||
## 5. Token storage & refresh
|
||||
|
||||
Pulse stores exactly one row per connected realm in a dedicated tokens table.
|
||||
Generalized (not Postgres-specific — use whatever your new app's data layer
|
||||
supports, even a simple encrypted-at-rest key/value store), the record shape
|
||||
that must be persisted is:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `realm_id` | string, unique | The QBO company id this token pair belongs to. Unique constraint because Pulse only ever supports one active connection per realm |
|
||||
| `access_token` | string (secret) | Short-lived bearer token sent on every API call |
|
||||
| `refresh_token` | string (secret) | Long-lived token used to mint new access tokens |
|
||||
| `access_token_expires_at` | timestamp | `now + expires_in seconds` at time of exchange/refresh |
|
||||
| `refresh_token_expires_at` | timestamp | `now + x_refresh_token_expires_in seconds` at time of exchange/refresh |
|
||||
| `created_at` / `updated_at` | timestamp | Standard audit columns |
|
||||
|
||||
Refresh strategy (generalized pseudocode):
|
||||
|
||||
```
|
||||
function getValidAccessToken(realmId):
|
||||
token = loadToken(realmId)
|
||||
if token is null:
|
||||
raise "not connected — run OAuth flow"
|
||||
if now() < token.access_token_expires_at:
|
||||
return token.access_token
|
||||
if now() >= token.refresh_token_expires_at:
|
||||
raise "refresh token expired — re-authorization required"
|
||||
return refreshAccessToken(token.refresh_token) # calls token URL, persists new pair, returns new access_token
|
||||
```
|
||||
|
||||
Treat both tokens as secrets: encrypt at rest if your storage layer supports
|
||||
it, never log them, and never return them in any API response (Pulse's
|
||||
"connection status" endpoint returns only a derived `valid | expired |
|
||||
missing` enum — never the token values themselves — and the codebase's own
|
||||
security posture doc calls out that this table's contents, plus the whole
|
||||
`.env` file, must never be echoed or logged verbatim).
|
||||
|
||||
**Disconnect is soft, not a full teardown.** Pulse's disconnect action only
|
||||
deletes the local token row; it does **not** call Intuit's token-revocation
|
||||
endpoint. This means Intuit's side may still consider the connection
|
||||
"authorized" until the refresh token naturally expires. A more correct
|
||||
implementation should also POST to Intuit's revoke endpoint
|
||||
(`https://developer.api.intuit.com/v2/oauth2/tokens/revoke`, same
|
||||
Basic-auth pattern, body `token=<access_token or refresh_token>`) as part of
|
||||
disconnect, so the authorization is actually torn down on Intuit's side too.
|
||||
|
||||
## 6. API usage
|
||||
|
||||
Base URLs (these differ **only** by the `sandbox-` prefix):
|
||||
|
||||
- **Sandbox:** `https://sandbox-quickbooks.api.intuit.com`
|
||||
- **Production:** `https://quickbooks.api.intuit.com`
|
||||
|
||||
Path shape for every accounting-entity call:
|
||||
|
||||
```
|
||||
{baseUrl}/v3/company/{realmId}/{resource-or-query}
|
||||
```
|
||||
|
||||
Pulse pins **`minorversion=65`** as a query parameter on every request. QBO's
|
||||
"minor version" controls which incremental API revision you get (new fields,
|
||||
behavior changes); pin an explicit value rather than omitting it, so QBO
|
||||
adding a new minor version later doesn't silently change your response
|
||||
shape. Check Intuit's current minor-version changelog before picking a value
|
||||
for a new integration — don't just copy `65` — but the pattern of pinning one
|
||||
explicit version is the right one to keep.
|
||||
|
||||
Every authenticated request needs:
|
||||
```
|
||||
Authorization: Bearer <valid access token>
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Reading data** — QBO exposes a SQL-like query language over each entity.
|
||||
Example: list invoices, paginated (QBO caps each response at a max page
|
||||
size; keep paging with `STARTPOSITION`/`MAXRESULTS` until you get a short
|
||||
page back):
|
||||
|
||||
```
|
||||
GET {baseUrl}/v3/company/{realmId}/query
|
||||
?query=SELECT * FROM Invoice STARTPOSITION 1 MAXRESULTS 1000
|
||||
&minorversion=65
|
||||
```
|
||||
|
||||
For incremental sync, add a `WHERE` clause filtering on QBO's own
|
||||
last-modified metadata, e.g.:
|
||||
```
|
||||
SELECT * FROM Invoice WHERE MetaData.LastUpdatedTime > '2026-07-01T00:00:00.000Z'
|
||||
STARTPOSITION 1 MAXRESULTS 1000
|
||||
```
|
||||
|
||||
**Writing data** — POST the entity's JSON body to its dedicated endpoint,
|
||||
e.g. creating a Payment:
|
||||
```
|
||||
POST {baseUrl}/v3/company/{realmId}/payment?minorversion=65
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"CustomerRef": { "value": "<QBO customer id>" },
|
||||
"TotalAmt": 539.40,
|
||||
"TxnDate": "2026-05-18",
|
||||
"DepositToAccountRef": { "value": "<QBO bank account id>" },
|
||||
"PaymentRefNum": "0000997294",
|
||||
"Line": [
|
||||
{ "Amount": 539.40, "LinkedTxn": [ { "TxnId": "<invoice id>", "TxnType": "Invoice" } ] }
|
||||
]
|
||||
}
|
||||
```
|
||||
QBO echoes back the full created object (including its new `Id` and
|
||||
`SyncToken`) on success — always check for that `Id` in the response before
|
||||
considering the write successful; a 200-with-unexpected-body is possible and
|
||||
should be treated as a failure.
|
||||
|
||||
**Reports** — pre-aggregated reports (Profit & Loss, Balance Sheet, Cash
|
||||
Flow, Aged Receivable Detail/Summary) are separate GET endpoints under
|
||||
`/v3/company/{realmId}/reports/{ReportName}` with their own query params
|
||||
(`start_date`, `end_date`, `accounting_method`, `report_date`,
|
||||
`aging_method`, plus `showrows=all&showcols=all` to get fully expanded
|
||||
rows/columns rather than a collapsed summary). Reports come back as a deeply
|
||||
nested `Header` / `Columns` / `Rows` tree that must be walked recursively —
|
||||
there is no flat tabular shape. Fetch the company's accounting preference
|
||||
(`GET /v3/company/{realmId}/preferences?minorversion=65`, look at
|
||||
`Preferences.ReportPrefs.ReportBasis`, e.g. `"Accrual"` or `"Cash"`) once and
|
||||
pass it explicitly as `accounting_method` on P&L/Balance Sheet requests —
|
||||
don't assume Accrual.
|
||||
|
||||
**Rate limits / transient errors** — Intuit enforces per-app-per-realm rate
|
||||
limits (roughly 500 requests/minute per realm at time of writing — verify
|
||||
current limits in Intuit's docs, they change). Every QBO response includes
|
||||
an `intuit_tid` header (sometimes lowercased as `intuit-tid` depending on
|
||||
proxy/CDN normalization — check both) — capture it in any error you log or
|
||||
surface, since Intuit support requests require this transaction id to
|
||||
investigate a specific failed call.
|
||||
|
||||
## 7. Gotchas & lessons learned
|
||||
|
||||
These are real issues Pulse hit, in rough order of how much time they cost:
|
||||
|
||||
1. **QBO never reports deletions — you must diff to find them.** The
|
||||
Invoice (and other entity) query endpoints only return records that
|
||||
*currently exist* in QBO. When an invoice is voided or deleted in QBO, it
|
||||
simply stops appearing in query results going forward — there is no
|
||||
"deleted" flag, no tombstone record, nothing to subscribe to. If your
|
||||
sync only *upserts* what it gets back, deleted/voided invoices silently
|
||||
linger forever in your own database and keep inflating any aggregate
|
||||
(e.g. "Total Accounts Receivable") that sums over them. Pulse hit this in
|
||||
production (an invoice for one customer had been voided in QBO but kept
|
||||
counting toward Pulse's headline A/R for weeks). **Fix:** on every *full*
|
||||
sync, collect the full set of entity IDs QBO actually returned, then mark
|
||||
(soft-delete) any locally-stored row for that realm whose id was *not* in
|
||||
that set. Do **not** apply this same logic on an *incremental* sync
|
||||
(one that only asks for rows changed since a timestamp) — an incremental
|
||||
response is deliberately a small subset of all records, so diffing
|
||||
against "everything QBO returned this call" would wrongly tombstone your
|
||||
entire historical ledger. Only do the tombstone diff on syncs that fetch
|
||||
the complete current entity set.
|
||||
|
||||
2. **Reconcile against QBO's own reports periodically, not just raw entity
|
||||
sync.** Even with the tombstone fix above, subtle drift can still appear
|
||||
between "sum of invoice balances I have stored" and "what QBO's own Aged
|
||||
Receivable report says." Building a small reconciliation
|
||||
check — pull QBO's live Aged-Receivable-Detail report, pull your own
|
||||
stored open-invoice balances, and diff by invoice/document number in
|
||||
three buckets (present in QBO but missing locally = sync miss; present
|
||||
locally but missing from QBO = likely stale/tombstone candidate; present
|
||||
in both but balance differs = a data-mapping or timing bug) — is cheap
|
||||
insurance and caught issues that a pure "did the sync job succeed"
|
||||
check would not have surfaced.
|
||||
|
||||
3. **Report endpoints reject the wrong parameter combinations.** Early
|
||||
attempts to fetch P&L/Balance Sheet passed a `summarize_column_by`
|
||||
parameter that QBO's API rejected outright; the fix was to drop it and
|
||||
instead explicitly pass `accounting_method` (sourced from the company's
|
||||
real preference, see section 6) plus `showrows=all&showcols=all`. If a
|
||||
report call returns an error, check the exact param set against Intuit's
|
||||
current report-endpoint docs rather than assuming your first guess is
|
||||
right — report endpoints are pickier about params than the generic
|
||||
`query` endpoint.
|
||||
|
||||
4. **Empty report periods return a distinct "no data" marker, not an empty
|
||||
array.** When you request a report for a period where the company had no
|
||||
activity, QBO returns a response with `Header.NoReportData` set to the
|
||||
*string* `"true"` rather than a normal empty report body. If you don't
|
||||
check for this explicitly, you'll either crash trying to parse a
|
||||
report with no rows, or you'll store a garbage "empty" report row and
|
||||
have to distinguish "the business had zero P&L that month" from "we
|
||||
never successfully fetched this month" later. Check for
|
||||
`Header.NoReportData === 'true'` and skip persisting anything for that
|
||||
period.
|
||||
|
||||
5. **Sandbox and Production are separate universes with separate
|
||||
credentials.** It is easy to assume "sandbox" is just a URL flag with the
|
||||
same client id/secret — it isn't. Each Intuit app has independent
|
||||
Sandbox and Production OAuth client id/secret pairs, independent
|
||||
authorized companies, and independent data. A `QBO_SANDBOX=true` toggle
|
||||
only changes which API *base URL* you hit — you still need to swap the
|
||||
client id/secret pair to match, or every request will fail
|
||||
authentication. Keep the credential pair and the sandbox/production flag
|
||||
changed together, never independently.
|
||||
|
||||
6. **Bank check numbers don't round-trip cleanly.** When reconciling bank
|
||||
remittance files against invoices/payments, physical paper checks often
|
||||
have leading zeros in their check number (e.g. `0000996226`) that the
|
||||
bank's own electronic remittance file strips down to a bare number
|
||||
(`996226`). If you're matching check numbers between an external file and
|
||||
QBO/your own records, normalize both sides (e.g. strip leading zeros
|
||||
before comparing) or you'll get false "no match found" failures on
|
||||
otherwise-correct data.
|
||||
|
||||
7. **Idempotency for write operations needs its own mechanism — QBO does
|
||||
not give you one for free.** POSTing the same Payment or Deposit twice
|
||||
creates two separate records in QBO; there's no natural dedupe key QBO
|
||||
enforces on your behalf for a client-supplied write like an idempotency
|
||||
key header would provide. Any script or job that writes to QBO
|
||||
(Payments, Deposits, or otherwise) needs its own tracking of "have I
|
||||
already successfully posted this specific record" (Pulse's reconciliation
|
||||
script does this with a sibling JSON manifest that records the QBO-assigned
|
||||
id after each successful POST, and skips any item already recorded on
|
||||
re-run) so that retrying a partially-failed batch job doesn't duplicate
|
||||
the successful half.
|
||||
|
||||
8. **Lazy-construct the API client if any code path shouldn't require
|
||||
credentials.** A tool that has a "dry run" or "preview" mode (e.g.
|
||||
validate + print what *would* be sent, without actually calling QBO)
|
||||
should not eagerly construct the QBO client at the top of its execution
|
||||
path, because a client constructor that validates `CLIENT_ID` /
|
||||
`CLIENT_SECRET` / `REALM_ID` up front will throw immediately in
|
||||
environments where those aren't configured — even though the dry run
|
||||
never needed a real connection. Defer client construction to the exact
|
||||
point where you're about to make a real, live API call.
|
||||
|
||||
9. **The disconnect action is local-only unless you explicitly call revoke.**
|
||||
See section 5 — a "disconnect" button that only deletes your locally
|
||||
stored tokens does not actually revoke the authorization on Intuit's
|
||||
side. Decide deliberately whether "disconnect" in your new app means
|
||||
"forget locally" or "forget locally AND revoke with Intuit," and
|
||||
implement the revoke call if you mean the latter.
|
||||
|
||||
10. **CSRF `state` handling needs to be real, not decorative.** Generating a
|
||||
`state` value and sending it to Intuit is only half of CSRF protection —
|
||||
you must also store what you sent (session, signed cookie, or a
|
||||
short-lived server-side record) and verify the value that comes back on
|
||||
the callback matches, before trusting `code`/`realmId`. Skipping the
|
||||
verification half (which Pulse currently does) leaves the callback
|
||||
endpoint accepting any `code`/`realmId` pair presented to it with no way
|
||||
to confirm it originated from an authorization flow you actually
|
||||
initiated.
|
||||
|
||||
11. **The OAuth callback and any scheduler-triggered sync endpoints must be
|
||||
reachable without a logged-in session, but everything else should stay
|
||||
behind auth.** Intuit's redirect back to your app after user consent is
|
||||
an unauthenticated browser request — if your app's normal auth
|
||||
middleware requires a session cookie on every route, the OAuth callback
|
||||
route (and, if you expose a webhook/scheduler-triggered "run a sync now"
|
||||
endpoint, that route too) need to be carved out as public routes, while
|
||||
read-oriented admin/diagnostic endpoints stay behind normal
|
||||
authorization (Pulse gates its AR-diagnostic endpoint behind an
|
||||
admin-only check, for example, while the OAuth callback and the sync
|
||||
trigger are unauthenticated by design).
|
||||
|
||||
## 8. Minimal code-flow example
|
||||
|
||||
Framework-agnostic sketch (TypeScript-flavored pseudocode) of the whole
|
||||
lifecycle — this is illustrative, not a copy-paste of Pulse's actual files:
|
||||
|
||||
```ts
|
||||
const AUTHORIZE_URL = 'https://appcenter.intuit.com/connect/oauth2';
|
||||
const TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
|
||||
const REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke';
|
||||
|
||||
function basicAuthHeader(clientId: string, clientSecret: string): string {
|
||||
return 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
|
||||
}
|
||||
|
||||
// 1. Kick off the flow
|
||||
function buildAuthorizeUrl(clientId: string, redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
scope: 'com.intuit.quickbooks.accounting',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `${AUTHORIZE_URL}?${params}`;
|
||||
}
|
||||
// -> persist `state` server-side (session/db) before redirecting the browser here
|
||||
|
||||
// 2. Handle the callback
|
||||
async function handleCallback(code: string, realmId: string, state: string, expectedState: string, redirectUri: string) {
|
||||
if (state !== expectedState) throw new Error('CSRF: state mismatch');
|
||||
const tokens = await exchangeCodeForTokens(code, redirectUri);
|
||||
await saveTokens(realmId, tokens);
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(code: string, redirectUri: string) {
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(CLIENT_ID, CLIENT_SECRET),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`token exchange failed: ${res.status} ${await res.text()}`);
|
||||
return res.json(); // { access_token, refresh_token, expires_in, x_refresh_token_expires_in, ... }
|
||||
}
|
||||
|
||||
async function saveTokens(realmId: string, tokens: any) {
|
||||
const now = Date.now();
|
||||
await tokenStore.upsert(realmId, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
accessTokenExpiresAt: new Date(now + tokens.expires_in * 1000),
|
||||
refreshTokenExpiresAt: new Date(now + tokens.x_refresh_token_expires_in * 1000),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Get a valid access token for any API call, refreshing if needed
|
||||
async function getValidAccessToken(realmId: string): Promise<string> {
|
||||
const token = await tokenStore.load(realmId);
|
||||
if (!token) throw new Error('not connected');
|
||||
if (Date.now() < token.accessTokenExpiresAt.getTime()) return token.accessToken;
|
||||
if (Date.now() >= token.refreshTokenExpiresAt.getTime()) throw new Error('refresh token expired; reconnect required');
|
||||
|
||||
const res = await fetch(TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(CLIENT_ID, CLIENT_SECRET),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: token.refreshToken }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`token refresh failed: ${res.status} ${await res.text()}`);
|
||||
const fresh = await res.json();
|
||||
await saveTokens(realmId, fresh);
|
||||
return fresh.access_token;
|
||||
}
|
||||
|
||||
// 4. Call the actual API
|
||||
async function qboRequest<T>(realmId: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
const accessToken = await getValidAccessToken(realmId);
|
||||
const base = USE_SANDBOX ? 'https://sandbox-quickbooks.api.intuit.com' : 'https://quickbooks.api.intuit.com';
|
||||
const res = await fetch(`${base}/v3/company/${realmId}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
const tid = res.headers.get('intuit_tid') ?? res.headers.get('intuit-tid') ?? 'unknown';
|
||||
if (!res.ok) throw new Error(`QBO ${res.status} on ${path} [intuit_tid=${tid}]: ${await res.text()}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// Example read
|
||||
async function getInvoicesUpdatedSince(realmId: string, since: Date) {
|
||||
const q = encodeURIComponent(
|
||||
`SELECT * FROM Invoice WHERE MetaData.LastUpdatedTime > '${since.toISOString()}' STARTPOSITION 1 MAXRESULTS 1000`
|
||||
);
|
||||
const data = await qboRequest<any>(realmId, `/query?query=${q}&minorversion=65`);
|
||||
return data.QueryResponse.Invoice ?? [];
|
||||
}
|
||||
|
||||
// Example write
|
||||
async function createReceivePayment(realmId: string, payload: any) {
|
||||
const data = await qboRequest<any>(realmId, `/payment?minorversion=65`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!data?.Payment?.Id) throw new Error('createPayment returned no Payment.Id');
|
||||
return data.Payment;
|
||||
}
|
||||
|
||||
// Optional: full teardown (not just forgetting locally stored tokens)
|
||||
async function revokeToken(token: string) {
|
||||
await fetch(REVOKE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: basicAuthHeader(CLIENT_ID, CLIENT_SECRET),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: new URLSearchParams({ token }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
That's the whole lifecycle: authorize redirect → callback with verified
|
||||
`state` → code-for-token exchange → persisted token record → transparent
|
||||
refresh on every call → reads via the `query` endpoint or report endpoints →
|
||||
writes via entity-specific POST endpoints, each guarded by your own
|
||||
idempotency bookkeeping since QBO provides none for you.
|
||||
Loading…
Add table
Add a link
Reference in a new issue