diff --git a/backend/src/core/config/feature-tiers.ts b/backend/src/core/config/feature-tiers.ts index f5d03a7..ca803df 100644 --- a/backend/src/core/config/feature-tiers.ts +++ b/backend/src/core/config/feature-tiers.ts @@ -36,6 +36,11 @@ export const FEATURE_TIERS: Record = { name: 'Receipt Scan', upgradePrompt: 'Upgrade to Pro to scan fuel receipts and auto-fill your fuel log entries.', }, + 'maintenance.receiptScan': { + minTier: 'pro', + name: 'Maintenance Receipt Scan', + upgradePrompt: 'Upgrade to Pro to scan maintenance receipts and extract service details automatically.', + }, } as const; /** diff --git a/backend/src/features/maintenance/data/maintenance.repository.ts b/backend/src/features/maintenance/data/maintenance.repository.ts index 24758e6..52c948e 100644 --- a/backend/src/features/maintenance/data/maintenance.repository.ts +++ b/backend/src/features/maintenance/data/maintenance.repository.ts @@ -21,6 +21,7 @@ export class MaintenanceRepository { cost: row.cost, shopName: row.shop_name, notes: row.notes, + receiptDocumentId: row.receipt_document_id, createdAt: row.created_at, updatedAt: row.updated_at }; @@ -66,11 +67,12 @@ export class MaintenanceRepository { cost?: number | null; shopName?: string | null; notes?: string | null; + receiptDocumentId?: string | null; }): Promise { const res = await this.db.query( `INSERT INTO maintenance_records ( - id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes - ) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10) + id, user_id, vehicle_id, category, subtypes, date, odometer_reading, cost, shop_name, notes, receipt_document_id + ) VALUES ($1, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11) RETURNING *`, [ record.id, @@ -83,6 +85,7 @@ export class MaintenanceRepository { record.cost ?? null, record.shopName ?? null, record.notes ?? null, + record.receiptDocumentId ?? null, ] ); return this.mapMaintenanceRecord(res.rows[0]); @@ -96,6 +99,26 @@ export class MaintenanceRepository { return res.rows[0] ? this.mapMaintenanceRecord(res.rows[0]) : null; } + async findRecordByIdWithDocument(id: string, userId: string): Promise<{ record: MaintenanceRecord; receiptDocument: { documentId: string; fileName: string; contentType: string; storageKey: string } | null } | null> { + const res = await this.db.query( + `SELECT mr.*, d.id AS doc_id, d.file_name AS doc_file_name, d.content_type AS doc_content_type, d.storage_key AS doc_storage_key + FROM maintenance_records mr + LEFT JOIN documents d ON mr.receipt_document_id = d.id + WHERE mr.id = $1 AND mr.user_id = $2`, + [id, userId] + ); + if (!res.rows[0]) return null; + const row = res.rows[0]; + const record = this.mapMaintenanceRecord(row); + const receiptDocument = row.doc_id ? { + documentId: row.doc_id, + fileName: row.doc_file_name, + contentType: row.doc_content_type, + storageKey: row.doc_storage_key, + } : null; + return { record, receiptDocument }; + } + async findRecordsByUserId( userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory } diff --git a/backend/src/features/maintenance/domain/maintenance.service.ts b/backend/src/features/maintenance/domain/maintenance.service.ts index da04b7e..0c21751 100644 --- a/backend/src/features/maintenance/domain/maintenance.service.ts +++ b/backend/src/features/maintenance/domain/maintenance.service.ts @@ -10,7 +10,8 @@ import type { MaintenanceScheduleResponse, MaintenanceCategory, ScheduleType, - MaintenanceCostStats + MaintenanceCostStats, + ReceiptDocumentMeta } from './maintenance.types'; import { validateSubtypes } from './maintenance.types'; import { MaintenanceRepository } from '../data/maintenance.repository'; @@ -40,6 +41,7 @@ export class MaintenanceService { cost: body.cost, shopName: body.shopName, notes: body.notes, + receiptDocumentId: body.receiptDocumentId, }); // Auto-link: Find and update matching 'time_since_last' schedules @@ -49,9 +51,9 @@ export class MaintenanceService { } async getRecord(userId: string, id: string): Promise { - const record = await this.repo.findRecordById(id, userId); - if (!record) return null; - return this.toRecordResponse(record); + const result = await this.repo.findRecordByIdWithDocument(id, userId); + if (!result) return null; + return this.toRecordResponse(result.record, result.receiptDocument); } async getRecords(userId: string, filters?: { vehicleId?: string; category?: MaintenanceCategory }): Promise { @@ -272,10 +274,11 @@ export class MaintenanceService { return { nextDueDate, nextDueMileage }; } - private toRecordResponse(record: MaintenanceRecord): MaintenanceRecordResponse { + private toRecordResponse(record: MaintenanceRecord, receiptDocument?: ReceiptDocumentMeta | null): MaintenanceRecordResponse { return { ...record, subtypeCount: record.subtypes.length, + receiptDocument: receiptDocument ?? null, }; } diff --git a/backend/src/features/maintenance/domain/maintenance.types.ts b/backend/src/features/maintenance/domain/maintenance.types.ts index ce85ac1..6b3d3be 100644 --- a/backend/src/features/maintenance/domain/maintenance.types.ts +++ b/backend/src/features/maintenance/domain/maintenance.types.ts @@ -68,6 +68,7 @@ export interface MaintenanceRecord { cost?: number; shopName?: string; notes?: string; + receiptDocumentId?: string | null; createdAt: string; updatedAt: string; } @@ -113,6 +114,7 @@ export const CreateMaintenanceRecordSchema = z.object({ cost: z.number().positive().optional(), shopName: z.string().max(200).optional(), notes: z.string().max(10000).optional(), + receiptDocumentId: z.string().uuid().optional(), }); export type CreateMaintenanceRecordRequest = z.infer; @@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({ }); export type UpdateScheduleRequest = z.infer; +// Receipt document metadata returned on GET +export interface ReceiptDocumentMeta { + documentId: string; + fileName: string; + contentType: string; + storageKey: string; +} + // Response types export interface MaintenanceRecordResponse extends MaintenanceRecord { subtypeCount: number; + receiptDocument?: ReceiptDocumentMeta | null; } // TCO aggregation stats diff --git a/backend/src/features/maintenance/migrations/004_add_receipt_document_id.sql b/backend/src/features/maintenance/migrations/004_add_receipt_document_id.sql new file mode 100644 index 0000000..36476bc --- /dev/null +++ b/backend/src/features/maintenance/migrations/004_add_receipt_document_id.sql @@ -0,0 +1,7 @@ +-- Add receipt_document_id FK to link maintenance records to scanned receipt documents +ALTER TABLE maintenance_records + ADD COLUMN receipt_document_id UUID REFERENCES documents(id) ON DELETE SET NULL; + +-- Index for querying records by receipt document +CREATE INDEX idx_maintenance_records_receipt_document_id ON maintenance_records(receipt_document_id) + WHERE receipt_document_id IS NOT NULL; diff --git a/backend/src/features/ocr/api/ocr.controller.ts b/backend/src/features/ocr/api/ocr.controller.ts index c511bab..4da998c 100644 --- a/backend/src/features/ocr/api/ocr.controller.ts +++ b/backend/src/features/ocr/api/ocr.controller.ts @@ -342,6 +342,114 @@ export class OcrController { } } + /** + * POST /api/ocr/extract/maintenance-receipt + * Extract data from a maintenance receipt image using maintenance-specific OCR. + * Requires Pro tier (maintenance.receiptScan). + */ + async extractMaintenanceReceipt( + request: FastifyRequest, + reply: FastifyReply + ) { + const userId = (request as any).user?.sub as string; + + logger.info('Maintenance receipt extract requested', { + operation: 'ocr.controller.extractMaintenanceReceipt', + userId, + }); + + const file = await (request as any).file({ limits: { files: 1 } }); + if (!file) { + logger.warn('No file provided for maintenance receipt extraction', { + operation: 'ocr.controller.extractMaintenanceReceipt.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 maintenance receipt extraction', { + operation: 'ocr.controller.extractMaintenanceReceipt.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 maintenance receipt extraction', { + operation: 'ocr.controller.extractMaintenanceReceipt.empty_file', + userId, + fileName: file.filename, + }); + return reply.code(400).send({ + error: 'Bad Request', + message: 'Empty file provided', + }); + } + + try { + const result = await ocrService.extractMaintenanceReceipt(userId, { + fileBuffer, + contentType, + }); + + logger.info('Maintenance receipt extract completed', { + operation: 'ocr.controller.extractMaintenanceReceipt.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, + }); + } + if (error.statusCode === 422) { + return reply.code(422).send({ + error: 'Unprocessable Entity', + message: error.message, + }); + } + + logger.error('Maintenance receipt extract failed', { + operation: 'ocr.controller.extractMaintenanceReceipt.error', + userId, + error: error.message, + }); + + return reply.code(500).send({ + error: 'Internal Server Error', + message: 'Maintenance receipt extraction failed', + }); + } + } + /** * POST /api/ocr/extract/manual * Submit an async manual extraction job for PDF owner's manuals. diff --git a/backend/src/features/ocr/api/ocr.routes.ts b/backend/src/features/ocr/api/ocr.routes.ts index 7144671..addd215 100644 --- a/backend/src/features/ocr/api/ocr.routes.ts +++ b/backend/src/features/ocr/api/ocr.routes.ts @@ -30,6 +30,12 @@ export const ocrRoutes: FastifyPluginAsync = async ( handler: ctrl.extractReceipt.bind(ctrl), }); + // POST /api/ocr/extract/maintenance-receipt - Maintenance receipt OCR extraction (Pro tier required) + fastify.post('/ocr/extract/maintenance-receipt', { + preHandler: [requireAuth, requireTier('maintenance.receiptScan')], + handler: ctrl.extractMaintenanceReceipt.bind(ctrl), + }); + // POST /api/ocr/extract/manual - Manual extraction (Pro tier required) fastify.post('/ocr/extract/manual', { preHandler: [requireAuth, fastify.requireTier({ featureKey: 'document.scanMaintenanceSchedule' })], diff --git a/backend/src/features/ocr/domain/ocr.service.ts b/backend/src/features/ocr/domain/ocr.service.ts index 567361b..30ef7e6 100644 --- a/backend/src/features/ocr/domain/ocr.service.ts +++ b/backend/src/features/ocr/domain/ocr.service.ts @@ -5,6 +5,7 @@ import { logger } from '../../../core/logging/logger'; import { ocrClient, JobNotFoundError } from '../external/ocr-client'; import type { JobResponse, + MaintenanceReceiptExtractRequest, ManualJobResponse, ManualJobSubmitRequest, OcrExtractRequest, @@ -221,6 +222,63 @@ export class OcrService { } } + /** + * Extract data from a maintenance receipt image using maintenance-specific OCR. + * + * @param userId - User ID for logging + * @param request - Maintenance receipt extraction request + * @returns Receipt extraction result + */ + async extractMaintenanceReceipt(userId: string, request: MaintenanceReceiptExtractRequest): 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('Maintenance receipt extract requested', { + operation: 'ocr.service.extractMaintenanceReceipt', + userId, + contentType: request.contentType, + fileSize: request.fileBuffer.length, + }); + + try { + const result = await ocrClient.extractMaintenanceReceipt( + request.fileBuffer, + request.contentType + ); + + logger.info('Maintenance receipt extract completed', { + operation: 'ocr.service.extractMaintenanceReceipt.success', + userId, + success: result.success, + receiptType: result.receiptType, + fieldCount: Object.keys(result.extractedFields).length, + processingTimeMs: result.processingTimeMs, + }); + + return result; + } catch (error) { + logger.error('Maintenance receipt extract failed', { + operation: 'ocr.service.extractMaintenanceReceipt.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 9209962..2f00b4c 100644 --- a/backend/src/features/ocr/domain/ocr.types.ts +++ b/backend/src/features/ocr/domain/ocr.types.ts @@ -62,6 +62,12 @@ export interface ReceiptExtractRequest { receiptType?: string; } +/** Request for maintenance receipt extraction */ +export interface MaintenanceReceiptExtractRequest { + fileBuffer: Buffer; + contentType: 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 d8fa5e2..627abf7 100644 --- a/backend/src/features/ocr/external/ocr-client.ts +++ b/backend/src/features/ocr/external/ocr-client.ts @@ -177,6 +177,57 @@ export class OcrClient { return result; } + /** + * Extract data from a maintenance receipt image using maintenance-specific OCR. + * + * @param fileBuffer - Image file buffer + * @param contentType - MIME type of the file + * @returns Receipt extraction result (receiptType: "maintenance") + */ + async extractMaintenanceReceipt( + fileBuffer: Buffer, + contentType: string + ): Promise { + const formData = this.buildFormData(fileBuffer, contentType); + const url = `${this.baseUrl}/extract/maintenance-receipt`; + + logger.info('OCR maintenance receipt extract request', { + operation: 'ocr.client.extractMaintenanceReceipt', + url, + contentType, + fileSize: fileBuffer.length, + }); + + const response = await this.fetchWithTimeout(url, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text(); + logger.error('OCR maintenance receipt extract failed', { + operation: 'ocr.client.extractMaintenanceReceipt.error', + status: response.status, + error: 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; + + logger.info('OCR maintenance receipt extract completed', { + operation: 'ocr.client.extractMaintenanceReceipt.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. *