From ea8a36b3948208abd8a14553427ffdc4374fbe16 Mon Sep 17 00:00:00 2001 From: lorentz Date: Sat, 18 Jul 2026 07:07:26 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- QBO_INTEGRATION_HANDOFF.md | 596 +++++++++++++++++++++++++++++++++++++ 1 file changed, 596 insertions(+) create mode 100644 QBO_INTEGRATION_HANDOFF.md diff --git a/QBO_INTEGRATION_HANDOFF.md b/QBO_INTEGRATION_HANDOFF.md new file mode 100644 index 0000000..b230c1f --- /dev/null +++ b/QBO_INTEGRATION_HANDOFF.md @@ -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= + &scope=com.intuit.quickbooks.accounting + &redirect_uri= + &response_type=code + &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 ` + - Header: `Content-Type: application/x-www-form-urlencoded` + - Header: `Accept: application/json` + - Body (form-encoded): `grant_type=authorization_code&code=&redirect_uri=` + + 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=`, 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=`) 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 +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": "" }, + "TotalAmt": 539.40, + "TxnDate": "2026-05-18", + "DepositToAccountRef": { "value": "" }, + "PaymentRefNum": "0000997294", + "Line": [ + { "Amount": 539.40, "LinkedTxn": [ { "TxnId": "", "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 { + 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(realmId: string, path: string, options: RequestInit = {}): Promise { + 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; +} + +// 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(realmId, `/query?query=${q}&minorversion=65`); + return data.QueryResponse.Invoice ?? []; +} + +// Example write +async function createReceivePayment(realmId: string, payload: any) { + const data = await qboRequest(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.