feat: Maintenance Receipt Upload with OCR Auto-populate (#16) #161
@@ -36,6 +36,11 @@ export const FEATURE_TIERS: Record<string, FeatureConfig> = {
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<MaintenanceRecord> {
|
||||
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 }
|
||||
|
||||
@@ -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<MaintenanceRecordResponse | null> {
|
||||
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<MaintenanceRecordResponse[]> {
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof CreateMaintenanceRecordSchema>;
|
||||
|
||||
@@ -157,9 +159,18 @@ export const UpdateScheduleSchema = z.object({
|
||||
});
|
||||
export type UpdateScheduleRequest = z.infer<typeof UpdateScheduleSchema>;
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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' })],
|
||||
|
||||
@@ -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<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('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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
51
backend/src/features/ocr/external/ocr-client.ts
vendored
51
backend/src/features/ocr/external/ocr-client.ts
vendored
@@ -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<ReceiptExtractionResponse> {
|
||||
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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user