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 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-11 09:26:57 -06:00
parent e98b45eb3a
commit e0e578a627
7 changed files with 444 additions and 1 deletions

View File

@@ -15,6 +15,14 @@ const SUPPORTED_TYPES = new Set([
'application/pdf', '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 { export class OcrController {
/** /**
* POST /api/ocr/extract * 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 * POST /api/ocr/jobs
* Submit an async OCR job for large files. * Submit an async OCR job for large files.

View File

@@ -23,6 +23,12 @@ export const ocrRoutes: FastifyPluginAsync = async (
handler: ctrl.extractVin.bind(ctrl), 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 // POST /api/ocr/jobs - Submit async OCR job
fastify.post('/ocr/jobs', { fastify.post('/ocr/jobs', {
preHandler: [requireAuth], preHandler: [requireAuth],

View File

@@ -8,6 +8,8 @@ import type {
OcrExtractRequest, OcrExtractRequest,
OcrJobSubmitRequest, OcrJobSubmitRequest,
OcrResponse, OcrResponse,
ReceiptExtractRequest,
ReceiptExtractionResponse,
VinExtractionResponse, VinExtractionResponse,
} from './ocr.types'; } from './ocr.types';
@@ -26,6 +28,14 @@ const SUPPORTED_TYPES = new Set([
'application/pdf', '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. * Domain service for OCR operations.
* Handles business logic and validation for OCR requests. * 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<ReceiptExtractionResponse> {
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. * Submit an async OCR job for large files.
* *

View File

@@ -45,6 +45,23 @@ export interface OcrExtractRequest {
preprocess?: boolean; preprocess?: boolean;
} }
/** Response from receipt-specific extraction */
export interface ReceiptExtractionResponse {
success: boolean;
receiptType: string;
extractedFields: Record<string, ExtractedField>;
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 */ /** Response from VIN-specific extraction */
export interface VinExtractionResponse { export interface VinExtractionResponse {
success: boolean; success: boolean;

View File

@@ -2,7 +2,7 @@
* @ai-summary HTTP client for OCR service communication * @ai-summary HTTP client for OCR service communication
*/ */
import { logger } from '../../../core/logging/logger'; 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 */ /** OCR service configuration */
const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000'; const OCR_SERVICE_URL = process.env.OCR_SERVICE_URL || 'http://mvp-ocr:8000';
@@ -119,6 +119,62 @@ export class OcrClient {
return result; 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<ReceiptExtractionResponse> {
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. * Submit an async OCR job for large files.
* *

View File

@@ -8,4 +8,5 @@ export type {
JobResponse, JobResponse,
JobStatus, JobStatus,
OcrResponse, OcrResponse,
ReceiptExtractionResponse,
} from './domain/ocr.types'; } from './domain/ocr.types';

View File

@@ -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');
});
});
});