From e0e578a62710d55c714264132cdbaf5ef8516060 Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:26:57 -0600 Subject: [PATCH] feat: add receipt extraction proxy endpoint (refs #130) Add POST /api/ocr/extract/receipt endpoint that proxies to the Python OCR service's /extract/receipt for receipt-specific field extraction. - ReceiptExtractionResponse type with receiptType, extractedFields, rawText - OcrClient.extractReceipt() with optional receipt_type form field - OcrService.extractReceipt() with 10MB max, image-only validation - OcrController.extractReceipt() with file upload and error mapping - Route with auth middleware - 9 unit tests covering normal, edge, and error scenarios Co-Authored-By: Claude Opus 4.6 --- .../src/features/ocr/api/ocr.controller.ts | 113 +++++++++++ backend/src/features/ocr/api/ocr.routes.ts | 6 + .../src/features/ocr/domain/ocr.service.ts | 69 +++++++ backend/src/features/ocr/domain/ocr.types.ts | 17 ++ .../src/features/ocr/external/ocr-client.ts | 58 +++++- backend/src/features/ocr/index.ts | 1 + .../ocr/tests/unit/ocr-receipt.test.ts | 181 ++++++++++++++++++ 7 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 backend/src/features/ocr/tests/unit/ocr-receipt.test.ts diff --git a/backend/src/features/ocr/api/ocr.controller.ts b/backend/src/features/ocr/api/ocr.controller.ts index b59b1ac..c7c61af 100644 --- a/backend/src/features/ocr/api/ocr.controller.ts +++ b/backend/src/features/ocr/api/ocr.controller.ts @@ -15,6 +15,14 @@ const SUPPORTED_TYPES = new Set([ 'application/pdf', ]); +/** Image-only MIME types for receipt extraction (no PDF) */ +const SUPPORTED_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/heic', + 'image/heif', +]); + export class OcrController { /** * POST /api/ocr/extract @@ -223,6 +231,111 @@ export class OcrController { } } + /** + * POST /api/ocr/extract/receipt + * Extract data from a receipt image using receipt-specific OCR. + */ + async extractReceipt( + request: FastifyRequest, + reply: FastifyReply + ) { + const userId = (request as any).user?.sub as string; + + logger.info('Receipt extract requested', { + operation: 'ocr.controller.extractReceipt', + userId, + }); + + const file = await (request as any).file({ limits: { files: 1 } }); + if (!file) { + logger.warn('No file provided for receipt extraction', { + operation: 'ocr.controller.extractReceipt.no_file', + userId, + }); + return reply.code(400).send({ + error: 'Bad Request', + message: 'No file provided', + }); + } + + const contentType = file.mimetype as string; + if (!SUPPORTED_IMAGE_TYPES.has(contentType)) { + logger.warn('Unsupported file type for receipt extraction', { + operation: 'ocr.controller.extractReceipt.unsupported_type', + userId, + contentType, + fileName: file.filename, + }); + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: `Unsupported file type: ${contentType}. Supported: JPEG, PNG, HEIC`, + }); + } + + const chunks: Buffer[] = []; + for await (const chunk of file.file) { + chunks.push(chunk); + } + const fileBuffer = Buffer.concat(chunks); + + if (fileBuffer.length === 0) { + logger.warn('Empty file provided for receipt extraction', { + operation: 'ocr.controller.extractReceipt.empty_file', + userId, + fileName: file.filename, + }); + return reply.code(400).send({ + error: 'Bad Request', + message: 'Empty file provided', + }); + } + + // Get optional receipt_type from form fields + const receiptType = file.fields?.receipt_type?.value as string | undefined; + + try { + const result = await ocrService.extractReceipt(userId, { + fileBuffer, + contentType, + receiptType, + }); + + logger.info('Receipt extract completed', { + operation: 'ocr.controller.extractReceipt.success', + userId, + success: result.success, + receiptType: result.receiptType, + processingTimeMs: result.processingTimeMs, + }); + + return reply.code(200).send(result); + } catch (error: any) { + if (error.statusCode === 413) { + return reply.code(413).send({ + error: 'Payload Too Large', + message: error.message, + }); + } + if (error.statusCode === 415) { + return reply.code(415).send({ + error: 'Unsupported Media Type', + message: error.message, + }); + } + + logger.error('Receipt extract failed', { + operation: 'ocr.controller.extractReceipt.error', + userId, + error: error.message, + }); + + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Receipt extraction failed', + }); + } + } + /** * POST /api/ocr/jobs * Submit an async OCR job for large files. diff --git a/backend/src/features/ocr/api/ocr.routes.ts b/backend/src/features/ocr/api/ocr.routes.ts index ca24175..67b25d7 100644 --- a/backend/src/features/ocr/api/ocr.routes.ts +++ b/backend/src/features/ocr/api/ocr.routes.ts @@ -23,6 +23,12 @@ export const ocrRoutes: FastifyPluginAsync = async ( handler: ctrl.extractVin.bind(ctrl), }); + // POST /api/ocr/extract/receipt - Receipt-specific OCR extraction + fastify.post('/ocr/extract/receipt', { + preHandler: [requireAuth], + handler: ctrl.extractReceipt.bind(ctrl), + }); + // POST /api/ocr/jobs - Submit async OCR job fastify.post('/ocr/jobs', { preHandler: [requireAuth], diff --git a/backend/src/features/ocr/domain/ocr.service.ts b/backend/src/features/ocr/domain/ocr.service.ts index e271eea..fd95d36 100644 --- a/backend/src/features/ocr/domain/ocr.service.ts +++ b/backend/src/features/ocr/domain/ocr.service.ts @@ -8,6 +8,8 @@ import type { OcrExtractRequest, OcrJobSubmitRequest, OcrResponse, + ReceiptExtractRequest, + ReceiptExtractionResponse, VinExtractionResponse, } from './ocr.types'; @@ -26,6 +28,14 @@ const SUPPORTED_TYPES = new Set([ 'application/pdf', ]); +/** Image-only MIME types for receipt extraction (no PDF) */ +const SUPPORTED_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/heic', + 'image/heif', +]); + /** * Domain service for OCR operations. * Handles business logic and validation for OCR requests. @@ -150,6 +160,65 @@ export class OcrService { } } + /** + * Extract data from a receipt image using receipt-specific OCR. + * + * @param userId - User ID for logging + * @param request - Receipt extraction request + * @returns Receipt extraction result + */ + async extractReceipt(userId: string, request: ReceiptExtractRequest): Promise { + if (request.fileBuffer.length > MAX_SYNC_SIZE) { + const err: any = new Error( + `File too large. Max: ${MAX_SYNC_SIZE / (1024 * 1024)}MB.` + ); + err.statusCode = 413; + throw err; + } + + if (!SUPPORTED_IMAGE_TYPES.has(request.contentType)) { + const err: any = new Error( + `Unsupported file type: ${request.contentType}. Supported: ${[...SUPPORTED_IMAGE_TYPES].join(', ')}` + ); + err.statusCode = 415; + throw err; + } + + logger.info('Receipt extract requested', { + operation: 'ocr.service.extractReceipt', + userId, + contentType: request.contentType, + fileSize: request.fileBuffer.length, + receiptType: request.receiptType, + }); + + try { + const result = await ocrClient.extractReceipt( + request.fileBuffer, + request.contentType, + request.receiptType + ); + + logger.info('Receipt extract completed', { + operation: 'ocr.service.extractReceipt.success', + userId, + success: result.success, + receiptType: result.receiptType, + fieldCount: Object.keys(result.extractedFields).length, + processingTimeMs: result.processingTimeMs, + }); + + return result; + } catch (error) { + logger.error('Receipt extract failed', { + operation: 'ocr.service.extractReceipt.error', + userId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + } + /** * Submit an async OCR job for large files. * diff --git a/backend/src/features/ocr/domain/ocr.types.ts b/backend/src/features/ocr/domain/ocr.types.ts index 84e112e..7ec5c15 100644 --- a/backend/src/features/ocr/domain/ocr.types.ts +++ b/backend/src/features/ocr/domain/ocr.types.ts @@ -45,6 +45,23 @@ export interface OcrExtractRequest { preprocess?: boolean; } +/** Response from receipt-specific extraction */ +export interface ReceiptExtractionResponse { + success: boolean; + receiptType: string; + extractedFields: Record; + rawText: string; + processingTimeMs: number; + error: string | null; +} + +/** Request for receipt extraction */ +export interface ReceiptExtractRequest { + fileBuffer: Buffer; + contentType: string; + receiptType?: string; +} + /** Response from VIN-specific extraction */ export interface VinExtractionResponse { success: boolean; diff --git a/backend/src/features/ocr/external/ocr-client.ts b/backend/src/features/ocr/external/ocr-client.ts index 42a1711..8388c1c 100644 --- a/backend/src/features/ocr/external/ocr-client.ts +++ b/backend/src/features/ocr/external/ocr-client.ts @@ -2,7 +2,7 @@ * @ai-summary HTTP client for OCR service communication */ import { logger } from '../../../core/logging/logger'; -import type { JobResponse, OcrResponse, VinExtractionResponse } from '../domain/ocr.types'; +import type { JobResponse, OcrResponse, ReceiptExtractionResponse, VinExtractionResponse } from '../domain/ocr.types'; /** OCR service configuration */ const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000'; @@ -119,6 +119,62 @@ export class OcrClient { return result; } + /** + * Extract data from a receipt image using receipt-specific OCR. + * + * @param fileBuffer - Image file buffer + * @param contentType - MIME type of the file + * @param receiptType - Optional receipt type hint (e.g., 'fuel') + * @returns Receipt extraction result + */ + async extractReceipt( + fileBuffer: Buffer, + contentType: string, + receiptType?: string + ): Promise { + const formData = this.buildFormData(fileBuffer, contentType); + if (receiptType) { + formData.append('receipt_type', receiptType); + } + + const url = `${this.baseUrl}/extract/receipt`; + + logger.info('OCR receipt extract request', { + operation: 'ocr.client.extractReceipt', + url, + contentType, + fileSize: fileBuffer.length, + receiptType, + }); + + const response = await this.fetchWithTimeout(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('OCR receipt extract failed', { + operation: 'ocr.client.extractReceipt.error', + status: response.status, + error: errorText, + }); + throw new Error(`OCR service error: ${response.status} - ${errorText}`); + } + + const result = (await response.json()) as ReceiptExtractionResponse; + + logger.info('OCR receipt extract completed', { + operation: 'ocr.client.extractReceipt.success', + success: result.success, + receiptType: result.receiptType, + fieldCount: Object.keys(result.extractedFields).length, + processingTimeMs: result.processingTimeMs, + }); + + return result; + } + /** * Submit an async OCR job for large files. * diff --git a/backend/src/features/ocr/index.ts b/backend/src/features/ocr/index.ts index 0ee79a7..0ca1c2d 100644 --- a/backend/src/features/ocr/index.ts +++ b/backend/src/features/ocr/index.ts @@ -8,4 +8,5 @@ export type { JobResponse, JobStatus, OcrResponse, + ReceiptExtractionResponse, } from './domain/ocr.types'; diff --git a/backend/src/features/ocr/tests/unit/ocr-receipt.test.ts b/backend/src/features/ocr/tests/unit/ocr-receipt.test.ts new file mode 100644 index 0000000..9cc3f85 --- /dev/null +++ b/backend/src/features/ocr/tests/unit/ocr-receipt.test.ts @@ -0,0 +1,181 @@ +/** + * @ai-summary Unit tests for OCR receipt extraction endpoint + */ + +import { OcrService } from '../../domain/ocr.service'; +import { ocrClient } from '../../external/ocr-client'; +import type { ReceiptExtractionResponse } from '../../domain/ocr.types'; + +jest.mock('../../external/ocr-client'); +jest.mock('../../../../core/logging/logger'); + +const mockExtractReceipt = ocrClient.extractReceipt as jest.MockedFunction< + typeof ocrClient.extractReceipt +>; + +describe('OcrService.extractReceipt', () => { + let service: OcrService; + + const userId = 'test-user-id'; + + const mockReceiptResponse: ReceiptExtractionResponse = { + success: true, + receiptType: 'fuel', + extractedFields: { + merchantName: { value: 'Shell Gas Station', confidence: 0.92 }, + transactionDate: { value: '2026-02-10', confidence: 0.88 }, + totalAmount: { value: '45.67', confidence: 0.95 }, + fuelQuantity: { value: '12.345', confidence: 0.87 }, + pricePerUnit: { value: '3.699', confidence: 0.90 }, + fuelGrade: { value: 'Regular 87', confidence: 0.85 }, + }, + rawText: 'SHELL\n02/10/2026\nREGULAR 87\n12.345 GAL\n$3.699/GAL\nTOTAL $45.67', + processingTimeMs: 1250, + error: null, + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new OcrService(); + }); + + describe('valid receipt extraction', () => { + it('should return receipt extraction response for valid image', async () => { + mockExtractReceipt.mockResolvedValue(mockReceiptResponse); + + const result = await service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-image-data'), + contentType: 'image/jpeg', + }); + + expect(result.success).toBe(true); + expect(result.receiptType).toBe('fuel'); + expect(result.extractedFields.merchantName.value).toBe('Shell Gas Station'); + expect(result.extractedFields.totalAmount.value).toBe('45.67'); + expect(result.extractedFields.fuelQuantity.value).toBe('12.345'); + expect(result.extractedFields.pricePerUnit.value).toBe('3.699'); + expect(result.extractedFields.fuelGrade.value).toBe('Regular 87'); + expect(result.extractedFields.transactionDate.value).toBe('2026-02-10'); + expect(result.processingTimeMs).toBe(1250); + }); + + it('should pass receipt_type hint to client when provided', async () => { + mockExtractReceipt.mockResolvedValue(mockReceiptResponse); + + await service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-image-data'), + contentType: 'image/jpeg', + receiptType: 'fuel', + }); + + expect(mockExtractReceipt).toHaveBeenCalledWith( + expect.any(Buffer), + 'image/jpeg', + 'fuel' + ); + }); + + it('should support PNG images', async () => { + mockExtractReceipt.mockResolvedValue(mockReceiptResponse); + + const result = await service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-png-data'), + contentType: 'image/png', + }); + + expect(result.success).toBe(true); + }); + + it('should support HEIC images', async () => { + mockExtractReceipt.mockResolvedValue(mockReceiptResponse); + + const result = await service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-heic-data'), + contentType: 'image/heic', + }); + + expect(result.success).toBe(true); + }); + }); + + describe('missing optional fields', () => { + it('should handle response with some fields not detected', async () => { + const partialResponse: ReceiptExtractionResponse = { + success: true, + receiptType: 'fuel', + extractedFields: { + merchantName: { value: 'Unknown Station', confidence: 0.60 }, + totalAmount: { value: '30.00', confidence: 0.88 }, + }, + rawText: 'UNKNOWN STATION\nTOTAL $30.00', + processingTimeMs: 980, + error: null, + }; + + mockExtractReceipt.mockResolvedValue(partialResponse); + + const result = await service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-image-data'), + contentType: 'image/jpeg', + }); + + expect(result.success).toBe(true); + expect(result.extractedFields.merchantName).toBeDefined(); + expect(result.extractedFields.totalAmount).toBeDefined(); + expect(result.extractedFields.fuelQuantity).toBeUndefined(); + expect(result.extractedFields.pricePerUnit).toBeUndefined(); + expect(result.extractedFields.fuelGrade).toBeUndefined(); + expect(result.extractedFields.transactionDate).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('should throw 415 for unsupported file type (PDF)', async () => { + await expect( + service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-pdf-data'), + contentType: 'application/pdf', + }) + ).rejects.toMatchObject({ + statusCode: 415, + }); + }); + + it('should throw 415 for text/plain', async () => { + await expect( + service.extractReceipt(userId, { + fileBuffer: Buffer.from('not an image'), + contentType: 'text/plain', + }) + ).rejects.toMatchObject({ + statusCode: 415, + }); + }); + + it('should throw 413 for oversized file', async () => { + const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB + + await expect( + service.extractReceipt(userId, { + fileBuffer: largeBuffer, + contentType: 'image/jpeg', + }) + ).rejects.toMatchObject({ + statusCode: 413, + }); + }); + + it('should propagate OCR service errors', async () => { + mockExtractReceipt.mockRejectedValue( + new Error('OCR service error: 500 - Internal error') + ); + + await expect( + service.extractReceipt(userId, { + fileBuffer: Buffer.from('fake-image-data'), + contentType: 'image/jpeg', + }) + ).rejects.toThrow('OCR service error: 500 - Internal error'); + }); + }); +});