An SMB using Stripe for subscription payments runs multiple AI agents that process invoices, analyze churn, and generate support replies. Without cost controls, a single runaway agent can drain the monthly API budget, causing unpredictable Stripe invoices from their LLM provider.
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 automated cost governance layer that enforces per-customer LLM spend limits for Stripe SMB subscription billing. You’ll wire together five REAA packages — BudgetController, SpendStore, PricingEngine, LLMRouter, and cost telemetry — to create an Express API server with a Next.js monitoring dashboard. When a tenant approaches their budget soft cap, the router automatically downgrades to a cheaper Mistral model; at the hard cap, all LLM calls are blocked until the billing cycle resets.
Prerequisites
Node.js >= 22 and pnpm (enable with corepack enable && corepack prepare pnpm@latest --activate)
Expected output: The dependencies are added to package.json with exact semver pins. You’ll see the REAA packages in node_modules/@reaatech/.
Step 2: Configure environment variables
Create a .env.local file and a .env.example that documents every variable your application reads.
terminal
cp .env.example .env.local
Write .env.example:
env
# Env vars used by mistral-ai-spend-governance-for-stripe-smb-subscription-billing.# Keep placeholders only — never commit real values.NODE_ENV=development# Mistral AIMISTRAL_API_KEY=<your-mistral-key># StripeSTRIPE_SECRET_KEY=<your-stripe-secret-key>STRIPE_WEBHOOK_SECRET=<your-stripe-webhook-secret># OpenTelemetry (read by @reaatech/llm-cost-telemetry loadConfig())OTEL_SERVICE_NAME=mistral-spend-governance# Budget defaults (read by @reaatech/llm-cost-telemetry loadBudgetConfig())DEFAULT_DAILY_BUDGET=10.0# Dashboard URLNEXT_PUBLIC_APP_URL=http://localhost:3000
Expected output: The .env.example now lists all seven environment variables with placeholder values. Edit .env.local with your real Mistral API key and Stripe credentials.
Step 3: Create shared types and Zod schemas
Define the BudgetScope enum and a Zod schema for validating budget policy POST bodies. These types are used by every module in the system.
Expected output: Five exported types — one enum, one Zod schema (with an inferred type), and two interfaces. The BudgetPolicyCreateSchema includes a .refine() guard ensuring hardCap > softCap.
Step 4: Wire the budget service with REAA packages
This is the core of the system. You’ll create three singletons — SpendStore, PricingEngine, and BudgetController — and export a set of facade functions that the rest of your application calls.
Create src/services/budget-service.ts:
ts
import { BudgetController, PolicyEvaluator, DowngradeEngine, ToolFilter } from '@reaatech/agent-budget-engine';import { SpendStore } from '@reaatech/agent-budget-spend-tracker';import { PricingEngine, ModelNormalizer } from '@reaatech/agent-budget-pricing';import { BudgetScope as UpstreamBudgetScope } from '@reaatech/agent-budget-types';export const spendTracker = new SpendStore({ maxEntries: 500_000 });export const pricing = new PricingEngine({ cacheTtlMs: 3600_000 });export const controller = new BudgetController({ spendTracker, pricing });pricing.loadTable('mistral', { 'mistral-large-latest'
Expected output: Three REAA package singletons are initialized and configured. The pricing.loadTable('mistral', ...) call registers Mistral-specific pricing (the built-in tables only cover Anthropic, OpenAI, Google, and AWS Bedrock). Two event listeners log threshold-breach and hard-stop events to the console. The module exports 13 facade functions plus the raw singletons for advanced use.
Step 5: Create the Mistral AI client
Create a lazy-initialized singleton for the Mistral AI SDK, with typed error wrapping so callers don’t deal with raw SDK exceptions.
Expected output: A singleton Mistral client that reads MISTRAL_API_KEY from the environment. The MistralClientError class wraps SDK errors with a statusCode and cause, so upstream code never sees raw MistralError objects. Both chatComplete and chatStream are exported as typed helpers.
Step 6: Create the Stripe client
A lazy singleton for Stripe with three typed helper methods.
Expected output: Three functions — getSubscription (returns a Stripe subscription), createUsageRecord (posts a usage record to a subscription item), and getCustomers (lists customers).
Step 7: Build the cost pipeline
The cost pipeline wraps every Mistral API call with telemetry. It extracts token usage from the Mistral response, computes the cost via PricingEngine.computeCost(), validates the span with CostSpanSchema, and records the spend.
Expected output: The pipeline accepts a thunk that makes the Mistral call, extracts the token usage from the response, computes the cost using the pricing engine, validates a CostSpan object with Zod (CostSpanSchema.parse), and calls recordSpend to push the cost into the budget controller. If validation fails, a typed CostPipelineError is thrown.
Step 8: Build the LLM router service
The router reads a YAML configuration that defines your Mistral model pool and routing strategies. It integrates a pre-flight budget check before dispatching.
Expected output: Two routing strategies are registered — cost-optimized (tries the cheapest model first, falls back to more capable ones) and quality (pins mistral-large-latest). The routeLLM function checks the budget before dispatching: if the hard cap is hit, it returns an error without making any API call. The executeModel callback creates a Mistral client, calls chat.complete, and returns the response content plus token counts.
Step 9: Handle Stripe webhooks
Stripe webhooks trigger budget resets (on invoice.paid) and policy updates (on customer.subscription.updated).
Create src/lib/stripe-webhook.ts:
ts
import Stripe from 'stripe';import { z } from 'zod';import { getStripeClient } from './stripe-client.js';import { resetBudget, defineBudget } from '../services/budget-service.js';import { BudgetScope } from '../types.js';const StripeWebhookEventSchema = z.object({ id: z.string().optional(), type: z.string(), data: z.object({ object: z.record(z.string(), z.unknown()) }),});export function constructEvent( rawBody: string, signature: string, secret: string,): Stripe.Event { const stripe = getStripeClient(); return stripe.webhooks.constructEvent(rawBody, signature, secret);}export function handleInvoicePaid(event: Record<string, unknown>): void { const parsed = StripeWebhookEventSchema.parse(event); if (parsed.type !== 'invoice.paid') return; const invoice = parsed.data.object; const amountPaid = invoice.amount_paid as number | undefined; if (!amountPaid || amountPaid === 0) return; const customerId = invoice.customer as string | undefined; if (customerId) { resetBudget(BudgetScope.Tenant, customerId); console.warn(`Budget reset for customer ${customerId} — invoiced $${(amountPaid / 100).toFixed(2)}`); }}export function handleSubscriptionUpdated(event: Record<string, unknown>): void { const parsed = StripeWebhookEventSchema.parse(event); if (parsed.type !== 'customer.subscription.updated') return; const subscription = parsed.data.object; const customerId = subscription.customer as string | undefined; if (!customerId) return; const items = subscription.items as Record<string, unknown> | undefined; const itemsData = items?.data as Array<Record<string, unknown>> | undefined; let budgetLimit = 50; if (itemsData) { for (const item of itemsData) { const price = item.price as Record<string, unknown> | undefined; if (price) { const product = price.product as string | undefined; if (product && product.includes('premium')) budgetLimit = 200; } } } defineBudget(BudgetScope.Tenant, customerId, budgetLimit, { softCap: 0.8, hardCap: 1.0, }); console.warn(`Budget policy updated for customer ${customerId}: limit=${String(budgetLimit)}`);}
Expected output: The constructEvent function verifies the Stripe webhook signature. handleInvoicePaid resets the budget for a tenant when their invoice is paid. handleSubscriptionUpdated adjusts the budget limit based on the subscription plan tier — premium products get a $200 limit, standard products get $50.
Step 10: Create the Express API server and routes
The Express server mounts four route modules: budget admin, spend tracker, webhooks, and health. The webhooks router uses a raw body parser (required for Stripe signature verification), while the other routes use express.json().
Create src/app.ts:
ts
import express, { type Request, type Response, type NextFunction } from 'express';import budgetAdminRouter from './routes/budget-admin.js';import spendTrackerRouter from './routes/spend-tracker.js';import webhookRouter from './routes/webhooks.js';import healthRouter from './routes/health.js';import { registerCronInApp } from './cron/budget-reset.js';const app = express();app.use('/api/webhooks', webhookRouter);app.use(express.json());app.use('/api/budgets', budgetAdminRouter);app.use('/api/spend', spendTrackerRouter);app.use('/api/health', healthRouter);registerCronInApp();interface HttpError extends Error { statusCode?: number; status?: number;}app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { void _req; void _next; const httpErr = err as HttpError; const statusCode = httpErr.statusCode ?? httpErr.status ?? 500; res.status(statusCode).json({ error: err.message, statusCode });});export function start(port: number): void { app.listen(port, () => { console.warn(`Server listening on port ${String(port)}`); });}export { app };
Now create the four route files.
src/routes/budget-admin.ts — CRUD for budget policies:
import { Router, type Request, type Response } from 'express';import { now } from "@reaatech/llm-cost-telemetry";import { getMistralClient } from '../lib/mistral-client.js';import { getStripeClient } from '../lib/stripe-client.js';import { listAllBudgets } from '../services/budget-service.js';const router = Router();router.get('/', (_req: Request, res: Response) => { let budgetHealthy = false; try { listAllBudgets(); budgetHealthy = true; } catch { budgetHealthy = false; } let mistralHealthy = false; try { getMistralClient(); mistralHealthy = true; } catch { mistralHealthy = false; } let stripeHealthy = false; try { getStripeClient(); stripeHealthy = true; } catch { stripeHealthy = false; } res.json({ status: "ok", timestamp: now().toISOString(), services: { budget: budgetHealthy, mistral: mistralHealthy, stripe: stripeHealthy, }, });});export default router;
Expected output: Four route modules mounted at /api/budgets, /api/spend, /api/webhooks, and /api/health. The webhooks router applies express.raw() before any route handler so the Stripe signature verification receives the raw body. The global error handler catches unhandled errors and returns { error, statusCode } JSON.
Step 11: Add the daily budget reset cron
The cron job checks every budget’s billing window and resets counters when the window has passed. It uses getWindowStart and getWindowEnd from @reaatech/llm-cost-telemetry for time-window arithmetic.
Create src/cron/budget-reset.ts:
ts
import { getWindowEnd, getWindowStart } from "@reaatech/llm-cost-telemetry";import { listAllBudgets, resetBudget, getBudgetState } from '../services/budget-service.js';export function runBudgetReset(): void { const budgets = listAllBudgets(); if (!Array.isArray(budgets) || budgets.length === 0) return; for (const budget of budgets) { try { const b = budget as Record<string, unknown>; const scopeType = b.scopeType as string; const scopeKey = b.scopeKey as string; const windowStart = getWindowStart(new Date(), "day"); const windowEnd = getWindowEnd(new Date(), "day"); console.warn(`Budget cycle window: ${windowStart.toISOString()} to ${windowEnd.toISOString()}`); if (new Date() >= windowEnd) { const state = getBudgetState(scopeType, scopeKey); const stateRecord = state as { spent?: number } | undefined; const preResetTotal = stateRecord?.spent ?? 0; console.warn(`Resetting budget for ${scopeType}:${scopeKey} — pre-reset total: ${String(preResetTotal)}`); resetBudget(scopeType, scopeKey); } } catch (error) { const b = budget as Record<string, unknown>; console.warn(`Budget reset failed for ${String(b.scopeType)}:${String(b.scopeKey)}`, error); } }}export function registerCronInApp(): void { const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; runBudgetReset(); setInterval(() => { runBudgetReset(); }, TWENTY_FOUR_HOURS_MS);}
Expected output: The registerCronInApp function runs runBudgetReset immediately on startup and then every 24 hours. Each budget is handled independently — if one reset fails, the error is logged and the loop continues to the next budget without cascading.
Step 12: Build the dashboard overview and budgets list
You’ll create the Next.js layout and the first two dashboard pages: the overview dashboard and the budgets list.
Update app/layout.tsx with the dashboard metadata:
tsx
import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css";const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });export const metadata: Metadata = { title: "Mistral AI Spend Governance | Dashboard", description: "Real-time budget monitoring and configuration for Mistral AI LLM spend",};export default function RootLayout({ children,}: Readonly<{ children: React.ReactNode }>) { return ( <html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}> <body>{children}</body> </html> );}
Replace app/page.tsx with the overview dashboard:
tsx
import Link from "next/link";import styles from "./page.module.css";const API = "http://localhost:3001";type BudgetState = "Active" | "Warned" | "Degraded" | "Stopped";interface Budget { scopeType: string; scopeKey: string; limit: number; spent: number; state: BudgetState; remaining: number;}async
Create app/budgets/page.tsx — a table listing all budgets:
tsx
import Link from "next/link";const API = "http://localhost:3001";interface Budget { scopeType: string; scopeKey: string; limit: number; spent: number; state: string; remaining: number;}async function fetchBudgets(): Promise<Budget[]> { try { const res = await fetch(`${
Expected output: The layout sets the page title and description. The overview dashboard fetches all budgets and renders five summary cards (Active, Warned, Degraded, Stopped, Total Spent Today) plus links to the budgets and spend pages. The budgets page shows a searchable table with color-coded state badges and links to detail pages.
Step 13: Build the budget detail and spend analysis pages
The budget detail page includes an update form and reset button using Server Actions ("use server"). The spend analysis page shows a per-tenant breakdown and anomaly alerts.
import Link from "next/link";import { revalidatePath } from "next/cache";const API = "http://localhost:3001";interface BudgetPolicy { softCap: number; hardCap: number; autoDowngrade?: string[]; disabledTools?: string[];}interface BudgetDetail { scopeType: string; scopeKey: string; limit: number; spent: number;
Create app/spend/page.tsx — spend analysis with per-tenant breakdown and anomaly alerts:
tsx
import Link from "next/link";const API = "http://localhost:3001";interface Budget { scopeType: string; scopeKey: string; limit: number; spent: number; state: string; remaining: number;}async function fetchBudgets(): Promise<Budget[]> { try { const res = await fetch(`${
Expected output: The detail page fetches budget state and spend history from the Express API, renders seven info cards in a grid, provides a form to update the budget policy (limit, soft cap, hard cap) via Server Actions, a reset button, and a spend history table. The spend analysis page shows aggregate stats, anomaly alerts from spike detection, and a per-tenant breakdown table with utilization percentages.
Step 14: Set up MSW mocks and write tests
You’ll use MSW (Mock Service Worker) to intercept all HTTP calls to Mistral AI and Stripe, making the test suite run without real API credentials.
Now create tests/services/budget-service.test.ts. The test uses vi.hoisted() and class-based mocks to replace all three REAA packages, then exercises every facade function:
Expected output: MSW interceptors for Mistral chat completions (both standard and streaming), Stripe subscriptions, customers, and usage records. The budget service test uses vi.hoisted() and class-based mocks to replace all three REAA packages. The integration test walks through the full Active → Warned → Degraded → Stopped state chain. The full test suite includes separate test files for routes, cron, library modules, and an integration test — all run without real API credentials.
Step 15: Run the tests
Run the full test suite to verify everything works.
terminal
pnpm typecheckpnpm lintpnpm test
Expected output: TypeScript type checking passes without errors. ESLint reports no violations. The vitest report shows zero failed tests and all four coverage metrics (lines, branches, functions, statements) at 90% or above. The coverage report is written to coverage/ as JSON, text, and JSON-summary formats.
Next steps
Add alerting. Wire the threshold-breach and hard-stop events to a notification service (email via SendGrid, Slack webhooks, or PagerDuty) so operators get real-time alerts when tenants approach their budget limits.
Add multi-provider support. Extend the PricingEngine to also register Anthropic and OpenAI pricing, then add those models to the router configuration so you can route across providers based on cost.
Deploy with Docker. Containerize the Express API and Next.js dashboard with a multi-stage Dockerfile, add a docker-compose.yml that runs both services plus a PostgreSQL database for persistent spend history.