Skip to content
Indra on Cloud
Go back

The OAuth Succeeded. The Reports Still Had to Reconcile.

QuickBooks tokens stay server side. Reports still need a snapshot. Browser session only no token Next.js API Route Supabase + server secret token vault encrypted at rest QuickBooks API reports OAuth 2.0 signed cookie with token never this path: browser straight to QuickBooks

The only path that carries a QuickBooks token is server to provider. The browser path carries a session, never a provider token.

A prototype financial intelligence platform already hits QuickBooks Online, pulls a report and renders a dashboard in Next.js on Vercel with Supabase Postgres behind it. The Intuit Developer account exists, OAuth completes once, a headline number appears. The work that decides whether that dashboard stays correct is after the demo, when the token needs refresh without leaking, when three reports must still agree, and when the database must say no to a write that would make displayed totals lie. I build Genesis, a multi-tenant developer platform on GCP where customers design backend infrastructure on a visual canvas and the platform compiles that to Terraform and applies it inside the customer’s own Google Cloud project. I authored the Terraform template and project scaffold layers every tenant repository is built from, and I am the second largest contributor to the generation engine. The largest tenant is 613 cloud resources from a 10,000 plus line generated config, applying in about three minutes. Prior to that I spent Feb 2023 to Dec 2025 on a platform that ingested from about 1,000 fixed devices over MQTT into a realtime pipeline on GCP, server side in Node and Go. Go is my strongest language, Node and TypeScript daily.

Tokens stay server side or they leak

Anything shipped to the browser is public. A QuickBooks access token, refresh token or realm id placed in local storage, query string or JSON response for debugging ends up in devtools, a proxy log or a copied HAR file. One leaked refresh token lets anyone read that company file until rotation.

The browser calls your own Next.js API route with your own session. The server reads the QuickBooks tokens from an encrypted vault row, calls the Reports API, strips the response to the fields the dashboard needs, and returns only those fields. The token never crosses into the response, into an error message or into a log line.

// app/api/quickbooks/report/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";

const ALLOWED = ["ProfitAndLoss", "BalanceSheet", "CashFlow"] as const;

export async function GET(req: Request) {
  const report = new URL(req.url).searchParams.get("report") ?? "";
  if (!ALLOWED.includes(report as typeof ALLOWED[number])) {
    return NextResponse.json({ error: "bad report" }, { status: 400 });
  }
  const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);
  const { data: vault } = await supabase.from("qb_connections").select("*").eq("company_id", "primary").single();
  if (!vault) return NextResponse.json({ error: "not connected" }, { status: 500 });
  const fresh = await ensureFreshToken(vault, supabase);
  const upstream = await fetch(
    `https://quickbooks.api.intuit.com/v3/company/${fresh.realm_id}/reports/${report}?date_macro=This Fiscal Year`,
    { headers: { Authorization: `Bearer ${fresh.access_token}` } }
  );
  if (!upstream.ok) return NextResponse.json({ error: "upstream error" }, { status: 502 });
  const raw = await upstream.json();
  return NextResponse.json({ report, fetchedAt: new Date().toISOString(), data: shapeReport(raw) });
}

The helpful log is the failure to catch. Someone logs the upstream body during an incident and the full refresh token sits in plain text next to the realm id. Redact at the edge. Log status and a hashed company identifier, never the header and never the body. The same applies to Vercel function logs.

Refresh has a race you will hit when two users view the dashboard at once. QuickBooks access tokens are short lived, refresh tokens rotate on use. Two requests can trigger refresh at the same moment. Both read the old token, both call the provider, one wins, the other gets invalid grant because the provider rotated on first use. The loser then overwrites the winner’s fresh pair if the write is last write wins.

Fix it by serializing refresh per company in Postgres. Acquire a short lived lock row, recheck whether someone else already refreshed while waiting, only then call refresh, write the new pair, release. Reads that arrive during refresh serve the last cached report with a stale flag rather than failing the page.

Three reports from three calls are three moments, not one

Separate fetches drift. A snapshot keeps them consistent. Without snapshot P and L at 10:00:03 Balance Sheet at 10:00:07 Cash Flow at 10:00:11 With snapshot lock, fetch, store together all reports share fetchedAt reconciliation reads snapshot totals can disagree by a write that landed between calls totals agree within snapshot disagreement is real change

Three reports fetched seconds apart can reflect different underlying states. One snapshot makes agreement testable.

Profit and Loss, Balance Sheet and Cash Flow as three independent calls a few seconds apart each reflect the company file at a slightly different instant. If a payment or journal entry posts between the first and third call, the dashboard shows numbers that never existed together. The user sees a profitable P and L beside a Balance Sheet that does not sum to the same equity, and assumes your math is wrong.

Treat the three reports as one snapshot. Lock per company, fetch all three with a shared fetchedAt, write them to Postgres in one transaction, and let the dashboard read from that snapshot. If one fetch fails, the entire snapshot fails and the dashboard keeps serving the last good snapshot with a freshness indicator. Never show a page assembled from two snapshots.

Raw reports are verbose nested JSON. Storing only the raw payload preserves source but makes every query depend on QuickBooks shape. Normalize on write. Each report becomes header plus line rows with account, amount in integer minor units, currency, and snapshot id. Keep the raw payload in a separate column for audit and for handling the next shape change without migration.

The database decides when a snapshot is current

Postgres is the last writer that can say no. If the invariant must hold when the app has a bug and when someone runs SQL by hand, it belongs in Postgres. For financial reports that means a snapshot is internally consistent and money stays in integers.

create table qb_snapshots (
  id uuid primary key default gen_random_uuid(),
  company_id text not null,
  fetched_at timestamptz not null,
  status text not null check (status in ('fresh','stale','failed')),
  unique (company_id, fetched_at)
);
create table qb_report_lines (
  id uuid primary key default gen_random_uuid(),
  snapshot_id uuid not null references qb_snapshots(id),
  report text not null check (report in ('ProfitAndLoss','BalanceSheet','CashFlow')),
  account text not null,
  amount_minor bigint not null check (amount_minor <> 0),
  currency text not null check (char_length(currency)=3)
);

Money stays in integer minor units. A float leaves a missing minor unit at the second rounding step. Amounts cross boundaries as integers with explicit currency. Parsing to minor units happens once at the edge, formatting happens once at the edge, everything between is integer arithmetic. Snapshots are append only by trigger, never updated in place. A new snapshot supersedes the previous one. The dashboard reads the latest successful snapshot per company. History remains for variance checks. Row Level Security applies when the dashboard reads as the requesting user, with policies per tenant. It does not apply to the service role key, which bypasses RLS by design for server jobs. Test isolation role by role with that distinction explicit. A permissive realtime or storage rule is the second variant that leaks rows through a side channel the API test never queried.

The dashboard reads the snapshot, not the provider

Dashboard never waits for QuickBooks. Dashboard reads snapshot Postgres qb_snapshots + lines QuickBooks source of truth refresh job lock, fetch all three, commit background only The dashboard never blocks on the provider. Freshness is a field, not hope.

The refresh job writes a new snapshot. The dashboard reads the last one. The user sees age, not a spinner.

Next.js routes that serve the dashboard should never call QuickBooks on the request path. They read the latest snapshot from Postgres and return it with its fetchedAt. A background refresh, by webhook or schedule, is the only writer that contacts QuickBooks. If refresh fails, the snapshot age grows and the UI shows stale rather than a spinner that times out and leaves the user unsure whether numbers are old or just slow.

Deployment on Vercel fits because the routes are small functions that do one read. Supabase keeps Postgres, auth and storage together, which keeps the lock, the snapshot tables and the RLS policies in one place.

What I would tell someone building this

Seal the token before you render a number. A route that proxies one read with a server secret and an allowlist teaches the deploy and CORS shape. Charts follow without moving the boundary.

Snapshot the three reports in one transaction. Three calls seconds apart are three different states. One fetchedAt and one commit makes agreement testable.

Store money as integers and keep the raw payload. The second rounding step is where the missing minor unit comes from, and raw jsonb gives a way back when QuickBooks adds a field.

Make the database the backstop. Append only snapshots, a refresh lock per company, and RLS tested role by role. If the rule lives only in docs, it fails on weekends.

Serve the dashboard from Postgres, refresh in background. The user should see how old the data is and that it is internally consistent, not whether a provider happened to be fast.


Share this post:

Previous Post
The Test Passed. The Agent Never Called the Tool.
Next Post
The Code Changed. The Description Had to Survive It.