feat: Expand OCR with fuel receipt scanning and maintenance extraction (#129) #147

Merged
egullickson merged 26 commits from issue-129-expand-ocr-fuel-receipt-maintenance into main 2026-02-13 02:25:55 +00:00
4 changed files with 40 additions and 3 deletions
Showing only changes of commit 88c2d7fbcd - Show all commits

View File

@@ -322,6 +322,12 @@ export class OcrController {
message: error.message,
});
}
if (error.statusCode === 422) {
return reply.code(422).send({
error: 'Unprocessable Entity',
message: error.message,
});
}
logger.error('Receipt extract failed', {
operation: 'ocr.controller.extractReceipt.error',

View File

@@ -2,6 +2,7 @@
* @ai-summary Fastify routes for OCR API
*/
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
import { requireTier } from '../../../core/middleware/require-tier';
import { OcrController } from './ocr.controller';
export const ocrRoutes: FastifyPluginAsync = async (
@@ -23,9 +24,9 @@ export const ocrRoutes: FastifyPluginAsync = async (
handler: ctrl.extractVin.bind(ctrl),
});
// POST /api/ocr/extract/receipt - Receipt-specific OCR extraction
// POST /api/ocr/extract/receipt - Receipt-specific OCR extraction (Pro tier required)
fastify.post('/ocr/extract/receipt', {
preHandler: [requireAuth],
preHandler: [requireAuth, requireTier('fuelLog.receiptScan')],
handler: ctrl.extractReceipt.bind(ctrl),
});

View File

@@ -159,7 +159,9 @@ export class OcrClient {
status: response.status,
error: errorText,
});
throw new Error(`OCR service error: ${response.status} - ${errorText}`);
const err: any = new Error(`OCR service error: ${response.status} - ${errorText}`);
err.statusCode = response.status;
throw err;
}
const result = (await response.json()) as ReceiptExtractionResponse;

View File

@@ -165,6 +165,22 @@ describe('OcrService.extractReceipt', () => {
});
});
it('should propagate Python 422 with statusCode for controller forwarding', async () => {
const err: any = new Error('OCR service error: 422 - Failed to extract receipt data');
err.statusCode = 422;
mockExtractReceipt.mockRejectedValue(err);
await expect(
service.extractReceipt(userId, {
fileBuffer: Buffer.from('fake-image-data'),
contentType: 'image/jpeg',
})
).rejects.toMatchObject({
statusCode: 422,
message: 'OCR service error: 422 - Failed to extract receipt data',
});
});
it('should propagate OCR service errors', async () => {
mockExtractReceipt.mockRejectedValue(
new Error('OCR service error: 500 - Internal error')
@@ -179,3 +195,15 @@ describe('OcrService.extractReceipt', () => {
});
});
});
describe('Receipt route tier guard', () => {
it('route is configured with requireTier fuelLog.receiptScan', async () => {
// Tier guard is enforced at route level via requireTier('fuelLog.receiptScan')
// preHandler: [requireAuth, requireTier('fuelLog.receiptScan')]
// Free-tier users receive 403 TIER_REQUIRED before the handler executes.
// Middleware behavior is tested in core/middleware/require-tier.test.ts
const { requireTier } = await import('../../../../core/middleware/require-tier');
const handler = requireTier('fuelLog.receiptScan');
expect(typeof handler).toBe('function');
});
});