Merge remote-tracking branch 'origin/feat/quickbooks'

# Conflicts:
#	pnpm-workspace.yaml
This commit is contained in:
Hanzo Dev
2026-07-04 01:32:12 -07:00
29 changed files with 3196 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
# Hanzo AI for QuickBooks Online
AI over your books, in plain language. `@hanzo/quickbooks` reads your QuickBooks
Online financials and answers questions about them — **summarize financials**,
**explain a report**, **draft invoice/estimate line descriptions**,
**categorize / explain transactions**, and a freeform **ask about the books**.
Two surfaces, one shared core:
- **Panel** (`dist/index.html`) — a web page (hosted behind hanzoai/ingress at
`quickbooks.hanzo.ai`) that runs the AI actions over a QuickBooks report
(Profit & Loss, Balance Sheet), an entity query (Invoices / Bills /
Transactions / Customers / Accounts), or plain figures pasted into it.
- **Service** (`dist/server.js`) — a small Node service that handles the Intuit
OAuth2 flow and a **QBO API proxy** (`/v1/analyze`) so the Intuit
`client_secret` and the OAuth tokens stay **server-side**: it fetches the
report or runs the query against the [Accounting API v3](https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities),
assembles it into analysis text, and summarizes it via
[api.hanzo.ai](https://api.hanzo.ai).
Both are built on the shared Hanzo foundation — `@hanzo/ai` (the published
headless OpenAI-compatible model client) and `@hanzo/iam` (Hanzo identity) — and
speak the gateway's `/v1` wire protocol, identical to `@hanzo/docusign`,
`@hanzo/pdf`, and `@hanzo/meetings`.
> Analysis is informational, grounded strictly in your QuickBooks data — **not
> tax, audit, or accounting advice**. The system prompt forbids inventing
> accounts, amounts, dates, or trends.
## Architecture
```
src/
config.ts env boundary: gateway, model, Intuit OAuth + QBO API hosts, scopes, server config
qbo-oauth.ts Intuit OAuth2 Authorization Code Grant request shaping + callback/token parsing (pure)
qbo-api.ts Accounting API v3 request wrappers (report/query/get/create) + response parsing (pure)
finance.ts QBO report/entity JSON → readable analysis text + truncation to a budget (pure)
hanzo.ts prompt assembly + the single `ask` path over the @hanzo/ai SDK (pure/injected)
actions.ts the AI actions as prompts over `ask` (one code path to the model)
qbo-analysis.ts the pipeline: fetch report/query → assemble context → run action (injected fetch)
panel.ts pure panel logic (launch-context parse, interpret pasted data → FinanceContext)
app.ts browser panel: DOM glue over the pure modules
server.ts Node service: /oauth/install, /oauth/callback, /v1/analyze, /v1/companies, /healthz
index.html panel markup styles.css panel styles
```
Everything logic-heavy is pure and unit-tested; `app.ts` (browser) and
`server.ts` (Node) are the only impure entry points. The gateway is reached
through **one** shape — the published `createAiClient().chat.completions.create()`
from `@hanzo/ai` — never a hand-rolled client.
## Build & test
```bash
pnpm --filter @hanzo/quickbooks install
pnpm --filter @hanzo/quickbooks test # vitest — 91 tests
pnpm --filter @hanzo/quickbooks typecheck # tsc --noEmit, clean
pnpm --filter @hanzo/quickbooks build # esbuild -> dist/
```
`build.js` bundles the panel (`app.js`, with `@hanzo/ai` inlined for the
browser), copies `index.html` + `styles.css`, and bundles the Node service
(`server.js`, with `@hanzo/ai` left external and loaded from `node_modules`).
## Create an Intuit developer app
1. Sign in at the [Intuit Developer portal](https://developer.intuit.com) and
**Create an app****QuickBooks Online and Payments**.
2. Under **Keys & OAuth**, note the **Client ID** and **Client Secret** for each
environment (Development / Production). The secret is **server-only** — it
lives in KMS and is read by `server.ts`; it is never bundled or sent to the
browser.
3. Add the **Redirect URI**: `https://quickbooks.hanzo.ai/oauth/callback` (and a
local one, e.g. `http://localhost:8790/oauth/callback`, for dev).
4. The app requests these **OAuth scopes**:
- `com.intuit.quickbooks.accounting` — the Accounting API.
- `openid` — returns the `id_token` (identity) at token exchange.
5. **Sandbox company:** the developer account comes with a free
[sandbox company](https://developer.intuit.com/app/developer/sandbox). Use
`QBO_ENV=sandbox` (API host `sandbox-quickbooks.api.intuit.com`) against it
before going to production (`quickbooks.api.intuit.com`).
### OAuth2 flow
| Endpoint | Host |
| --- | --- |
| Authorize | `https://appcenter.intuit.com/connect/oauth2` |
| Token (exchange + refresh) | `https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer` |
| Revoke (disconnect) | `https://developer.api.intuit.com/v2/oauth2/tokens/revoke` |
The authorize + token hosts are the **same** for sandbox and production; the
environment selects only the **Accounting API host**. Token exchange uses HTTP
Basic auth (`base64(client_id:client_secret)`) and a form body that includes
`redirect_uri`. On approval Intuit redirects to the callback with `code`,
`state`, and — critically — **`realmId`** (the company id), which scopes every
API path and keys the server's stored authorization. Access tokens live ~1 hour
(`/v1/analyze` refreshes transparently on a 401); refresh tokens rotate ~daily
and live ~100 days.
## Reports and entities used
Everything is on the **Accounting API v3** root
`https://{host}/v3/company/{realmId}/…` with a pinned `minorversion`:
| Data | Request |
| --- | --- |
| Profit & Loss | `GET /reports/ProfitAndLoss?start_date=…&end_date=…` |
| Balance Sheet | `GET /reports/BalanceSheet` |
| Cash Flow / Aged Receivables / Aged Payables | `GET /reports/{name}` |
| Invoices / Bills / Customers / Accounts / Transactions | `GET /query?query=SELECT * FROM {Entity} …` |
| A single record | `GET /{entity}/{id}` |
| Light write-back (create/update invoice, add a memo) | `POST /{entity}` |
Reports come back as a nested `Header` / `Columns` / `Rows` tree (sections with
`Summary` subtotals); `finance.ts` flattens that into an indented, readable
statement the model reasons over. Queries wrap an entity array under
`QueryResponse.{Entity}`; each record is projected to its accounting-relevant
fields.
### Write-back (gated)
The one write path is `qbo-api.create(env, realmId, token, entity, payload)` — a
`POST /{entity}`. Primary value is **read + analyze**; write-back
(create/update an Invoice, add a memo) is deliberately behind an explicit action
and is off by default. Never wired to an AI action that runs without a human
confirming the payload.
## The summarize-financials flow
The canonical path, end to end (`qbo-analysis.analyzeReport`):
1. **Fetch**`GET /v3/company/{realmId}/reports/ProfitAndLoss` with the period
params + `minorversion`, `Authorization: Bearer <access_token>`.
2. **Render**`finance.renderReport` walks the `Rows` tree into an indented
statement: section headers, line items with their period columns, section
subtotals (`Total Income`, `Total Expenses`), and the top-level `Net Income`.
3. **Window**`finance.buildReportContext` caps the rendered statement at the
character budget on a line boundary, flagging truncation honestly.
4. **Assemble**`hanzo.buildMessages` fences the statement as data under a
grounded system prompt (work only from the figures shown; cite lines by name;
not accounting advice).
5. **Summarize** — one non-streaming completion via the published `@hanzo/ai`
`createAiClient().chat.completions.create()` against `api.hanzo.ai`.
In the **panel**, the user pastes the report JSON and the same pure
`finance.ts` / `hanzo.ts` produce the answer directly against the gateway with
their pasted `hk-…` key — zero backend. In the **service**, `/v1/analyze` runs
the whole flow server-side so the Intuit token and the Hanzo key never leave the
server.
## Deploy over hanzoai/ingress
The panel is static (`dist/index.html`, `app.js`, `styles.css`) served by
`hanzoai/static`; the service (`dist/server.js`) is a small container behind
`hanzoai/gateway` + `hanzoai/ingress` at `quickbooks.hanzo.ai`. Secrets come from
**KMS** (`kms.hanzo.ai`) synced via KMSSecret CRDs — never in manifests, never in
env files committed to git.
Required environment (see `config.readServerConfig`):
| Var | Notes |
| --- | --- |
| `INTUIT_CLIENT_ID` | OAuth2 client id (public). |
| `INTUIT_CLIENT_SECRET` | OAuth2 secret — **server only**, from KMS. |
| `INTUIT_REDIRECT_URI` | `https://quickbooks.hanzo.ai/oauth/callback`. |
| `QBO_ENV` | `sandbox` (default) or `production`. |
| `HANZO_API_KEY` | Optional — enables the `/v1/analyze` proxy; without it the panel uses its own pasted key. |
| `PORT` | Default `8790`. |
The server exposes `GET /healthz` for readiness. Run: `node dist/server.js`.
## Intuit App Store path
To list publicly on the [QuickBooks App Store](https://apps.intuit.com):
1. Move the app to **Production keys** and pass Intuit's technical + security
review (OAuth2, token handling, `Disconnect` via the revoke endpoint,
least-privilege scopes — this app requests only `accounting` + `openid`).
2. Complete the app listing (categories, screenshots, description) and the
security questionnaire.
3. Intuit reviews and publishes. Until then the app runs against the sandbox and
any development companies you connect directly.
## License
MIT © Hanzo AI
+90
View File
@@ -0,0 +1,90 @@
// Build @hanzo/quickbooks into dist/: bundle the web panel (app.ts) as ESM for
// the browser, copy index.html (with its entry script stamped) + styles.css, and
// bundle the OAuth + API-proxy server for Node. No framework — esbuild + Node
// stdlib only.
//
// node build.js → production build
// node build.js --watch → rebuild on change
// HANZO_QUICKBOOKS_BASE=https://localhost:8443 node build.js → dev base (informational)
//
// Output layout:
// dist/index.html dist/app.js dist/styles.css (panel)
// dist/server.js (service)
//
// The panel is HOSTED over hanzoai/static behind hanzoai/ingress at
// quickbooks.hanzo.ai; the server runs as a small service (OAuth callback + QBO
// API proxy). All model calls go to api.hanzo.ai regardless of the host base.
// @hanzo/ai is bundled INTO the browser panel (it is pure fetch, no Node deps)
// and left EXTERNAL for the server (loaded from node_modules at runtime).
import esbuild from 'esbuild';
import { rmSync, mkdirSync, copyFileSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = (process.env.HANZO_QUICKBOOKS_BASE || 'https://quickbooks.hanzo.ai').replace(/\/+$/, '');
const watch = process.argv.includes('--watch');
const src = join(__dirname, 'src');
const dist = join(__dirname, 'dist');
async function build() {
rmSync(dist, { recursive: true, force: true });
mkdirSync(dist, { recursive: true });
// Bundle the panel as ESM for the browser. @hanzo/ai bundles in (pure fetch).
const panelCtx = await esbuild.context({
entryPoints: { app: join(src, 'app.ts') },
outdir: dist,
bundle: true,
format: 'esm',
platform: 'browser',
target: ['chrome90', 'edge90', 'firefox90', 'safari15'],
sourcemap: true,
minify: !watch,
logLevel: 'info',
});
await panelCtx.rebuild();
// Copy index.html (stamp __ENTRY__ + base) and styles.css.
const html = readFileSync(join(src, 'index.html'), 'utf8')
.split('__ENTRY__').join('app.js')
.replace('</head>', ` <meta name="hanzo:base" content="${BASE}" />\n</head>`);
writeFileSync(join(dist, 'index.html'), html);
copyFileSync(join(src, 'styles.css'), join(dist, 'styles.css'));
// Bundle the server as a Node ESM binary. @hanzo/ai is left external so the
// server loads the installed package at runtime; everything else is our pure
// stdlib code.
const serverCtx = await esbuild.context({
entryPoints: [join(src, 'server.ts')],
outfile: join(dist, 'server.js'),
bundle: true,
format: 'esm',
platform: 'node',
target: ['node18'],
sourcemap: true,
minify: !watch,
external: ['@hanzo/ai'],
banner: { js: "import { createRequire as __cr } from 'module'; const require = __cr(import.meta.url);" },
logLevel: 'info',
});
await serverCtx.rebuild();
console.log(`Hanzo AI for QuickBooks built -> dist/ (base ${BASE})`);
console.log(' Panel: dist/index.html · Service: dist/server.js');
if (watch) {
await panelCtx.watch();
await serverCtx.watch();
console.log('watching...');
} else {
await panelCtx.dispose();
await serverCtx.dispose();
}
}
build().catch((e) => {
console.error(e);
process.exit(1);
});
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@hanzo/quickbooks",
"version": "0.1.0",
"description": "Hanzo AI for QuickBooks Online — AI over your books: summarize financials, explain reports, draft invoice line descriptions, categorize transactions, and ask about the books. A web panel plus an Intuit OAuth2 + Accounting API v3 proxy, built on @hanzo/ai and @hanzo/iam over the api.hanzo.ai gateway.",
"private": true,
"type": "module",
"scripts": {
"build": "node build.js",
"watch": "node build.js --watch",
"start": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hanzo/ai": "^0.2.0",
"@hanzo/iam": "0.13.2"
},
"devDependencies": {
"@types/node": "20.14.0",
"esbuild": "0.25.8",
"typescript": "5.8.3",
"vitest": "3.2.6"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"hanzo",
"quickbooks",
"quickbooks-online",
"accounting",
"finance",
"intuit",
"ai"
],
"author": "Hanzo AI",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/hanzoai/extension.git",
"directory": "packages/quickbooks"
}
}
+95
View File
@@ -0,0 +1,95 @@
// The AI actions over the books — summarize financials, explain a report, draft
// invoice/estimate line descriptions, categorize/explain transactions. Each is a
// prompt template applied to the assembled financial context via the single
// `ask` primitive in hanzo.ts. There is exactly ONE code path to the model: an
// action is (id → prompt), and the panel, the picker, and the server proxy all
// resolve an id here and call `ask`. No action speaks to the gateway directly.
//
// (A freeform "ask about the books" is `ask` in hanzo.ts with the user's own
// question — not an entry here — so the catalog stays the set of one-click
// canned analyses.)
import { ask, type AskOptions } from './hanzo.js';
import type { FinanceContext } from './finance.js';
// The action catalog. `id` is the stable key the panel's chips and the proxy
// use; `label` is the button text; `prompt` is the instruction handed to the
// model as the task, over the fenced financial data. Prompts are specific and
// output-shaped (short sections, bullet lists) so results are consistent.
export const ACTIONS = {
summarizeFinancials: {
label: 'Summarize financials',
prompt:
'Summarize these financials in plain language for a busy owner or ' +
'manager. Cover: the headline result (net income / net position), the ' +
'largest revenue and expense drivers by amount, and any notable trends or ' +
'swings across the columns/periods shown. Call out the top 3 line items ' +
'that most affect the bottom line. Use short sections or bullets, cite ' +
'figures by their line/account name, and keep amounts in the reported ' +
'currency. Do not invent accounts or amounts that are not in the data. No ' +
'preamble.',
},
explainReport: {
label: 'Explain this report',
prompt:
'Explain what this report shows to someone who is not an accountant. ' +
'Define the sections and subtotals in the statement, walk through how the ' +
'total is built up from the lines, and point out the two or three numbers ' +
'a reader should pay attention to and why. Do not give advice and do not ' +
'invent lines that are not in the report — just make the report ' +
'understandable. Reference lines by name.',
},
draftLineDescriptions: {
label: 'Draft line descriptions',
prompt:
'Draft clear, professional line-item descriptions for an invoice or ' +
'estimate based on the products/services and amounts shown. For each line, ' +
'write a concise customer-facing description (one line each) that states ' +
'what was delivered. Return them as "Amount — description" rows, ready to ' +
'paste. If the data does not include line items, say what is needed instead ' +
'of inventing services.',
},
categorizeTransactions: {
label: 'Categorize / explain transactions',
prompt:
'Review these transactions and, for each, suggest the most likely expense ' +
'or income account category and briefly explain the transaction in plain ' +
'language (payee, what it appears to be). Format as "Date · Payee · ' +
'Amount → Suggested category (reason)" rows. Where the category is ' +
'genuinely ambiguous, say so and give the top two candidates rather than ' +
'guessing one. Do not invent transactions that are not listed.',
},
} as const;
// An action id from the catalog.
export type ActionId = keyof typeof ACTIONS;
// isActionId narrows an arbitrary string to a known action id. Boundary guard —
// the panel and the server validate an inbound id here before running it.
export function isActionId(id: string): id is ActionId {
return Object.prototype.hasOwnProperty.call(ACTIONS, id);
}
// actionPrompt resolves an action id to its prompt template. Throws on an
// unknown id (a boundary error, surfaced to the caller) rather than silently
// running a default.
export function actionPrompt(id: string): string {
if (!isActionId(id)) throw new Error(`Unknown action: ${id}`);
return ACTIONS[id].prompt;
}
// actionList is the ordered catalog for building the UI (chips) — id + label,
// derived from ACTIONS so the panel and the catalog can never drift.
export function actionList(): Array<{ id: ActionId; label: string }> {
return (Object.keys(ACTIONS) as ActionId[]).map((id) => ({ id, label: ACTIONS[id].label }));
}
// runAction is the single entry point every surface calls: resolve the action's
// prompt and run it over the assembled financial context via `ask`. This is the
// one code path from an action id to the model — the chips, the proxy, and any
// programmatic caller share it. Async so an unknown id surfaces as a rejected
// promise (not a synchronous throw), giving callers ONE way to handle failure:
// `await`/`.catch`. A gateway error rejects via `ask`.
export async function runAction(id: string, ctx: FinanceContext, opts: AskOptions = {}): Promise<string> {
return ask(actionPrompt(id), ctx, opts);
}
+159
View File
@@ -0,0 +1,159 @@
// The QuickBooks panel glue: the web page (hosted behind hanzoai/ingress at
// quickbooks.hanzo.ai) that drives the AI actions over pasted financials. All the
// logic-heavy work (action prompts, report/entity rendering, context windowing,
// chat shaping, auth) lives in its own tested modules; this file binds them to
// the DOM. It is the one impure, browser-only entry point.
//
// How the panel gets the data: the user pastes a QBO report JSON (P&L, Balance
// Sheet), a query response, or plain financial text into the box; the panel
// interprets it (buildContextFromPaste) and runs actions over the windowed
// context straight against the api.hanzo.ai gateway with the pasted Hanzo key.
// The panel never holds the Intuit secret — server-side reads (qbo-analysis) are
// the deployment's proxy path; the standalone panel is paste-driven so it works
// with zero backend. The interpret + windowing logic is pure (panel.ts,
// finance.ts), unit-tested.
import { actionList, isActionId, runAction } from './actions.js';
import { ask as askHanzo, listModels } from './hanzo.js';
import { contextNote } from './finance.js';
import { DEFAULT_MODEL } from './config.js';
import { getApiKey, setApiKey, hasApiKey, bearer, validateKey } from './auth.js';
import { buildContextFromPaste, panelTitle, parseLaunchContext } from './panel.js';
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
let controller: AbortController | null = null;
window.addEventListener('DOMContentLoaded', () => {
const ctx = parseLaunchContext(window.location.search);
const titleEl = $('company-title');
const dataEl = $<HTMLTextAreaElement>('data');
const outputEl = $<HTMLTextAreaElement>('output');
const statusEl = $('status');
const modelEl = $<HTMLSelectElement>('model');
const chipRow = $('chips');
const runBtn = $<HTMLButtonElement>('run');
const stopBtn = $<HTMLButtonElement>('stop');
const promptEl = $<HTMLTextAreaElement>('prompt');
const apiKeyEl = $<HTMLInputElement>('apikey');
const saveKeyBtn = $<HTMLButtonElement>('savekey');
const authHint = $('authhint');
titleEl.textContent = panelTitle(ctx);
apiKeyEl.value = getApiKey();
reflectAuth();
void populateModels();
// Action chips — derived from the catalog so UI and logic never drift.
for (const a of actionList()) {
const b = document.createElement('button');
b.className = 'chip';
b.textContent = a.label;
b.dataset.action = a.id;
b.onclick = () => void run(a.id);
chipRow.appendChild(b);
}
runBtn.onclick = () => {
const prompt = promptEl.value.trim();
if (prompt) void ask(prompt);
};
stopBtn.onclick = () => controller?.abort();
saveKeyBtn.onclick = async () => {
const key = apiKeyEl.value.trim();
setStatus('Checking key…');
try {
const models = await validateKey(key);
setApiKey(key);
fillModels(models);
reflectAuth();
setStatus('Key saved.', 'ok');
} catch (e: any) {
setStatus(e?.message || 'Key rejected.', 'error');
}
};
// run executes one of the named actions over the current financial data.
async function run(actionId: string): Promise<void> {
if (!isActionId(actionId)) return;
await execute((financeCtx, opts) => runAction(actionId, financeCtx, opts));
}
// ask executes a freeform question over the financial data.
async function ask(prompt: string): Promise<void> {
await execute((financeCtx, opts) => askHanzo(prompt, financeCtx, opts));
}
// execute is the shared runner: interpret the pasted data, call the model, show
// the result. Both the chips and freeform ask funnel through here (one path).
async function execute(
call: (
financeCtx: ReturnType<typeof buildContextFromPaste>,
opts: { token: string; model: string; signal: AbortSignal },
) => Promise<string>,
): Promise<void> {
const financeCtx = buildContextFromPaste(dataEl.value);
if (financeCtx.text.length === 0) {
setStatus('Paste a QuickBooks report (P&L / Balance Sheet JSON), a query response, or figures.', 'warn');
return;
}
controller?.abort();
controller = new AbortController();
setBusy(true);
setStatus(contextNote(financeCtx));
outputEl.value = '';
try {
const text = await call(financeCtx, {
token: bearer(),
model: modelEl.value || DEFAULT_MODEL,
signal: controller.signal,
});
outputEl.value = text;
setStatus('Done.', 'ok');
} catch (e: any) {
if (e?.name === 'AbortError') setStatus('Stopped.');
else setStatus(e?.message || 'Request failed.', 'error');
} finally {
setBusy(false);
}
}
async function populateModels(): Promise<void> {
try {
fillModels(await listModels({ token: bearer() }));
} catch {
fillModels([DEFAULT_MODEL]);
}
}
function fillModels(ids: string[]): void {
const list = ids.length ? ids : [DEFAULT_MODEL];
modelEl.innerHTML = '';
for (const id of list) {
const opt = document.createElement('option');
opt.value = id;
opt.textContent = id;
modelEl.appendChild(opt);
}
if (list.includes(DEFAULT_MODEL)) modelEl.value = DEFAULT_MODEL;
}
function reflectAuth(): void {
authHint.textContent = hasApiKey()
? 'Using your saved Hanzo key.'
: 'No key saved — using public models. Paste an hk-… key for your org models.';
}
function setBusy(b: boolean): void {
runBtn.disabled = b;
stopBtn.disabled = !b;
for (const c of Array.from(chipRow.querySelectorAll('button'))) (c as HTMLButtonElement).disabled = b;
}
function setStatus(msg: string, kind: '' | 'ok' | 'warn' | 'error' = ''): void {
statusEl.textContent = msg;
statusEl.className = `status${kind ? ' ' + kind : ''}`;
}
});
+55
View File
@@ -0,0 +1,55 @@
// Auth for the web panel: the zero-setup pasted-key path. The QuickBooks panel
// is a static web page hosted behind hanzoai/ingress, so the Hanzo credential
// lives in localStorage and is validated by a real /v1/models call — a key that
// can't list models is rejected before it's saved, so the user learns at paste
// time, not at first action. Mirrors @hanzo/docusign and @hanzo/pdf exactly (one
// way to hold a key).
//
// The Intuit OAuth token (for reading the books) is a SEPARATE credential minted
// and held SERVER-SIDE by the OAuth flow + proxy; the panel never holds the
// Intuit client secret. This module is only the Hanzo-gateway bearer.
import { APIKEY_STORAGE_KEY, pickBearer } from './config.js';
import { listModels } from './hanzo.js';
export function getApiKey(): string {
try {
return localStorage.getItem(APIKEY_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setApiKey(key: string): void {
try {
const k = key.trim();
if (k) localStorage.setItem(APIKEY_STORAGE_KEY, k);
else localStorage.removeItem(APIKEY_STORAGE_KEY);
} catch {
/* private-mode / storage disabled — the in-memory key still works this session */
}
}
export function clearApiKey(): void {
setApiKey('');
}
export function hasApiKey(): boolean {
return !!getApiKey();
}
// bearer is the credential the chat call sends: the pasted key (or empty for
// anonymous public models). When Hanzo OAuth lands it slots in as the second
// argument to pickBearer with no change to callers.
export function bearer(): string {
return pickBearer(getApiKey(), '');
}
// validateKey confirms a pasted key actually works by listing models with it.
// Returns the models on success so the caller populates the picker in one
// round-trip; throws with the gateway's message on failure.
export async function validateKey(key: string): Promise<string[]> {
const k = key.trim();
if (!k) throw new Error('Enter a Hanzo API key (hk-…).');
return listModels({ token: k });
}
+153
View File
@@ -0,0 +1,153 @@
// QuickBooks Online config — the api.hanzo.ai model gateway, the default model,
// the pasted-key storage for the web panel, and the server-side secret set
// (Intuit OAuth2 + the QBO API base). The gateway endpoints and bearer choice
// mirror @hanzo/docusign / @hanzo/pdf so the productivity suite stays DRY; the
// accounting-specific pieces (report → analysis text, the AI actions) live in
// finance.ts / hanzo.ts, not here.
// ---- Hanzo model gateway --------------------------------------------------
// Where the Hanzo model gateway lives. `@hanzo/ai` defaults here too. /v1 only,
// never an /api/ prefix (api.hanzo.ai IS the api host).
export const HANZO_API_BASE_URL = 'https://api.hanzo.ai';
// Default model. A Zen model (qwen3+). Overridable per-request via the picker;
// the gateway routes it.
export const DEFAULT_MODEL = 'zen5';
// Public IAM origin that mints Hanzo user tokens, and the OAuth client id an
// inbound Hanzo token is audienced to (owner-scoping validation via @hanzo/iam).
export const DEFAULT_IAM_SERVER_URL = 'https://hanzo.id';
export const DEFAULT_IAM_CLIENT_ID = 'hanzo-quickbooks';
// localStorage key for the pasted Hanzo API key (`hk-…`) in the web panel. The
// panel is a static web page hosted behind hanzoai/ingress, not an Office host
// with roamingSettings, so the zero-setup credential lives in localStorage —
// same as @hanzo/docusign and @hanzo/pdf.
export const APIKEY_STORAGE_KEY = 'hanzo.quickbooks.apiKey';
// Financial-context budget. A P&L or a page of transactions serializes to a lot
// of JSON; the whole thing won't fit a model window and shouldn't be sent. This
// caps the characters of assembled analysis text we attach to any one request —
// chosen so it fits comfortably inside a modern context window alongside the
// reply, and is honest rather than optimal (we truncate visibly, never drop
// silently). finance.ts enforces it.
export const CONTEXT_CHAR_BUDGET = 60_000;
// pickBearer chooses the credential to send to the Hanzo gateway: a pasted API
// key wins over an OAuth token (an explicit key is a deliberate override), else
// the token, else empty (anonymous — the gateway still serves public models).
// Pure — unit-tested.
export function pickBearer(apiKey: string, oauthToken: string): string {
return (apiKey && apiKey.trim()) || (oauthToken && oauthToken.trim()) || '';
}
// chatCompletionsURL / modelsURL — the model gateway endpoints.
export function chatCompletionsURL(): string {
return `${HANZO_API_BASE_URL}/v1/chat/completions`;
}
export function modelsURL(): string {
return `${HANZO_API_BASE_URL}/v1/models`;
}
// ---- Intuit OAuth2 + QuickBooks Online API bases --------------------------
//
// Intuit runs ONE OAuth2 authorization server (appcenter + oauth.platform) for
// both sandbox and production; only the Accounting API host differs (sandbox vs
// prod). The environment therefore selects the API host, not the OAuth host.
// Docs: developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization/oauth-2.0
// The Intuit OAuth2 authorization endpoint (browser consent screen). Single host
// for both environments.
export const INTUIT_AUTHORIZE_URL = 'https://appcenter.intuit.com/connect/oauth2';
// The Intuit OAuth2 token endpoint (code→token + refresh). Single host.
export const INTUIT_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
// The Intuit OAuth2 token-revocation endpoint (disconnect).
export const INTUIT_REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke';
// The QuickBooks Online Accounting API v3 host, per environment. `sandbox` is
// the safe developer company; `production` is the live company.
export const QBO_API_HOST = {
sandbox: 'https://sandbox-quickbooks.api.intuit.com',
production: 'https://quickbooks.api.intuit.com',
} as const;
// Which environment is a QBO env value.
export type QboEnv = keyof typeof QBO_API_HOST;
// qboApiHost resolves the Accounting API origin for an environment.
export function qboApiHost(env: QboEnv): string {
return QBO_API_HOST[env];
}
// The Accounting API version segment. v3 is current; we never bump to a
// speculative v4 — one version, forward only.
export const QBO_API_VERSION = 'v3';
// The minor version pins the exact QBO schema so a field we depend on cannot
// shift under us. Sent as the `minorversion` query param on every call.
export const QBO_MINOR_VERSION = '73';
// The OAuth scopes the app requests. The accounting scope grants the Accounting
// API; openid returns the id_token (identity) at token exchange.
export const OAUTH_SCOPES = ['com.intuit.quickbooks.accounting', 'openid'] as const;
// isQboEnv narrows an arbitrary string to a QboEnv, defaulting unknown values to
// 'sandbox' (the safe, non-production developer company). Boundary guard.
export function isQboEnv(v: string | undefined): v is QboEnv {
return v === 'sandbox' || v === 'production';
}
// ---- Server-side configuration (Intuit OAuth2) ----------------------------
//
// These are read from the environment by src/server.ts. They NEVER reach the
// browser bundle: the client id is public, but the client secret is server-only
// and is validated to be present before the server will start (readServerConfig
// throws on a missing secret).
export interface ServerConfig {
/** Intuit OAuth2 client id (public). */
intuitClientId: string;
/** Intuit OAuth2 client secret (SERVER ONLY — token exchange + refresh). */
intuitClientSecret: string;
/** OAuth redirect registered on the app (e.g. https://quickbooks.hanzo.ai/oauth/callback). */
intuitRedirectUri: string;
/** QBO environment: 'sandbox' (developer company) or 'production'. */
qboEnv: QboEnv;
/**
* Hanzo API key the server uses to run AI over the books via the proxy. The
* proxy funnels the panel's model calls server-side; without a key the server
* still proxies QBO reads and runs anonymous/public models, which
* readServerConfig reports honestly.
*/
hanzoApiKey: string;
/** Listen port. */
port: number;
}
// readServerConfig fails fast (throws) if a required Intuit secret is missing —
// a server that can neither exchange OAuth codes nor refresh tokens must not
// pretend to start. hanzoApiKey is the one optional field (see above). Pure
// given an env map, so it is unit-tested without touching process.env.
export function readServerConfig(env: Record<string, string | undefined>): ServerConfig {
const intuitClientId = env.INTUIT_CLIENT_ID;
const intuitClientSecret = env.INTUIT_CLIENT_SECRET;
const intuitRedirectUri = env.INTUIT_REDIRECT_URI;
const missing: string[] = [];
if (!intuitClientId) missing.push('INTUIT_CLIENT_ID');
if (!intuitClientSecret) missing.push('INTUIT_CLIENT_SECRET');
if (!intuitRedirectUri) missing.push('INTUIT_REDIRECT_URI');
if (missing.length > 0) {
throw new Error(`Missing required environment: ${missing.join(', ')}`);
}
return {
intuitClientId: intuitClientId!,
intuitClientSecret: intuitClientSecret!,
intuitRedirectUri: intuitRedirectUri!,
qboEnv: isQboEnv(env.QBO_ENV) ? env.QBO_ENV : 'sandbox',
hanzoApiKey: env.HANZO_API_KEY || '',
port: Number(env.PORT) || 8790,
};
}
+308
View File
@@ -0,0 +1,308 @@
// The accounting-aware core: turn a QuickBooks Online report or entity JSON into
// compact, readable analysis-context TEXT, windowed to a character budget. PURE
// and host-agnostic — no QBO SDK, no DOM, no network. The panel and the server
// proxy read QBO JSON and hand it here; the AI actions (hanzo.ts) then run over
// the assembled text.
//
// Why flatten to text at all: a QBO report is a deeply nested Header/Columns/
// Rows tree (sections, subtotals, a Summary per section). A model reasons far
// better over a clean indented statement than over that raw JSON, and the JSON
// is several times larger for the same information. We render the statement the
// way an accountant reads it — labels left, amounts right, sections indented —
// and truncate visibly at the budget so we never silently drop the bottom of a
// P&L.
import { CONTEXT_CHAR_BUDGET } from './config.js';
// ---- QBO report shape (only the fields we read) ---------------------------
//
// developer.intuit.com/app/developer/qbo/docs/api/accounting/report-entities/profitandloss
// A report is: Header (metadata), Columns (the value columns), Rows (a tree).
// Each Row is one of: a data row (ColData[]), a Section (Header + nested Rows +
// Summary), or a summary row. type/group tag the row kind.
export interface ReportColData {
value?: string;
id?: string;
}
export interface ReportRow {
Header?: { ColData?: ReportColData[] };
Rows?: { Row?: ReportRow[] };
Summary?: { ColData?: ReportColData[] };
ColData?: ReportColData[];
type?: string;
group?: string;
}
export interface QboReport {
Header?: {
ReportName?: string;
StartPeriod?: string;
EndPeriod?: string;
Currency?: string;
ReportBasis?: string;
};
Columns?: { Column?: Array<{ ColTitle?: string; ColType?: string }> };
Rows?: { Row?: ReportRow[] };
}
// The result of assembling context: the rendered text, a short label for the
// source, and whether anything was dropped (so the prompt and UI can say so
// honestly). Mirrors @hanzo/docusign's DocumentContext contract.
export interface FinanceContext {
text: string;
/** Human label for the source, e.g. "Profit and Loss (2026-01-01 to 2026-06-30)". */
label: string;
truncated: boolean;
}
// ---- Report rendering -----------------------------------------------------
// colLabel is the leftmost cell of a row — the account/line label.
function colLabel(cols: ReportColData[] | undefined): string {
return (cols?.[0]?.value ?? '').trim();
}
// colValues are the numeric/amount cells to the right of the label (every column
// after the first). Empty cells are kept as '' so alignment across columns is
// preserved.
function colValues(cols: ReportColData[] | undefined): string[] {
if (!cols || cols.length <= 1) return [];
return cols.slice(1).map((c) => (c.value ?? '').trim());
}
// renderRowLine renders one label + its amounts as a single indented line:
// ` Label: 1,000.00 | 2,000.00`. A label with no amounts (a bare heading) omits
// the colon. Pure and total.
function renderRowLine(label: string, values: string[], depth: number): string {
const indent = ' '.repeat(depth);
const amounts = values.filter((v) => v.length > 0);
if (amounts.length === 0) return `${indent}${label}`;
return `${indent}${label}: ${amounts.join(' | ')}`;
}
// walkRows renders the row tree depth-first into indented lines. A Section
// renders its Header line, then its child rows one level deeper, then its
// Summary line (the section subtotal). A data row renders its ColData. Rows with
// neither a label nor amounts are skipped. Pure — the exact rendering is what we
// also measure against the budget.
function walkRows(rows: ReportRow[] | undefined, depth: number, out: string[]): void {
if (!Array.isArray(rows)) return;
for (const row of rows) {
const headerCols = row.Header?.ColData;
const summaryCols = row.Summary?.ColData;
const isSection = !!row.Rows?.Row || !!row.Header || !!row.Summary;
if (isSection) {
const hLabel = colLabel(headerCols);
if (hLabel) out.push(renderRowLine(hLabel, colValues(headerCols), depth));
walkRows(row.Rows?.Row, depth + 1, out);
const sLabel = colLabel(summaryCols);
if (sLabel) out.push(renderRowLine(sLabel, colValues(summaryCols), depth));
continue;
}
const label = colLabel(row.ColData);
if (label || colValues(row.ColData).some((v) => v.length > 0)) {
out.push(renderRowLine(label || '—', colValues(row.ColData), depth));
}
}
}
// reportLabel is the human title for a report from its Header — the report name
// plus the period. Used as the FinanceContext label and echoed to the user.
export function reportLabel(report: QboReport): string {
const h = report.Header ?? {};
const name = (h.ReportName ?? 'Report').trim();
const period =
h.StartPeriod && h.EndPeriod ? ` (${h.StartPeriod} to ${h.EndPeriod})` : '';
return `${name}${period}`;
}
// renderReport turns a QBO report into a plain-text statement: a metadata header
// (name, period, basis, currency, column titles) followed by the indented row
// tree. This is the readable form the model reasons over. Pure — no truncation
// here; buildReportContext applies the budget.
export function renderReport(report: QboReport): string {
const h = report.Header ?? {};
const meta: string[] = [];
if (h.ReportName) meta.push(`Report: ${h.ReportName}`);
if (h.StartPeriod && h.EndPeriod) meta.push(`Period: ${h.StartPeriod} to ${h.EndPeriod}`);
if (h.ReportBasis) meta.push(`Basis: ${h.ReportBasis}`);
if (h.Currency) meta.push(`Currency: ${h.Currency}`);
const colTitles = (report.Columns?.Column ?? [])
.map((c) => (c.ColTitle ?? '').trim())
.filter(Boolean);
if (colTitles.length > 0) meta.push(`Columns: ${colTitles.join(' | ')}`);
const lines: string[] = [];
walkRows(report.Rows?.Row, 0, lines);
return [...meta, '', ...lines].join('\n');
}
// truncateToBudget caps text at `budget` characters on a line boundary (so we
// never cut a number in half), appending an explicit marker. Returns the text
// and whether it was cut. Pure and total. Shared by every context builder so
// "fit rendered text to a budget" has exactly one implementation.
export function truncateToBudget(
text: string,
budget: number = CONTEXT_CHAR_BUDGET,
): { text: string; truncated: boolean } {
if (text.length <= budget) return { text, truncated: false };
const marker = '\n… [truncated to fit — figures below this point were omitted]';
const room = Math.max(0, budget - marker.length);
const slice = text.slice(0, room);
const lastNl = slice.lastIndexOf('\n');
const cut = lastNl > room * 0.5 ? slice.slice(0, lastNl) : slice;
return { text: cut + marker, truncated: true };
}
// buildReportContext is the one call for a report: render it, then window it to
// the budget. Pure and total. The label is always the report's title even when
// truncated. Mirrors @hanzo/docusign's buildDocumentContext contract.
export function buildReportContext(
report: QboReport,
budget: number = CONTEXT_CHAR_BUDGET,
): FinanceContext {
const rendered = renderReport(report);
const { text, truncated } = truncateToBudget(rendered, budget);
return { text, label: reportLabel(report), truncated };
}
// ---- Entity rendering -----------------------------------------------------
//
// The AI actions also run over lists/records of entities (Invoices, Bills,
// Transactions, Customers, Accounts). Rather than send raw QBO JSON — which is
// verbose and full of link/metadata noise — we render a compact "field: value"
// block per record, keyed to what an accountant cares about. Unknown entity
// shapes fall back to a shallow key/value dump so nothing is ever lost silently.
// A picked field: the label shown and the dotted path into the record.
interface FieldSpec {
label: string;
path: string;
}
// ENTITY_FIELDS is the per-entity projection — the fields worth attaching, in
// reading order. An entity not listed here renders via the generic fallback.
const ENTITY_FIELDS: Record<string, FieldSpec[]> = {
Invoice: [
{ label: 'Invoice #', path: 'DocNumber' },
{ label: 'Date', path: 'TxnDate' },
{ label: 'Due', path: 'DueDate' },
{ label: 'Customer', path: 'CustomerRef.name' },
{ label: 'Total', path: 'TotalAmt' },
{ label: 'Balance', path: 'Balance' },
{ label: 'Currency', path: 'CurrencyRef.value' },
],
Bill: [
{ label: 'Bill #', path: 'DocNumber' },
{ label: 'Date', path: 'TxnDate' },
{ label: 'Due', path: 'DueDate' },
{ label: 'Vendor', path: 'VendorRef.name' },
{ label: 'Total', path: 'TotalAmt' },
{ label: 'Balance', path: 'Balance' },
],
Customer: [
{ label: 'Name', path: 'DisplayName' },
{ label: 'Company', path: 'CompanyName' },
{ label: 'Balance', path: 'Balance' },
{ label: 'Email', path: 'PrimaryEmailAddr.Address' },
{ label: 'Active', path: 'Active' },
],
Account: [
{ label: 'Name', path: 'Name' },
{ label: 'Type', path: 'AccountType' },
{ label: 'Subtype', path: 'AccountSubType' },
{ label: 'Balance', path: 'CurrentBalance' },
{ label: 'Active', path: 'Active' },
],
Purchase: [
{ label: 'Date', path: 'TxnDate' },
{ label: 'Payee', path: 'EntityRef.name' },
{ label: 'Account', path: 'AccountRef.name' },
{ label: 'Payment', path: 'PaymentType' },
{ label: 'Total', path: 'TotalAmt' },
],
};
// dig reads a dotted path out of a record, returning '' when any segment is
// missing. Values are coerced to string. Pure.
function dig(record: Record<string, unknown>, path: string): string {
let cur: unknown = record;
for (const key of path.split('.')) {
if (cur == null || typeof cur !== 'object') return '';
cur = (cur as Record<string, unknown>)[key];
}
if (cur == null) return '';
return String(cur);
}
// renderEntity renders one record as a compact "Label: value" block using the
// entity's projection, or a generic shallow dump of scalar fields when the
// entity is unknown. Empty fields are skipped. Pure.
export function renderEntity(entity: string, record: Record<string, unknown>): string {
const specs = ENTITY_FIELDS[entity];
const lines: string[] = [];
if (specs) {
for (const s of specs) {
const v = dig(record, s.path);
if (v) lines.push(`${s.label}: ${v}`);
}
} else {
for (const [k, v] of Object.entries(record)) {
if (v == null) continue;
if (typeof v === 'object') continue; // skip nested link/metadata objects
lines.push(`${k}: ${String(v)}`);
}
}
return lines.join('\n');
}
// buildEntitiesContext renders a list of records into a numbered set of blocks,
// windowed to the budget. It stops adding records once the budget is reached and
// flags truncation, so a 10,000-invoice pull attaches a meaningful prefix rather
// than blowing the window. Pure and total. `label` describes the set for the UI
// and prompt.
export function buildEntitiesContext(
entity: string,
records: Array<Record<string, unknown>>,
label: string,
budget: number = CONTEXT_CHAR_BUDGET,
): FinanceContext {
const blocks: string[] = [];
let used = 0;
let truncated = false;
for (let i = 0; i < records.length; i++) {
const body = renderEntity(entity, records[i]);
const block = `[${entity} ${i + 1}]\n${body}`;
const addition = (blocks.length === 0 ? '' : '\n\n') + block;
if (used + addition.length > budget) {
truncated = true;
break;
}
blocks.push(addition);
used += addition.length;
}
const included = blocks.length;
const fullLabel =
records.length > included
? `${label}${included} of ${records.length} ${entity} records (truncated to fit)`
: `${label}${records.length} ${entity} record${records.length === 1 ? '' : 's'}`;
return { text: blocks.join(''), label: fullLabel, truncated };
}
// contextNote is the one honest sentence prepended to the financial data so the
// model (and, echoed in the panel, the user) knows the scope of what it sees.
// Never claim the whole dataset was sent when it wasn't. Mirrors
// @hanzo/docusign's contextNote.
export function contextNote(ctx: FinanceContext): string {
if (ctx.text.length === 0) return 'No financial data was available.';
return ctx.truncated
? `${ctx.label} — truncated to fit; answer only from what is shown and say so if the omitted part is needed.`
: ctx.label;
}
+122
View File
@@ -0,0 +1,122 @@
// The Hanzo call and its request/response shaping over FINANCIAL data — thin
// over the published @hanzo/ai SDK, pure prompt assembly, fully unit-testable.
// The panel and the server proxy read QBO JSON, turn it into analysis text via
// finance.ts, and hand the FinanceContext here. Speaks the OpenAI-compatible /v1
// wire protocol the whole Hanzo suite uses (identical to @hanzo/docusign and
// @hanzo/pdf) so the gateway sees one shape from every surface.
//
// We import `createAiClient` from @hanzo/ai — the published headless client — and
// do NOT reimplement it. `ask`/`listModels` accept an injected AiClient so tests
// drive them without a network; production passes a real client built from the
// pasted key (panel) or the server's HANZO_API_KEY (proxy).
import { createAiClient, type AiClient, type ChatCompletion } from '@hanzo/ai';
import { DEFAULT_MODEL, HANZO_API_BASE_URL } from './config.js';
import type { FinanceContext } from './finance.js';
import { contextNote } from './finance.js';
// SYSTEM_PROMPT grounds every answer in the financial data provided. It forbids
// inventing figures nobody reported (the failure mode that makes an accounting
// assistant dangerous), asks the model to reference the specific line/account,
// stays honest about a truncated statement, and states plainly that the output
// is informational — not tax, audit, or accounting advice.
export const SYSTEM_PROMPT =
'You are Hanzo AI, an assistant that helps people understand their ' +
'QuickBooks Online books — financial reports, invoices, bills, customers, ' +
'accounts, and transactions. Work ONLY from the financial data provided ' +
'below — never invent accounts, amounts, dates, or trends that are not ' +
'supported by the data. Reference the specific line item or account by name ' +
'when you cite a figure. Be concise, precise, and neutral, and keep monetary ' +
'amounts in the reported currency. If the data is marked truncated and a ' +
'complete answer needs the omitted part, say so plainly rather than ' +
'guessing. You provide informational analysis, not tax, audit, or accounting ' +
'advice.';
// ---- Prompt assembly ------------------------------------------------------
// A ChatMessage in the OpenAI-compatible schema. Local alias so call sites and
// tests do not depend on the SDK's exported member name.
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
// buildMessages turns a task (the user's question, or one of the action prompts)
// plus the assembled financial context into the OpenAI-compatible message list.
// The data is fenced so the model treats it as data, not instructions, and the
// honest context note rides inside the user turn so scope is never lost. Pure —
// unit-tested. Mirrors @hanzo/docusign's buildMessages exactly (one prompt shape
// across the suite).
export function buildMessages(task: string, ctx: FinanceContext): ChatMessage[] {
const system: ChatMessage = { role: 'system', content: SYSTEM_PROMPT };
const user: ChatMessage = {
role: 'user',
content:
`${contextNote(ctx)}\n\n` +
`---- financial data ----\n${ctx.text}\n---- end financial data ----\n\n` +
`---- task ----\n${task}`,
};
return [system, user];
}
// extractContent pulls the assistant text out of an OpenAI-compatible completion,
// throwing on an empty/error body so callers surface the real gateway message.
// Pure — unit-tested. (The SDK's create() rejects on non-2xx; this guards a 2xx
// body that carries no usable content.)
export function extractContent(res: ChatCompletion): string {
const content = res?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || content.length === 0) {
throw new Error('Hanzo API returned no content');
}
return content;
}
// ---- The single call path -------------------------------------------------
export interface AskOptions {
model?: string;
temperature?: number;
/** Bearer credential: an `hk-…` key or an IAM token. Empty → anonymous. */
token?: string;
baseURL?: string;
/** Injected client (tests). Defaults to a real createAiClient. */
client?: AiClient;
signal?: AbortSignal;
}
// buildClient returns the injected client or a real one from @hanzo/ai bound to
// the gateway + bearer. One place constructs the SDK client so token/baseURL
// handling lives in a single spot.
function buildClient(opts: AskOptions): AiClient {
return (
opts.client ??
createAiClient({ token: opts.token, baseUrl: opts.baseURL ?? HANZO_API_BASE_URL })
);
}
// ask runs ONE non-streaming completion over financial data and returns the
// answer. This is the single path from every QuickBooks surface to the model —
// the actions, a freeform question, and the server proxy all funnel here. token
// may be empty (the gateway serves anonymous/limited models).
export async function ask(task: string, ctx: FinanceContext, opts: AskOptions = {}): Promise<string> {
const client = buildClient(opts);
const res = await client.chat.completions.create(
{
model: opts.model ?? DEFAULT_MODEL,
messages: buildMessages(task, ctx),
stream: false,
temperature: opts.temperature,
},
{ signal: opts.signal },
);
return extractContent(res);
}
// listModels returns the model ids the caller may route to, from /v1/models via
// the SDK client. The endpoint is org-scoped by the bearer; an empty token lists
// public models.
export async function listModels(opts: AskOptions = {}): Promise<string[]> {
const client = buildClient(opts);
const models = await client.models.list({ signal: opts.signal });
return models.map((m) => m.id);
}
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hanzo AI for QuickBooks</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header>
<span class="title">Hanzo AI</span>
<select id="model" aria-label="Model"></select>
<span class="host" id="company-title">QuickBooks Online</span>
</header>
<!-- One-click actions over the books. Chips are built from the catalog. -->
<div class="chips" id="chips"></div>
<label for="data">Financial data</label>
<textarea id="data" placeholder="Paste a QuickBooks report JSON (Profit &amp; Loss, Balance Sheet), a query response (Invoices / Bills / Transactions), or plain figures. Reports → /reports/ProfitAndLoss ; lists → /query in the Accounting API."></textarea>
<label for="prompt">Ask about the books</label>
<textarea id="prompt" placeholder="e.g. Why did net income drop this quarter? · Which customers owe the most? · What are my three biggest expenses?"></textarea>
<div class="row">
<button id="run">Ask Hanzo</button>
<button id="stop" class="secondary" disabled>Stop</button>
<span class="spacer"></span>
</div>
<details>
<summary>Hanzo API key</summary>
<div class="row">
<input id="apikey" type="password" placeholder="hk-…" autocomplete="off" />
<button id="savekey" class="secondary">Save</button>
</div>
<div id="authhint" class="hint"></div>
</details>
<div class="status" id="status"></div>
<label for="output">Result</label>
<textarea id="output" placeholder="Hanzo's analysis appears here." readonly></textarea>
<footer>Routed through api.hanzo.ai · analysis is grounded in your QuickBooks data · informational, not tax or accounting advice.</footer>
<script type="module" src="__ENTRY__"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
// Public surface of @hanzo/quickbooks: the pure, host-agnostic modules. The
// browser panel (app.ts) and the Node service (server.ts) are entry points, not
// re-exported here. A consumer embedding the books-analysis pipeline in their own
// service imports from here.
export * from './config.js';
export * from './qbo-oauth.js';
export * from './qbo-api.js';
export * from './finance.js';
export * from './hanzo.js';
export * from './actions.js';
export * from './qbo-analysis.js';
export * from './panel.js';
+105
View File
@@ -0,0 +1,105 @@
// Pure browser-context helpers for the panel: read the launch query string and
// turn whatever the user pasted into a FinanceContext. No DOM, no network — so
// the context-building logic the panel depends on is unit-tested. app.ts binds
// these to the textarea/URL; everything decision-making lives here.
import {
buildReportContext,
buildEntitiesContext,
truncateToBudget,
type FinanceContext,
type QboReport,
} from './finance.js';
import { CONTEXT_CHAR_BUDGET } from './config.js';
// The launch context the panel opens with. QuickBooks / the deployment can pass
// the company name (realm) and a starting report name in the URL so the panel
// titles itself; all fields are optional (the panel also works standalone).
export interface LaunchContext {
company: string;
realmId: string;
report: string;
}
// parseLaunchContext reads the panel's launch query string. Defensive over a
// malformed search string. Pure.
export function parseLaunchContext(search: string): LaunchContext {
let params: URLSearchParams;
try {
params = new URLSearchParams(search || '');
} catch {
params = new URLSearchParams();
}
return {
company: params.get('company') || '',
realmId: params.get('realmId') || '',
report: params.get('report') || '',
};
}
// panelTitle is the header label: the company name when present, else a generic
// title. Pure.
export function panelTitle(ctx: LaunchContext): string {
return ctx.company ? ctx.company : 'QuickBooks Online';
}
// looksLikeReport / looksLikeQueryResponse are the boundary sniffs that decide
// how to render pasted JSON. A QBO report has a Rows/Columns tree; a query
// response wraps entities under QueryResponse. Pure.
function looksLikeReport(data: any): boolean {
return !!data && typeof data === 'object' && (!!data.Rows || !!data.Columns) && !data.QueryResponse;
}
function looksLikeQueryResponse(data: any): boolean {
return !!data && typeof data === 'object' && !!data.QueryResponse && typeof data.QueryResponse === 'object';
}
// firstEntityKey finds the entity array a QueryResponse carries (e.g. "Invoice")
// and returns its name + records, ignoring QBO's bookkeeping keys. Pure.
function firstEntityKey(qr: Record<string, unknown>): { entity: string; records: Array<Record<string, unknown>> } | null {
const skip = new Set(['startPosition', 'maxResults', 'totalCount']);
for (const [key, val] of Object.entries(qr)) {
if (skip.has(key)) continue;
if (Array.isArray(val)) {
return { entity: key, records: val as Array<Record<string, unknown>> };
}
}
return null;
}
// buildContextFromPaste turns whatever the user pasted into a FinanceContext:
// • a QBO report JSON → rendered statement (buildReportContext)
// • a QBO query response JSON → rendered entity blocks (buildEntitiesContext)
// • anything else → treated as plain financial text, budget-capped
// This is the ONE place the panel decides how to interpret pasted data, so the
// three shapes share the same downstream `ask` path. Pure and total.
export function buildContextFromPaste(
raw: string,
budget: number = CONTEXT_CHAR_BUDGET,
): FinanceContext {
const trimmed = raw.trim();
if (!trimmed) return { text: '', label: '', truncated: false };
let data: any;
try {
data = JSON.parse(trimmed);
} catch {
// Not JSON — treat as pasted plain-text figures, capped to the budget.
const { text, truncated } = truncateToBudget(trimmed, budget);
return { text, label: 'Pasted financial data', truncated };
}
if (looksLikeReport(data)) {
return buildReportContext(data as QboReport, budget);
}
if (looksLikeQueryResponse(data)) {
const found = firstEntityKey(data.QueryResponse);
if (found) {
return buildEntitiesContext(found.entity, found.records, `${found.entity} query`, budget);
}
}
// Valid JSON of an unrecognized shape — stringify it and cap it rather than
// dropping it, so the user still gets an answer over what they pasted.
const { text, truncated } = truncateToBudget(JSON.stringify(data, null, 2), budget);
return { text, label: 'Pasted JSON data', truncated };
}
+116
View File
@@ -0,0 +1,116 @@
// The books-analysis pipeline: given a company (env + realmId) + access token,
// fetch a QBO report (or an entity query), assemble it into financial context,
// and run an AI action over it. This is the shared core the server proxy uses to
// answer the panel server-side (Intuit token stays server-only). It composes the
// pure modules (qbo-api, finance, hanzo, actions) and takes its `fetch` injected,
// so it is unit-testable end-to-end without a network.
import { report, query, parseQueryResponse, parseFault, type ReportName } from './qbo-api.js';
import { buildReportContext, buildEntitiesContext, type FinanceContext } from './finance.js';
import { ask, type AskOptions } from './hanzo.js';
import { actionPrompt } from './actions.js';
import type { QboEnv } from './config.js';
// Shared inputs: the company + token the QBO reads need, plus the Hanzo gateway
// options (bearer, model) the AI call rides on.
interface BaseInput {
env: QboEnv;
realmId: string;
/** Intuit OAuth access token — authorizes the QBO reads. */
accessToken: string;
/** Hanzo gateway options (bearer token, model, injected client for tests). */
ask?: AskOptions;
/** Injected fetch (tests). Defaults to global fetch. */
fetch?: typeof fetch;
}
// A report analysis: which report, over what period, and either a named action
// or a freeform task to run on it.
export interface AnalyzeReportInput extends BaseInput {
name: ReportName;
/** Report params (start_date, end_date, accounting_method, …). */
params?: Record<string, string>;
/** A named action id (actions.ACTIONS) — mutually exclusive with `task`. */
actionId?: string;
/** A freeform instruction — used when no actionId is given. */
task?: string;
}
// An entity-query analysis: a QBO query statement + the entity it returns.
export interface AnalyzeQueryInput extends BaseInput {
statement: string;
/** The entity the query selects (e.g. "Invoice") — keys parseQueryResponse. */
entity: string;
actionId?: string;
task?: string;
}
// The pipeline result: the AI output plus the context that produced it, so the
// caller can show the source label and whether it was truncated.
export interface AnalyzeResult {
result: string;
label: string;
truncated: boolean;
}
// resolveTask picks the instruction to run: a named action's prompt, or the
// freeform task. Throws when neither is given (a boundary error). One rule for
// "what do we ask the model", shared by both analyze entry points.
function resolveTask(actionId: string | undefined, task: string | undefined): string {
if (actionId) return actionPrompt(actionId);
if (task && task.trim()) return task;
throw new Error('analyze requires an actionId or a task');
}
// fetchJson GETs a QBO url and parses JSON, surfacing QBO's Fault message on a
// non-2xx so the caller learns the real reason (e.g. 401 expired token, 400 bad
// query). One JSON read path for every QBO fetch.
async function fetchJson(url: string, headers: Record<string, string>, doFetch: typeof fetch): Promise<any> {
const resp = await doFetch(url, { headers });
const text = await resp.text();
let data: any;
try {
data = text ? JSON.parse(text) : {};
} catch {
throw new Error(`QuickBooks API ${resp.status}: ${text.slice(0, 200)}`);
}
if (!resp.ok) {
const fault = parseFault(data);
throw new Error(`QuickBooks API ${resp.status}: ${fault || 'request failed'}`);
}
return data;
}
// runOver is the shared tail: given an assembled FinanceContext and the resolved
// task, run the model and return the result + context metadata. Both analyze
// entry points converge here — one AI call path.
async function runOver(ctx: FinanceContext, task: string, opts: AskOptions | undefined): Promise<AnalyzeResult> {
const result = await ask(task, ctx, opts);
return { result, label: ctx.label, truncated: ctx.truncated };
}
// analyzeReport fetches a report, builds its context, and runs the action/task
// over it. The ONE place the report-analysis flow lives (the "summarize
// financials" flow: fetch P&L → render statement → summarize). Pure composition
// over injected effects; each step's failure surfaces as a thrown Error.
export async function analyzeReport(input: AnalyzeReportInput): Promise<AnalyzeResult> {
const doFetch = input.fetch ?? fetch;
const task = resolveTask(input.actionId, input.task);
const req = report(input.env, input.realmId, input.accessToken, input.name, input.params);
const data = await fetchJson(req.url, req.headers, doFetch);
const ctx = buildReportContext(data);
return runOver(ctx, task, input.ask);
}
// analyzeQuery runs a QBO entity query, builds its context, and runs the
// action/task over the returned records. Used for invoice/bill/transaction
// analyses. Pure composition over injected effects.
export async function analyzeQuery(input: AnalyzeQueryInput): Promise<AnalyzeResult> {
const doFetch = input.fetch ?? fetch;
const task = resolveTask(input.actionId, input.task);
const req = query(input.env, input.realmId, input.accessToken, input.statement);
const data = await fetchJson(req.url, req.headers, doFetch);
const records = parseQueryResponse<Record<string, unknown>>(data, input.entity);
const ctx = buildEntitiesContext(input.entity, records, `${input.entity} query`);
return runOver(ctx, task, input.ask);
}
+173
View File
@@ -0,0 +1,173 @@
// QuickBooks Online Accounting API v3 — pure request shaping + response parsing.
// Every function returns a PreparedApiRequest (url + headers [+ body]); none opens
// a socket, so the whole API surface is unit-testable without a network.
// server.ts / app.ts are the thin glue that fetch these shapes with a Bearer
// access token.
//
// The REST root is `${host}/v3/company/${realmId}` (see config.qboApiHost +
// QBO_API_VERSION), where realmId (the company id) comes from the OAuth callback
// and host is sandbox vs production. Every path below is built on that root and
// carries the pinned minorversion. Docs:
// developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities
import { qboApiHost, QBO_API_VERSION, QBO_MINOR_VERSION, type QboEnv } from './config.js';
// A prepared GET/POST: the url + headers (+ body for writes) a single fetch
// needs. Bearer-only — the Accounting API is authorized by the OAuth access
// token. `method` and `body` are present only for writes.
export interface PreparedApiRequest {
url: string;
method: 'GET' | 'POST';
headers: Record<string, string>;
body?: string;
}
// companyBase builds the per-company REST root: `${host}/v3/company/${realmId}`.
// Every request is built on top of this. Pure — the wrappers below take env +
// realmId and derive it once.
export function companyBase(env: QboEnv, realmId: string): string {
return `${qboApiHost(env)}/${QBO_API_VERSION}/company/${enc(realmId)}`;
}
// bearer builds the JSON read headers for an Accounting API request. QBO returns
// JSON when asked; without Accept it defaults to XML.
function readHeaders(accessToken: string): Record<string, string> {
return { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' };
}
// writeHeaders adds Content-Type for a JSON write body.
function writeHeaders(accessToken: string): Record<string, string> {
return { ...readHeaders(accessToken), 'Content-Type': 'application/json' };
}
// withMinor appends the pinned minorversion query param, preserving any existing
// query. Every Accounting API call carries it so the schema can't drift.
function withMinor(path: string): string {
const sep = path.includes('?') ? '&' : '?';
return `${path}${sep}minorversion=${QBO_MINOR_VERSION}`;
}
// The reports we surface. `ProfitAndLoss` and `BalanceSheet` are the two core
// financial statements; the others are common SMB reads. The name is a path
// segment on /reports/{name}.
export const REPORTS = {
profitAndLoss: 'ProfitAndLoss',
balanceSheet: 'BalanceSheet',
cashFlow: 'CashFlow',
agedReceivables: 'AgedReceivables',
agedPayables: 'AgedPayables',
} as const;
export type ReportName = (typeof REPORTS)[keyof typeof REPORTS];
// report — a financial report (P&L, Balance Sheet, …). Optional params
// (start_date, end_date, accounting_method, summarize_column_by, …) ride as the
// query. QBO returns a nested Row/Column report structure that finance.ts
// flattens. Docs: developer.intuit.com/app/developer/qbo/docs/api/accounting/
// report-entities/profitandloss
export function report(
env: QboEnv,
realmId: string,
accessToken: string,
name: ReportName,
params: Record<string, string> = {},
): PreparedApiRequest {
const q = new URLSearchParams(params).toString();
const path = `${companyBase(env, realmId)}/reports/${enc(name)}${q ? `?${q}` : ''}`;
return { url: withMinor(path), method: 'GET', headers: readHeaders(accessToken) };
}
// query — a read via the QBO SQL-like query language against the /query endpoint
// (e.g. `SELECT * FROM Invoice WHERE TxnDate > '2026-01-01'`). This is the one
// way to read lists of entities (Invoices, Bills, Customers, Accounts,
// Transactions via a Purchase/JournalEntry query). The statement is URL-encoded
// into the `query` param. Docs: developer.intuit.com/app/developer/qbo/docs/
// api/accounting/all-entities/query
export function query(
env: QboEnv,
realmId: string,
accessToken: string,
statement: string,
): PreparedApiRequest {
const q = new URLSearchParams({ query: statement }).toString();
const path = `${companyBase(env, realmId)}/query?${q}`;
return { url: withMinor(path), method: 'GET', headers: readHeaders(accessToken) };
}
// get — a single entity by id (e.g. `Invoice`/`123`). Used to fetch one record
// for explain/analysis. Docs: .../all-entities/invoice#read-an-invoice
export function get(
env: QboEnv,
realmId: string,
accessToken: string,
entity: string,
id: string,
): PreparedApiRequest {
const path = `${companyBase(env, realmId)}/${enc(entity.toLowerCase())}/${enc(id)}`;
return { url: withMinor(path), method: 'GET', headers: readHeaders(accessToken) };
}
// create — a POST to /{entity} that creates (or, with a sparse+Id body, updates)
// a record. The single write path: the light write-back actions (create an
// invoice, add a memo) build the entity JSON and hand it here. Gated behind an
// explicit action upstream; this wrapper only shapes the request. Docs:
// .../all-entities/invoice#create-an-invoice
export function create(
env: QboEnv,
realmId: string,
accessToken: string,
entity: string,
payload: unknown,
): PreparedApiRequest {
const path = `${companyBase(env, realmId)}/${enc(entity.toLowerCase())}`;
return {
url: withMinor(path),
method: 'POST',
headers: writeHeaders(accessToken),
body: JSON.stringify(payload),
};
}
// ---- Response parsing -----------------------------------------------------
// parseQueryResponse reads a /query response into the entity array for a given
// entity name. QBO wraps results under `QueryResponse[<Entity>]` (e.g.
// QueryResponse.Invoice). Returns [] when the entity key is absent (a valid
// empty result). Pure — unit-tested with plain objects.
export function parseQueryResponse<T = Record<string, unknown>>(data: any, entity: string): T[] {
const qr = data?.QueryResponse;
if (!qr || typeof qr !== 'object') return [];
const arr = qr[entity];
return Array.isArray(arr) ? (arr as T[]) : [];
}
// parseEntityResponse reads a single-entity read/create response. QBO returns
// the record under its entity key at the top level (e.g. { Invoice: {…} }).
// Returns undefined when absent so the caller can surface a not-found. Pure.
export function parseEntityResponse<T = Record<string, unknown>>(data: any, entity: string): T | undefined {
const v = data?.[entity];
return v && typeof v === 'object' ? (v as T) : undefined;
}
// parseFault reads QBO's error envelope (`Fault.Error[]`) into a single message,
// or '' when there is no fault. QBO returns HTTP 400 with a Fault body on a bad
// query/write; the server uses this to surface the real reason. Pure.
export function parseFault(data: any): string {
const errors = data?.Fault?.Error;
if (!Array.isArray(errors) || errors.length === 0) return '';
return errors
.map((e: any) => {
const msg = String(e?.Message ?? '').trim();
const detail = String(e?.Detail ?? '').trim();
return detail && detail !== msg ? `${msg}: ${detail}` : msg;
})
.filter(Boolean)
.join('; ');
}
// enc encodes a single path segment. realmId is numeric and entity ids are short
// tokens, but we encode defensively at this boundary so a stray character can
// never break the path (or worse, traverse it).
function enc(segment: string): string {
return encodeURIComponent(segment);
}
+181
View File
@@ -0,0 +1,181 @@
// Intuit OAuth2 (Authorization Code Grant) — pure request shaping. The client
// secret is held by the server (server.ts) and never reaches the browser; these
// functions build the exact URL/headers/body of each OAuth call so the wire
// shape is unit-testable without a network round-trip. The token exchange and
// refresh are each one fetch in the server.
//
// Intuit uses HTTP Basic auth (base64 client_id:client_secret) on the token
// endpoint and form-encoded bodies — the OAuth2 confidential-client shape,
// documented at developer.intuit.com/app/developer/qbo/docs/develop/
// authentication-and-authorization/oauth-2.0. The authorization and token hosts
// are the SAME for sandbox and production; only the Accounting API host differs
// (config.qboApiHost). The company id — Intuit's `realmId` — arrives as a query
// param on the OAuth callback (NOT from a userinfo call) and scopes every API
// path.
import {
INTUIT_AUTHORIZE_URL,
INTUIT_TOKEN_URL,
INTUIT_REVOKE_URL,
} from './config.js';
// authorizeUrl is where the install flow sends the user's browser to grant the
// app. `scope` is space-joined (the OAuth2 convention), `state` is the CSRF
// token the server generates and re-checks on callback. Intuit appends `realmId`
// (and `code`, `state`) to the redirect on approval.
export function authorizeUrl(args: {
clientId: string;
redirectUri: string;
scopes: readonly string[];
state: string;
}): string {
const q = new URLSearchParams({
client_id: args.clientId,
response_type: 'code',
scope: args.scopes.join(' '),
redirect_uri: args.redirectUri,
state: args.state,
});
return `${INTUIT_AUTHORIZE_URL}?${q.toString()}`;
}
// A prepared HTTP request: the pieces a single fetch needs. Pure output so a
// test asserts the Basic header + form body without opening a socket.
export interface PreparedRequest {
url: string;
headers: Record<string, string>;
body: string;
}
// basicAuth builds the Authorization header value for the token endpoint. The
// secret rides only here, never in the body or the URL.
export function basicAuth(clientId: string, clientSecret: string): string {
return 'Basic ' + Buffer.from(`${clientId}:${clientSecret}`).toString('base64');
}
// tokenExchange builds the code→token request against Intuit's token endpoint.
// grant_type=authorization_code + the code + the redirect_uri (Intuit REQUIRES
// redirect_uri in the body and matches it against the registered value — unlike
// DocuSign, which omits it). Intuit wants Accept: application/json.
export function tokenExchange(args: {
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): PreparedRequest {
return {
url: INTUIT_TOKEN_URL,
headers: {
Authorization: basicAuth(args.clientId, args.clientSecret),
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: args.code,
redirect_uri: args.redirectUri,
}).toString(),
};
}
// refreshExchange builds the refresh-token→token request. Same token endpoint,
// grant_type=refresh_token. QBO access tokens live ~1 hour; a long-running
// deployment refreshes the stored token this way rather than re-consenting. The
// refresh token itself rotates ~every 24h and lives ~100 days.
export function refreshExchange(args: {
clientId: string;
clientSecret: string;
refreshToken: string;
}): PreparedRequest {
return {
url: INTUIT_TOKEN_URL,
headers: {
Authorization: basicAuth(args.clientId, args.clientSecret),
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: args.refreshToken,
}).toString(),
};
}
// revokeExchange builds the disconnect request. Intuit revokes either the access
// or the refresh token (revoking the refresh token disconnects the app). Basic
// auth, JSON body, application/json.
export function revokeExchange(args: {
clientId: string;
clientSecret: string;
token: string;
}): PreparedRequest {
return {
url: INTUIT_REVOKE_URL,
headers: {
Authorization: basicAuth(args.clientId, args.clientSecret),
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ token: args.token }),
};
}
// The token response we care about. Intuit returns access_token (+
// refresh_token, expires_in, x_refresh_token_expires_in, token_type, and — when
// openid is requested — id_token). parseTokenResponse validates the fields we
// must have and surfaces Intuit's own error otherwise.
export interface QboTokenSet {
accessToken: string;
refreshToken: string;
expiresIn?: number;
refreshExpiresIn?: number;
tokenType?: string;
idToken?: string;
}
export function parseTokenResponse(data: any): QboTokenSet {
if (!data || typeof data !== 'object') throw new Error('empty token response');
if (data.error) {
const reason = data.error_description || data.message || data.error;
throw new Error(`Intuit OAuth error: ${reason}`);
}
if (typeof data.access_token !== 'string' || data.access_token.length === 0) {
throw new Error('Intuit OAuth response missing access_token');
}
if (typeof data.refresh_token !== 'string' || data.refresh_token.length === 0) {
throw new Error('Intuit OAuth response missing refresh_token');
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresIn: typeof data.expires_in === 'number' ? data.expires_in : undefined,
refreshExpiresIn:
typeof data.x_refresh_token_expires_in === 'number' ? data.x_refresh_token_expires_in : undefined,
tokenType: typeof data.token_type === 'string' ? data.token_type : undefined,
idToken: typeof data.id_token === 'string' ? data.id_token : undefined,
};
}
// parseCallback reads the OAuth redirect's query params into the two values the
// server needs: the authorization `code` and the `realmId` (company id). `state`
// is validated by the caller against the value it minted. Throws on Intuit's
// error redirect (?error=access_denied) or a missing code/realmId so the caller
// learns immediately. Pure over a URLSearchParams-like map.
export interface QboCallback {
code: string;
realmId: string;
state: string;
}
export function parseCallback(params: URLSearchParams): QboCallback {
const error = params.get('error');
if (error) {
throw new Error(`Intuit OAuth error: ${params.get('error_description') || error}`);
}
const code = params.get('code') || '';
const realmId = params.get('realmId') || '';
const state = params.get('state') || '';
if (!code) throw new Error('OAuth callback missing code');
if (!realmId) throw new Error('OAuth callback missing realmId (company id)');
return { code, realmId, state };
}
+265
View File
@@ -0,0 +1,265 @@
// The QuickBooks install + API-proxy service. This is the ONLY place the Intuit
// client secret and the OAuth tokens exist — the secret is read from the
// environment (never bundled, never sent to the browser); the tokens are minted
// here at callback and held server-side, keyed by realmId (company id). It:
//
// GET /oauth/install → redirect the user to Intuit's OAuth consent
// GET /oauth/callback → exchange the code for tokens, capture the realmId,
// and store the authorization for the company
// GET /healthz → readiness
// GET /v1/companies → the company ids currently authorized (no tokens)
// POST /v1/analyze → run an AI action/question over a report or an
// entity query for a company — Intuit token +
// Hanzo key stay server-side (the panel proxy path)
//
// It is a dependency-free Node http handler built on the pure modules (config,
// qbo-oauth, qbo-api, qbo-analysis, hanzo, actions) so it is deployable behind
// hanzoai/ingress at quickbooks.hanzo.ai. The pure logic it wraps is what the
// tests cover; the http server is an integration concern.
//
// node dist/server.js (after build.js bundles it)
//
// Required environment (see config.readServerConfig):
// INTUIT_CLIENT_ID, INTUIT_CLIENT_SECRET, INTUIT_REDIRECT_URI
// QBO_ENV (sandbox | production, default sandbox)
// HANZO_API_KEY (optional — needed for the proxy to run models; without it
// /v1/analyze returns 503 and the panel uses its own pasted key)
// PORT (default 8790)
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { randomUUID } from 'node:crypto';
import { OAUTH_SCOPES, readServerConfig, type ServerConfig } from './config.js';
import {
authorizeUrl,
tokenExchange,
refreshExchange,
parseTokenResponse,
parseCallback,
type QboTokenSet,
} from './qbo-oauth.js';
import { analyzeReport, analyzeQuery } from './qbo-analysis.js';
import { isActionId } from './actions.js';
import { REPORTS, type ReportName } from './qbo-api.js';
// A stored company authorization: the tokens to call the Accounting API. In
// memory here keyed by realmId; a production deployment persists this to
// KMS/Valkey (keyed by realmId) so the proxy can act on any authorized company
// after a restart. The install flow populates it; the proxy reads it.
interface CompanyAuth {
accessToken: string;
refreshToken: string;
}
const companies = new Map<string, CompanyAuth>();
// Pending OAuth states: the CSRF token minted at /oauth/install, checked at
// callback. A production deployment persists these (KMS/Valkey) so a callback
// survives a restart; in-memory is correct for a single instance.
const pendingStates = new Set<string>();
function json(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function log(fields: Record<string, unknown>): void {
console.log(JSON.stringify(fields));
}
// readJsonBody collects and parses a JSON request body. Returns {} for an empty
// body; throws on malformed JSON so the handler can 400.
async function readJsonBody(req: IncomingMessage): Promise<any> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const raw = Buffer.concat(chunks).toString('utf8');
if (!raw) return {};
return JSON.parse(raw);
}
// exchangeToken POSTs a prepared OAuth request and parses the token set. One
// fetch path for both code-exchange and refresh.
async function exchangeToken(prepared: { url: string; headers: Record<string, string>; body: string }): Promise<QboTokenSet> {
const resp = await fetch(prepared.url, { method: 'POST', headers: prepared.headers, body: prepared.body });
return parseTokenResponse(await resp.json().catch(() => ({})));
}
// GET /oauth/install → 302 to Intuit's consent screen. `state` is a fresh CSRF
// token remembered until callback.
function handleInstall(cfg: ServerConfig, res: ServerResponse): void {
const state = randomUUID();
pendingStates.add(state);
const url = authorizeUrl({
clientId: cfg.intuitClientId,
redirectUri: cfg.intuitRedirectUri,
scopes: OAUTH_SCOPES,
state,
});
res.writeHead(302, { Location: url });
res.end();
}
// GET /oauth/callback?code=…&state=…&realmId=… → validate state, exchange the
// code for tokens (secret in the Basic header, server-side), and store the
// authorization keyed by realmId (the company id Intuit returns on the redirect).
async function handleOAuthCallback(cfg: ServerConfig, url: URL, res: ServerResponse): Promise<void> {
let cb;
try {
cb = parseCallback(url.searchParams);
} catch (e: any) {
return json(res, 400, { error: e?.message || 'invalid callback' });
}
if (!pendingStates.delete(cb.state)) {
return json(res, 400, { error: 'invalid or expired state' });
}
try {
const tokens = await exchangeToken(
tokenExchange({
clientId: cfg.intuitClientId,
clientSecret: cfg.intuitClientSecret,
code: cb.code,
redirectUri: cfg.intuitRedirectUri,
}),
);
companies.set(cb.realmId, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken });
log({ msg: 'oauth: company authorized', realmId: cb.realmId });
return json(res, 200, { ok: true, installed: true, realmId: cb.realmId });
} catch (e: any) {
return json(res, 400, { error: e?.message || 'token exchange failed' });
}
}
// withFreshToken runs `call` with the company's access token, transparently
// refreshing once on a 401 (expired access token) and retrying. This is the ONE
// place token refresh happens, so every proxied read gets it for free.
async function withFreshToken<T>(
cfg: ServerConfig,
realmId: string,
call: (accessToken: string) => Promise<T>,
): Promise<T> {
const auth = companies.get(realmId);
if (!auth) throw new Error(`company ${realmId} is not authorized`);
try {
return await call(auth.accessToken);
} catch (e: any) {
if (!/\b401\b/.test(String(e?.message))) throw e;
const tokens = await exchangeToken(
refreshExchange({
clientId: cfg.intuitClientId,
clientSecret: cfg.intuitClientSecret,
refreshToken: auth.refreshToken,
}),
);
companies.set(realmId, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken });
log({ msg: 'oauth: token refreshed', realmId });
return call(tokens.accessToken);
}
}
// resolveReportName narrows an inbound report name to a known REPORTS value,
// throwing on anything else (boundary guard) so the proxy never forwards an
// arbitrary path segment.
function resolveReportName(name: unknown): ReportName {
const values = Object.values(REPORTS) as string[];
if (typeof name === 'string' && values.includes(name)) return name as ReportName;
throw new Error(`unknown report: ${String(name)}`);
}
// POST /v1/analyze → the panel's server-side path. Body:
// { realmId, kind: "report", name, params?, actionId? | task? }
// { realmId, kind: "query", statement, entity, actionId? | task? }
// The Intuit token and the Hanzo key both stay server-side. Requires
// HANZO_API_KEY (else 503 — the panel falls back to its own pasted-key path).
async function handleAnalyze(cfg: ServerConfig, req: IncomingMessage, res: ServerResponse): Promise<void> {
if (!cfg.hanzoApiKey) {
return json(res, 503, { error: 'proxy AI disabled: HANZO_API_KEY unset' });
}
let body: any;
try {
body = await readJsonBody(req);
} catch {
return json(res, 400, { error: 'invalid json' });
}
const realmId = String(body?.realmId ?? '');
if (!realmId) return json(res, 400, { error: 'missing realmId' });
if (body?.actionId && !isActionId(body.actionId)) {
return json(res, 400, { error: `unknown action: ${body.actionId}` });
}
const askOpts = { token: cfg.hanzoApiKey, model: typeof body?.model === 'string' ? body.model : undefined };
try {
if (body?.kind === 'report') {
const name = resolveReportName(body?.name);
const out = await withFreshToken(cfg, realmId, (accessToken) =>
analyzeReport({
env: cfg.qboEnv,
realmId,
accessToken,
name,
params: typeof body?.params === 'object' && body.params ? body.params : undefined,
actionId: body?.actionId,
task: body?.task,
ask: askOpts,
}),
);
return json(res, 200, out);
}
if (body?.kind === 'query') {
const statement = String(body?.statement ?? '');
const entity = String(body?.entity ?? '');
if (!statement || !entity) return json(res, 400, { error: 'query needs statement and entity' });
const out = await withFreshToken(cfg, realmId, (accessToken) =>
analyzeQuery({
env: cfg.qboEnv,
realmId,
accessToken,
statement,
entity,
actionId: body?.actionId,
task: body?.task,
ask: askOpts,
}),
);
return json(res, 200, out);
}
return json(res, 400, { error: 'kind must be "report" or "query"' });
} catch (e: any) {
log({ msg: 'analyze failed', realmId, error: e?.message });
return json(res, 400, { error: e?.message || 'analyze failed' });
}
}
// createHandler is the request router. Everything is a small handler over the
// pure modules. Exported so a test can drive it with a mock req/res if desired.
export function createHandler(cfg: ServerConfig) {
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const url = new URL(req.url || '/', `http://localhost:${cfg.port}`);
try {
if (req.method === 'GET' && url.pathname === '/oauth/install') return handleInstall(cfg, res);
if (req.method === 'GET' && url.pathname === '/oauth/callback') return await handleOAuthCallback(cfg, url, res);
if (req.method === 'POST' && url.pathname === '/v1/analyze') return await handleAnalyze(cfg, req, res);
if (req.method === 'GET' && url.pathname === '/v1/companies') {
return json(res, 200, { companies: Array.from(companies.keys()) });
}
if (req.method === 'GET' && url.pathname === '/healthz') return json(res, 200, { ok: true });
return json(res, 404, { error: 'not found' });
} catch (e: any) {
log({ msg: 'unhandled error', error: e?.message });
return json(res, 500, { error: 'internal error' });
}
};
}
// main boots the server when run directly. Import-safe: only the direct entry
// listens, so tests import the handlers without opening a port.
export function main(): void {
const cfg = readServerConfig(process.env);
const server = createServer(createHandler(cfg));
server.listen(cfg.port, () => {
log({ msg: 'hanzo quickbooks service up', port: cfg.port, env: cfg.qboEnv, proxyAi: !!cfg.hanzoApiKey });
});
}
if (process.argv[1] && process.argv[1].endsWith('server.js')) {
main();
}
+48
View File
@@ -0,0 +1,48 @@
:root {
color-scheme: light dark;
--fg: #1a1a1a; --muted: #666; --bg: #fff; --line: #e3e3e3; --accent: #111;
--ok: #1a7f37; --warn: #9a6700; --error: #cf222e;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #eaeaea; --muted: #9a9a9a; --bg: #1e1e1e; --line: #333; --accent: #fff;
--ok: #3fb950; --warn: #d29922; --error: #f85149;
}
}
* { box-sizing: border-box; }
body {
font: 14px/1.45 -apple-system, "Segoe UI", Roboto, sans-serif;
color: var(--fg); background: var(--bg); margin: 0 auto; padding: 14px;
max-width: 720px;
}
header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
header .title { font-weight: 600; font-size: 15px; }
header .host { color: var(--muted); font-size: 12px; margin-left: auto; max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; }
textarea, select, input {
width: 100%; font: inherit; color: var(--fg); background: var(--bg);
border: 1px solid var(--line); border-radius: 6px; padding: 8px;
}
textarea#data { min-height: 120px; resize: vertical; }
textarea#prompt { min-height: 56px; resize: vertical; }
textarea#output { min-height: 220px; resize: vertical; }
.row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
button {
font: inherit; border: 1px solid var(--line); background: var(--accent);
color: var(--bg); border-radius: 6px; padding: 8px 14px; cursor: pointer;
}
button.secondary { background: transparent; color: var(--fg); }
button:disabled { opacity: .5; cursor: default; }
.spacer { flex: 1; }
.status { min-height: 18px; font-size: 12px; margin-top: 10px; color: var(--muted); }
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); } .status.error { color: var(--error); }
.hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
footer { margin-top: 12px; font-size: 11px; color: var(--muted); }
select#model { width: auto; max-width: 150px; padding: 4px 8px; font-size: 12px; }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
.chip {
padding: 5px 10px; border-radius: 999px; font-size: 13px;
background: transparent; color: var(--fg); border: 1px solid var(--line);
}
.chip:hover:not(:disabled) { border-color: var(--accent); }
details summary { cursor: pointer; font-size: 12px; color: var(--muted); margin-top: 10px; }
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { ACTIONS, isActionId, actionPrompt, actionList, runAction } from '../src/actions';
import type { AiClient } from '@hanzo/ai';
import type { FinanceContext } from '../src/finance';
const ctx: FinanceContext = { text: 'Net Income: 37000.00', label: 'P&L', truncated: false };
describe('the action catalog', () => {
it('has the four books actions with stable ids and labels', () => {
expect(Object.keys(ACTIONS)).toEqual([
'summarizeFinancials',
'explainReport',
'draftLineDescriptions',
'categorizeTransactions',
]);
expect(ACTIONS.summarizeFinancials.label).toBe('Summarize financials');
});
it('every prompt forbids inventing data (grounding is baked into each action)', () => {
for (const a of Object.values(ACTIONS)) {
expect(a.prompt.length).toBeGreaterThan(40);
expect(a.prompt).toMatch(/do not invent|not invent|instead of inventing|say (so|what is needed)/i);
}
});
});
describe('isActionId', () => {
it('narrows only known ids', () => {
expect(isActionId('summarizeFinancials')).toBe(true);
expect(isActionId('explainReport')).toBe(true);
expect(isActionId('nope')).toBe(false);
expect(isActionId('__proto__')).toBe(false);
});
});
describe('actionPrompt', () => {
it('resolves a known id to its prompt', () => {
expect(actionPrompt('summarizeFinancials')).toBe(ACTIONS.summarizeFinancials.prompt);
});
it('throws on an unknown id', () => {
expect(() => actionPrompt('bogus')).toThrow(/Unknown action/);
});
});
describe('actionList', () => {
it('derives the ordered id+label list from the catalog', () => {
const list = actionList();
expect(list.map((a) => a.id)).toEqual(Object.keys(ACTIONS));
expect(list[0]).toEqual({ id: 'summarizeFinancials', label: 'Summarize financials' });
});
});
function stubClient(capture: { messages?: any }): AiClient {
return {
chat: {
completions: {
async create(params: any) {
capture.messages = params.messages;
return {
id: 'x',
object: 'chat.completion',
created: 0,
model: params.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'SUMMARY' }, finish_reason: 'stop' }],
};
},
},
},
models: { async list() { return []; } },
} as unknown as AiClient;
}
describe('runAction (one path from id to the model)', () => {
it('runs the resolved action prompt over the context and returns the model text', async () => {
const cap: { messages?: any } = {};
const out = await runAction('summarizeFinancials', ctx, { client: stubClient(cap) });
expect(out).toBe('SUMMARY');
// the task in the user turn is the action's prompt
expect(cap.messages[1].content).toContain(ACTIONS.summarizeFinancials.prompt);
});
it('rejects (does not throw synchronously) on an unknown id', async () => {
await expect(runAction('bogus', ctx, { client: stubClient({}) })).rejects.toThrow(/Unknown action/);
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import {
pickBearer,
chatCompletionsURL,
modelsURL,
qboApiHost,
isQboEnv,
readServerConfig,
OAUTH_SCOPES,
HANZO_API_BASE_URL,
QBO_API_VERSION,
QBO_MINOR_VERSION,
INTUIT_AUTHORIZE_URL,
INTUIT_TOKEN_URL,
} from '../src/config';
describe('pickBearer', () => {
it('prefers the pasted API key over the oauth token', () => {
expect(pickBearer('hk-123', 'oauth-tok')).toBe('hk-123');
});
it('falls back to the oauth token when no key', () => {
expect(pickBearer('', 'oauth-tok')).toBe('oauth-tok');
expect(pickBearer(' ', 'oauth-tok')).toBe('oauth-tok');
});
it('is empty (anonymous) when neither is present', () => {
expect(pickBearer('', '')).toBe('');
});
});
describe('gateway URLs', () => {
it('build /v1 endpoints on api.hanzo.ai with no /api/ prefix', () => {
expect(chatCompletionsURL()).toBe(`${HANZO_API_BASE_URL}/v1/chat/completions`);
expect(modelsURL()).toBe(`${HANZO_API_BASE_URL}/v1/models`);
expect(chatCompletionsURL()).not.toContain('/api/');
expect(HANZO_API_BASE_URL).toBe('https://api.hanzo.ai');
});
});
describe('Intuit + QBO hosts', () => {
it('pins the single Intuit OAuth authorize + token hosts', () => {
expect(INTUIT_AUTHORIZE_URL).toBe('https://appcenter.intuit.com/connect/oauth2');
expect(INTUIT_TOKEN_URL).toBe('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer');
});
it('maps sandbox and production to the two Accounting API hosts', () => {
expect(qboApiHost('sandbox')).toBe('https://sandbox-quickbooks.api.intuit.com');
expect(qboApiHost('production')).toBe('https://quickbooks.api.intuit.com');
});
it('narrows only sandbox/production', () => {
expect(isQboEnv('sandbox')).toBe(true);
expect(isQboEnv('production')).toBe(true);
expect(isQboEnv('prod')).toBe(false);
expect(isQboEnv(undefined)).toBe(false);
});
it('requests the accounting + openid scopes', () => {
expect(OAUTH_SCOPES).toContain('com.intuit.quickbooks.accounting');
expect(OAUTH_SCOPES).toContain('openid');
});
it('uses Accounting API v3 with a pinned minorversion', () => {
expect(QBO_API_VERSION).toBe('v3');
expect(QBO_MINOR_VERSION).toMatch(/^\d+$/);
});
});
describe('readServerConfig', () => {
const base = {
INTUIT_CLIENT_ID: 'ik',
INTUIT_CLIENT_SECRET: 'sk',
INTUIT_REDIRECT_URI: 'https://quickbooks.hanzo.ai/oauth/callback',
};
it('reads a full config and defaults env=sandbox, port=8790', () => {
const cfg = readServerConfig(base);
expect(cfg.intuitClientId).toBe('ik');
expect(cfg.intuitClientSecret).toBe('sk');
expect(cfg.intuitRedirectUri).toBe('https://quickbooks.hanzo.ai/oauth/callback');
expect(cfg.qboEnv).toBe('sandbox');
expect(cfg.hanzoApiKey).toBe('');
expect(cfg.port).toBe(8790);
});
it('honors QBO_ENV=production, HANZO_API_KEY, and PORT', () => {
const cfg = readServerConfig({ ...base, QBO_ENV: 'production', HANZO_API_KEY: 'hk-x', PORT: '9100' });
expect(cfg.qboEnv).toBe('production');
expect(cfg.hanzoApiKey).toBe('hk-x');
expect(cfg.port).toBe(9100);
});
it('falls back to sandbox for an unknown QBO_ENV', () => {
expect(readServerConfig({ ...base, QBO_ENV: 'staging' }).qboEnv).toBe('sandbox');
});
it.each(['INTUIT_CLIENT_ID', 'INTUIT_CLIENT_SECRET', 'INTUIT_REDIRECT_URI'])(
'throws when %s is missing',
(key) => {
const env: Record<string, string | undefined> = { ...base };
delete env[key];
expect(() => readServerConfig(env)).toThrow(key);
},
);
});
+139
View File
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest';
import {
renderReport,
reportLabel,
buildReportContext,
truncateToBudget,
renderEntity,
buildEntitiesContext,
contextNote,
type QboReport,
} from '../src/finance';
import { PROFIT_AND_LOSS, INVOICE_QUERY } from './fixtures';
describe('renderReport (P&L → readable statement)', () => {
const text = renderReport(PROFIT_AND_LOSS as QboReport);
it('renders the metadata header (name, period, basis, currency, columns)', () => {
expect(text).toContain('Report: ProfitAndLoss');
expect(text).toContain('Period: 2026-01-01 to 2026-06-30');
expect(text).toContain('Basis: Accrual');
expect(text).toContain('Currency: USD');
expect(text).toContain('Columns: Jan - Jun 2026 | Jan - Jun 2025');
});
it('renders section headers, indented line items, and section subtotals', () => {
expect(text).toContain('Income');
// a leaf line is indented and carries both period columns
expect(text).toContain(' Services: 120000.00 | 90000.00');
expect(text).toContain(' Product Sales: 48000.00 | 52000.00');
expect(text).toContain('Total Income: 168000.00 | 142000.00');
expect(text).toContain(' Payroll: 95000.00 | 80000.00');
expect(text).toContain('Total Expenses: 131000.00 | 111000.00');
});
it('renders the top-level Net Income summary row', () => {
expect(text).toContain('Net Income: 37000.00 | 31000.00');
});
it('keeps rows in statement order (Income before Expenses before Net Income)', () => {
expect(text.indexOf('Income')).toBeLessThan(text.indexOf('Expenses'));
expect(text.indexOf('Total Expenses')).toBeLessThan(text.indexOf('Net Income'));
});
});
describe('reportLabel', () => {
it('is the report name plus the period', () => {
expect(reportLabel(PROFIT_AND_LOSS as QboReport)).toBe('ProfitAndLoss (2026-01-01 to 2026-06-30)');
});
it('degrades gracefully with no header', () => {
expect(reportLabel({} as QboReport)).toBe('Report');
});
});
describe('truncateToBudget', () => {
it('returns text unchanged and not truncated when under budget', () => {
const r = truncateToBudget('short', 100);
expect(r).toEqual({ text: 'short', truncated: false });
});
it('cuts on a line boundary and appends a marker when over budget', () => {
const long = Array.from({ length: 50 }, (_, i) => `line ${i} value 1000.00`).join('\n');
const r = truncateToBudget(long, 200);
expect(r.truncated).toBe(true);
expect(r.text.length).toBeLessThanOrEqual(200);
expect(r.text).toMatch(/truncated to fit/);
// never cuts mid-line: everything before the marker ends on a full line
const beforeMarker = r.text.split('\n…')[0];
expect(long.startsWith(beforeMarker)).toBe(true);
});
});
describe('buildReportContext', () => {
it('assembles the full P&L context, labeled, not truncated under a generous budget', () => {
const ctx = buildReportContext(PROFIT_AND_LOSS as QboReport, 100_000);
expect(ctx.label).toBe('ProfitAndLoss (2026-01-01 to 2026-06-30)');
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('Net Income: 37000.00 | 31000.00');
});
it('truncates a large report to the budget and flags it, keeping the label', () => {
const ctx = buildReportContext(PROFIT_AND_LOSS as QboReport, 120);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBeLessThanOrEqual(120);
expect(ctx.label).toBe('ProfitAndLoss (2026-01-01 to 2026-06-30)');
});
});
describe('renderEntity', () => {
it('projects an Invoice to its accounting-relevant fields via dotted paths', () => {
const inv = INVOICE_QUERY.QueryResponse.Invoice[0];
const text = renderEntity('Invoice', inv as any);
expect(text).toContain('Invoice #: 1001');
expect(text).toContain('Customer: Acme Co'); // CustomerRef.name
expect(text).toContain('Total: 5000');
expect(text).toContain('Currency: USD'); // CurrencyRef.value
// nested Line array is not dumped
expect(text).not.toContain('Line');
});
it('falls back to a shallow scalar dump for an unknown entity, skipping nested objects', () => {
const text = renderEntity('Widget', { Id: '7', Name: 'W', Meta: { x: 1 }, Active: true } as any);
expect(text).toContain('Id: 7');
expect(text).toContain('Name: W');
expect(text).toContain('Active: true');
expect(text).not.toContain('Meta');
});
});
describe('buildEntitiesContext', () => {
const invoices = INVOICE_QUERY.QueryResponse.Invoice as any[];
it('renders numbered entity blocks and labels the full count', () => {
const ctx = buildEntitiesContext('Invoice', invoices, 'Invoice query', 100_000);
expect(ctx.truncated).toBe(false);
expect(ctx.text).toContain('[Invoice 1]');
expect(ctx.text).toContain('[Invoice 3]');
expect(ctx.text).toContain('Globex');
expect(ctx.label).toBe('Invoice query — 3 Invoice records');
});
it('stops at the budget and reports "N of M" when truncated', () => {
const ctx = buildEntitiesContext('Invoice', invoices, 'Invoice query', 120);
expect(ctx.truncated).toBe(true);
expect(ctx.text).toContain('[Invoice 1]');
expect(ctx.label).toMatch(/of 3 Invoice records \(truncated to fit\)/);
});
it('handles an empty result', () => {
const ctx = buildEntitiesContext('Invoice', [], 'Invoice query');
expect(ctx.text).toBe('');
expect(ctx.truncated).toBe(false);
expect(ctx.label).toBe('Invoice query — 0 Invoice records');
});
});
describe('contextNote', () => {
it('is honest about empty vs full vs truncated', () => {
expect(contextNote({ text: '', label: '', truncated: false })).toMatch(/No financial data/);
expect(contextNote({ text: 'x', label: 'P&L', truncated: false })).toBe('P&L');
expect(contextNote({ text: 'x', label: 'P&L', truncated: true })).toMatch(/truncated to fit/);
});
});
+119
View File
@@ -0,0 +1,119 @@
// Realistic QuickBooks Online report + query fixtures for the finance / analysis
// tests. Trimmed from the shapes the Accounting API v3 actually returns (nested
// Header/Columns/Rows sections with Summary subtotals; QueryResponse wrapping an
// entity array). Kept small but structurally faithful so the renderers are
// exercised against the real tree, not a toy.
// A two-column (period-over-period) Profit and Loss with Income and Expenses
// sections, each with rows + a section Summary, and a top-level Net Income
// summary row. This is the canonical fixture the "summarize financials" flow
// runs over.
export const PROFIT_AND_LOSS = {
Header: {
ReportName: 'ProfitAndLoss',
StartPeriod: '2026-01-01',
EndPeriod: '2026-06-30',
Currency: 'USD',
ReportBasis: 'Accrual',
},
Columns: {
Column: [
{ ColTitle: '', ColType: 'Account' },
{ ColTitle: 'Jan - Jun 2026', ColType: 'Money' },
{ ColTitle: 'Jan - Jun 2025', ColType: 'Money' },
],
},
Rows: {
Row: [
{
type: 'Section',
group: 'Income',
Header: { ColData: [{ value: 'Income' }, { value: '' }, { value: '' }] },
Rows: {
Row: [
{ type: 'Data', ColData: [{ value: 'Services', id: '1' }, { value: '120000.00' }, { value: '90000.00' }] },
{ type: 'Data', ColData: [{ value: 'Product Sales', id: '2' }, { value: '48000.00' }, { value: '52000.00' }] },
],
},
Summary: { ColData: [{ value: 'Total Income' }, { value: '168000.00' }, { value: '142000.00' }] },
},
{
type: 'Section',
group: 'Expenses',
Header: { ColData: [{ value: 'Expenses' }, { value: '' }, { value: '' }] },
Rows: {
Row: [
{ type: 'Data', ColData: [{ value: 'Payroll', id: '10' }, { value: '95000.00' }, { value: '80000.00' }] },
{ type: 'Data', ColData: [{ value: 'Rent', id: '11' }, { value: '24000.00' }, { value: '24000.00' }] },
{ type: 'Data', ColData: [{ value: 'Software', id: '12' }, { value: '12000.00' }, { value: '7000.00' }] },
],
},
Summary: { ColData: [{ value: 'Total Expenses' }, { value: '131000.00' }, { value: '111000.00' }] },
},
{
type: 'Data',
group: 'NetIncome',
Summary: { ColData: [{ value: 'Net Income' }, { value: '37000.00' }, { value: '31000.00' }] },
},
],
},
};
// A QueryResponse wrapping three invoices — the shape `SELECT * FROM Invoice`
// returns. Used by the entity-context + panel-paste tests.
export const INVOICE_QUERY = {
QueryResponse: {
startPosition: 1,
maxResults: 3,
totalCount: 3,
Invoice: [
{
Id: '101',
DocNumber: '1001',
TxnDate: '2026-06-01',
DueDate: '2026-07-01',
CustomerRef: { value: '55', name: 'Acme Co' },
TotalAmt: 5000.0,
Balance: 5000.0,
CurrencyRef: { value: 'USD' },
Line: [],
},
{
Id: '102',
DocNumber: '1002',
TxnDate: '2026-06-05',
DueDate: '2026-07-05',
CustomerRef: { value: '56', name: 'Globex' },
TotalAmt: 1200.0,
Balance: 0.0,
CurrencyRef: { value: 'USD' },
Line: [],
},
{
Id: '103',
DocNumber: '1003',
TxnDate: '2026-06-10',
DueDate: '2026-07-10',
CustomerRef: { value: '57', name: 'Initech' },
TotalAmt: 8750.5,
Balance: 8750.5,
CurrencyRef: { value: 'USD' },
Line: [],
},
],
},
};
// A QBO Fault error envelope — what a bad query/write returns with HTTP 400.
export const FAULT = {
Fault: {
Error: [
{
Message: 'Invalid query',
Detail: 'QueryParserError: Encountered "FRM" at line 1, column 10.',
code: '4000',
},
],
type: 'ValidationFault',
},
};
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import { buildMessages, extractContent, ask, listModels, SYSTEM_PROMPT } from '../src/hanzo';
import type { AiClient } from '@hanzo/ai';
import type { FinanceContext } from '../src/finance';
const ctx: FinanceContext = { text: 'Net Income: 37000.00', label: 'ProfitAndLoss (H1)', truncated: false };
const truncCtx: FinanceContext = { text: 'partial', label: 'P&L', truncated: true };
describe('SYSTEM_PROMPT', () => {
it('grounds answers in the data and disclaims advice', () => {
expect(SYSTEM_PROMPT).toMatch(/QuickBooks/);
expect(SYSTEM_PROMPT).toMatch(/never invent/i);
expect(SYSTEM_PROMPT).toMatch(/not tax, audit, or accounting advice/i);
});
});
describe('buildMessages', () => {
it('sets the grounded system prompt and fences the financial data', () => {
const msgs = buildMessages('Summarize.', ctx);
expect(msgs[0]).toEqual({ role: 'system', content: SYSTEM_PROMPT });
expect(msgs[1].role).toBe('user');
expect(msgs[1].content).toContain('---- financial data ----');
expect(msgs[1].content).toContain('Net Income: 37000.00');
expect(msgs[1].content).toContain('ProfitAndLoss (H1)'); // the context note (label)
expect(msgs[1].content).toContain('---- task ----\nSummarize.');
});
it('carries the truncation note into the user turn when truncated', () => {
expect(buildMessages('t', truncCtx)[1].content).toMatch(/truncated to fit/);
});
});
describe('extractContent', () => {
it('returns the assistant text of the first choice', () => {
const out = extractContent({
id: 'x',
object: 'chat.completion',
created: 0,
model: 'zen5',
choices: [{ index: 0, message: { role: 'assistant', content: 'ANSWER' }, finish_reason: 'stop' }],
} as any);
expect(out).toBe('ANSWER');
});
it('throws on an empty content body', () => {
expect(() => extractContent({ choices: [{ message: { role: 'assistant', content: '' } }] } as any)).toThrow(/no content/);
expect(() => extractContent({ choices: [] } as any)).toThrow(/no content/);
});
});
// A stub AiClient records the request and returns a canned completion. Typed to
// the SDK's AiClient so the shapes are checked against the real contract.
function stubClient(capture: { model?: string; messages?: unknown; signal?: unknown }): AiClient {
return {
chat: {
completions: {
async create(params: any, options: any) {
capture.model = params.model;
capture.messages = params.messages;
capture.signal = options?.signal;
return {
id: 'x',
object: 'chat.completion',
created: 0,
model: params.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'ANSWER' }, finish_reason: 'stop' }],
};
},
},
},
models: {
async list() {
return [
{ id: 'zen5', object: 'model' },
{ id: 'zen-coder', object: 'model' },
];
},
},
} as unknown as AiClient;
}
describe('ask (single call path, injected client)', () => {
it('routes the default model, sends the fenced messages, and returns the assistant text', async () => {
const cap: { model?: string; messages?: any } = {};
const out = await ask('Summarize.', ctx, { client: stubClient(cap) });
expect(out).toBe('ANSWER');
expect(cap.model).toBe('zen5');
expect(cap.messages[0].role).toBe('system');
expect(cap.messages[1].content).toContain('Net Income: 37000.00');
});
it('honors an explicit model', async () => {
const cap: { model?: string } = {};
await ask('t', ctx, { client: stubClient(cap), model: 'zen-coder' });
expect(cap.model).toBe('zen-coder');
});
it('forwards the abort signal to the client', async () => {
const cap: { signal?: unknown } = {};
const c = new AbortController();
await ask('t', ctx, { client: stubClient(cap), signal: c.signal });
expect(cap.signal).toBe(c.signal);
});
});
describe('listModels (injected client)', () => {
it('returns the model ids', async () => {
expect(await listModels({ client: stubClient({}) })).toEqual(['zen5', 'zen-coder']);
});
});
+53
View File
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { parseLaunchContext, panelTitle, buildContextFromPaste } from '../src/panel';
import { PROFIT_AND_LOSS, INVOICE_QUERY } from './fixtures';
describe('parseLaunchContext / panelTitle', () => {
it('reads company, realmId, and report from the launch query', () => {
const ctx = parseLaunchContext('?company=Acme+LLC&realmId=123&report=ProfitAndLoss');
expect(ctx).toEqual({ company: 'Acme LLC', realmId: '123', report: 'ProfitAndLoss' });
expect(panelTitle(ctx)).toBe('Acme LLC');
});
it('defaults to a generic title with no company', () => {
expect(panelTitle(parseLaunchContext(''))).toBe('QuickBooks Online');
});
});
describe('buildContextFromPaste (the one place the panel interprets pasted data)', () => {
it('renders a pasted QBO report JSON as a statement', () => {
const ctx = buildContextFromPaste(JSON.stringify(PROFIT_AND_LOSS));
expect(ctx.label).toBe('ProfitAndLoss (2026-01-01 to 2026-06-30)');
expect(ctx.text).toContain('Net Income: 37000.00 | 31000.00');
});
it('renders a pasted QBO query response as entity blocks, detecting the entity', () => {
const ctx = buildContextFromPaste(JSON.stringify(INVOICE_QUERY));
expect(ctx.label).toBe('Invoice query — 3 Invoice records');
expect(ctx.text).toContain('[Invoice 1]');
expect(ctx.text).toContain('Acme Co');
});
it('treats non-JSON as plain financial text', () => {
const ctx = buildContextFromPaste('Revenue 100\nCosts 60\nProfit 40');
expect(ctx.label).toBe('Pasted financial data');
expect(ctx.text).toContain('Profit 40');
expect(ctx.truncated).toBe(false);
});
it('caps oversized plain text to the budget and flags truncation', () => {
const big = 'x'.repeat(500);
const ctx = buildContextFromPaste(big, 100);
expect(ctx.truncated).toBe(true);
expect(ctx.text.length).toBeLessThanOrEqual(100);
});
it('is empty for empty input', () => {
expect(buildContextFromPaste(' ')).toEqual({ text: '', label: '', truncated: false });
});
it('stringifies valid JSON of an unknown shape rather than dropping it', () => {
const ctx = buildContextFromPaste(JSON.stringify({ hello: 'world', n: 3 }));
expect(ctx.label).toBe('Pasted JSON data');
expect(ctx.text).toContain('hello');
});
});
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest';
import { analyzeReport, analyzeQuery } from '../src/qbo-analysis';
import type { AiClient } from '@hanzo/ai';
import { REPORTS } from '../src/qbo-api';
import { QBO_MINOR_VERSION } from '../src/config';
import { PROFIT_AND_LOSS, INVOICE_QUERY, FAULT } from './fixtures';
// A stub AiClient that captures the messages it was asked to complete and
// returns a canned answer — so the pipeline is exercised without a network.
function stubClient(capture: { messages?: any }): AiClient {
return {
chat: {
completions: {
async create(params: any) {
capture.messages = params.messages;
return {
id: 'x',
object: 'chat.completion',
created: 0,
model: params.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'FINANCIAL SUMMARY' }, finish_reason: 'stop' }],
};
},
},
},
models: { async list() { return []; } },
} as unknown as AiClient;
}
// jsonResponse builds a minimal Response-like object for the injected fetch.
function jsonResponse(status: number, body: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
async text() {
return JSON.stringify(body);
},
} as unknown as Response;
}
describe('analyzeReport — the summarize-financials flow (fetch P&L → render → summarize)', () => {
it('fetches the P&L at the right URL and summarizes it via the action', async () => {
const seen: { url?: string } = {};
const cap: { messages?: any } = {};
const fakeFetch = (async (url: any) => {
seen.url = String(url);
return jsonResponse(200, PROFIT_AND_LOSS);
}) as typeof fetch;
const out = await analyzeReport({
env: 'sandbox',
realmId: '123456789',
accessToken: 'AT',
name: REPORTS.profitAndLoss,
params: { start_date: '2026-01-01', end_date: '2026-06-30' },
actionId: 'summarizeFinancials',
fetch: fakeFetch,
ask: { client: stubClient(cap) },
});
// hit the correct company-scoped report endpoint with the pinned minorversion
const u = new URL(seen.url!);
expect(u.pathname).toBe('/v3/company/123456789/reports/ProfitAndLoss');
expect(u.searchParams.get('start_date')).toBe('2026-01-01');
expect(u.searchParams.get('minorversion')).toBe(QBO_MINOR_VERSION);
// the rendered statement (with Net Income) reached the model
expect(cap.messages[1].content).toContain('Net Income: 37000.00 | 31000.00');
// the result carries the AI answer + the report label
expect(out.result).toBe('FINANCIAL SUMMARY');
expect(out.label).toBe('ProfitAndLoss (2026-01-01 to 2026-06-30)');
expect(out.truncated).toBe(false);
});
it('supports a freeform task instead of a named action', async () => {
const cap: { messages?: any } = {};
const out = await analyzeReport({
env: 'sandbox',
realmId: '1',
accessToken: 'AT',
name: REPORTS.profitAndLoss,
task: 'What was net income?',
fetch: (async () => jsonResponse(200, PROFIT_AND_LOSS)) as typeof fetch,
ask: { client: stubClient(cap) },
});
expect(cap.messages[1].content).toContain('---- task ----\nWhat was net income?');
expect(out.result).toBe('FINANCIAL SUMMARY');
});
it('throws with the QBO Fault message on a non-2xx report fetch', async () => {
await expect(
analyzeReport({
env: 'sandbox',
realmId: '1',
accessToken: 'AT',
name: REPORTS.balanceSheet,
actionId: 'summarizeFinancials',
fetch: (async () => jsonResponse(400, FAULT)) as typeof fetch,
ask: { client: stubClient({}) },
}),
).rejects.toThrow(/Invalid query|QuickBooks API 400/);
});
it('rejects when neither an actionId nor a task is given', async () => {
await expect(
analyzeReport({
env: 'sandbox',
realmId: '1',
accessToken: 'AT',
name: REPORTS.profitAndLoss,
fetch: (async () => jsonResponse(200, PROFIT_AND_LOSS)) as typeof fetch,
ask: { client: stubClient({}) },
}),
).rejects.toThrow(/actionId or a task/);
});
});
describe('analyzeQuery — entity analysis', () => {
it('runs the query, renders the invoices, and analyzes them', async () => {
const seen: { url?: string } = {};
const cap: { messages?: any } = {};
const out = await analyzeQuery({
env: 'sandbox',
realmId: '123',
accessToken: 'AT',
statement: 'SELECT * FROM Invoice',
entity: 'Invoice',
actionId: 'categorizeTransactions',
fetch: (async (url: any) => {
seen.url = String(url);
return jsonResponse(200, INVOICE_QUERY);
}) as typeof fetch,
ask: { client: stubClient(cap) },
});
const u = new URL(seen.url!);
expect(u.pathname).toBe('/v3/company/123/query');
expect(u.searchParams.get('query')).toBe('SELECT * FROM Invoice');
expect(cap.messages[1].content).toContain('[Invoice 1]');
expect(cap.messages[1].content).toContain('Acme Co');
expect(out.result).toBe('FINANCIAL SUMMARY');
expect(out.label).toBe('Invoice query — 3 Invoice records');
});
});
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import {
companyBase,
report,
query,
get,
create,
parseQueryResponse,
parseEntityResponse,
parseFault,
REPORTS,
} from '../src/qbo-api';
import { QBO_MINOR_VERSION } from '../src/config';
import { INVOICE_QUERY, FAULT } from './fixtures';
const REALM = '123456789';
const TOK = 'AT-abc';
describe('companyBase', () => {
it('builds /v3/company/{realmId} on the sandbox host', () => {
expect(companyBase('sandbox', REALM)).toBe(
`https://sandbox-quickbooks.api.intuit.com/v3/company/${REALM}`,
);
});
it('uses the production host for env=production', () => {
expect(companyBase('production', REALM)).toBe(
`https://quickbooks.api.intuit.com/v3/company/${REALM}`,
);
});
it('encodes the realmId defensively', () => {
expect(companyBase('sandbox', 'a/b')).toContain('/company/a%2Fb');
});
});
describe('report', () => {
it('shapes a P&L GET at /reports/ProfitAndLoss with a Bearer, JSON Accept, and the pinned minorversion', () => {
const r = report('sandbox', REALM, TOK, REPORTS.profitAndLoss);
const u = new URL(r.url);
expect(u.pathname).toBe(`/v3/company/${REALM}/reports/ProfitAndLoss`);
expect(u.searchParams.get('minorversion')).toBe(QBO_MINOR_VERSION);
expect(r.method).toBe('GET');
expect(r.headers.Authorization).toBe('Bearer AT-abc');
expect(r.headers.Accept).toBe('application/json');
});
it('passes report params (start_date/end_date) through as query and still carries minorversion', () => {
const r = report('sandbox', REALM, TOK, REPORTS.profitAndLoss, {
start_date: '2026-01-01',
end_date: '2026-06-30',
});
const u = new URL(r.url);
expect(u.searchParams.get('start_date')).toBe('2026-01-01');
expect(u.searchParams.get('end_date')).toBe('2026-06-30');
expect(u.searchParams.get('minorversion')).toBe(QBO_MINOR_VERSION);
});
it('exposes ProfitAndLoss and BalanceSheet as the two core report names', () => {
expect(REPORTS.profitAndLoss).toBe('ProfitAndLoss');
expect(REPORTS.balanceSheet).toBe('BalanceSheet');
});
});
describe('query', () => {
it('URL-encodes the statement into the query param on /query', () => {
const r = query('sandbox', REALM, TOK, "SELECT * FROM Invoice WHERE TxnDate > '2026-01-01'");
const u = new URL(r.url);
expect(u.pathname).toBe(`/v3/company/${REALM}/query`);
expect(u.searchParams.get('query')).toBe("SELECT * FROM Invoice WHERE TxnDate > '2026-01-01'");
expect(u.searchParams.get('minorversion')).toBe(QBO_MINOR_VERSION);
expect(r.method).toBe('GET');
expect(r.headers.Authorization).toBe('Bearer AT-abc');
});
});
describe('get', () => {
it('shapes a single-entity read at /{entity}/{id} (entity lowercased)', () => {
const r = get('sandbox', REALM, TOK, 'Invoice', '101');
const u = new URL(r.url);
expect(u.pathname).toBe(`/v3/company/${REALM}/invoice/101`);
expect(r.method).toBe('GET');
});
});
describe('create', () => {
it('shapes a POST to /{entity} with a JSON body, Content-Type, and the entity payload', () => {
const payload = { Line: [{ Amount: 100 }], CustomerRef: { value: '55' } };
const r = create('sandbox', REALM, TOK, 'Invoice', payload);
const u = new URL(r.url);
expect(u.pathname).toBe(`/v3/company/${REALM}/invoice`);
expect(r.method).toBe('POST');
expect(r.headers['Content-Type']).toBe('application/json');
expect(r.headers.Authorization).toBe('Bearer AT-abc');
expect(JSON.parse(r.body!)).toEqual(payload);
});
});
describe('parseQueryResponse', () => {
it('reads the entity array under QueryResponse[<Entity>]', () => {
const invoices = parseQueryResponse(INVOICE_QUERY, 'Invoice');
expect(invoices).toHaveLength(3);
expect((invoices[0] as any).DocNumber).toBe('1001');
});
it('returns [] when the entity key is absent or the body is empty', () => {
expect(parseQueryResponse(INVOICE_QUERY, 'Bill')).toEqual([]);
expect(parseQueryResponse({}, 'Invoice')).toEqual([]);
expect(parseQueryResponse(null, 'Invoice')).toEqual([]);
});
});
describe('parseEntityResponse', () => {
it('reads a single record under its entity key', () => {
const rec = parseEntityResponse({ Invoice: { Id: '9', DocNumber: 'X' } }, 'Invoice');
expect(rec).toEqual({ Id: '9', DocNumber: 'X' });
});
it('returns undefined when the entity is absent', () => {
expect(parseEntityResponse({ Bill: {} }, 'Invoice')).toBeUndefined();
expect(parseEntityResponse(null, 'Invoice')).toBeUndefined();
});
});
describe('parseFault', () => {
it('joins QBO Fault errors into a single message', () => {
const msg = parseFault(FAULT);
expect(msg).toMatch(/Invalid query/);
expect(msg).toMatch(/QueryParserError/);
});
it('is empty when there is no fault', () => {
expect(parseFault({})).toBe('');
expect(parseFault(INVOICE_QUERY)).toBe('');
});
});
+135
View File
@@ -0,0 +1,135 @@
import { describe, it, expect } from 'vitest';
import {
authorizeUrl,
basicAuth,
tokenExchange,
refreshExchange,
revokeExchange,
parseTokenResponse,
parseCallback,
} from '../src/qbo-oauth';
import { OAUTH_SCOPES, INTUIT_TOKEN_URL, INTUIT_REVOKE_URL } from '../src/config';
describe('authorizeUrl', () => {
it('builds the Intuit authorize URL with the documented params', () => {
const url = authorizeUrl({
clientId: 'ik-123',
redirectUri: 'https://quickbooks.hanzo.ai/oauth/callback',
scopes: OAUTH_SCOPES,
state: 'csrf-1',
});
const u = new URL(url);
expect(u.origin + u.pathname).toBe('https://appcenter.intuit.com/connect/oauth2');
expect(u.searchParams.get('response_type')).toBe('code');
expect(u.searchParams.get('client_id')).toBe('ik-123');
expect(u.searchParams.get('redirect_uri')).toBe('https://quickbooks.hanzo.ai/oauth/callback');
expect(u.searchParams.get('state')).toBe('csrf-1');
// scopes are space-joined
expect(u.searchParams.get('scope')).toBe('com.intuit.quickbooks.accounting openid');
});
it('uses the same authorize host regardless of environment (env selects the API host, not OAuth)', () => {
const a = authorizeUrl({ clientId: 'ik', redirectUri: 'https://x/cb', scopes: ['openid'], state: 's' });
expect(new URL(a).origin).toBe('https://appcenter.intuit.com');
});
});
describe('basicAuth', () => {
it('is Basic base64(client_id:client_secret)', () => {
const header = basicAuth('ikey', 'skey');
expect(header.startsWith('Basic ')).toBe(true);
expect(Buffer.from(header.slice(6), 'base64').toString('utf8')).toBe('ikey:skey');
});
});
describe('tokenExchange', () => {
it('shapes the code→token request: Basic header + form body with redirect_uri; secret not in body/url', () => {
const req = tokenExchange({
clientId: 'ik',
clientSecret: 'SECRET',
code: 'abc',
redirectUri: 'https://quickbooks.hanzo.ai/oauth/callback',
});
expect(req.url).toBe(INTUIT_TOKEN_URL);
expect(req.headers.Authorization.startsWith('Basic ')).toBe(true);
expect(req.headers['Content-Type']).toBe('application/x-www-form-urlencoded');
expect(req.headers.Accept).toBe('application/json');
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('authorization_code');
expect(body.get('code')).toBe('abc');
// Intuit REQUIRES redirect_uri in the token body (unlike DocuSign).
expect(body.get('redirect_uri')).toBe('https://quickbooks.hanzo.ai/oauth/callback');
// secret must never leak into the body or URL
expect(req.body).not.toContain('SECRET');
expect(req.url).not.toContain('SECRET');
});
});
describe('refreshExchange', () => {
it('shapes the refresh_token request against the token endpoint', () => {
const req = refreshExchange({ clientId: 'ik', clientSecret: 's', refreshToken: 'rt' });
expect(req.url).toBe(INTUIT_TOKEN_URL);
expect(req.headers.Authorization.startsWith('Basic ')).toBe(true);
const body = new URLSearchParams(req.body);
expect(body.get('grant_type')).toBe('refresh_token');
expect(body.get('refresh_token')).toBe('rt');
});
});
describe('revokeExchange', () => {
it('shapes the revoke request as JSON to the revoke endpoint', () => {
const req = revokeExchange({ clientId: 'ik', clientSecret: 's', token: 'rt' });
expect(req.url).toBe(INTUIT_REVOKE_URL);
expect(req.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(req.body)).toEqual({ token: 'rt' });
});
});
describe('parseTokenResponse', () => {
it('returns the token set on success, normalizing snake_case fields', () => {
const t = parseTokenResponse({
access_token: 'AT',
refresh_token: 'RT',
expires_in: 3600,
x_refresh_token_expires_in: 8726400,
token_type: 'bearer',
id_token: 'IDT',
});
expect(t.accessToken).toBe('AT');
expect(t.refreshToken).toBe('RT');
expect(t.expiresIn).toBe(3600);
expect(t.refreshExpiresIn).toBe(8726400);
expect(t.tokenType).toBe('bearer');
expect(t.idToken).toBe('IDT');
});
it('throws Intuit error on a failure body', () => {
expect(() => parseTokenResponse({ error: 'invalid_grant', error_description: 'code expired' })).toThrow(/code expired/);
});
it('throws when access_token is missing', () => {
expect(() => parseTokenResponse({ refresh_token: 'RT' })).toThrow(/access_token/);
});
it('throws when refresh_token is missing', () => {
expect(() => parseTokenResponse({ access_token: 'AT' })).toThrow(/refresh_token/);
});
it('throws on an empty body', () => {
expect(() => parseTokenResponse(null)).toThrow();
});
});
describe('parseCallback', () => {
it('reads code, realmId, and state from the redirect query', () => {
const cb = parseCallback(new URLSearchParams('code=AUTH&realmId=123456789&state=csrf-1'));
expect(cb).toEqual({ code: 'AUTH', realmId: '123456789', state: 'csrf-1' });
});
it('throws on the Intuit error redirect', () => {
expect(() =>
parseCallback(new URLSearchParams('error=access_denied&error_description=user+declined')),
).toThrow(/user declined|access_denied/);
});
it('throws when code is missing', () => {
expect(() => parseCallback(new URLSearchParams('realmId=1&state=s'))).toThrow(/code/);
});
it('throws when realmId is missing', () => {
expect(() => parseCallback(new URLSearchParams('code=AUTH&state=s'))).toThrow(/realmId/);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["node"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
environment: 'node',
},
});
+1
View File
@@ -22,6 +22,7 @@ packages:
- 'packages/outlook'
- 'packages/pdf'
- 'packages/procore'
- 'packages/quickbooks'
- 'packages/raycast'
- 'packages/salesforce'
- 'packages/shopify'