Merge remote-tracking branch 'origin/feat/quickbooks'
# Conflicts: # pnpm-workspace.yaml
This commit is contained in:
@@ -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
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 : ''}`;
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ packages:
|
||||
- 'packages/outlook'
|
||||
- 'packages/pdf'
|
||||
- 'packages/procore'
|
||||
- 'packages/quickbooks'
|
||||
- 'packages/raycast'
|
||||
- 'packages/salesforce'
|
||||
- 'packages/shopify'
|
||||
|
||||
Reference in New Issue
Block a user