Skip to content
/ solutions / perplexity-security-guardrails-for-quickbooks-online-invoice-processing Perplexity Security Guardrails for QuickBooks Online Invoice Processing Wrap every Perplexity‑powered invoice classification with PII redaction, financial policy enforcement, and full audit trails — so SMBs safely automate QuickBooks Online workflows.
The problem SMBs are eager to use AI to categorise invoices, detect duplicates, and flag anomalies from QuickBooks Online, but fear leaking sensitive vendor and customer PII or having the model hallucinate unauthorised posting actions.
Example artifact 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.
194 kB · 113 tests· 96.7% coverage· vitest passing
SHA-256 f43fcee99f7d600c4db5173ebdc19f0552d85ae6c9cc4871e17135133806da64 Comments Sign in to commentSign in with GitHub to comment and vote.
Las Vegas, Nevada USA © 2026 REAA Technologies Inc. — Open-Source AI Solutions for Small Business.
On this page Intro
This tutorial walks you through building a security guardrail layer for AI-powered invoice processing. You’ll create a Next.js API that fetches invoices from QuickBooks Online, passes them through a multi-stage guardrail chain (PII redaction, prompt injection detection, topic boundaries, cost budgeting), classifies them via Perplexity AI, and logs every decision to Langfuse for audit. By the end you’ll have a pipeline that keeps sensitive financial data safe while automating accounting workflows.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
A Perplexity API key (from perplexity.ai )
A QuickBooks Online sandbox account and OAuth 2.0 app credentials (from developer.intuit.com )
A Langfuse account for observability (from langfuse.com , or self-host)
Familiarity with Next.js App Router route handlers and TypeScript
Step 1: Scaffold and install dependencies
Create a new Next.js project and install the dependencies. This recipe pins every version exactly so results are reproducible.
npx create-next-app@16.2.9
perplexity-qbo-guardrails
--typescript
--eslint
--app
--src-dir
--no-tailwind
--import-alias
"@/*"
cd perplexity-qbo-guardrails
Add the core dependencies:
pnpm add @reaatech/guardrail-chain@0.1.0 @reaatech/guardrail-chain-guardrails@0.1.0 @reaatech/guardrail-chain-config@0.1.0 @reaatech/structured-repair-core@1.0.0
pnpm add @reaatech/llm-cost-telemetry@0.2.0 @reaatech/guardrail-chain-observability@0.1.0
pnpm add @presidio-dev/hai-guardrails@1.12.0 perplexity-sdk@1.0.4 langfuse@3.38.20 zod@4.4.3 p-retry@8.0.0
pnpm add intuit-oauth@4.2.3 @opentelemetry/api@1.9.1 @opentelemetry/sdk-node@0.219.0 Add the dev dependencies:
pnpm add -D vitest@4.1.9 @vitest/coverage-v8@4.1.9 msw@2.14.6
pnpm add -D @types/node@20.19.43 @types/react@19.2.17 @types/react-dom@19.2.3 Expected output: pnpm resolves all versions and writes a lockfile. Your package.json should now list each dependency with an exact version (no ^ or ~).
Step 2: Configure environment variables Create a .env.local file by copying the example. These variables wire every external service the application needs.
cp .env.example .env.local Open .env.local and fill in your real credentials. At minimum you need:
# Perplexity API
PERPLEXITY_API_KEY=<your-perplexity-api-key>
PERPLEXITY_MODEL=pplx-70b-online
PERPLEXITY_MAX_TOKENS=1024
# QuickBooks Online OAuth
QBO_CLIENT_ID=<your-intuit-client-id>
QBO_CLIENT_SECRET=<your-intuit-client-secret>
QBO_ENVIRONMENT=sandbox
QBO_REDIRECT_URI=http://localhost:3000/callback
QBO_COMPANY_ID=<your-quickbooks-company-id>
# Langfuse observability
LANGFUSE_SECRET_KEY=<your-langfuse-secret>
LANGFUSE_PUBLIC_KEY=<your-langfuse-public>
LANGFUSE_BASE_URL=https://cloud.langfuse.com
# OpenTelemetry
OTEL_SERVICE_NAME=perplexity-qbo-guardrails
OTEL_EXPORTER_OTLP_ENDPOINT=<your-otlp-endpoint>
# Cost telemetry
DEFAULT_DAILY_BUDGET=5.00
# Guardrail chain configuration
GUARDRAIL_CHAIN_BUDGET_MAX_LATENCY_MS=5000
GUARDRAIL_CHAIN_BUDGET_MAX_TOKENS=4000
# Node environment
NODE_ENV=development Expected output: The file is saved. The .env.example already lists every variable with angle-bracket placeholders — you replace them with your own values.
Step 3: Define the Zod schemas for invoices Create the invoice and classification type definitions. These schemas validate both QuickBooks API responses and Perplexity outputs.
import { z } from "zod" ;
export const InvoiceLineSchema = z. object ({
Id: z. number (). optional (),
LineNum: z. number (). optional (),
Description: z. string (). optional (),
Amount: z. number (),
DetailType: z. string (). optional (),
SalesItemLineDetail: z. object ({
ItemRef: z. object ({ name: z. string (). optional (), value: z. string () }). optional (),
UnitPrice: z. number (). optional (),
Qty: z. number (). optional (),
TaxCodeRef: z. object ({ value: z. string () }). optional (),
}). optional (),
});
export const InvoiceSchema = z. object ({
Id: z. string (),
SyncToken: z. string (). optional (),
DocNumber: z. string (). optional (),
TxnDate: z. string (). optional (),
CustomerRef: z. object ({ name: z. string (). optional (), value: z. string () }),
Line: z. array (InvoiceLineSchema),
DueDate: z. string (). optional (),
TotalAmt: z. number (),
Balance: z. number (). optional (),
CurrencyRef: z. object ({ value: z. string (), name: z. string (). optional () }). optional (),
VendorRef: z. object ({ name: z. string (). optional (), value: z. string () }). optional (),
EmailStatus: z. string (). optional (),
BillAddr: z. object ({ Line1: z. string (). optional (), City: z. string (). optional (), Country: z. string (). optional (), PostalCode: z. string (). optional () }). optional (),
});
export type InvoiceLine = z . infer < typeof InvoiceLineSchema>;
export type Invoice = z . infer < typeof InvoiceSchema>; src/types/invoice-classification.ts:
import { z } from "zod" ;
export const InvoiceClassificationSchema = z. object ({
category: z. string (),
confidence: z. number (). min ( 0 ). max ( 1 ),
department: z. string (). optional (),
taxCode: z. string (). optional (),
dueDate: z. string (). optional (),
notes: z. string (). optional (),
});
export type InvoiceClassification = z . infer < typeof InvoiceClassificationSchema>; Expected output: TypeScript type-checks these files with no errors.
Step 4: Create the error classes and constants Custom error types give each failure mode a distinct identity for debugging and monitoring.
export class InvoiceFetchError extends Error {
constructor (
message : string ,
public readonly statusCode ?: number ,
public readonly intuitTid ?: string ,
) {
super(message);
this.name = "InvoiceFetchError" ;
}
}
export class GuardrailRejectionError extends Error {
constructor (
message : string ,
public readonly guardrailId : string ,
public readonly violationType : string ,
) {
super(message);
this.name = "GuardrailRejectionError" ;
}
}
export class PerplexityApiError extends Error {
constructor (
message : string ,
public readonly statusCode : number ,
public readonly responseBody ?: string ,
) {
super(message);
this.name = "PerplexityApiError" ;
}
}
export class BudgetExceededError extends Error {
constructor (
message : string ,
public readonly budgetState ?: Record < string , unknown >,
) {
super(message);
this.name = "BudgetExceededError" ;
}
}
export class OAuthError extends Error {
constructor (
message : string ,
public readonly errorCode ?: string ,
public readonly intuitTid ?: string ,
) {
super(message);
this.name = "OAuthError" ;
}
}
export class RepairFailedError extends Error {
constructor (
message : string ,
public readonly originalInput ?: string ,
public readonly fieldErrors ?: Array <{ path : string ; message : string }>,
) {
super(message);
this.name = "RepairFailedError" ;
}
} export const PERPLEXITY_MODEL = "pplx-70b-online" ;
export const DEFAULT_MAX_TOKENS = 1024 ;
export const INVOICE_BUDGET_USD = 0.05 ;
export const DEFAULT_MAX_RETRIES = 3 ;
export const PERPLEXITY_API_BASE = "https://api.perplexity.ai" ;
export const PERPLEXITY_CHAT_ENDPOINT = `${ PERPLEXITY_API_BASE }/chat/completions` ;
export const QBO_MINOR_VERSION = 65 ;
export const GUARDRAIL_CHAIN_DEFAULT_PREFIX = "GUARDRAIL_CHAIN" ;
export const QBO_API_BASE_SANDBOX = "https://sandbox-quickbooks.api.intuit.com" ;
export const QBO_API_BASE_PRODUCTION = "https://quickbooks.api.intuit.com" ;
export const DEFAULT_DAILY_BUDGET = 5.0 ; Expected output: Both files type-check cleanly. The error classes extend Error with structured payloads covering OAuth failures, repair failures, and budget violations.
Step 5: Build the QuickBooks Online service This service authenticates via OAuth 2.0 and fetches invoices from the Intuit QuickBooks API.
src/services/qbo-auth.ts:
import OAuthClient from "intuit-oauth" ;
import pRetry, { AbortError } from "p-retry" ;
export interface TokenData {
accessToken : string ;
refreshToken : string ;
expiresIn : number ;
tokenType : string ;
realmId ?: string ;
idToken ?: string ;
}
export class QboAuthService {
private client : OAuthClient ;
constructor (config : {
clientId : string ;
clientSecret : string ;
environment : "sandbox" | "production" ;
redirectUri : string ;
}) {
this.client = new OAuthClient ({
clientId: config.clientId,
clientSecret: config.clientSecret,
environment: config.environment,
redirectUri: config.redirectUri,
logging: false ,
});
}
getAuthorizationUri (state : string ) : string {
return this.client. authorizeUri ({
scope: [OAuthClient.scopes.Accounting],
state,
});
}
async createToken (callbackUrl : string ) : Promise < TokenData > {
return pRetry (
async () => {
const authResponse = await this.client. createToken (callbackUrl);
const token = authResponse. getToken ();
return token as TokenData ;
},
{ retries: 3 , onFailedAttempt : (err) => { console. warn ( "createToken retry" , err.attemptNumber); } }
);
}
async refreshToken () : Promise < TokenData > {
return pRetry (
async () => {
if ( ! this.client. isAccessTokenValid ()) {
const authResponse = await this.client. refresh ();
const token = authResponse. getToken ();
return token as TokenData ;
}
throw new AbortError ( "Token still valid, no refresh needed" );
},
{ retries: 2 }
);
}
isAccessTokenValid () : boolean {
return this.client. isAccessTokenValid ();
}
setToken (tokenData : Record < string , unknown >) : void {
this.client. setToken (tokenData);
}
async revokeToken () : Promise < void > {
await this.client. revoke ();
}
getClient () : OAuthClient {
return this.client;
}
} src/services/qbo-invoice.ts:
import OAuthClient from "intuit-oauth" ;
import { InvoiceSchema, type Invoice } from "../types/invoice.js" ;
import type { InvoiceClassification } from "../types/invoice-classification.js" ;
import { InvoiceFetchError } from "../lib/errors.js" ;
import { QBO_API_BASE_SANDBOX, QBO_API_BASE_PRODUCTION, QBO_MINOR_VERSION } from "../lib/constants.js" ;
interface QBOInvoiceResponse {
Invoice ?: Record < string , unknown >;
}
interface QBOQueryResponse {
QueryResponse ?: {
Invoice ?: Record < string , unknown >[];
Expected output: TypeScript checks these services with no errors. The QboAuthService wraps intuit-oauth with retry logic; the QuickBooksInvoiceService fetches, validates, and writes back invoices through a Zod schema, with support for both single-invoice fetch and query-based batch retrieval.
Step 6: Build the security guardrail service with PII redaction This is the core security layer. It uses the presidio guardrails to detect prompt injection, PII, and secrets, and wraps the REAA PIIRedaction guardrail for mask-in-place redaction.
src/services/security-guardrail-service.ts:
import {
injectionGuard,
GuardrailsEngine,
piiGuard,
secretGuard,
SelectionType,
} from "@presidio-dev/hai-guardrails" ;
import { type Guardrail, type GuardrailResult, type ChainContext, createChainContext } from "@reaatech/guardrail-chain" ;
import { PIIRedaction } from "@reaatech/guardrail-chain-guardrails" ;
interface GuardrailsMessage {
role : string ;
content : string ;
id ?: string ;
passed ?: boolean ;
}
export class SecurityGuardrailService implements Guardrail< string , string > {
readonly id = "security-guardrail" ;
readonly name = "Security Guardrail" ;
readonly type = "input" as const ;
enabled = true ;
private engine : GuardrailsEngine ;
constructor () {
this.engine = new GuardrailsEngine ({
guards: [
injectionGuard ({ roles: [ "user" ] }, { mode: "heuristic" , threshold: 0.7 }),
piiGuard ({ selection: SelectionType.All }),
secretGuard ({ selection: SelectionType.All }),
],
});
}
async execute (input : string , context : ChainContext ) : Promise < GuardrailResult < string >> {
void context;
const start = Date. now ();
const result = await this. runPreCheck ([{ role: "user" , content: input }]);
const duration = Date. now () - start;
if (result.passed) {
return { passed: true , output: input, metadata: { duration } };
}
return {
passed: false ,
output: input,
error: new Error ( "Security guardrail rejected input" ),
metadata: { duration, violations: result.results },
};
}
async redactPii (text : string ) : Promise < string > {
const redactor = new PIIRedaction ({ redactionStrategy: "mask" });
const ctx = createChainContext (text, { maxLatencyMs: 1000 , maxTokens: 1000 });
const result = await redactor. execute (text, ctx);
return result.output ?? text;
}
async runPreCheck (messages : GuardrailsMessage []) : Promise <{ passed : boolean ; results : GuardrailsMessage [] }> {
const results = await this.engine. run (messages);
const passed = (results.messages as GuardrailsMessage [])[ 0 ]?.passed === true ;
return { passed, results: results.messages };
}
async runPostCheck (messages : GuardrailsMessage []) : Promise <{ passed : boolean ; results : GuardrailsMessage [] }> {
const results = await this.engine. run (messages);
const passed = (results.messages as GuardrailsMessage [])[ 0 ]?.passed === true ;
return { passed, results: results.messages };
}
} Expected output: The service uses presidio’s injectionGuard, piiGuard, and secretGuard to scan every message, and the PIIRedaction from @reaatech/guardrail-chain-guardrails to mask emails, phones, and SSNs inline.
Step 7: Create the cost budget guardrail This guardrail prevents runaway spending by checking daily budget before every inference call.
src/services/cost-budget-guardrail.ts:
import { type Guardrail, type GuardrailResult, type ChainContext } from "@reaatech/guardrail-chain" ;
import { CostTelemetryService } from "./cost-telemetry-service.js" ;
export class CostBudgetGuardrail implements Guardrail< string , string > {
readonly id = "cost-budget" ;
readonly name = "Cost Budget Guardrail" ;
readonly type = "input" as const ;
enabled = true ;
constructor (
private readonly costService : CostTelemetryService ,
private readonly tenant : string ,
) {}
execute (input : string , _context : ChainContext ) : Promise < GuardrailResult < string >> {
void _context;
try {
this.costService. checkBudget (this.tenant);
return Promise . resolve ({ passed: true , output: input });
} catch (e : unknown ) {
return Promise . resolve ({
passed: false ,
output: input,
error: e instanceof Error ? e : new Error ( `Budget exceeded for tenant ${ this . tenant }` ),
});
}
}
} Expected output: If the daily budget is exhausted, this guardrail rejects the request before it reaches Perplexity.
Step 8: Build the guardrail chain service The guardrail chain ties everything together — it validates config, registers all guardrails in order, adds circuit-breaking, caching, and retry logic.
src/services/guardrail-chain-service.ts:
import {
GuardrailChain,
ChainBuilder,
withRetry,
CircuitBreaker,
LRUCache,
type ChainResult,
type ExecutionOptions,
} from "@reaatech/guardrail-chain" ;
import {
CachedGuardrail,
PIIRedaction,
PromptInjection,
ToxicityFilter,
PIIScan,
HallucinationCheck,
TopicBoundary,
CostPrecheck,
} from "@reaatech/guardrail-chain-guardrails" ;
import { validateConfig } from "@reaatech/guardrail-chain-config" ;
import { repair, UnrepairableError, type RepairResult } from "@reaatech/structured-repair-core" ;
import { type z }
Expected output: The chain runs guardrails in order: security → cost budget → PII redaction → prompt injection → topic boundary → cost precheck → PII scan → hallucination check → toxicity filter. Slow guardrails are skipped under pressure.
Step 9: Build the Perplexity classifier with circuit-breaking This service sends invoice data to Perplexity, applies structured repair on the response, and caches results. Because perplexity-sdk is a CJS-only package, the import uses createRequire for ESM interop.
src/services/perplexity-service.ts:
import { type z } from "zod" ;
import { createRequire } from "module" ;
import { repair } from "@reaatech/structured-repair-core" ;
import { type Invoice } from "../types/invoice.js" ;
import { CircuitBreaker, LRUCache } from "@reaatech/guardrail-chain" ;
import { PerplexityApiError } from "../lib/errors.js" ;
import { PERPLEXITY_MODEL, DEFAULT_MAX_TOKENS } from "../lib/constants.js" ;
import pRetry from "p-retry" ;
interface PerplexityChatMessage {
role : string ;
content : string ;
Expected output: The classifier uses p-retry for network resilience, CircuitBreaker for API health, and repair from @reaatech/structured-repair-core to fix malformed JSON before returning it. The classifyInvoiceWithRetry method adds an extra retry layer on top of the internal retry + circuit breaker.
Step 10: Add cost telemetry This service tracks per-tenant spending and enforces daily and monthly budgets.
src/services/cost-telemetry-service.ts:
import { generateId, now, calculateCostFromTokens, loadConfig, type CostSpan, type BudgetConfig } from "@reaatech/llm-cost-telemetry" ;
import { BudgetExceededError } from "../lib/errors.js" ;
import type { BudgetState } from "../types/budget.js" ;
import { DEFAULT_DAILY_BUDGET } from "../lib/constants.js" ;
type ServiceBudgetConfig = BudgetConfig | { dailyBudgetUsd : number ; monthlyBudgetUsd ?: number };
export class CostTelemetryService {
private dailySpent : Map < string , number > = new Map ();
private today : string = new Date (). toISOString (). split ( "T" )[ 0 ];
private readonly dailyBudgetUsd : number ;
private readonly monthlyBudgetUsd ?: number ;
constructor (config : ServiceBudgetConfig ) {
loadConfig ();
if ( "dailyBudgetUsd" in config) {
this.dailyBudgetUsd = config.dailyBudgetUsd;
this.monthlyBudgetUsd = (config as { monthlyBudgetUsd ?: number }).monthlyBudgetUsd;
} else {
this.dailyBudgetUsd = config.global?.daily ?? DEFAULT_DAILY_BUDGET;
this.monthlyBudgetUsd = config.global?.monthly;
}
}
recordSpan (provider : string , model : string , inputTokens : number , outputTokens : number , feature : string , tenant : string ) : CostSpan {
const totalTokens = inputTokens + outputTokens;
const costUsd = calculateCostFromTokens (totalTokens, 30 );
const span : CostSpan = {
id: generateId (),
provider: provider as CostSpan [ "provider" ],
model,
inputTokens,
outputTokens,
costUsd,
tenant,
feature,
timestamp: now (),
};
this. updateDailySpend (tenant, costUsd);
return span;
}
private updateDailySpend (tenant : string , costUsd : number ) : void {
const currentDate = new Date (). toISOString (). split ( "T" )[ 0 ];
if (currentDate !== this.today) {
this.dailySpent. clear ();
this.today = currentDate;
}
const current = this.dailySpent. get (tenant) ?? 0 ;
this.dailySpent. set (tenant, current + costUsd);
}
checkBudget (tenant : string ) : BudgetState {
const spentToday = this.dailySpent. get (tenant) ?? 0 ;
const remaining = this.dailyBudgetUsd - spentToday;
if (remaining <= 0 ) {
throw new BudgetExceededError (
`Daily budget exceeded for tenant ${ tenant }` ,
{ tenant, dailyBudgetUsd: this.dailyBudgetUsd, dailySpentUsd: spentToday },
);
}
return {
tenant,
dailyBudgetUsd: this.dailyBudgetUsd,
dailySpentUsd: spentToday,
remainingDailyUsd: remaining,
lastResetDate: this.today,
};
}
getBatchBudget (invoiceCount : number ) : { maxCostUsd : number ; currentCostUsd : number ; remainingUsd : number ; invoiceCount : number } {
const perInvoiceBudget = this.dailyBudgetUsd / Math. max (invoiceCount, 1 );
return {
maxCostUsd: perInvoiceBudget * invoiceCount,
currentCostUsd: 0 ,
remainingUsd: perInvoiceBudget * invoiceCount,
invoiceCount,
};
}
} import { z } from "zod" ;
export const BudgetStateSchema = z. object ({
tenant: z. string (),
dailyBudgetUsd: z. number (),
dailySpentUsd: z. number (),
monthlyBudgetUsd: z. number (). optional (),
monthlySpentUsd: z. number (). optional (),
remainingDailyUsd: z. number (),
remainingMonthlyUsd: z. number (). optional (),
lastResetDate: z. string (),
});
export type BudgetState = z . infer < typeof BudgetStateSchema>;
export interface InvoiceBudget {
maxCostUsd : number ;
currentCostUsd : number ;
remainingUsd : number ;
invoiceCount : number ;
} Expected output: The telemetry service records each LLM call and throws BudgetExceededError when the daily limit is reached. It supports both daily and monthly budget caps, and getBatchBudget pre-allocates budget for bulk invoice processing.
Step 11: Wire the Langfuse audit trail Every step of invoice processing gets logged to Langfuse for audit and debugging.
src/services/audit-service.ts:
import { Langfuse } from "langfuse" ;
import type { AuditActionType } from "../types/audit.js" ;
export class AuditService {
private client : Langfuse ;
constructor (config : { secretKey : string ; publicKey : string ; baseUrl ?: string }) {
this.client = new Langfuse ({
secretKey: config.secretKey,
publicKey: config.publicKey,
baseUrl: config.baseUrl,
});
}
traceGuardrail (action : AuditActionType , details : Record < string , unknown >) : void {
try {
this.client. trace ({ name: action, metadata: details });
} catch (err) {
console. warn ( "Langfuse trace failed (non-blocking):" , (err as Error ).message);
}
}
traceInvoiceProcessing (invoiceId : string , steps : Array <{ action : AuditActionType ; details ?: Record < string , unknown > }>) : void {
try {
const trace = this.client. trace ({
name: "invoice-processing" ,
input: { invoiceId },
metadata: { stepCount: steps.length },
});
for ( const step of steps) {
trace. span ({ name: step.action, metadata: step.details });
}
} catch (err) {
console. warn ( "Langfuse trace failed (non-blocking):" , (err as Error ).message);
}
}
traceBudgetViolation (budgetState : Record < string , unknown >) : void {
try {
this.client. trace ({ name: "budget-violation" , metadata: budgetState });
} catch (err) {
console. warn ( "Langfuse trace failed (non-blocking):" , (err as Error ).message);
}
}
} import { z } from "zod" ;
export const AuditAction = {
INVOICE_FETCHED: "INVOICE_FETCHED" ,
GUARDRAIL_RAN: "GUARDRAIL_RAN" ,
PROMPT_SENT: "PROMPT_SENT" ,
OUTPUT_REPAIRED: "OUTPUT_REPAIRED" ,
CLASSIFICATION_COMPLETE: "CLASSIFICATION_COMPLETE" ,
} as const ;
export type AuditActionType = ( typeof AuditAction)[ keyof typeof AuditAction];
export const AuditEventSchema = z. object ({
id: z. string (),
action: z. enum ([AuditAction.INVOICE_FETCHED, AuditAction.GUARDRAIL_RAN, AuditAction.PROMPT_SENT, AuditAction.OUTPUT_REPAIRED, AuditAction.CLASSIFICATION_COMPLETE]),
invoiceId: z. string (),
timestamp: z. string (),
details: z. record (z. string (), z. unknown ()). optional (),
durationMs: z. number (). optional (),
tenant: z. string (). optional (),
});
export type AuditEvent = z . infer < typeof AuditEventSchema>; Expected output: Langfuse receives a trace with child spans for each pipeline step. Failures are non-blocking — the pipeline keeps running even if Langfuse is down.
Step 12: Create the API route handler The main POST /api/process-invoice endpoint orchestrates every component: Zod validation, QBO fetch, guardrail chain, Perplexity classification, cost telemetry, and Langfuse audit.
app/api/process-invoice/route.ts:
import { type NextRequest, NextResponse } from "next/server" ;
import { z } from "zod" ;
import OAuthClient from "intuit-oauth" ;
import { InvoiceSchema } from "../../../src/types/invoice.js" ;
import { InvoiceClassificationSchema } from "../../../src/types/invoice-classification.js" ;
import { QuickBooksInvoiceService } from "../../../src/services/qbo-invoice.js" ;
import { SecurityGuardrailService } from "../../../src/services/security-guardrail-service.js" ;
import { GuardrailChainService } from "../../../src/services/guardrail-chain-service.js" ;
import { CostTelemetryService } from "../../../src/services/cost-telemetry-service.js" ;
import { AuditService } from "../../../src/services/audit-service.js" ;
Expected output: Sending a POST to /api/process-invoice with { invoiceId: "123", companyId: "c1" } triggers the full pipeline. The classification response is serialized before post-call guardrails inspect it. On guardrail rejection you get a 422 with guardrailViolations details.
Step 13: Enable OpenTelemetry instrumentation The instrumentation hook starts the OpenTelemetry SDK on server startup so every span is exported.
import type { NextConfig } from "next" ;
const nextConfig : NextConfig = {
experimental: {
instrumentationHook: true ,
} as NextConfig [ "experimental" ],
};
export default nextConfig; export async function register () : Promise < void > {
if (process.env.NEXT_RUNTIME === "nodejs" ) {
const { NodeSDK } = await import ( "@opentelemetry/sdk-node" );
const sdk = new NodeSDK ({
serviceName: process.env.OTEL_SERVICE_NAME ?? "perplexity-qbo-guardrails" ,
});
try {
sdk. start ();
console. log ( "OTel SDK initialized" );
} catch (err) {
console. error ( "Failed to initialize OTel SDK" , err);
}
}
} Add the admin routes for inspecting budget and violations at runtime:
app/api/admin/budget/route.ts:
import { NextResponse } from "next/server" ;
let budgetState = {
dailyBudgetUsd: 5.0 ,
dailySpentUsd: 0 ,
remainingDailyUsd: 5.0 ,
tenant: "default" ,
};
export function updateBudgetState (state : typeof budgetState) : void {
budgetState = state;
}
export function GET () {
return NextResponse. json (budgetState);
} app/api/admin/violations/route.ts:
import { NextResponse } from "next/server" ;
const violationsStore : Array <{
guardrailId : string ;
guardrailName : string ;
violationType : string ;
severity : string ;
message : string ;
timestamp : string ;
}> = [];
export function addViolation (violation : typeof violationsStore[number]) : void {
violationsStore. push (violation);
}
export function GET () {
return NextResponse. json ({
violations: violationsStore,
total: violationsStore.length,
});
} Expected output: The instrumentation hook fires on server startup, registering the OTel SDK. The admin routes let you check budget state and violations at /api/admin/budget and /api/admin/violations.
Step 14: Write and run the tests Tests verify the guardrail works, the QBO service fetches correctly, and the API returns proper error codes for every failure mode. Here’s the integration test for the route handler:
tests/api/api-process-invoice.test.ts:
import { describe, it, expect, vi } from "vitest" ;
import { NextRequest } from "next/server" ;
const mockState = vi. hoisted (() => ({ passed: true }));
vi. mock ( "intuit-oauth" , () => {
function OAuthClient () {
return {
authorizeUri: vi. fn (). mockReturnValue ( "https://test.com/auth" ),
createToken: vi. fn (). mockResolvedValue ({ getToken : () => ({ accessToken: "test" }) }),
refresh: vi. fn (). mockResolvedValue ({ getToken : ()
pnpm vitest run --coverage --reporter=json --outputFile=vitest-report.json Expected output: All tests pass. The test suite covers validation errors (missing invoiceId, missing companyId), guardrail rejection (422), malformed JSON bodies, non-Error throws, and the happy path with a valid classification response. A separate GET describe block verifies the health endpoint.
Next steps
Add a dashboard UI — Build a Next.js page under app/dashboard/ that shows live budget usage, recent violations, and a stream of processed invoices.
Extend the guardrail chain — Add a SensitiveDataScan guardrail that detects credit card numbers or bank routing numbers in invoice line items.
Batch processing — Use getBatchBudget from the cost telemetry service to pre-allocate budget for nightly batch runs of 50+ invoices.
Multi-tenant isolation — Replace the in-memory violationsStore and budget state with a database (SQLite for small deployments, Postgres for production) so state survives restarts.
Admin webhook notifications — Wire a route that POSTs guardrail violation summaries to Slack or email when severity is “high” or “critical”.
};
}
export class QuickBooksInvoiceService {
constructor (
private readonly oauthClient : OAuthClient ,
private readonly companyId : string ,
private readonly environment : "sandbox" | "production" = "sandbox" ,
) {}
private get baseUrl () : string {
return this.environment === "sandbox" ? QBO_API_BASE_SANDBOX : QBO_API_BASE_PRODUCTION;
}
async fetchInvoice (invoiceId : string ) : Promise < Invoice > {
try {
const response = await this.oauthClient. makeApiCall ({
url: `${ this . baseUrl }/v3/company/${ this . companyId }/invoice/${ invoiceId }?minorversion=${ String ( QBO_MINOR_VERSION ) }` ,
method: "GET" ,
});
return InvoiceSchema. parse ((response.json as QBOInvoiceResponse ).Invoice);
} catch (err : unknown ) {
if (err instanceof InvoiceFetchError ) throw err;
throw new InvoiceFetchError (
`Failed to fetch invoice ${ invoiceId }: ${ ( err as Error ). message }` ,
(err as { status ?: number }).status,
);
}
}
async fetchRecentInvoices (maxResults : number ) : Promise < Invoice []> {
try {
const query = `SELECT * FROM Invoice MAXRESULTS ${ String ( maxResults ) } ORDERBY TxnDate DESC` ;
const response = await this.oauthClient. makeApiCall ({
url: `${ this . baseUrl }/v3/company/${ this . companyId }/query?query=${ encodeURIComponent ( query ) }&minorversion=${ String ( QBO_MINOR_VERSION ) }` ,
method: "GET" ,
});
const queryResponse = (response.json as QBOQueryResponse ).QueryResponse;
return (queryResponse?.Invoice ?? []) as Invoice [];
} catch (err : unknown ) {
throw new InvoiceFetchError (
`Failed to fetch recent invoices: ${ ( err as Error ). message }` ,
(err as { status ?: number }).status,
);
}
}
validateResponse (response : { status ?: number ; statusText ?: string }) : void {
if (response.status && response.status >= 400 ) {
throw new InvoiceFetchError (
`QBO API returned status ${ String ( response . status ) }: ${ response . statusText ?? "Unknown error"}` ,
response.status,
);
}
}
async classifyAndUpdateInvoice (invoiceId : string , classification : InvoiceClassification ) : Promise < void > {
try {
await this.oauthClient. makeApiCall ({
url: `${ this . baseUrl }/v3/company/${ this . companyId }/invoice/${ invoiceId }?minorversion=${ String ( QBO_MINOR_VERSION ) }` ,
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON. stringify ({ sparse: true , ... classification }),
});
} catch (err : unknown ) {
throw new InvoiceFetchError (
`Failed to update invoice ${ invoiceId }: ${ ( err as Error ). message }` ,
(err as { status ?: number }).status,
);
}
}
}
from
"zod"
;
import { SecurityGuardrailService } from "./security-guardrail-service.js" ;
import { CostBudgetGuardrail } from "./cost-budget-guardrail.js" ;
import { CostTelemetryService } from "./cost-telemetry-service.js" ;
export class GuardrailChainService {
private chain : GuardrailChain ;
private breaker : CircuitBreaker ;
private cache : LRUCache < string , ChainResult >;
constructor (config : { maxLatencyMs ?: number ; maxTokens ?: number ; maxRetries ?: number }) {
const validated = validateConfig ({
budget: {
maxLatencyMs: config.maxLatencyMs ?? 5000 ,
maxTokens: config.maxTokens ?? 4000 ,
skipSlowGuardrailsUnderPressure: true ,
},
});
this.breaker = new CircuitBreaker ( "perplexity-api" , {
failureThreshold: 5 ,
resetTimeoutMs: 30_000 ,
successThreshold: 3 ,
});
this.cache = new LRUCache < string , ChainResult >({ maxSize: 100 , ttlMs: 300_000 });
const costService = new CostTelemetryService ({ dailyBudgetUsd: 5.0 });
this.chain = new ChainBuilder ()
. withBudget (validated.budget)
. withGuardrail ( new SecurityGuardrailService ())
. withGuardrail ( new CostBudgetGuardrail (costService, "default" ))
. withGuardrail ( new CachedGuardrail ( new PIIRedaction ({ redactionStrategy: "mask" }), { ttlMs: 300_000 }))
. withGuardrail ( new CachedGuardrail ( new PromptInjection (), { ttlMs: 60_000 }))
. withGuardrail ( new TopicBoundary ({
allowedTopics: [ "invoice" , "accounting" , "finance" , "vendor" , "customer" , "payment" , "tax" ],
blockedTopics: [ "politics" , "religion" , "gambling" , "illegal" ],
}))
. withGuardrail ( new CostPrecheck ({ maxTokens: validated.budget.maxTokens }))
. withGuardrail ( new PIIScan ())
. withGuardrail ( new HallucinationCheck ())
. withGuardrail ( new ToxicityFilter ())
. withSlowGuardrailSkipping ( true )
. withErrorHandling ({ failOpen: true , maxRetries: config.maxRetries ?? 2 , retryDelayMs: 500 })
. build ();
}
async execute (prompt : string , context ?: ExecutionOptions ) : Promise < ChainResult > {
return withRetry (
() => this.breaker. execute (() => this.chain. execute (prompt, context)),
undefined ,
{ maxRetries: 2 , initialDelayMs: 200 }
);
}
async validateOutput < T >(schema : z . ZodType < T >, raw : string ) : Promise < RepairResult < T >> {
try {
const data = await repair (schema, raw);
return { success: true , data, originalInput: raw, steps: [], errors: [] };
} catch (err) {
const error = err instanceof UnrepairableError ? err : new Error ( String (err));
return {
success: false ,
data: null ,
originalInput: raw,
steps: [],
errors: [{ code: "unrepairable" , message: error.message }],
};
}
}
getCircuitBreaker () : CircuitBreaker {
return this.breaker;
}
getCache () : LRUCache < string , ChainResult > {
return this.cache;
}
}
}
interface PerplexityChatChoice {
message ?: { content ?: string };
finish_reason ?: string ;
}
interface PerplexityChatResponse {
choices ?: PerplexityChatChoice [];
usage ?: { prompt_tokens ?: number ; completion_tokens ?: number };
}
interface PerplexityClient {
chatCompletionsPost (params : {
model : string ;
messages : PerplexityChatMessage [];
}) : Promise < PerplexityChatResponse >;
}
interface PerplexitySdkWrapper {
client () : PerplexityClient ;
}
type PerplexityConstructor = new (config : { apiKey : string }) => PerplexitySdkWrapper ;
interface PerplexitySdkModule {
default : PerplexityConstructor ;
ChatCompletionsPostRequestModelEnum : Record < string , string >;
}
const _require = createRequire ( import .meta.url);
const sdkModule = _require ( "perplexity-sdk" ) as PerplexitySdkModule ;
const PerplexitySdk = sdkModule;
const ChatCompletionsPostRequestModelEnum = sdkModule.ChatCompletionsPostRequestModelEnum;
export { ChatCompletionsPostRequestModelEnum };
export class PerplexityInvoiceClassifier {
private client : PerplexityClient ;
private model : string ;
private maxTokens : number ;
private breaker : CircuitBreaker ;
private cache : LRUCache < string , unknown >;
constructor (
config : { apiKey : string ; model ?: string ; maxTokens ?: number },
deps ?: { client ?: PerplexityClient },
) {
if (deps?.client) {
this.client = deps.client;
} else {
const sdk = new PerplexitySdk. default ({ apiKey: config.apiKey });
this.client = sdk. client ();
}
this.model = config.model ?? PERPLEXITY_MODEL;
this.maxTokens = config.maxTokens ?? DEFAULT_MAX_TOKENS;
this.breaker = new CircuitBreaker ( "perplexity-api" , {
failureThreshold: 3 ,
resetTimeoutMs: 30_000 ,
successThreshold: 3 ,
});
this.cache = new LRUCache < string , unknown >({ maxSize: 100 , ttlMs: 300_000 });
}
async classifyInvoice < T >(invoice : Invoice , prompt : string , schema : z . ZodType < T >) : Promise < T > {
const invoiceJson = JSON. stringify (invoice);
const cacheKey = `${ this . model }:${ prompt }:${ invoiceJson . substring ( 0 , 100 ) }` ;
const cached = this.cache. get (cacheKey);
if (cached) return cached as T ;
const raw = await pRetry (
async () => {
const result = await this.breaker. execute (() =>
this.client. chatCompletionsPost ({
model: this.model,
messages: [
{ role: "system" , content: "You are an invoice classification assistant. Return valid JSON only." },
{ role: "user" , content: `${ prompt }\n\nInvoice data:\n${ invoiceJson }` },
],
}),
);
return result;
},
{ retries: 3 },
);
const content = raw.choices?.[ 0 ]?.message?.content;
if ( ! content) {
throw new PerplexityApiError ( "Empty response from Perplexity" , 200 );
}
const repaired = await repair (schema, content);
this.cache. set (cacheKey, repaired);
return repaired;
}
async classifyInvoiceWithRetry < T >(invoice : Invoice , prompt : string , schema : z . ZodType < T >) : Promise < T > {
return pRetry (
() => this. classifyInvoice (invoice, prompt, schema),
{ retries: 3 },
);
}
getCircuitBreaker () : CircuitBreaker {
return this.breaker;
}
getCache () : LRUCache < string , unknown > {
return this.cache;
}
}
import { PerplexityInvoiceClassifier } from "../../../src/services/perplexity-service.js" ;
const ProcessInvoiceBodySchema = z. object ({
invoiceId: z. string (). min ( 1 ),
companyId: z. string (). min ( 1 ),
prompt: z. string (). optional (),
});
export async function POST (req : NextRequest ) {
try {
const parsed = ProcessInvoiceBodySchema. parse ( await req. json ());
const { invoiceId, companyId, prompt } = parsed;
const env = process.env;
// QuickBooks service — fetch the invoice
const qboService = new QuickBooksInvoiceService (
new OAuthClient ({
clientId: env.QBO_CLIENT_ID ?? "" ,
clientSecret: env.QBO_CLIENT_SECRET ?? "" ,
environment: (env.QBO_ENVIRONMENT ?? "sandbox" ) as "sandbox" | "production" ,
redirectUri: env.QBO_REDIRECT_URI ?? "http://localhost:3000/callback" ,
}),
companyId,
);
const rawInvoice = await qboService. fetchInvoice (invoiceId);
const invoice = InvoiceSchema. parse (rawInvoice);
// Pre-call guardrails — redact PII, check policy
const securityService = new SecurityGuardrailService ();
const guardrailService = new GuardrailChainService ({ maxLatencyMs: 5000 , maxTokens: 4000 });
const userPrompt = prompt ?? `Classify invoice ${ invoiceId } for accounting` ;
const redactedPrompt = await securityService. redactPii (userPrompt);
const preCheck = await securityService. runPreCheck ([{ role: "user" , content: redactedPrompt }]);
if ( ! preCheck.passed) {
return NextResponse. json ({ error: "Pre-call guardrail rejection" , guardrailViolations: preCheck.results }, { status: 422 });
}
await guardrailService. execute (redactedPrompt);
// Classify via Perplexity
const classifier = new PerplexityInvoiceClassifier ({ apiKey: env.PERPLEXITY_API_KEY ?? "" });
const classification = await classifier. classifyInvoice (invoice, userPrompt, InvoiceClassificationSchema);
// Serialize classification for post-call guardrails
const classificationJson = JSON. stringify (classification);
// Post-call guardrails — scan output
await securityService. runPostCheck ([{ role: "assistant" , content: classificationJson }]);
// Cost telemetry
const costService = new CostTelemetryService ({ dailyBudgetUsd: 5.0 });
costService. recordSpan ( "perplexity" , "sonar-pro" , 100 , 50 , "invoice-classification" , companyId);
const budgetState = costService. checkBudget (companyId);
// Langfuse audit
const auditService = new AuditService ({
secretKey: env.LANGFUSE_SECRET_KEY ?? "" ,
publicKey: env.LANGFUSE_PUBLIC_KEY ?? "" ,
});
auditService. traceInvoiceProcessing (invoiceId, [
{ action: "INVOICE_FETCHED" , details: { invoiceId } },
{ action: "GUARDRAIL_RAN" , details: { passed: preCheck.passed } },
{ action: "CLASSIFICATION_COMPLETE" , details: { classification } },
]);
return NextResponse. json ({
classification,
invoice,
auditTraceId: `audit-${ String ( Date . now ()) }` ,
budget: budgetState,
message: `Invoice ${ invoiceId } processed for company ${ companyId }` ,
});
} catch (err) {
if (err instanceof z . ZodError ) {
return NextResponse. json ({ error: "Validation failed" , details: err.issues }, { status: 400 });
}
const message = err instanceof Error ? err.message : "Unknown error" ;
return NextResponse. json ({ error: message }, { status: 500 });
}
}
export function GET () {
return NextResponse. json ({ status: "ok" });
}
=>
({ accessToken:
"refreshed"
}) }),
isAccessTokenValid: vi. fn (). mockReturnValue ( true ),
setToken: vi. fn (),
revoke: vi. fn (). mockResolvedValue ( undefined ),
makeApiCall: vi. fn (). mockResolvedValue ({
status: 200 ,
statusText: "OK" ,
headers: {},
body: "" ,
json: { Invoice: { Id: "inv1" , CustomerRef: { name: "Test" , value: "c1" }, Line: [{ Amount: 100 }], TotalAmt: 100 } },
}),
};
}
OAuthClient.scopes = { Accounting: "com.intuit.quickbooks.accounting" };
return { default: OAuthClient };
});
vi. mock ( "langfuse" , () => {
function Langfuse () {
return { trace: vi. fn (). mockReturnValue ({ span: vi. fn () }) };
}
return { Langfuse };
});
vi. mock ( "@presidio-dev/hai-guardrails" , () => {
const mockGuard = { name: "mockGuard" };
return {
injectionGuard: vi. fn (). mockReturnValue (mockGuard),
piiGuard: vi. fn (). mockReturnValue (mockGuard),
secretGuard: vi. fn (). mockReturnValue (mockGuard),
SelectionType: { All: "all" },
GuardrailsEngine: vi. fn ( function (this : Record < string , unknown >) {
this.run = vi. fn (). mockImplementation (() =>
Promise . resolve ({ messages: [{ passed: mockState.passed }], messagesWithGuardResult: [] })
);
return this;
}),
};
});
async function getHandlers () {
const mod = await import ( "../../app/api/process-invoice/route.js" );
return mod as { POST : (req : NextRequest ) => Promise < Response >; GET : () => Response };
}
describe ( "POST /api/process-invoice" , () => {
it ( "returns 400 when invoiceId is missing" , async () => {
const { POST } = await getHandlers ();
const req = new NextRequest ( "http://localhost/api/process-invoice" , {
method: "POST" ,
body: JSON. stringify ({ companyId: "c1" }),
});
const res = await POST (req);
expect (res.status). toBe ( 400 );
const data = await (res. json () as Promise <{ error : string }>);
expect (data.error). toBe ( "Validation failed" );
});
it ( "returns 400 when companyId is missing" , async () => {
const { POST } = await getHandlers ();
const req = new NextRequest ( "http://localhost/api/process-invoice" , {
method: "POST" ,
body: JSON. stringify ({ invoiceId: "inv1" }),
});
const res = await POST (req);
expect (res.status). toBe ( 400 );
const data = await (res. json () as Promise <{ error : string ; issues ?: Array <{ path : string }> }>);
expect (data.error). toBe ( "Validation failed" );
});
it ( "returns 200 with classification on valid input" , async () => {
const { POST } = await getHandlers ();
const req = new NextRequest ( "http://localhost/api/process-invoice" , {
method: "POST" ,
body: JSON. stringify ({ invoiceId: "inv1" , companyId: "c1" }),
});
const res = await POST (req);
expect (res.status). toBe ( 200 );
const data = await (res. json () as Promise <{ classification : unknown ; message : string }>);
expect (data.classification). toBeDefined ();
expect (data.message). toContain ( "inv1" );
});
it ( "returns 500 on malformed JSON body" , async () => {
const { POST } = await getHandlers ();
const req = new NextRequest ( "http://localhost/api/process-invoice" , {
method: "POST" ,
body: "not-json" ,
});
const res = await POST (req);
expect (res.status). toBe ( 500 );
});
it ( "returns 500 with unknown error for non-Error throw" , async () => {
const { POST } = await getHandlers ();
const req = new NextRequest ( "http://localhost/api/process-invoice" , {
method: "POST" ,
body: JSON. stringify ({ invoiceId: "inv1" , companyId: "c1" }),
});
vi. spyOn (req, "json" ). mockRejectedValue ( "some string error" );
const res = await POST (req);
expect (res.status). toBe ( 500 );
const data = await (res. json () as Promise <{ error : string }>);
expect (data.error). toBe ( "Unknown error" );
});
it ( "returns 422 when guardrail rejects input" , async () => {
mockState.passed = false ;
const { POST } = await getHandlers ();
const req = new NextRequest ( "http://localhost/api/process-invoice" , {
method: "POST" ,
body: JSON. stringify ({ invoiceId: "inv1" , companyId: "c1" }),
});
const res = await POST (req);
expect (res.status). toBe ( 422 );
const data = await (res. json () as Promise <{ guardrailViolations : unknown }>);
expect (data.guardrailViolations). toBeDefined ();
mockState.passed = true ;
});
});
describe ( "GET /api/process-invoice" , () => {
it ( "returns ok status" , async () => {
const { GET } = await getHandlers ();
const res = GET ();
expect (res.status). toBe ( 200 );
const data = await (res. json () as Promise <{ status : string }>);
expect (data.status). toBe ( "ok" );
});
});