SMBs using multiple LLM providers for different tasks have no unified view of their AI spend, making it impossible to budget or allocate costs to the right projects without manual spreadsheet work.
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 tutorial walks you through building an agnostic AI cost control service that monitors and caps spending across any LLM provider, then syncs categorized costs into QuickBooks Online (QBO) as operational expenses. You’ll build a Next.js 16 App Router project with an Express server, using the @reaatech/agent-budget-* package family as the budget enforcement engine. By the end you’ll have a working service with five API routes, real-time spend tracking, OTel-span-based cost recording, and a daily QBO journal entry pipeline.
Prerequisites
Node.js >=22 and pnpm >=10 installed
PostgreSQL running locally (or a remote connection string)
A QuickBooks Online developer account at developer.intuit.com — you’ll need OAuth 2.0 credentials and a company ID
Familiarity with TypeScript, Next.js App Router, and basic Express patterns
Step 1: Set up the project and install dependencies
The project shell is a Next.js 16 App Router app with TypeScript, Vitest, ESLint, and all dependencies pinned. Install them:
terminal
pnpm install
Copy the environment file and fill in your credentials:
Expected output: The estimateCost({ model, inputTokens, outputTokens }) method takes an object argument and returns a USD cost. Unknown models fall back to the default $2/$8 per million tokens.
Also create a small factory wrapper in src/lib/spend-tracker-factory.ts:
ts
import { SpendStore } from "@reaatech/agent-budget-spend-tracker";export function createSpendStore(maxEntries?: number): SpendStore { return new SpendStore({ maxEntries });}
Expected output:createSpendStore() returns a configured SpendStore instance ready for use.
Step 3: Implement the budget service
BudgetService wraps BudgetController from @reaatech/agent-budget-engine. It handles pre-flight checks, spend recording, state queries, and event-driven alerting.
Create src/services/budget-service.ts:
ts
import { BudgetController } from "@reaatech/agent-budget-engine";import { BudgetScope } from "@reaatech/agent-budget-types";import type { SpendStore } from "@reaatech/agent-budget-spend-tracker";import type { BudgetCheckRequest, BudgetCheckResponse } from "../types/budget.js";import type { CostTrackingPricingProvider } from "../lib/pricing-provider.js";export class BudgetService { private controller: BudgetController; constructor(options: { spendTracker: SpendStore; pricing?: CostTrackingPricingProvider; defaultEstimateTokens?: number
Expected output:BudgetService fires threshold-breach and hard-stop events at the configured caps. initializeDefault() creates wildcard budgets for User and Org scopes using env-configured limits.
Don’t forget the type definitions. Create src/types/budget.ts:
Expected output: Each method delegates to the equivalent SpendStore method. getTotalSpend returns 0 for never-recorded scopes. detectSpikes returns an empty array when costs are uniform.
Step 5: Add the AI budget gate and LLM router
The generateTextWithBudget function wraps the Vercel AI SDK’s generateText. Before every LLM call it runs a budget check; after the call it records the actual spend.
Expected output: If check returns { allowed: false }, the function throws "Budget check failed: Blocked". Otherwise it returns the generated text and token usage, and records the actual cost.
Now add the LLM router integration. BudgetRouter wraps BudgetAwareStrategy to filter model candidates by remaining budget.
Expected output:selectModels("user-42", [{ id: "gpt-5.2", estimatedCost: 0.01 }]) returns only models that fit within the remaining budget. When the budget is exhausted, blocked is true and models is empty.
Step 6: Set up QuickBooks Online integration
The QboClient handles OAuth 2.0 token management and posts journal entries to the Intuit QuickBooks Online API.
Create src/accounting/qbo-client.ts:
ts
import type { QboCredentials, QboJournalEntry } from "../types/qbo.js";export class QboAuthError extends Error { constructor(message: string) { super(message); this.name = "QboAuthError"; }}export class QboApiError extends Error { constructor(message: string, public statusCode: number) { super(message); this.name = "QboApiError"; }}export class QboClient { private credentials:
Expected output:postJournalEntry sends a journal entry to the Intuit API with a 30-second timeout. On a 401, it refreshes the token and retries once. On a 429, it throws QboApiError with the Retry-After header value.
Step 7: Build the sync service
QboSyncService reads spend entries since the last sync, transforms them into QBO journal entry lines, posts the entry, and records the sync in the database.
Create src/services/qbo-sync-service.ts:
ts
import { SpendAggregator } from "./spend-aggregator.js";import type { QboSyncResult, QboJournalLine } from "../types/qbo.js";import { BudgetScope } from "@reaatech/agent-budget-types";export interface SqlLike { (strings: TemplateStringsArray, ...values: unknown[]): unknown; json(value: unknown): unknown;}export interface QboClientLike { postJournalEntry(entry: { Line: unknown[]; TxnDate: string; PrivateNote?: string
Expected output:syncPeriod() groups entries by model, creates one debit line per model and one credit line for the total, then posts to QBO. If no spend entries exist, it returns { synced: false, reason: "No spend data for period" }.
Step 8: Wire the database schema
Create the database tables in src/db/schema.ts:
ts
export async function createTables( sql: (strings: TemplateStringsArray, ...values: unknown[]) => Promise<unknown[]>,): Promise<void> { await sql` CREATE TABLE IF NOT EXISTS cost_sync_log ( id SERIAL PRIMARY KEY, period_start TIMESTAMPTZ NOT NULL, period_end TIMESTAMPTZ NOT NULL, total_cost NUMERIC(12,6) NOT NULL, journal_entry_id TEXT, model_breakdown JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `; await sql` CREATE TABLE IF NOT EXISTS budget_definitions ( id SERIAL PRIMARY KEY, scope_type TEXT NOT NULL, scope_key TEXT NOT NULL, spend_limit NUMERIC(12,6) NOT NULL, policy JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `;}
And the database client in src/db/client.ts:
ts
import postgres from "postgres";import type { Sql } from "postgres";let client: Sql | null = null;export function getSqlClient(): Sql { if (!client) { const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { throw new Error("DATABASE_URL environment variable is not set"); } client = postgres(databaseUrl, { transform: postgres.camel, }); } return client;}
Expected output:createTables creates cost_sync_log and budget_definitions tables. It’s idempotent — running it twice doesn’t error.
Step 9: Create the API routes
This project uses Next.js App Router. Five API routes provide the service interface.
Create app/api/budget/check/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import { z } from "zod";import { BudgetScope } from "@reaatech/agent-budget-types";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetService } from "@/src/services/budget-service.js";import { CostTrackingPricingProvider } from "@/src/lib/pricing-provider.js";export const runtime = "nodejs";const store = new SpendStore({ maxEntries: 100_000 });const pricing = new CostTrackingPricingProvider();const budgetService = new BudgetService({ spendTracker: store, pricing });const checkSchema = z.object({ scopeType: z.enum(BudgetScope), scopeKey: z.string(), estimatedCost: z.number().positive(), modelId: z.string(), tools: z.array(z.string()).optional(),});export async function POST(req: NextRequest) { try { const body: unknown = await req.json(); const parsed = checkSchema.parse(body); const result = budgetService.check(parsed); if (!result.allowed) { return NextResponse.json( { allowed: false, action: "Blocked", reason: "Hard cap reached" }, { status: 403 }, ); } return NextResponse.json(result); } catch (err) { if (err instanceof z.ZodError) { return NextResponse.json( { error: "ValidationError", details: err.issues }, { status: 400 }, ); } throw err; }}
Create app/api/cost/record/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import { z } from "zod";import { BudgetScope } from "@reaatech/agent-budget-types";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetService } from "@/src/services/budget-service.js";import { CostTrackingPricingProvider } from "@/src/lib/pricing-provider.js";export const runtime = "nodejs";const store = new SpendStore({ maxEntries: 100_000 });const pricing = new CostTrackingPricingProvider();const budgetService = new BudgetService({ spendTracker: store, pricing });const recordSchema = z.object({ requestId: z.string(), scopeType: z.enum(BudgetScope), scopeKey: z.string(), cost: z.number(), inputTokens: z.number(), outputTokens: z.number(), modelId: z.string(), provider: z.string(), timestamp: z.string().transform((s) => new Date(s)),});export async function POST(req: NextRequest) { try { const body: unknown = await req.json(); const parsed = recordSchema.parse(body); budgetService.record(parsed); const state = budgetService.getState(parsed.scopeType, parsed.scopeKey); return NextResponse.json({ recorded: true, state }); } catch (err) { if (err instanceof z.ZodError) { return NextResponse.json( { error: "ValidationError", details: err.issues }, { status: 400 }, ); } throw err; }}
Create app/api/cost/sync/route.ts:
ts
import { NextResponse } from "next/server";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { SpendAggregator } from "@/src/services/spend-aggregator.js";import { QboClient } from "@/src/accounting/qbo-client.js";import { QboSyncService } from "@/src/services/qbo-sync-service.js";import { getSqlClient } from "@/src/db/client.js";export const runtime = "nodejs";const store = new SpendStore({ maxEntries: 100_000 });const aggregator = new SpendAggregator(store);export async function POST() { try { const qboClient = new QboClient({ clientId: process.env.QBO_CLIENT_ID ?? "", clientSecret: process.env.QBO_CLIENT_SECRET ?? "", refreshToken: process.env.QBO_REFRESH_TOKEN ?? "", companyId: process.env.QBO_COMPANY_ID ?? "", environment: process.env.QBO_ENVIRONMENT === "production" ? "production" : "sandbox", }); const sql = getSqlClient(); const syncService = new QboSyncService(aggregator, qboClient, sql); const result = await syncService.syncPeriod(); if (!result.synced && result.error === "QBO_AUTH_FAILED") { return NextResponse.json( { synced: false, error: "QBO_AUTH_FAILED" }, { status: 502 }, ); } if (!result.synced) { return NextResponse.json({ synced: false, reason: "No spend data for period", totalCost: 0, }); } return NextResponse.json(result); } catch { return NextResponse.json( { synced: false, error: "QBO_AUTH_FAILED" }, { status: 502 }, ); }}
Create app/api/budget/status/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import { BudgetScope } from "@reaatech/agent-budget-types";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetService } from "@/src/services/budget-service.js";import { CostTrackingPricingProvider } from "@/src/lib/pricing-provider.js";export const runtime = "nodejs";const store = new SpendStore({ maxEntries: 100_000 });const pricing = new CostTrackingPricingProvider();const budgetService = new BudgetService({ spendTracker: store, pricing });export function GET(req: NextRequest) { const scopeType = req.nextUrl.searchParams.get("scopeType"); const scopeKey = req.nextUrl.searchParams.get("scopeKey"); if (!scopeType || !scopeKey) { return NextResponse.json( { error: "Missing scopeType or scopeKey" }, { status: 400 }, ); } const state = budgetService.getState(scopeType as BudgetScope, scopeKey); return NextResponse.json(state);}
Create app/api/spend/dashboard/route.ts:
ts
import { NextRequest, NextResponse } from "next/server";import { BudgetScope } from "@reaatech/agent-budget-types";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { SpendAggregator } from "@/src/services/spend-aggregator.js";export const runtime = "nodejs";export const store = new SpendStore({ maxEntries: 100_000 });const aggregator = new SpendAggregator(store);export function GET(req: NextRequest) { const scopeType = (req.nextUrl.searchParams.get("scopeType") as string | undefined) ?? BudgetScope.User; const scopeKey = req.nextUrl.searchParams.get("scopeKey") ?? "*"; const windowMinutes = req.nextUrl.searchParams.get("windowMinutes") ? Number(req.nextUrl.searchParams.get("windowMinutes")) : undefined; const totalSpend = aggregator.getTotalSpend(scopeType as BudgetScope, scopeKey); const rate = aggregator.getRate(scopeType as BudgetScope, scopeKey, windowMinutes); const projection = aggregator.projectCost(scopeType as BudgetScope, scopeKey); const spikes = aggregator.detectSpikes(scopeType as BudgetScope, scopeKey); const recentModels = [...new Set(aggregator.getEntriesForPeriod( new Date(Date.now() - 86400000), new Date(), scopeType as BudgetScope, scopeKey ).map((e) => e.modelId))]; return NextResponse.json({ totalSpend, rate, projection, recentModels, spikeCount: spikes.length, });}
Expected output: Each route handler validates input with Zod, delegates to the service layer, and returns structured JSON. Budget exceeded returns 403. Validation errors return 400 with { error: "ValidationError" }. QBO auth failures return 502.
Step 10: Add the Express server entry point
For environments where you want to run the service outside Next.js, there’s an Express server in src/app.ts:
ts
import "dotenv/config";import express from "express";import { SpendStore } from "@reaatech/agent-budget-spend-tracker";import { BudgetService } from "./services/budget-service.js";import { SpendAggregator } from "./services/spend-aggregator.js";import { CostTrackingPricingProvider } from "./lib/pricing-provider.js";import { initOtel } from "./services/otel-init.js";import { BudgetScope } from "@reaatech/agent-budget-types";import { z } from "zod";const PORT = Number(process.env.PORT) || 3000;async function
Expected output: Run pnpm dev to start the Next.js dev server, or npx tsx src/app.ts to start the Express server on port 3000.
Step 11: Run the tests
The project ships with a comprehensive Vitest suite — 93 tests covering every module. Run them:
terminal
pnpm typecheckpnpm lintpnpm test
The output should look like this (numbers may vary slightly):
Expected output:pnpm typecheck exits with zero type errors across src/ and tests/. pnpm lint has no ESLint errors. pnpm test runs all 93 tests, generates vitest-report.json, and produces coverage above 90% on lines, branches, functions, and statements for the runtime code in src/ and app/**/route.ts.
Next steps
Add per-scope overrides — extend initializeDefault() to load budget definitions from the budget_definitions table, enabling per-user or per-project limits stored in PostgreSQL.
Add rate-limiting middleware — protect the /cost/sync route from being called more than once per sync window using an Express or Next.js middleware with a sliding-window throttle.
Build a dashboard UI — replace the placeholder app/page.tsx with a React dashboard showing spend charts, rate projections, and recent spike detections using the /api/spend/dashboard endpoint.