feat: Expand OCR with fuel receipt scanning and maintenance extraction (#129) #147
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<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.
|
||||
*
|
||||
|
||||
@@ -45,6 +45,23 @@ export interface OcrExtractRequest {
|
||||
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 */
|
||||
export interface VinExtractionResponse {
|
||||
success: boolean;
|
||||
|
||||
58
backend/src/features/ocr/external/ocr-client.ts
vendored
58
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -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<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.
|
||||
*
|
||||
|
||||
@@ -8,4 +8,5 @@ export type {
|
||||
JobResponse,
|
||||
JobStatus,
|
||||
OcrResponse,
|
||||
ReceiptExtractionResponse,
|
||||
} from './domain/ocr.types';
|
||||
|
||||
181
backend/src/features/ocr/tests/unit/ocr-receipt.test.ts
Normal file
181
backend/src/features/ocr/tests/unit/ocr-receipt.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user