feat: Maintenance Receipt Upload with OCR Auto-populate #16
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Add receipt upload and camera capture to the maintenance record form. OCR extracts maintenance-specific fields (service name, date, cost, shop name, odometer) and auto-populates the form. Receipts are stored as documents and linked to the maintenance record.
This mirrors the existing fuel receipt OCR flow (
ReceiptOcrReviewModal) but targets maintenance-specific field extraction rather than fuel-specific fields.Requirements
Receipt Upload on Maintenance Record Form
MaintenanceRecordForm(mirrors fuel log pattern)CameraCapturecomponentmaintenance.receiptScanfeature flag)OCR Extraction for Maintenance Receipts
Review Modal
MaintenanceReceiptReviewModalcomponent (mirrorsReceiptOcrReviewModal)Auto-populate Maintenance Record Form
MaintenanceRecordFormfields viasetValue()Document Storage and Linking
receipt_document_id(nullable FK) tomaintenance_recordstableBackend API
POST /api/ocr/extract/maintenance-receiptPOST /api/maintenance/recordsto accept optionalreceiptDocumentIdGET /api/maintenance/records/:idto include receipt document metadataTechnical Considerations
CameraCapture, confidence indicator, and review modal patterns from fuel logsuseMaintenanceReceiptOcrhook (mirrorsuseReceiptOcr)useTierAccess('maintenance.receiptScan')Dependencies
Acceptance Criteria
Email Receipt Ingestion via Resend APIto feat: Email Receipt Ingestion via Resend APIfeat: Email Receipt Ingestion via Resend APIto feat: Maintenance Receipt Upload with OCR Auto-populatePlan: Maintenance Receipt Upload with OCR Auto-populate
Phase: Planning | Agent: Planner | Status: AWAITING_REVIEW
Decision Critic Result
Decision: OCR extraction approach for maintenance receipts.
Verdict: REVISE to Gemini-primary extraction with regex cross-validation.
FuelReceiptExtractorcross-validation pattern (extract first, validate second)Sub-Issue Decomposition
Branch:
issue-16-maintenance-receipt-ocr(ONE branch for all milestones)PR: ONE PR targeting main, closes #16, #150, #151, #152, #153
Milestone 1: OCR Microservice Extraction Pipeline (#150)
Goal: Add maintenance receipt extraction endpoint to Python OCR microservice.
Files:
ocr/app/extractors/maintenance_receipt_extractor.pyocr/app/patterns/maintenance_receipt_patterns.pyocr/app/routers/extract.pyPOST /extract/maintenance-receiptrouteocr/app/models/Implementation Details:
MaintenanceReceiptExtractor (mirrors
FuelReceiptExtractor):ReceiptExtractorand Gemini moduleextract(image_bytes, content_type)method:a. Call base
ReceiptExtractor.extract()to get OCR text via preprocessing pipelineb. Send OCR text to Gemini with structured prompt requesting JSON output:
serviceName(string) - service performedserviceDate(string, YYYY-MM-DD)totalCost(number)shopName(string)laborCost(number, optional)partsCost(number, optional)partsBreakdown(list of {name, cost}, optional)odometerReading(number, optional)vehicleInfo(string, optional)c. Cross-validate with regex:
date_matcherfor serviceDate,currency_matcherfor costs, numeric pattern for odometerd. Adjust confidence: boost 1.1x if regex confirms Gemini value, reduce 0.8x if mismatch
e. Return
ReceiptExtractionResultwith per-fieldExtractedField(value, confidence)Cross-validation patterns (
maintenance_receipt_patterns.py):/(\d{1,3}[,.]?\d{3})\s*(mi|miles|km|odometer)/iFastAPI endpoint:
POST /extract/maintenance-receipt{ success, receiptType: "maintenance", extractedFields: { fieldName: { value, confidence } }, rawText, processingTimeMs }Acceptance Criteria: Endpoint returns structured fields with confidence scores. Gemini extracts semantic fields. Regex cross-validates structured fields.
Milestone 2: Backend Migration and API Updates (#151)
Goal: Add receipt document linking to maintenance records and OCR proxy endpoint.
Files:
backend/src/features/maintenance/data/migrations/XXX_add_receipt_document_id.sqlbackend/src/features/maintenance/domain/maintenance.types.tsbackend/src/features/maintenance/data/maintenance.repository.tsbackend/src/features/maintenance/domain/maintenance.service.tsbackend/src/features/maintenance/api/maintenance.controller.tsbackend/src/features/maintenance/api/maintenance.routes.tsbackend/src/features/ocr/api/ocr.controller.tsbackend/src/core/config/feature-tiers.tsImplementation Details:
Migration:
ALTER TABLE maintenance_records ADD COLUMN receipt_document_id UUID REFERENCES documents(id) ON DELETE SET NULL;Types update:
receiptDocumentId?: stringtoMaintenanceRecordinterfacereceiptDocumentId?: stringtoCreateMaintenanceRecordSchema(Zod, optional UUID)receiptDocument?: { id, fileName, contentType, storageKey }to response type for GETRepository update:
mapRow(): addreceiptDocumentId: row.receipt_document_idcreate(): includereceipt_document_idin INSERTgetById(): LEFT JOIN documents ON maintenance_records.receipt_document_id = documents.id, return file metadataOCR proxy endpoint:
POST /api/ocr/extract/maintenance-receiptcanAccessFeature(userTier, 'maintenance.receiptScan')POST http://mvp-ocr:8000/extract/maintenance-receiptFeature tier config: Add
'maintenance.receiptScan': { minTier: 'pro', name: 'Maintenance Receipt Scan', upgradePrompt: 'Upgrade to Pro to scan maintenance receipts' }Acceptance Criteria: Migration adds FK. Create accepts receiptDocumentId. GET returns receipt metadata. Proxy endpoint works with tier gating.
Milestone 3: Frontend OCR Hook and Review Modal (#152)
Goal: Create the maintenance receipt OCR orchestration hook and review modal.
Files:
frontend/src/features/maintenance/types/maintenance-receipt.types.tsfrontend/src/features/maintenance/hooks/useMaintenanceReceiptOcr.tsfrontend/src/features/maintenance/components/MaintenanceReceiptReviewModal.tsxImplementation Details:
Types (
maintenance-receipt.types.ts):Hook (
useMaintenanceReceiptOcr.ts, mirrorsuseReceiptOcr):startCapture(): opens CameraCaptureprocessImage(file): uploads to/api/ocr/extract/maintenance-receipt, stores document via documents APIacceptResult(): maps extracted fields to form values:serviceName->category+subtypesvia keyword mappingserviceDate->datetotalCost->costshopName->shopNameodometerReading->odometerReadingReview Modal (
MaintenanceReceiptReviewModal.tsx, mirrorsReceiptOcrReviewModal):Acceptance Criteria: Hook manages full OCR flow. Modal displays fields with confidence. Inline editing works. Category suggestion works. Mobile and desktop layouts.
Milestone 4: Form Integration, Tier Gating, Receipt Display (#153)
Goal: Wire everything into the maintenance record form and detail view.
Files:
frontend/src/features/maintenance/components/MaintenanceRecordForm.tsxfrontend/src/features/maintenance/components/MaintenanceRecordDetail.tsxfrontend/src/features/maintenance/api/maintenance.api.tsfrontend/src/core/config/feature-tiers.tsImplementation Details:
Scan Receipt button on MaintenanceRecordForm:
useTierAccess('maintenance.receiptScan')Form auto-populate on OCR accept:
setValue('category', mappedFields.category)setValue('subtypes', mappedFields.subtypes)(triggers subtype reset)setValue('date', mappedFields.date)setValue('cost', mappedFields.cost)setValue('shopName', mappedFields.shopName)setValue('odometerReading', mappedFields.odometerReading)receiptDocumentIdin form state for submissionCreate record payload: Add
receiptDocumentIdtoCreateMaintenanceRecordRequestReceipt display on MaintenanceRecordDetail:
receiptDocumentexists: show thumbnail + "View Receipt" buttonAcceptance Criteria: Scan button appears (tier-gated). Auto-populate works. Receipt stored and linked. Receipt viewable on detail. Mobile and desktop responsive.
Execution Order
M1 and M2 can potentially run in parallel (OCR microservice is independent of backend). M3 depends on M2 (needs proxy endpoint). M4 depends on M3 (needs hook and modal).
Risk Assessment
Verdict: AWAITING_REVIEW | Next: QR plan-completeness review
QR Review: plan-completeness
Phase: Plan-Review | Agent: Quality Reviewer | Status: PASS_WITH_CONCERNS
VERDICT: PASS_WITH_CONCERNS
Findings
[RULE 1] [SHOULD_FIX]: Missing Policy Defaults for Error Handling
[RULE 1] [SHOULD_FIX]: Incomplete File Manifest for M3
frontend/src/features/maintenance/utils/category-mapper.tsto M3 files[RULE 1] [SHOULD_FIX]: Mobile/Desktop Behavior Not Explicit for Receipt Display
[RULE 2] [SHOULD_FIX]: Test Strategy Not Documented
[RULE 2] [SHOULD_FIX]: Migration Filename Pattern Not Specified
XXX_add_receipt_document_id.sqlIssue Acceptance Criteria Coverage: 10/10 covered
Verdict: PASS_WITH_CONCERNS | Next: Address concerns, then TW plan-scrub
Plan Addendum: Addressing QR plan-completeness Concerns
Phase: Planning | Agent: Planner | Status: IN_PROGRESS
1. Policy Defaults (Error Handling)
{ error: "OCR_UNAVAILABLE" }, frontend shows retry toast2. M3 File Manifest Addition
Add to Milestone 3 files:
frontend/src/features/maintenance/utils/category-mapper.tsCategory mapper exports:
mapServiceToCategory(serviceName: string): { category: MaintenanceCategory, subtypes: string[] }with keyword lookup table.3. M4 Receipt Display Mobile/Desktop Specs
Mobile (< 768px):
Desktop (>= 768px):
4. Testing Strategy
MaintenanceReceiptExtractorwith sample receipt text, cross-validation edge cases (mismatched dates, invalid amounts), Gemini response parsingMaintenanceReceiptReviewModal(render, edit, confidence display), hook state machine transitions5. Migration Filename Convention
Use timestamp format matching existing migrations:
{YYYYMMDDHHMMSS}_add_receipt_document_id.sqlExample:
20260213000000_add_receipt_document_id.sqlVerdict: CONCERNS_ADDRESSED | Next: TW plan-scrub
TW Review: plan-scrub
Phase: Plan-Review | Agent: Technical Writer | Status: NEEDS_REVISION
Findings (11 items: 3 HIGH, 5 MEDIUM, 1 LOW, 2 BONUS)
HIGH:
backend/src/features/maintenance/data/migrations/XXX_...but actual path isbackend/src/features/maintenance/migrations/(nodata/). Naming is001_,002_,003_sequential, not timestamp.maintenance.routes.tsbut all OCR proxy endpoints live inbackend/src/features/ocr/. Missing full chain:ocr.routes.ts->ocr.controller.ts->ocr.service.ts->ocr-client.ts->ocr.types.ts.MaintenanceRecordDetail.tsxdoes not exist - Referenced as MODIFY but file is not in codebase.MEDIUM:
4. Tier gating should use
requireTier()preHandler pattern (notcanAccessFeatureinline check)5. Clarify M1 extractor reuses existing
ReceiptExtractionResultdataclass (generic dict)6. Clarify M1 reuses existing
ReceiptExtractionResponsePydantic model7. Explicitly state frontend owns category mapping, OCR returns raw
serviceName8. Clarify document storage ordering in hook (OCR first, then store doc on success)
9. Clarify FK cascade behavior in both directions
LOW:
10. Consolidate category mapping description (redundant across M3/M4)
BONUS:
11. Rename
maintenance_receipt_patterns.pyto avoid collision with existingmaintenance_patterns.py- suggestmaintenance_receipt_validation.pyNaming Consistency: All primary names consistent across plan and sub-issues. No mismatches in component/hook/endpoint/feature key names.
Verdict: NEEDS_REVISION | Next: Address HIGH findings, re-scrub
Plan Revision: Addressing TW plan-scrub Findings
Phase: Planning | Agent: Planner | Status: IN_PROGRESS
All 11 TW findings addressed below. This revision supersedes conflicting details in the original plan.
HIGH Fixes
1. Migration path corrected
Correct path:
backend/src/features/maintenance/migrations/004_add_receipt_document_id.sqldata/segment004_prefix (follows existing001_,002_,003_)2. Proxy route architecture corrected
The OCR proxy endpoint lives entirely in the
ocrfeature, notmaintenance. M2 files table revised:backend/src/features/maintenance/migrations/004_add_receipt_document_id.sqlbackend/src/features/maintenance/domain/maintenance.types.tsbackend/src/features/maintenance/data/maintenance.repository.tsbackend/src/features/maintenance/domain/maintenance.service.tsbackend/src/features/maintenance/api/maintenance.controller.tsbackend/src/features/ocr/api/ocr.routes.tsPOST /ocr/extract/maintenance-receiptroute withrequireTierpreHandlerbackend/src/features/ocr/api/ocr.controller.tsextractMaintenanceReceipthandlerbackend/src/features/ocr/domain/ocr.service.tsextractMaintenanceReceiptmethodbackend/src/features/ocr/domain/ocr.types.tsbackend/src/features/ocr/external/ocr-client.tsextractMaintenanceReceiptmethod (HTTP call to OCR microservice)backend/src/core/config/feature-tiers.tsmaintenance.receiptScanfeature key3.
MaintenanceRecordDetail.tsxresolvedNo dedicated detail view exists. Receipt display goes into two existing components:
MaintenanceRecordEditDialog.tsx(MODIFY) - Show receipt thumbnail + "View Receipt" button when record has linked receipt (in the dialog content area, above or below the form fields)MaintenanceRecordsList.tsx(MODIFY) - Show receipt indicator icon on list rows for records that have linked receiptsM4 files table revised:
frontend/src/features/maintenance/components/MaintenanceRecordForm.tsxfrontend/src/features/maintenance/components/MaintenanceRecordEditDialog.tsxfrontend/src/features/maintenance/components/MaintenanceRecordsList.tsxfrontend/src/features/maintenance/api/maintenance.api.tsfrontend/src/core/config/feature-tiers.tsMEDIUM Fixes
4. Tier gating pattern
Route preHandler pattern (matching existing OCR routes):
NOT
canAccessFeature()inline check. Followsocr.routes.tsline 29 pattern.5. M1 extractor reuses existing
ReceiptExtractionResultMaintenanceReceiptExtractor.extract()returns the existingReceiptExtractionResultdataclass fromreceipt_extractor.py. Theextracted_fields: dict[str, ExtractedField]structure already supports arbitrary field names. Maintenance fields use keys:serviceName,serviceDate,totalCost,shopName,laborCost,partsCost,partsBreakdown,odometerReading,vehicleInfo. No new result dataclass needed.6. M1 reuses existing
ReceiptExtractionResponsePydantic modelThe existing
ReceiptExtractionResponsemodel supports this without modification (response shape is identical, field keys differ). Removeocr/app/models/from M1 files table. ThereceiptTypefield will be"maintenance".M1 files table revised:
ocr/app/extractors/maintenance_receipt_extractor.pyocr/app/patterns/maintenance_receipt_validation.pyocr/app/routers/extract.pyPOST /extract/maintenance-receiptroute7. Category mapping ownership
Design decision: The OCR endpoint returns the raw
serviceNamestring as extracted by Gemini. The frontendcategory-mapper.tsindependently maps this to a category and subtypes, because the user reviews and can override the suggestion before submission. The OCR microservice has no knowledge of frontend maintenance categories.8. Document storage ordering in hook
Flow in
useMaintenanceReceiptOcr.processImage(file):POST /api/ocr/extract/maintenance-receiptfor extractionPOST /api/documents) to get adocumentIddocumentIdin hook state for review step9. FK cascade behavior (both directions)
ON DELETE SET NULL)LOW Fix
10. Category mapping consolidated to M3 only
All category mapping description owned by M3. M4 simply references "use
mappedFieldsfrom the hook'sacceptResult()callback" without re-describing the mapping logic.BONUS Fix
11. Patterns file renamed
maintenance_receipt_patterns.pyrenamed tomaintenance_receipt_validation.pyto avoid collision with existingmaintenance_patterns.py.Verdict: ALL_FINDINGS_ADDRESSED | Next: QR plan-code review
QR Review: plan-code
Phase: Plan-Review | Agent: Quality Reviewer | Status: PASS_WITH_CONCERNS
VERDICT: PASS_WITH_CONCERNS
Findings
[RULE 0] [HIGH]: Missing Error Handling for Gemini API Rate Limits
OCR_RATE_LIMITEDerror code[RULE 0] [HIGH]: SQL Injection Risk via receiptDocumentId
[RULE 1] [HIGH]: Mobile Interaction for Review Modal Secondary Fields
[RULE 0] [SHOULD_FIX]: File Upload MIME Type Verification
ReceiptExtractor._detect_mime_type()already uses python-magic. New extractor inherits this. Acknowledged.[RULE 1] [SHOULD_FIX]: receiptDocumentId Zod UUID Validation
.uuid()in Zod. Service layer validates document exists and belongs to user.[RULE 2] [SHOULD_FIX]: Confidence Constants Duplication
app/patterns/confidence.py(CONFIDENCE_BOOST = 1.1, CONFIDENCE_PENALTY = 0.8)[RULE 2] [SUGGESTION]: Category Mapper Maintainability
Considered But Not Flagged
Verdict: PASS_WITH_CONCERNS | Next: Address HIGH findings, then QR plan-docs
Plan Addendum: Addressing QR plan-code Findings
Phase: Planning | Agent: Planner | Status: IN_PROGRESS
HIGH Fixes
1. Gemini API Rate Limits (M1)
Add to
MaintenanceReceiptExtractor.extract():ReceiptExtractionResult(success=False, error="OCR_RATE_LIMITED")2. Parameterized Queries (M2)
All repository queries involving
receipt_document_idMUST use parameterized placeholders ($N). This is already the established pattern in the repository (all existing queries use$1, $2, ...placeholders). Explicit statement for developer agents:INSERT INTO maintenance_records (..., receipt_document_id) VALUES (..., $N)LEFT JOIN documents d ON mr.receipt_document_id = d.id WHERE mr.id = $13. Mobile Secondary Fields Interaction (M3)
Review modal secondary fields on mobile:
Accordioncomponent for collapsible secondary fields sectionSHOULD_FIX Acknowledgments
4. Zod UUID Validation (M2)
CreateMaintenanceRecordSchemafield:receiptDocumentId: z.string().uuid().optional(). Service layer validates document exists and belongs to user with adocumentsRepository.getById()check before insertion. Return 404 if document not found, 403 if wrong user.5. Confidence Constants (M1)
Create shared module
ocr/app/patterns/confidence.py:Both
FuelReceiptExtractorandMaintenanceReceiptExtractorimport from this shared module.Verdict: ALL_HIGH_FINDINGS_ADDRESSED | Next: QR plan-docs (final gate)