Files
pn-new-crm/src/app/api/v1/expenses/scan-receipt/route.ts
Matt Ciaccio e7d23b254c feat(ai): per-port token budgets + usage ledger for AI features
Adds a token-denominated guardrail in front of every server-side AI call
so a misconfigured port can't run up an unbounded bill. Soft caps surface
a banner; hard caps refuse new requests until the period rolls over.
Usage flows into a feature-typed ledger so future AI surfaces (summary,
embeddings, reply-draft) can drop in without schema changes.

- New table ai_usage_ledger (port, user, feature, provider, model,
  input/output/total tokens, request id) with two indexes for rollup
- New service ai-budget.service.ts: getAiBudget/setAiBudget,
  checkBudget (pre-flight gate), recordAiUsage, currentPeriodTokens,
  periodBreakdown — all token-based, period boundaries in UTC
- runOcr now returns provider usage so the route can record the actual
  spend instead of estimating
- Scan-receipt route gates on checkBudget before invoking AI; returns
  source: manual / reason: budget-exceeded when blocked, surfaces
  softCapWarning on the success path
- Admin UI: new AiBudgetCard on the OCR settings page — shows current
  spend, per-feature breakdown, soft/hard cap inputs, period selector
- Permission: admin.manage_settings on both routes

Tests: 766/766 vitest (was 756) — +10 budget tests covering enforce/
disabled/cap-exceed/estimate-exceed/soft-warn/period boundaries/
cross-port isolation/silent ledger failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:53:09 +02:00

114 lines
3.7 KiB
TypeScript

import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse } from '@/lib/errors';
import { logger } from '@/lib/logger';
import { getResolvedOcrConfig } from '@/lib/services/ocr-config.service';
import {
runOcr,
type ParsedReceipt,
OCR_FEATURE,
OCR_ESTIMATED_TOKENS,
} from '@/lib/services/ocr-providers';
import { checkBudget, recordAiUsage } from '@/lib/services/ai-budget.service';
const EMPTY: ParsedReceipt = {
establishment: null,
date: null,
amount: null,
currency: null,
lineItems: [],
confidence: 0,
};
export const POST = withAuth(
withPermission('expenses', 'create', async (req, ctx) => {
try {
const formData = await req.formData();
const file = formData.get('file') as File | null;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
const mimeType = file.type || 'image/jpeg';
const config = await getResolvedOcrConfig(ctx.portId);
// Tesseract.js (in-browser) is the default. The server only invokes
// an AI provider when (a) the port admin has flipped `aiEnabled` on
// and (b) a key resolves. Otherwise the client falls back to its
// local Tesseract result.
if (!config.aiEnabled) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'ai-disabled' },
});
}
if (!config.apiKey) {
return NextResponse.json({
data: { parsed: EMPTY, source: 'manual', reason: 'no-ocr-configured' },
});
}
// Per-port budget gate — refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking.
const budget = await checkBudget({
portId: ctx.portId,
estimatedTokens: OCR_ESTIMATED_TOKENS,
});
if (!budget.ok) {
return NextResponse.json({
data: {
parsed: EMPTY,
source: 'manual',
reason: 'budget-exceeded',
providerError: `AI budget reached (${budget.usedTokens}/${budget.capTokens} tokens this period).`,
},
});
}
try {
const result = await runOcr({
provider: config.provider,
model: config.model,
apiKey: config.apiKey,
imageBuffer: buffer,
mimeType,
});
await recordAiUsage({
portId: ctx.portId,
userId: ctx.userId,
feature: OCR_FEATURE,
provider: config.provider,
model: config.model,
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
requestId: result.usage.requestId,
});
return NextResponse.json({
data: {
parsed: result.parsed,
source: 'ai',
provider: config.provider,
model: config.model,
softCapWarning: budget.softCap,
},
});
} catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup — degrade to manual entry rather than 500-ing.
return NextResponse.json({
data: {
parsed: EMPTY,
source: 'manual',
reason: 'provider-error',
providerError: err instanceof Error ? err.message.slice(0, 200) : 'Unknown error',
},
});
}
} catch (error) {
return errorResponse(error);
}
}),
);