Anthropic Receipt-to-Expense Automation for SMB Bookkeeping
Extract line items from receipts and PDF invoices using Anthropic, repair malformed outputs, and log costs – all within a simple Express API an SMB can deploy in minutes.
Small businesses still waste hours manually typing receipt data into accounting software. Off‑the‑shelf OCR misses varied layouts and handwritten notes, and poorly formatted LLM outputs break downstream automation.
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 a receipt-to-expense automation API using Anthropic’s Claude, Zod schemas, and a pipeline that extracts structured expense data from uploaded receipts and PDF invoices. You’ll wire up OCR, LLM vision extraction, malformed-output repair, session continuity, cost telemetry, and Langfuse observability — all behind a Next.js App Router endpoint.
You’ll learn how to chain REAA packages for media pipeline extraction, structured repair, session continuity with memory storage, and LLM cost telemetry. By the end, you’ll have POST /api/extract that accepts a receipt file and returns structured expense records, plus GET /api/sessions to list extraction history.
Prerequisites
Node.js >= 22 and pnpm 10 installed
An Anthropic API key with access to claude-sonnet-4-6
A Langfuse account and project (for observability tracing)
Familiarity with TypeScript and Zod schemas
Set these environment variables in a .env file before starting:
Expected output:pnpm install exits with 0, and node_modules/ contains all packages. No peer-dependency warnings (all versions are compatible).
Step 2: Set up configuration files
Create next.config.ts with the bare Next.js App Router configuration:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { /* config options here */};export default nextConfig;
Create .env.example with placeholder values so no secrets are ever committed:
env
# Env vars used by anthropic-receipt-to-expense-automation-for-smb-bookkeeping.# Keep placeholders only — never commit real values.NODE_ENV=developmentANTHROPIC_API_KEY=<your-anthropic-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret>LANGFUSE_PUBLIC_KEY=<your-langfuse-public>LANGFUSE_BASE_URL=<your-langfuse-base-url>SESSION_TTL_MS=3600000MAX_FILE_SIZE_BYTES=10485760
Copy it to .env and fill in your real keys:
terminal
cp .env.example .env
Expected output: Two env files exist. The .env file has your real API keys (never committed) while .env.example holds placeholders.
Step 3: Define the expense types with Zod
Create src/types/expense.ts — a Zod schema that describes a structured expense record. This schema is used for LLM output validation and for the repair engine.
export type { ExpenseLineItem, ExpenseRecord, ExtractionJob,} from "./expense.js";export { ExpenseLineItemSchema, ExpenseRecordSchema,} from "./expense.js";export type { SessionSummary,} from "./session.js";
Expected output: Three files under src/types/. The ExpenseRecordSchema is a Zod object with fields for vendor, dates, totals, currency, line items, metadata, and confidence.
Step 4: Build the document processor
The document processor detects file types (PDF, JPEG, PNG) and extracts text. PDFs go through unpdf, images go through tesseract.js OCR with sharp preprocessing for resizing.
Expected output:detectFileType reads the first 4 bytes as a hex signature. processReceipt routes PDFs to text extraction and images to OCR + base64 encoding (for Claude’s vision API).
Step 5: Create the Anthropic client
This module calls Claude’s Messages API for both vision (image receipts) and text (PDF text) inputs.
Create src/lib/anthropic-client.ts:
ts
import Anthropic from "@anthropic-ai/sdk";export interface AnthropicCallResult { text: string; inputTokens: number; outputTokens: number;}const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY ?? "",});export { client };export async function callClaudeForVision( base64Image: string, mimeType: string, prompt: string,): Promise<AnthropicCallResult> { const message = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 4096, system: "You extract structured expense data from receipts and invoices. Return strictly valid JSON matching the ExpenseRecord schema.", messages: [ { role: "user", content: [ { type: "image", source: { type: "base64", media_type: mimeType as "image/jpeg" | "image/png", data: base64Image, }, }, { type: "text", text: prompt }, ], }, ], }); const block = message.content[0]; if (block.type === "text") { return { text: block.text, inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, }; } throw new Error("Expected text response from Claude");}export async function callClaudeForText( textContent: string, prompt: string,): Promise<AnthropicCallResult> { const message = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 4096, system: "You extract structured expense data from receipts and invoices. Return strictly valid JSON matching the ExpenseRecord schema.", messages: [ { role: "user", content: [ { type: "text", text: prompt }, { type: "text", text: textContent }, ], }, ], }); const block = message.content[0]; if (block.type === "text") { return { text: block.text, inputTokens: message.usage.input_tokens, outputTokens: message.usage.output_tokens, }; } throw new Error("Expected text response from Claude");}
Expected output: Both functions invoke client.messages.create with the claude-sonnet-4-6 model. The vision variant sends an image block; the text variant sends the extracted text directly. Both return the parsed JSON string plus token counts.
Step 6: Wire up Structured Output Repair
Claude sometimes wraps JSON in markdown fences, uses wrong types (string instead of number), or hallucinates extra fields. The @reaatech/structured-repair-core package handles this with six graduated strategies.
Create src/lib/output-repair.ts:
ts
import { repair, repairOutput, isValid, analyzeInput, type RepairResult,} from "@reaatech/structured-repair-core";import { ExpenseRecordSchema, type ExpenseRecord } from "../types/expense.js";void analyzeInput("{}");export async function repairExpenseRecord( rawJson: string,): Promise<ExpenseRecord> { return repair(ExpenseRecordSchema, rawJson);}export async function repairExpenseWithDiagnostics( rawJson: string,): Promise<RepairResult<ExpenseRecord>> { return await Promise.resolve( repairOutput({ schema: ExpenseRecordSchema, input: rawJson, debug: true, }), );}export function isValidExpenseData(rawJson: string): boolean { return isValid(ExpenseRecordSchema, rawJson);}export { analyzeInput };
Expected output:repair() returns typed data or throws. repairExpenseWithDiagnostics() returns the full RepairResult with per-step metadata so you can see which strategy fixed the output.
Step 7: Build the session store
Use @reaatech/session-continuity-storage-memory to store extraction sessions in-memory with configurable TTL. This lets users upload multiple receipt pages and have the pipeline remember context from previous pages.
Expected output: The MemoryAdapter stores sessions in a Map with a configurable TTL (default 1 hour). createSession auto-generates an ID and timestamp. getRecentEvents fetches recent messages for building context prompts on multi-page uploads.
Step 8: Create the cost tracker
Log every Anthropic API call as a CostSpan using @reaatech/llm-cost-telemetry. This gives you per-session cost tracking that you can expose in the API response.
Expected output:recordAnthropicCall creates a CostSpan with the model pricing from claude-sonnet-4-6 (input $3/M, output $15/M). Each span is validated by CostSpanSchema at runtime before being stored.
Step 9: Set up Langfuse observability
Create src/lib/observability.ts to create and end Langfuse traces for each extraction job:
ts
import { Langfuse } from "langfuse";let langfuse: Langfuse | null = null;export function initLangfuse(): Langfuse { if (!langfuse) { langfuse = new Langfuse({ secretKey: process.env.LANGFUSE_SECRET_KEY, publicKey: process.env.LANGFUSE_PUBLIC_KEY, baseUrl: process.env.LANGFUSE_BASE_URL, }); } return langfuse;}export function createTrace(jobId: string, sessionId: string) { return initLangfuse().trace({ id: jobId, sessionId, name: "receipt-extraction" });}export function endTrace(trace: ReturnType<typeof createTrace>) { trace.update({ output: "completed" });}
Expected output:initLangfuse lazily creates a single Langfuse instance. Each extraction job gets a trace with the job ID as the trace ID, linked to the session.
Step 10: Build the Instructor client (optional structured extraction)
For even more reliable structured output, @instructor-ai/instructor provides typed extraction via Anthropic. It needs an OpenAI-compatible adapter wrapping the Anthropic client.
Expected output: The adapter wraps the Anthropic SDK to look like an OpenAI client. Instructor uses TOOLS mode to extract typed data matching the ExpenseRecordSchema directly. The max_retries: 3 gives you automatic retries on parse failures.
Step 11: Wire up the extraction pipeline
The ExtractionPipeline is the core orchestrator. It takes a file buffer, detects the type, sends it to Claude (vision for images, text for PDFs), repairs the JSON, records cost, and appends the event to the session.
Create src/services/pipeline.ts:
ts
import { type CostSpan } from "@reaatech/llm-cost-telemetry";import { type RepairResult } from "@reaatech/structured-repair-core";import { type ExpenseRecord } from "../types/expense.js";import { callClaudeForVision, callClaudeForText } from "../lib/anthropic-client.js";import { processReceipt } from "../lib/document-processor.js";import { repairExpenseWithDiagnostics } from "../lib/output-repair.js";import { repairExpenseRecord } from "../lib/output-repair.js";void repairExpenseRecord;import { createSession, getSession, setSessionActive, appendEvent, getRecentEvents,
Expected output: The extract method runs the full pipeline: session lookup/creation, document processing, Claude API call (vision or text), cost recording, JSON repair, event logging, and trace finalization. If the repair engine can’t produce valid data, it throws with diagnostic details.
Step 12: Create the API routes
POST /api/extract
This route accepts a multipart form upload with a receipt file (and optional sessionId), runs the pipeline, and returns the structured expense record.
Expected output: Two route handlers in app/api/. Both use NextRequest/NextResponse from next/server (never bare Request/Response). The extract route returns NextResponse.json(...) with the record, cost span, and session metadata.
Step 13: Run the tests
Create a global test setup that mocks the Anthropic API with MSW. This intercepts all outgoing HTTP calls to api.anthropic.com and returns a realistic mock response.
Create a test for the extraction route. It mocks the external dependencies (Anthropic, unpdf, tesseract, sharp, output-repair, session-store) using vi.mock and tests the happy path.
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json
Expected output: All tests pass (numFailedTests=0, numTotalTests>=3). Coverage thresholds hit 90%+ across lines, branches, functions, and statements on runtime code (everything under src/ and app/**/route.ts). The full test corpus covers the pipeline happy path, PDF path, repair failure, session context, empty buffers, missing file errors, file-too-large errors, and the sessions listing endpoint.
Next steps
Add a database-backed session store — replace MemoryAdapter with @reaatech/session-continuity-storage-firestore or @reaatech/session-continuity-storage-redis for durability across restarts.
Add budget enforcement — use @reaatech/llm-cost-telemetry’s loadBudgetConfig() to cap daily spend per tenant and reject requests over budget.
Add a web frontend — create a drag-and-drop receipt upload page in app/page.tsx that calls POST /api/extract and renders the extracted line items in a table.
Add a repair dashboard — surface diagnosticSteps from the pipeline in a monitoring endpoint so you can track which repair strategies fire most often across receipts.
}
from
"../lib/session-store.js"
;
import { recordAnthropicCall } from "../lib/cost-tracker.js";
import { createTrace, endTrace } from "../lib/observability.js";
import { extractExpenseWithInstructor } from "../lib/instructor-client.js";
void extractExpenseWithInstructor;
function generateJobId(): string {
const timestamp = String(Date.now());
const random = Math.random().toString(36).slice(2, 8);