A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This recipe builds an invoice automation pipeline in Next.js. You’ll create an HTTP endpoint that accepts a PDF, scanned image, or plain-text invoice file; extracts structured invoice data using Anthropic’s Claude AI; validates the result with Zod; syncs it to QuickBooks Online via its REST API; and enforces a configurable budget so LLM costs stay under control. The pipeline runs on five REAA packages that handle document ingestion, chunking, embeddings, cost tracking, and evaluation.
An Anthropic API key — set one up at console.anthropic.com
An OpenAI API key — for generating text embeddings during document ingestion
QuickBooks Online API credentials — consumer key, consumer secret, realm ID, OAuth access token, and refresh token (sandbox mode supported via QBO_USE_SANDBOX=true)
Familiarity with TypeScript and Next.js App Router — you’ll write route handlers, not pages
Step 1: Scaffold a Next.js project
Create a new Next.js project with the App Router. This gives you the project shell — package.json, tsconfig.json, next.config.ts, and the app/ directory.
Expected output: The package.json now has exact versions — no ^ or ~ prefixes. Every @reaatech/* package is pinned to 0.1.0.
Now install everything:
terminal
pnpm install
Expected output: A pnpm-lock.yaml is generated, node_modules/ is populated, and the install exits with 0.
Step 3: Configure environment variables
Create a .env.example file listing every environment variable the pipeline reads. The Next.js app will use these at runtime:
env
# Env vars used by anthropic-document-pipeline-for-quickbooks-online-invoice-automation.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Anthropic / ClaudeANTHROPIC_API_KEY=<your-anthropic-key># OpenAI (for embeddings)OPENAI_API_KEY=<your-openai-key># QuickBooks OnlineQBO_CONSUMER_KEY=<your-quickbooks-consumer-key>QBO_CONSUMER_SECRET=<your-quickbooks-consumer-secret>QBO_REALM_ID=<your-quickbooks-company-id>QBO_ACCESS_TOKEN=<your-quickbooks-oauth-token>QBO_REFRESH_TOKEN=<your-quickbooks-refresh-token>QBO_USE_SANDBOX=true# Budget controlINVOICE_BUDGET_LIMIT=10.0
Copy it to .env and fill in real values:
terminal
cp .env.example .env
Expected output: The .env file exists alongside .env.example. Never commit .env — .gitignore already covers it.
Step 4: Define shared types with Zod
Create src/services/types.ts. This file is the single source of truth for every data shape in the pipeline: invoice schemas, ingestion results, extraction results, and sync results.
Expected output:src/services/types.ts exists and exports all the schemas and interfaces above. The Zod schemas use z.output<> to derive TypeScript types so the two stay in sync automatically.
Step 5: Build the pricing provider
Create src/services/pricing-provider.ts. This class hard-codes the per-model token pricing for Claude models and the embedding model. The budget controller uses it to estimate costs before making LLM calls.
Expected output: The estimateCost method accepts a model ID and input token count, estimates output tokens at roughly 1/3 of input, and returns (inputTokens * rateIn + outputTokens * rateOut) / 1_000_000. It throws an Error for unknown model IDs. The constructor accepts an optional overrides parameter to replace any model’s price.
Step 6: Build the spend store
Create src/services/spend-store.ts. This is a simple in-memory key-value store that tracks cumulative spend per scope ID.
Expected output: Three async methods — get (returns zero-state for unknown scopes), set, and reset. All return promises to match the interface expected by the budget controller.
Step 7: Wire up the budget service
Create src/services/budget.ts. This connects the AnthropicPricingProvider and InMemorySpendStore through @reaatech/agent-budget-engine’s BudgetController. The controller enforces a configurable limit with a soft cap (80%) and hard cap (100%), and emits events when thresholds are breached.
Expected output: Three exports — getBudgetController() (singleton factory), checkBudget() (pre-flight authorisation), and recordSpend() (post-hoc cost recording). The threshold-breach and hard-stop event handlers write warnings to console.warn.
Step 8: Create the ingestion pipeline
Create src/pipeline/ingest.ts. This is the document preprocessing layer. It accepts an IngestionSource (PDF, image, or text), extracts raw text using unpdf or tesseract.js, preprocesses and validates it with @reaatech/hybrid-rag-ingestion, chunks it using a configurable strategy, and generates embeddings via OpenAI.
typescript
import { DocumentLoader, TextPreprocessor, DocumentValidator, chunkDocument, UnsupportedFormatError, FileSizeExceededError, DocumentParseError,} from "@reaatech/hybrid-rag-ingestion";import { ChunkingStrategy } from "@reaatech/hybrid-rag";import type { Document, ChunkingConfig } from "@reaatech/hybrid-rag";import { EmbeddingService } from "@reaatech/hybrid-rag-embedding";import { extractText, getDocumentProxy } from "unpdf";import { createWorker } from "tesseract.js";import type { IngestionSource, IngestionResult } from "../services/types.js";export class IngestionPipeline
Expected output: The pipeline routes PDFs through unpdf’s extractText, images through tesseract.js OCR, and text sources directly. Each path creates a Document, validates it, chunks the content using the configured strategy, and returns embeddings. Errors from the ingestion package (UnsupportedFormatError, FileSizeExceededError, DocumentParseError) are caught and rethrown with a pipeline-context prefix.
Step 9: Build the invoice extractor
Create src/extract/invoice.ts. This sends the chunked document text to Claude with a Zod system prompt, parses the structured JSON response, validates it against the schema, and retries once if validation fails.
typescript
import Anthropic from "@anthropic-ai/sdk";import { BudgetScope } from "@reaatech/agent-budget-types";import { getBudgetController } from "../services/budget.js";import { AnthropicPricingProvider } from "../services/pricing-provider.js";import { ParsedInvoiceSchema } from "../services/types.js";import type { ParsedInvoice, ExtractionResult, IngestionResult } from "../services/types.js";const SYSTEM_PROMPT = "You are a senior invoice data extraction specialist. Extract invoice details from the provided document text. Return ONLY valid JSON matching this schema: { vendorName (string), vendorAddress (optional string), invoiceNumber (optional string), invoiceDate (string), dueDate (optional string), lineItems (array of { description (string), quantity (number), unitAmount (number), totalAmount (number), accountCode (string) }), subtotal (number), taxTotal (optional number), totalAmount (number), currency (optional string), notes (optional string) }. Do not include markdown fences or commentary.";export class InvoiceExtractor { private
Expected output: The extractor checks budget before making any API call (throwing { code: "budget_exceeded" } if denied). It handles the tool_use stop reason by returning a no-op tool result and re-prompting. JSON parsing failures trigger a single retry with the Zod error message as a correction hint. Confidence is computed from a weighted heuristic: field presence (60%), line item count (20%), and math consistency between line totals and invoice total (20%).
Step 10: Create the QuickBooks integration
Create src/integration/quickbooks.ts. This wraps node-quickbooks — a CommonJS module loaded via dynamic import(). It maps the parsed invoice fields to the QuickBooks Online Invoice shape.
Expected output: The QuickBooksClient lazily loads node-quickbooks via import() and constructs it with all OAuth credentials from environment variables. syncInvoice maps each ParsedInvoice line item to the QBO SalesItemLineDetail shape. createInvoice is promisified since node-quickbooks uses callbacks. Errors return a SyncResult with status: "failed" instead of throwing.
Step 11: Wire up pipeline evaluation
Create src/evaluation/evaluate.ts. This uses @reaatech/hybrid-rag-evaluation to run benchmarks against the pipeline. It’s used during development to measure retrieval quality metrics.
typescript
import { EvaluationRunner, loadEvaluationDataset } from "@reaatech/hybrid-rag-evaluation";import type { QueryFunction } from "@reaatech/hybrid-rag-evaluation";export function loadDataset(path: string): Promise<unknown> { return Promise.resolve(loadEvaluationDataset(path));}export async function evaluatePipeline( topK: number, metrics: Array<"precision" | "recall" | "ndcg" | "map" | "mrr">, queryFn: QueryFunction,): Promise<unknown> { const dataset = loadEvaluationDataset("./datasets/eval.jsonl"); const runner = new EvaluationRunner(queryFn, { topK, metrics }); const result = await runner.evaluate(dataset); return result;}
Expected output: Two named exports — loadDataset (wraps loadEvaluationDataset in a promise) and evaluatePipeline (runs an evaluation with configurable topK and metrics). This is imported in src/index.ts to satisfy the reaa_pkg_not_imported requirement for @reaatech/hybrid-rag-evaluation.
Step 12: Create the upload API route
Create app/api/invoice/upload/route.ts. This is the public-facing endpoint — a Next.js App Router route handler that accepts multipart file uploads and chains the ingestion, extraction, and sync pipeline.
Overwrite src/index.ts to re-export every public class and function, validate required environment variables on module load, and ensure the evaluation module is imported (preventing tree-shaking):
typescript
export { IngestionPipeline } from "./pipeline/ingest.js";export { InvoiceExtractor } from "./extract/invoice.js";export { QuickBooksClient } from "./integration/quickbooks.js";export { checkBudget, recordSpend, getBudgetController } from "./services/budget.js";if (process.env.NODE_ENV !== "production") { import("./evaluation/evaluate.js").catch(() => {});}const REQUIRED_VARS = [ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "QBO_CONSUMER_KEY", "QBO_CONSUMER_SECRET", "QBO_REALM_ID", "QBO_ACCESS_TOKEN", "QBO_REFRESH_TOKEN", "QBO_USE_SANDBOX", "INVOICE_BUDGET_LIMIT",];for (const v of REQUIRED_VARS) { if (!process.env[v]) { console.error(`Missing required env var: ${v}`); }}
Expected output: On import, src/index.ts logs warnings for any missing environment variables (without throwing, so tests can run without real keys). It dynamically imports the evaluation module in non-production environments and re-exports all four key symbols.
Step 14: Write the tests
Create tests/setup.ts to provide a shared test harness:
import { describe, it, expect } from "vitest";import { InMemorySpendStore } from "../../src/services/spend-store.js";describe("InMemorySpendStore", () => { it("set and get return the same SpendState", async () => { const store = new InMemorySpendStore(); const state = { totalSpend: 5.0, scopeType: "user", scopeKey: "u1" }; await store.set("u1", state); const result = await store.get("u1"); expect(result.totalSpend).toBe(5.0); expect(result.scopeType).toBe("user"); }); it("get for unknown scope returns zero state", async () => { const store = new InMemorySpendStore(); const result = await store.get("nonexistent"); expect(result.totalSpend).toBe(0); }); it("reset clears stored state", async () => { const store = new InMemorySpendStore(); await store.set("u1", { totalSpend: 5.0, scopeType: "user", scopeKey: "u1" }); await store.reset("u1"); const result = await store.get("u1"); expect(result.totalSpend).toBe(0); });});
Now create the upload route test at tests/api/invoice/upload.test.ts. This is the core integration test — it mocks all three pipeline stages (IngestionPipeline, InvoiceExtractor, QuickBooksClient) and the budget controller, then exercises the full route handler with PDF, image, text, empty, and error scenarios:
Create the remaining test files matching the same approach — mock external dependencies with vi.mock, cover happy paths and error cases, and never make real API calls:
tests/services/budget.test.ts — verifies checkBudget returns allowed: true when under limit, allowed: false when over hard cap, and auto-downgrade suggests a cheaper model for claude-opus-4-7.
tests/services/budget.integration.test.ts — records spend against the real BudgetController and confirms that crossing the 80% soft cap triggers disallowed checks.
tests/pipeline/ingest.test.ts — mocks unpdf, tesseract.js, @reaatech/hybrid-rag-ingestion, and @reaatech/hybrid-rag-embedding; tests PDF, image, text, and empty-buffer sources, plus validation failures.
tests/extract/invoice.test.ts — uses MSW (setupServer) to intercept Anthropic HTTP calls; tests valid extraction, malformed JSON retry, Claude 500 errors, missing text blocks, budget-exceeded rejection, and empty line items.
tests/integration/quickbooks.test.ts — mocks node-quickbooks; tests successful invoice creation, error callback (failed status), and findCustomerByName returning a value, null for unknown vendors, and null when QueryResponse is null.
tests/evaluation/evaluate.test.ts — mocks @reaatech/hybrid-rag-evaluation; verifies loadDataset returns an array and evaluatePipeline returns structured results.
Expected output: 51 test cases across 10 test files (plus setup.ts), all passing. Every external dependency is mocked — HTTP calls via MSW (onUnhandledRequest: "error"), SDK modules via vi.mock, node-quickbooks via vi.mock. No real API calls are made during tests.
Step 15: Run type checking, linting, and tests
Run the full pipeline to validate everything compiles and passes quality gates:
terminal
pnpm typecheckpnpm lintpnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output:
pnpm typecheck exits 0 with no TypeScript errors
pnpm lint exits 0 with no ESLint errors
pnpm vitest run reports numFailedTests: 0 and 51 total tests
Coverage report shows over 90% on lines (95.86%), branches (93.67%), functions (91.66%), and statements (95.27%) for runtime code (src/**/*.ts and app/**/route.ts — UI files in app/**/*.tsx are excluded from coverage)
Next steps
Add a database-backed spend store — replace InMemorySpendStore with a PostgreSQL or Redis-backed implementation so budget state survives server restarts and works across multiple instances.
Support more file types — extend IngestionSource to handle Microsoft Office formats (.docx, .xlsx) using a library like mammoth or xlsx.
Add a webhook notification — emit a webhook event when sync completes (success or failure) so accounting dashboards get real-time updates.
Deploy to production — containerize with Docker and deploy to Railway, Fly.io, or AWS ECS. Set up a cron job for periodic token refresh with QuickBooks OAuth2.
Add an evaluation dashboard — run the evaluatePipeline function against a labelled dataset of sample invoices and visualise precision/recall/NDCG over time to track extraction quality.
{
private loader: DocumentLoader;
private preprocessor: TextPreprocessor;
private validator: DocumentValidator;
private embedder: EmbeddingService;
private chunkSize: number;
private chunkOverlap: number;
private chunkStrategy: string;
constructor(config: {
embeddingApiKey: string;
embeddingModel?: string;
chunkStrategy?: string;
chunkSize?: number;
chunkOverlap?: number;
}) {
this.loader = new DocumentLoader({ supportedFormats: ["pdf", "md", "html", "txt"] });