# 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.
