Merge pull request 'feat: Maintenance Receipt Upload with OCR Auto-populate (#16)' (#161) from issue-16-maintenance-receipt-upload-ocr into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 36s
Deploy to Staging / Deploy to Staging (push) Successful in 51s
Deploy to Staging / Verify Staging (push) Successful in 8s
Deploy to Staging / Notify Staging Ready (push) Successful in 7s
Deploy to Staging / Notify Staging Failure (push) Has been skipped

Reviewed-on: #161
This commit was merged in pull request #161.
This commit is contained in:
2026-02-13 22:19:44 +00:00
48 changed files with 5541 additions and 31 deletions

View File

@@ -24,12 +24,14 @@
"get-jwks": "^11.0.3",
"ioredis": "^5.4.2",
"js-yaml": "^4.1.0",
"mailparser": "^3.9.3",
"node-cron": "^3.0.3",
"opossum": "^8.0.0",
"pg": "^8.13.1",
"pino": "^9.6.0",
"resend": "^3.0.0",
"stripe": "^20.2.0",
"svix": "^1.85.0",
"tar": "^7.4.3",
"zod": "^3.24.1"
},
@@ -37,6 +39,7 @@
"@eslint/js": "^9.17.0",
"@types/jest": "^29.5.10",
"@types/js-yaml": "^4.0.9",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/opossum": "^8.0.0",
@@ -83,7 +86,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1766,6 +1768,12 @@
"@sinonjs/commons": "^3.0.0"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
@@ -1921,6 +1929,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/mailparser": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
"integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"iconv-lite": "^0.6.3"
}
},
"node_modules/@types/mailparser/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -1934,7 +1966,6 @@
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -2061,7 +2092,6 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -2273,6 +2303,17 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.8",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.8.tgz",
"integrity": "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.3.7",
"libqp": "2.1.1"
}
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -2306,7 +2347,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2773,7 +2813,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3470,6 +3509,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -3566,7 +3614,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3900,6 +3947,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
},
"node_modules/fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
@@ -4508,6 +4561,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
@@ -4580,6 +4642,22 @@
"node": ">=10.17.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -4920,7 +4998,6 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "^29.7.0",
"@jest/types": "^29.6.3",
@@ -5736,6 +5813,42 @@
"node": ">= 0.8.0"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.3.7",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.6.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libmime/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -5780,6 +5893,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -5827,6 +5949,7 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
@@ -5843,6 +5966,24 @@
"node": "20 || >=22"
}
},
"node_modules/mailparser": {
"version": "3.9.3",
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.3.tgz",
"integrity": "sha512-AnB0a3zROum6fLaa52L+/K2SoRJVyFDk78Ea6q1D0ofcZLxWEWDtsS1+OrVqKbV7r5dulKL/AwYQccFGAPpuYQ==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.8",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "9.0.5",
"iconv-lite": "0.7.2",
"libmime": "5.3.7",
"linkify-it": "5.0.0",
"nodemailer": "7.0.13",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/make-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -6071,6 +6212,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.11",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
@@ -6419,7 +6569,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -6785,6 +6934,15 @@
"node": ">=6"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pure-rand": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
@@ -7126,6 +7284,7 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
}
@@ -7381,6 +7540,16 @@
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
@@ -7602,6 +7771,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svix": {
"version": "1.85.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.85.0.tgz",
"integrity": "sha512-4OxNw++bnNay8SoBwESgzfjMnYmurS1qBX+luhzvljr6EAPn/hqqmkdCR1pbgIe1K1+BzKZEHjAKz9OYrKJYwQ==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
}
},
"node_modules/svix/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/tar": {
"version": "7.5.2",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.2.tgz",
@@ -7692,7 +7884,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7700,6 +7891,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/tmpl": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
@@ -7841,7 +8041,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -7929,7 +8128,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -7962,6 +8160,12 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",

View File

@@ -34,19 +34,22 @@
"get-jwks": "^11.0.3",
"ioredis": "^5.4.2",
"js-yaml": "^4.1.0",
"mailparser": "^3.9.3",
"node-cron": "^3.0.3",
"opossum": "^8.0.0",
"pg": "^8.13.1",
"pino": "^9.6.0",
"resend": "^3.0.0",
"stripe": "^20.2.0",
"svix": "^1.85.0",
"tar": "^7.4.3",
"pino": "^9.6.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/jest": "^29.5.10",
"@types/js-yaml": "^4.0.9",
"@types/mailparser": "^3.4.6",
"@types/node": "^22.0.0",
"@types/node-cron": "^3.0.11",
"@types/opossum": "^8.0.0",

View File

@@ -26,6 +26,7 @@ const MIGRATION_ORDER = [
'features/admin', // Admin role management and oversight; depends on update_updated_at_column()
'features/backup', // Admin backup feature; depends on update_updated_at_column()
'features/notifications', // Depends on maintenance and documents
'features/email-ingestion', // Depends on documents, notifications (extends email_templates)
'features/terms-agreement', // Terms & Conditions acceptance audit trail
'features/audit-log', // Centralized audit logging; independent
'features/ownership-costs', // Depends on vehicles and documents; TCO recurring costs

View File

@@ -35,6 +35,7 @@ import { userImportRoutes } from './features/user-import';
import { ownershipCostsRoutes } from './features/ownership-costs';
import { subscriptionsRoutes, donationsRoutes, webhooksRoutes } from './features/subscriptions';
import { ocrRoutes } from './features/ocr';
import { emailIngestionWebhookRoutes, emailIngestionRoutes } from './features/email-ingestion';
import { pool } from './core/config/database';
import { configRoutes } from './core/config/config.routes';
@@ -96,7 +97,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
timestamp: new Date().toISOString(),
environment: process.env['NODE_ENV'],
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion']
});
});
@@ -106,7 +107,7 @@ async function buildApp(): Promise<FastifyInstance> {
status: 'healthy',
scope: 'api',
timestamp: new Date().toISOString(),
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr']
features: ['admin', 'auth', 'config', 'onboarding', 'vehicles', 'documents', 'fuel-logs', 'stations', 'maintenance', 'platform', 'notifications', 'user-profile', 'user-preferences', 'user-export', 'user-import', 'ownership-costs', 'subscriptions', 'donations', 'ocr', 'email-ingestion']
});
});
@@ -152,6 +153,8 @@ async function buildApp(): Promise<FastifyInstance> {
await app.register(subscriptionsRoutes, { prefix: '/api' });
await app.register(donationsRoutes, { prefix: '/api' });
await app.register(webhooksRoutes, { prefix: '/api' });
await app.register(emailIngestionWebhookRoutes, { prefix: '/api' });
await app.register(emailIngestionRoutes, { prefix: '/api' });
await app.register(ocrRoutes, { prefix: '/api' });
await app.register(configRoutes, { prefix: '/api' });

View File

@@ -126,6 +126,7 @@ const secretsSchema = z.object({
auth0_management_client_secret: z.string(),
google_maps_api_key: z.string(),
resend_api_key: z.string(),
resend_webhook_secret: z.string().optional(),
// Stripe secrets (API keys only - price IDs are config, not secrets)
stripe_secret_key: z.string(),
stripe_webhook_secret: z.string(),
@@ -143,6 +144,10 @@ export interface AppConfiguration {
getRedisUrl(): string;
getAuth0Config(): { domain: string; audience: string; clientSecret: string };
getAuth0ManagementConfig(): { domain: string; clientId: string; clientSecret: string };
getResendConfig(): {
apiKey: string;
webhookSecret: string | undefined;
};
getStripeConfig(): {
secretKey: string;
webhookSecret: string;
@@ -185,6 +190,7 @@ class ConfigurationLoader {
'auth0-management-client-secret',
'google-maps-api-key',
'resend-api-key',
'resend-webhook-secret',
'stripe-secret-key',
'stripe-webhook-secret',
];
@@ -250,6 +256,13 @@ class ConfigurationLoader {
};
},
getResendConfig() {
return {
apiKey: secrets.resend_api_key,
webhookSecret: secrets.resend_webhook_secret,
};
},
getStripeConfig() {
return {
secretKey: secrets.stripe_secret_key,
@@ -258,8 +271,11 @@ class ConfigurationLoader {
},
};
// Set RESEND_API_KEY in environment for EmailService
// Set Resend environment variables for EmailService and webhook verification
process.env['RESEND_API_KEY'] = secrets.resend_api_key;
if (secrets.resend_webhook_secret) {
process.env['RESEND_WEBHOOK_SECRET'] = secrets.resend_webhook_secret;
}
logger.info('Configuration loaded successfully', {
configSource: 'yaml',

View File

@@ -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;
/**

View File

@@ -0,0 +1,188 @@
/**
* @ai-summary Controller for Resend inbound email webhook and user-facing pending association endpoints
* @ai-context Webhook handler (public) + pending association CRUD (JWT-authenticated)
*/
import { FastifyRequest, FastifyReply } from 'fastify';
import { ResendInboundClient } from '../external/resend-inbound.client';
import { EmailIngestionRepository } from '../data/email-ingestion.repository';
import { EmailIngestionService } from '../domain/email-ingestion.service';
import { logger } from '../../../core/logging/logger';
import type { ResendWebhookEvent } from '../domain/email-ingestion.types';
export class EmailIngestionController {
private resendClient: ResendInboundClient;
private repository: EmailIngestionRepository;
private service: EmailIngestionService;
constructor() {
this.resendClient = new ResendInboundClient();
this.repository = new EmailIngestionRepository();
this.service = new EmailIngestionService();
}
// ========================
// Pending Association Endpoints (JWT-authenticated)
// ========================
async getPendingAssociations(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const associations = await this.repository.getPendingAssociations(userId);
return reply.code(200).send(associations);
} catch (error: any) {
logger.error('Error listing pending associations', { error: error.message, userId: (request as any).user?.sub });
return reply.code(500).send({ error: 'Failed to list pending associations' });
}
}
async getPendingAssociationCount(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const userId = (request as any).user.sub;
const count = await this.repository.getPendingAssociationCount(userId);
return reply.code(200).send({ count });
} catch (error: any) {
logger.error('Error counting pending associations', { error: error.message, userId: (request as any).user?.sub });
return reply.code(500).send({ error: 'Failed to count pending associations' });
}
}
async resolveAssociation(
request: FastifyRequest<{ Params: { id: string }; Body: { vehicleId: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const { id } = request.params;
const { vehicleId } = request.body;
if (!vehicleId || typeof vehicleId !== 'string') {
return reply.code(400).send({ error: 'vehicleId is required' });
}
const result = await this.service.resolveAssociation(id, vehicleId, userId);
return reply.code(200).send(result);
} catch (error: any) {
const userId = (request as any).user?.sub;
logger.error('Error resolving pending association', {
error: error.message,
associationId: request.params.id,
userId,
});
if (error.message === 'Pending association not found' || error.message === 'Vehicle not found') {
return reply.code(404).send({ error: error.message });
}
if (error.message === 'Unauthorized') {
return reply.code(403).send({ error: 'Not authorized' });
}
if (error.message === 'Association already resolved') {
return reply.code(409).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to resolve association' });
}
}
async dismissAssociation(
request: FastifyRequest<{ Params: { id: string } }>,
reply: FastifyReply
): Promise<void> {
try {
const userId = (request as any).user.sub;
const { id } = request.params;
await this.service.dismissAssociation(id, userId);
return reply.code(204).send();
} catch (error: any) {
const userId = (request as any).user?.sub;
logger.error('Error dismissing pending association', {
error: error.message,
associationId: request.params.id,
userId,
});
if (error.message === 'Pending association not found') {
return reply.code(404).send({ error: error.message });
}
if (error.message === 'Unauthorized') {
return reply.code(403).send({ error: 'Not authorized' });
}
if (error.message === 'Association already resolved') {
return reply.code(409).send({ error: error.message });
}
return reply.code(500).send({ error: 'Failed to dismiss association' });
}
}
// ========================
// Webhook Endpoint (Public)
// ========================
async handleInboundWebhook(request: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
const rawBody = (request as any).rawBody;
if (!rawBody) {
logger.error('Missing raw body in Resend webhook request');
return reply.status(400).send({ error: 'Missing raw body' });
}
// Extract Svix headers for signature verification
const headers: Record<string, string> = {
'svix-id': (request.headers['svix-id'] as string) || '',
'svix-timestamp': (request.headers['svix-timestamp'] as string) || '',
'svix-signature': (request.headers['svix-signature'] as string) || '',
};
// Verify webhook signature
let event: ResendWebhookEvent;
try {
event = this.resendClient.verifyWebhookSignature(rawBody, headers);
} catch (error: any) {
logger.warn('Invalid Resend webhook signature', { error: error.message });
return reply.status(400).send({ error: 'Invalid signature' });
}
const emailId = event.data.email_id;
const senderEmail = event.data.from;
// Idempotency check: reject if email_id already exists in queue
const existing = await this.repository.findByEmailId(emailId);
if (existing) {
logger.info('Duplicate email webhook received, skipping', { emailId });
return reply.status(200).send({ received: true, duplicate: true });
}
// Insert queue record with status=pending via repository
await this.repository.insertQueueEntry({
emailId,
senderEmail,
userId: senderEmail, // Resolved to auth0_sub during processing
receivedAt: event.data.created_at || new Date().toISOString(),
subject: event.data.subject,
});
logger.info('Inbound email queued for processing', { emailId, senderEmail });
// Return 200 immediately before processing begins
reply.status(200).send({ received: true });
// Trigger async processing via setImmediate
setImmediate(() => {
this.service.processEmail(emailId, event).catch((error) => {
logger.error('Async email processing failed', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
});
} catch (error: any) {
logger.error('Resend webhook handler error', {
error: error.message,
stack: error.stack,
});
return reply.status(500).send({ error: 'Webhook processing failed' });
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* @ai-summary Resend inbound webhook + user-facing pending association routes
* @ai-context Public webhook (no JWT) + authenticated CRUD for pending vehicle associations
*/
import { FastifyPluginAsync } from 'fastify';
import { EmailIngestionController } from './email-ingestion.controller';
/** Public webhook route - no JWT auth, uses Svix signature verification */
export const emailIngestionWebhookRoutes: FastifyPluginAsync = async (fastify) => {
const controller = new EmailIngestionController();
// POST /api/webhooks/resend/inbound - PUBLIC endpoint (no JWT auth)
// Resend authenticates via webhook signature verification (Svix)
// rawBody MUST be enabled for signature verification to work
fastify.post(
'/webhooks/resend/inbound',
{
config: {
rawBody: true,
},
},
controller.handleInboundWebhook.bind(controller)
);
};
/** Authenticated user-facing routes for pending vehicle associations */
export const emailIngestionRoutes: FastifyPluginAsync = async (fastify) => {
const controller = new EmailIngestionController();
// GET /api/email-ingestion/pending - List pending associations for authenticated user
fastify.get('/email-ingestion/pending', {
preHandler: [fastify.authenticate],
handler: controller.getPendingAssociations.bind(controller),
});
// GET /api/email-ingestion/pending/count - Get count of pending associations
fastify.get('/email-ingestion/pending/count', {
preHandler: [fastify.authenticate],
handler: controller.getPendingAssociationCount.bind(controller),
});
// POST /api/email-ingestion/pending/:id/resolve - Resolve by selecting vehicle
fastify.post<{ Params: { id: string }; Body: { vehicleId: string } }>(
'/email-ingestion/pending/:id/resolve',
{
preHandler: [fastify.authenticate],
handler: controller.resolveAssociation.bind(controller),
}
);
// DELETE /api/email-ingestion/pending/:id - Dismiss/discard a pending association
fastify.delete<{ Params: { id: string } }>(
'/email-ingestion/pending/:id',
{
preHandler: [fastify.authenticate],
handler: controller.dismissAssociation.bind(controller),
}
);
};

View File

@@ -0,0 +1,257 @@
/**
* @ai-summary Data access layer for email ingestion queue and pending vehicle associations
* @ai-context Provides CRUD operations with standard mapRow() snake_case -> camelCase conversion
*/
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import type {
EmailIngestionQueueRecord,
EmailIngestionStatus,
EmailProcessingResult,
PendingVehicleAssociation,
PendingAssociationStatus,
EmailRecordType,
ExtractedReceiptData,
} from '../domain/email-ingestion.types';
export class EmailIngestionRepository {
constructor(private readonly db: Pool = pool) {}
// ========================
// Row Mappers
// ========================
private mapQueueRow(row: any): EmailIngestionQueueRecord {
return {
id: row.id,
emailId: row.email_id,
senderEmail: row.sender_email,
userId: row.user_id,
receivedAt: row.received_at,
subject: row.subject,
status: row.status,
processingResult: row.processing_result,
errorMessage: row.error_message,
retryCount: row.retry_count,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
private mapPendingAssociationRow(row: any): PendingVehicleAssociation {
return {
id: row.id,
userId: row.user_id,
recordType: row.record_type,
extractedData: row.extracted_data,
documentId: row.document_id,
status: row.status,
createdAt: row.created_at,
resolvedAt: row.resolved_at,
};
}
// ========================
// Queue Operations
// ========================
async insertQueueEntry(entry: {
emailId: string;
senderEmail: string;
userId: string;
receivedAt: string;
subject: string | null;
}): Promise<EmailIngestionQueueRecord> {
try {
const res = await this.db.query(
`INSERT INTO email_ingestion_queue
(email_id, sender_email, user_id, received_at, subject, status)
VALUES ($1, $2, $3, $4, $5, 'pending')
RETURNING *`,
[
entry.emailId,
entry.senderEmail,
entry.userId,
entry.receivedAt,
entry.subject,
]
);
return this.mapQueueRow(res.rows[0]);
} catch (error) {
logger.error('Error inserting queue entry', { error, emailId: entry.emailId });
throw error;
}
}
async updateQueueStatus(
emailId: string,
status: EmailIngestionStatus,
updates?: {
processingResult?: EmailProcessingResult;
errorMessage?: string;
retryCount?: number;
userId?: string;
}
): Promise<EmailIngestionQueueRecord | null> {
try {
const fields: string[] = ['status = $2'];
const params: any[] = [emailId, status];
let paramIndex = 3;
if (updates?.processingResult !== undefined) {
fields.push(`processing_result = $${paramIndex++}`);
params.push(JSON.stringify(updates.processingResult));
}
if (updates?.errorMessage !== undefined) {
fields.push(`error_message = $${paramIndex++}`);
params.push(updates.errorMessage);
}
if (updates?.retryCount !== undefined) {
fields.push(`retry_count = $${paramIndex++}`);
params.push(updates.retryCount);
}
if (updates?.userId !== undefined) {
fields.push(`user_id = $${paramIndex++}`);
params.push(updates.userId);
}
const res = await this.db.query(
`UPDATE email_ingestion_queue
SET ${fields.join(', ')}
WHERE email_id = $1
RETURNING *`,
params
);
return res.rows[0] ? this.mapQueueRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error updating queue status', { error, emailId, status });
throw error;
}
}
async getQueueEntry(emailId: string): Promise<EmailIngestionQueueRecord | null> {
try {
const res = await this.db.query(
`SELECT * FROM email_ingestion_queue WHERE email_id = $1`,
[emailId]
);
return res.rows[0] ? this.mapQueueRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error fetching queue entry', { error, emailId });
throw error;
}
}
async findByEmailId(emailId: string): Promise<EmailIngestionQueueRecord | null> {
return this.getQueueEntry(emailId);
}
async getRetryableEntries(maxRetries: number = 3): Promise<EmailIngestionQueueRecord[]> {
try {
const res = await this.db.query(
`SELECT * FROM email_ingestion_queue
WHERE status = 'failed'
AND retry_count < $1
ORDER BY created_at ASC`,
[maxRetries]
);
return res.rows.map(row => this.mapQueueRow(row));
} catch (error) {
logger.error('Error fetching retryable entries', { error });
throw error;
}
}
// ========================
// Pending Association Operations
// ========================
async insertPendingAssociation(association: {
userId: string;
recordType: EmailRecordType;
extractedData: ExtractedReceiptData;
documentId: string | null;
}): Promise<PendingVehicleAssociation> {
try {
const res = await this.db.query(
`INSERT INTO pending_vehicle_associations
(user_id, record_type, extracted_data, document_id, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING *`,
[
association.userId,
association.recordType,
JSON.stringify(association.extractedData),
association.documentId,
]
);
return this.mapPendingAssociationRow(res.rows[0]);
} catch (error) {
logger.error('Error inserting pending association', { error, userId: association.userId });
throw error;
}
}
async getPendingAssociationById(associationId: string): Promise<PendingVehicleAssociation | null> {
try {
const res = await this.db.query(
`SELECT * FROM pending_vehicle_associations WHERE id = $1`,
[associationId]
);
return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error fetching pending association by id', { error, associationId });
throw error;
}
}
async getPendingAssociationCount(userId: string): Promise<number> {
try {
const res = await this.db.query(
`SELECT COUNT(*)::int AS count FROM pending_vehicle_associations
WHERE user_id = $1 AND status = 'pending'`,
[userId]
);
return res.rows[0]?.count ?? 0;
} catch (error) {
logger.error('Error counting pending associations', { error, userId });
throw error;
}
}
async getPendingAssociations(userId: string): Promise<PendingVehicleAssociation[]> {
try {
const res = await this.db.query(
`SELECT * FROM pending_vehicle_associations
WHERE user_id = $1 AND status = 'pending'
ORDER BY created_at DESC`,
[userId]
);
return res.rows.map(row => this.mapPendingAssociationRow(row));
} catch (error) {
logger.error('Error fetching pending associations', { error, userId });
throw error;
}
}
async resolvePendingAssociation(
associationId: string,
status: PendingAssociationStatus = 'resolved'
): Promise<PendingVehicleAssociation | null> {
try {
const res = await this.db.query(
`UPDATE pending_vehicle_associations
SET status = $2, resolved_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING *`,
[associationId, status]
);
return res.rows[0] ? this.mapPendingAssociationRow(res.rows[0]) : null;
} catch (error) {
logger.error('Error resolving pending association', { error, associationId });
throw error;
}
}
}

View File

@@ -0,0 +1,844 @@
/**
* @ai-summary Core processing service for the email-to-record pipeline
* @ai-context Orchestrates sender validation, OCR extraction, record classification,
* vehicle association, status tracking, and retry logic. Delegates all notifications
* (emails, in-app, logging) to EmailIngestionNotificationHandler.
*/
import { Pool } from 'pg';
import pool from '../../../core/config/database';
import { logger } from '../../../core/logging/logger';
import { EmailIngestionRepository } from '../data/email-ingestion.repository';
import { ResendInboundClient, type ParsedEmailAttachment } from '../external/resend-inbound.client';
import { UserProfileRepository } from '../../user-profile/data/user-profile.repository';
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
import { NotificationsRepository } from '../../notifications/data/notifications.repository';
import { TemplateService } from '../../notifications/domain/template.service';
import { EmailService } from '../../notifications/domain/email.service';
import { ocrService } from '../../ocr/domain/ocr.service';
import type { ReceiptExtractionResponse } from '../../ocr/domain/ocr.types';
import { ReceiptClassifier } from './receipt-classifier';
import { EmailIngestionNotificationHandler } from './notification-handler';
import { FuelLogsService } from '../../fuel-logs/domain/fuel-logs.service';
import { FuelLogsRepository } from '../../fuel-logs/data/fuel-logs.repository';
import { FuelType } from '../../fuel-logs/domain/fuel-logs.types';
import type { EnhancedCreateFuelLogRequest } from '../../fuel-logs/domain/fuel-logs.types';
import { MaintenanceService } from '../../maintenance/domain/maintenance.service';
import type { MaintenanceCategory } from '../../maintenance/domain/maintenance.types';
import { validateSubtypes, getSubtypesForCategory } from '../../maintenance/domain/maintenance.types';
import type {
ResendWebhookEvent,
EmailProcessingResult,
ExtractedReceiptData,
EmailRecordType,
} from './email-ingestion.types';
/** Supported attachment MIME types */
const SUPPORTED_ATTACHMENT_TYPES = new Set([
'application/pdf',
'image/png',
'image/jpeg',
'image/heic',
'image/heif',
]);
/** Image types that work with receipt-specific OCR */
const OCR_RECEIPT_IMAGE_TYPES = new Set([
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
]);
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB
const MAX_RETRY_COUNT = 3;
export class EmailIngestionService {
private repository: EmailIngestionRepository;
private resendClient: ResendInboundClient;
private userProfileRepository: UserProfileRepository;
private vehiclesRepository: VehiclesRepository;
private notificationHandler: EmailIngestionNotificationHandler;
private classifier: ReceiptClassifier;
private fuelLogsService: FuelLogsService;
private maintenanceService: MaintenanceService;
constructor(dbPool?: Pool) {
const p = dbPool || pool;
this.repository = new EmailIngestionRepository(p);
this.resendClient = new ResendInboundClient();
this.userProfileRepository = new UserProfileRepository(p);
this.vehiclesRepository = new VehiclesRepository(p);
const notificationsRepository = new NotificationsRepository(p);
this.notificationHandler = new EmailIngestionNotificationHandler(
notificationsRepository,
new TemplateService(),
new EmailService(),
);
this.classifier = new ReceiptClassifier();
this.fuelLogsService = new FuelLogsService(new FuelLogsRepository(p));
this.maintenanceService = new MaintenanceService();
}
// ========================
// Main Processing Pipeline
// ========================
/**
* Process an inbound email through the full pipeline.
* Called asynchronously after webhook receipt is acknowledged.
*/
async processEmail(emailId: string, event: ResendWebhookEvent): Promise<void> {
const senderEmail = event.data.from;
const subject = event.data.subject;
try {
// 1. Mark as processing
await this.repository.updateQueueStatus(emailId, 'processing');
// 2. Validate sender
const userProfile = await this.validateSender(senderEmail);
if (!userProfile) {
await this.handleUnregisteredSender(emailId, senderEmail);
return;
}
const userId = userProfile.auth0Sub;
const userName = userProfile.displayName || userProfile.email;
// Update queue with resolved user_id
await this.repository.updateQueueStatus(emailId, 'processing', { userId });
// 3. Get attachments (from webhook data or by fetching raw email)
const attachments = await this.getAttachments(emailId, event);
// 4. Filter valid attachments
const validAttachments = this.filterAttachments(attachments);
if (validAttachments.length === 0) {
await this.handleNoValidAttachments(emailId, userId, userName, senderEmail);
return;
}
// 5. Classify receipt from email text first
const emailClassification = this.classifier.classifyFromText(subject, event.data.text);
logger.info('Email text classification result', {
emailId,
type: emailClassification.type,
confidence: emailClassification.confidence,
});
// 6. Process attachments through OCR using classification
const ocrResult = await this.processAttachmentsWithClassification(
userId, validAttachments, emailClassification, emailId
);
if (!ocrResult) {
await this.handleOcrFailure(emailId, userId, userName, senderEmail, 'No receipt data could be extracted from attachments');
return;
}
// 7. Build extracted data from OCR result
const extractedData = this.mapOcrToExtractedData(ocrResult.response);
const recordType = ocrResult.recordType;
// 8. Handle vehicle association
const processingResult = await this.handleVehicleAssociation(
userId, userName, senderEmail, recordType, extractedData
);
// 9. Mark as completed
await this.repository.updateQueueStatus(emailId, 'completed', {
processingResult,
});
logger.info('Email processing completed successfully', {
emailId,
userId,
recordType,
vehicleId: processingResult.vehicleId,
pendingAssociationId: processingResult.pendingAssociationId,
});
} catch (error) {
await this.handleProcessingError(emailId, senderEmail, subject, error);
}
}
// ========================
// Sender Validation
// ========================
private async validateSender(senderEmail: string): Promise<{
auth0Sub: string;
email: string;
displayName: string | null;
} | null> {
// Case-insensitive lookup by lowercasing the sender email
const profile = await this.userProfileRepository.getByEmail(senderEmail.toLowerCase());
if (profile) {
return {
auth0Sub: profile.auth0Sub,
email: profile.email,
displayName: profile.displayName ?? null,
};
}
// Try original case as fallback
if (senderEmail !== senderEmail.toLowerCase()) {
const fallback = await this.userProfileRepository.getByEmail(senderEmail);
if (fallback) {
return {
auth0Sub: fallback.auth0Sub,
email: fallback.email,
displayName: fallback.displayName ?? null,
};
}
}
return null;
}
// ========================
// Attachment Handling
// ========================
/**
* Get attachments from webhook data or by fetching the raw email
*/
private async getAttachments(
emailId: string,
event: ResendWebhookEvent
): Promise<ParsedEmailAttachment[]> {
// If webhook includes attachments with content, use those
if (event.data.attachments && event.data.attachments.length > 0) {
return event.data.attachments.map(att => ({
filename: att.filename,
contentType: att.content_type,
content: Buffer.from(att.content, 'base64'),
size: Buffer.from(att.content, 'base64').length,
}));
}
// Otherwise fetch and parse the raw email
try {
const { downloadUrl } = await this.resendClient.getEmail(emailId);
const rawEmail = await this.resendClient.downloadRawEmail(downloadUrl);
const parsed = await this.resendClient.parseEmail(rawEmail);
return parsed.attachments;
} catch (error) {
logger.warn('Failed to fetch raw email for attachments', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
/**
* Filter attachments by supported type and size
*/
private filterAttachments(attachments: ParsedEmailAttachment[]): ParsedEmailAttachment[] {
return attachments.filter(att => {
if (!SUPPORTED_ATTACHMENT_TYPES.has(att.contentType)) {
logger.info('Skipping unsupported attachment type', {
filename: att.filename,
contentType: att.contentType,
});
return false;
}
if (att.size > MAX_ATTACHMENT_SIZE) {
logger.info('Skipping oversized attachment', {
filename: att.filename,
size: att.size,
maxSize: MAX_ATTACHMENT_SIZE,
});
return false;
}
return true;
});
}
// ========================
// OCR Processing
// ========================
/**
* Process attachments using classifier-driven OCR extraction.
* If email text classification is confident, calls the specific OCR endpoint.
* If not, performs general OCR and classifies from rawText.
* Returns null if no usable result or receipt is unclassified.
*/
private async processAttachmentsWithClassification(
userId: string,
attachments: ParsedEmailAttachment[],
emailClassification: { type: string; confidence: number },
emailId: string
): Promise<{ response: ReceiptExtractionResponse; recordType: EmailRecordType } | null> {
const imageAttachments = attachments.filter(att => OCR_RECEIPT_IMAGE_TYPES.has(att.contentType));
for (const attachment of imageAttachments) {
// If email text gave a confident classification, call the specific OCR endpoint first
if (emailClassification.type === 'fuel') {
const result = await this.extractFuelReceipt(userId, attachment);
if (result?.success) return { response: result, recordType: 'fuel_log' };
// Fuel OCR failed, try maintenance as fallback
const fallbackResult = await this.extractMaintenanceReceipt(userId, attachment);
if (fallbackResult?.success) return { response: fallbackResult, recordType: 'maintenance_record' };
continue;
}
if (emailClassification.type === 'maintenance') {
const result = await this.extractMaintenanceReceipt(userId, attachment);
if (result?.success) return { response: result, recordType: 'maintenance_record' };
// Maintenance OCR failed, try fuel as fallback
const fallbackResult = await this.extractFuelReceipt(userId, attachment);
if (fallbackResult?.success) return { response: fallbackResult, recordType: 'fuel_log' };
continue;
}
// Email text was not confident - try both OCR endpoints and classify from rawText
const fuelResult = await this.extractFuelReceipt(userId, attachment);
const maintenanceResult = await this.extractMaintenanceReceipt(userId, attachment);
// Use rawText from whichever succeeded for secondary classification
const rawText = fuelResult?.rawText || maintenanceResult?.rawText || '';
if (rawText) {
const ocrClassification = this.classifier.classifyFromOcrRawText(rawText);
logger.info('OCR rawText classification result', {
emailId,
type: ocrClassification.type,
confidence: ocrClassification.confidence,
});
if (ocrClassification.type === 'fuel' && fuelResult?.success) {
return { response: fuelResult, recordType: 'fuel_log' };
}
if (ocrClassification.type === 'maintenance' && maintenanceResult?.success) {
return { response: maintenanceResult, recordType: 'maintenance_record' };
}
}
// Both classifiers failed - fall back to field-count heuristic
const fallback = this.selectBestResultByFields(fuelResult, maintenanceResult);
if (fallback) return fallback;
}
return null;
}
/**
* Extract fuel receipt via OCR. Returns null on failure.
*/
private async extractFuelReceipt(
userId: string,
attachment: ParsedEmailAttachment
): Promise<ReceiptExtractionResponse | null> {
try {
return await ocrService.extractReceipt(userId, {
fileBuffer: attachment.content,
contentType: attachment.contentType,
receiptType: 'fuel',
});
} catch (error) {
logger.info('Fuel receipt extraction failed', {
filename: attachment.filename,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Extract maintenance receipt via OCR. Returns null on failure.
*/
private async extractMaintenanceReceipt(
userId: string,
attachment: ParsedEmailAttachment
): Promise<ReceiptExtractionResponse | null> {
try {
return await ocrService.extractMaintenanceReceipt(userId, {
fileBuffer: attachment.content,
contentType: attachment.contentType,
});
} catch (error) {
logger.info('Maintenance receipt extraction failed', {
filename: attachment.filename,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}
/**
* Last-resort fallback: select the better OCR result based on domain-specific
* fields and field count when keyword classifiers could not decide.
*/
private selectBestResultByFields(
fuelResult: ReceiptExtractionResponse | null,
maintenanceResult: ReceiptExtractionResponse | null
): { response: ReceiptExtractionResponse; recordType: EmailRecordType } | null {
const fuelFieldCount = fuelResult?.success
? Object.keys(fuelResult.extractedFields).length
: 0;
const maintenanceFieldCount = maintenanceResult?.success
? Object.keys(maintenanceResult.extractedFields).length
: 0;
if (fuelFieldCount === 0 && maintenanceFieldCount === 0) {
return null;
}
const hasFuelFields = fuelResult?.extractedFields['gallons'] ||
fuelResult?.extractedFields['price_per_gallon'] ||
fuelResult?.extractedFields['fuel_type'];
const hasMaintenanceFields = maintenanceResult?.extractedFields['category'] ||
maintenanceResult?.extractedFields['shop_name'] ||
maintenanceResult?.extractedFields['description'];
if (hasFuelFields && !hasMaintenanceFields) {
return { response: fuelResult!, recordType: 'fuel_log' };
}
if (hasMaintenanceFields && !hasFuelFields) {
return { response: maintenanceResult!, recordType: 'maintenance_record' };
}
if (fuelFieldCount >= maintenanceFieldCount && fuelResult?.success) {
return { response: fuelResult, recordType: 'fuel_log' };
}
if (maintenanceResult?.success) {
return { response: maintenanceResult, recordType: 'maintenance_record' };
}
return null;
}
/**
* Map OCR extracted fields to our ExtractedReceiptData format
*/
private mapOcrToExtractedData(response: ReceiptExtractionResponse): ExtractedReceiptData {
const fields = response.extractedFields;
const getFieldValue = (key: string): string | null =>
fields[key]?.value || null;
const getFieldNumber = (key: string): number | null => {
const val = fields[key]?.value;
if (!val) return null;
const num = parseFloat(val);
return isNaN(num) ? null : num;
};
return {
vendor: getFieldValue('vendor') || getFieldValue('shop_name'),
date: getFieldValue('date'),
total: getFieldNumber('total'),
odometerReading: getFieldNumber('odometer') || getFieldNumber('odometer_reading'),
gallons: getFieldNumber('gallons'),
pricePerGallon: getFieldNumber('price_per_gallon'),
fuelType: getFieldValue('fuel_type'),
category: getFieldValue('category'),
subtypes: fields['subtypes']?.value ? fields['subtypes'].value.split(',').map(s => s.trim()) : null,
shopName: getFieldValue('shop_name'),
description: getFieldValue('description'),
};
}
// ========================
// Vehicle Association
// ========================
/**
* Handle vehicle association based on user's vehicle count.
* No vehicles: send error email.
* Single vehicle: auto-associate and create record.
* Multiple vehicles: create pending association for user selection.
*/
private async handleVehicleAssociation(
userId: string,
userName: string,
userEmail: string,
recordType: EmailRecordType,
extractedData: ExtractedReceiptData
): Promise<EmailProcessingResult> {
const vehicles = await this.vehiclesRepository.findByUserId(userId);
// No vehicles: user must add a vehicle first
if (vehicles.length === 0) {
await this.notificationHandler.notifyNoVehicles(userId, userName, userEmail);
return {
recordType,
vehicleId: null,
recordId: null,
documentId: null,
pendingAssociationId: null,
extractedData,
};
}
// Single vehicle: auto-associate and create record
if (vehicles.length === 1) {
const vehicle = vehicles[0];
let recordId: string | null = null;
try {
recordId = await this.createRecord(userId, vehicle.id, recordType, extractedData);
} catch (error) {
logger.error('Failed to create record from email receipt', {
userId,
vehicleId: vehicle.id,
recordType,
error: error instanceof Error ? error.message : String(error),
});
}
const vehicleName = vehicle.nickname
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|| 'your vehicle';
await this.notificationHandler.notifyReceiptProcessed({
userId,
userName,
userEmail,
vehicleName,
recordType,
recordId,
vehicleId: vehicle.id,
extractedData,
});
return {
recordType,
vehicleId: vehicle.id,
recordId,
documentId: null,
pendingAssociationId: null,
extractedData,
};
}
// Multiple vehicles: create pending association for user selection
const pendingAssociation = await this.repository.insertPendingAssociation({
userId,
recordType,
extractedData,
documentId: null,
});
await this.notificationHandler.notifyPendingVehicleSelection({
userId,
userName,
userEmail,
recordType,
pendingAssociationId: pendingAssociation.id,
extractedData,
});
return {
recordType,
vehicleId: null,
recordId: null,
documentId: null,
pendingAssociationId: pendingAssociation.id,
extractedData,
};
}
// ========================
// Public Resolution API
// ========================
/**
* Resolve a pending vehicle association by creating the record with the selected vehicle.
* Called from the user-facing API when a multi-vehicle user picks a vehicle.
*/
async resolveAssociation(
associationId: string,
vehicleId: string,
userId: string
): Promise<{ recordId: string; recordType: EmailRecordType }> {
const association = await this.repository.getPendingAssociationById(associationId);
if (!association) {
throw new Error('Pending association not found');
}
if (association.userId !== userId) {
throw new Error('Unauthorized');
}
if (association.status !== 'pending') {
throw new Error('Association already resolved');
}
// Verify vehicle belongs to user
const vehicles = await this.vehiclesRepository.findByUserId(userId);
const vehicle = vehicles.find(v => v.id === vehicleId);
if (!vehicle) {
throw new Error('Vehicle not found');
}
// Create the record
const recordId = await this.createRecord(userId, vehicleId, association.recordType, association.extractedData);
// Mark as resolved
await this.repository.resolvePendingAssociation(associationId, 'resolved');
logger.info('Pending association resolved', { associationId, vehicleId, userId, recordType: association.recordType, recordId });
return { recordId, recordType: association.recordType };
}
/**
* Dismiss a pending vehicle association without creating a record.
*/
async dismissAssociation(associationId: string, userId: string): Promise<void> {
const association = await this.repository.getPendingAssociationById(associationId);
if (!association) {
throw new Error('Pending association not found');
}
if (association.userId !== userId) {
throw new Error('Unauthorized');
}
if (association.status !== 'pending') {
throw new Error('Association already resolved');
}
await this.repository.resolvePendingAssociation(associationId, 'expired');
logger.info('Pending association dismissed', { associationId, userId });
}
// ========================
// Record Creation
// ========================
/**
* Create a fuel log or maintenance record from extracted receipt data.
* Returns the created record ID.
*/
private async createRecord(
userId: string,
vehicleId: string,
recordType: EmailRecordType,
extractedData: ExtractedReceiptData
): Promise<string> {
if (recordType === 'fuel_log') {
return this.createFuelLogRecord(userId, vehicleId, extractedData);
}
return this.createMaintenanceRecord(userId, vehicleId, extractedData);
}
/**
* Map extracted receipt data to EnhancedCreateFuelLogRequest and create fuel log.
*/
private async createFuelLogRecord(
userId: string,
vehicleId: string,
data: ExtractedReceiptData
): Promise<string> {
const fuelUnits = data.gallons ?? 0;
const costPerUnit = data.pricePerGallon ?? (data.total && fuelUnits > 0 ? data.total / fuelUnits : 0);
const request: EnhancedCreateFuelLogRequest = {
vehicleId,
dateTime: data.date || new Date().toISOString(),
fuelType: this.mapFuelType(data.fuelType),
fuelUnits,
costPerUnit,
odometerReading: data.odometerReading ?? undefined,
locationData: data.vendor ? { stationName: data.vendor } : undefined,
notes: 'Created from emailed receipt',
};
logger.info('Creating fuel log from email receipt', { userId, vehicleId, fuelUnits, costPerUnit });
const result = await this.fuelLogsService.createFuelLog(request, userId);
return result.id;
}
/**
* Map extracted receipt data to CreateMaintenanceRecordRequest and create maintenance record.
*/
private async createMaintenanceRecord(
userId: string,
vehicleId: string,
data: ExtractedReceiptData
): Promise<string> {
const category = this.mapMaintenanceCategory(data.category);
const subtypes = this.resolveMaintenanceSubtypes(category, data.subtypes);
const record = await this.maintenanceService.createRecord(userId, {
vehicleId,
category,
subtypes,
date: data.date || new Date().toISOString().split('T')[0],
odometerReading: data.odometerReading ?? undefined,
cost: data.total ?? undefined,
shopName: data.shopName || data.vendor || undefined,
notes: data.description
? `${data.description}\n\nCreated from emailed receipt`
: 'Created from emailed receipt',
});
logger.info('Created maintenance record from email receipt', { userId, vehicleId, recordId: record.id, category });
return record.id;
}
/**
* Map OCR fuel type string to FuelType enum. Defaults to gasoline.
*/
private mapFuelType(fuelTypeStr: string | null): FuelType {
if (!fuelTypeStr) return FuelType.GASOLINE;
const normalized = fuelTypeStr.toLowerCase().trim();
if (normalized.includes('diesel') || normalized === '#1' || normalized === '#2') {
return FuelType.DIESEL;
}
if (normalized.includes('electric') || normalized.includes('ev')) {
return FuelType.ELECTRIC;
}
return FuelType.GASOLINE;
}
/**
* Map OCR category string to MaintenanceCategory. Defaults to routine_maintenance.
*/
private mapMaintenanceCategory(categoryStr: string | null): MaintenanceCategory {
if (!categoryStr) return 'routine_maintenance';
const normalized = categoryStr.toLowerCase().trim();
if (normalized.includes('repair')) return 'repair';
if (normalized.includes('performance') || normalized.includes('upgrade')) return 'performance_upgrade';
return 'routine_maintenance';
}
/**
* Validate and resolve maintenance subtypes. Falls back to first valid
* subtype for the category if OCR subtypes are invalid or missing.
*/
private resolveMaintenanceSubtypes(
category: MaintenanceCategory,
ocrSubtypes: string[] | null
): string[] {
if (ocrSubtypes && ocrSubtypes.length > 0 && validateSubtypes(category, ocrSubtypes)) {
return ocrSubtypes;
}
// Attempt to match OCR subtypes against valid options (case-insensitive)
if (ocrSubtypes && ocrSubtypes.length > 0) {
const validOptions = getSubtypesForCategory(category);
const matched = ocrSubtypes
.map(s => validOptions.find(v => v.toLowerCase() === s.toLowerCase().trim()))
.filter((v): v is string => v !== undefined);
if (matched.length > 0) return matched;
}
// Default to first subtype of category
const defaults = getSubtypesForCategory(category);
return [defaults[0] as string];
}
// ========================
// Error Handling & Retries
// ========================
private async handleProcessingError(
emailId: string,
senderEmail: string,
_subject: string | null,
error: unknown
): Promise<void> {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Email processing pipeline error', { emailId, error: errorMessage });
// Get current queue entry for retry count and userId
const queueEntry = await this.repository.getQueueEntry(emailId);
const currentRetryCount = queueEntry?.retryCount || 0;
const newRetryCount = currentRetryCount + 1;
if (newRetryCount < MAX_RETRY_COUNT) {
// Mark for retry
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage,
retryCount: newRetryCount,
});
logger.info('Email queued for retry', {
emailId,
retryCount: newRetryCount,
maxRetries: MAX_RETRY_COUNT,
});
} else {
// Max retries exceeded - permanently failed
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: `Max retries (${MAX_RETRY_COUNT}) exceeded. Last error: ${errorMessage}`,
retryCount: newRetryCount,
});
// Send failure notification (email + in-app if userId available)
await this.notificationHandler.notifyProcessingFailure({
userId: queueEntry?.userId,
userEmail: senderEmail,
errorReason: errorMessage,
}).catch(notifyErr => {
logger.error('Failed to send failure notification', {
emailId,
error: notifyErr instanceof Error ? notifyErr.message : String(notifyErr),
});
});
}
}
private async handleUnregisteredSender(
emailId: string,
senderEmail: string
): Promise<void> {
logger.info('Unregistered sender rejected', { emailId, senderEmail });
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: 'Sender email is not registered with MotoVaultPro',
});
await this.notificationHandler.notifyUnregisteredSender(senderEmail).catch(error => {
logger.error('Failed to send unregistered sender notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async handleNoValidAttachments(
emailId: string,
userId: string,
userName: string,
userEmail: string
): Promise<void> {
logger.info('No valid attachments found', { emailId });
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: 'No valid attachments found. Supported types: PDF, PNG, JPG, JPEG, HEIC (max 10MB each)',
});
await this.notificationHandler.notifyNoValidAttachments(userId, userName, userEmail).catch(error => {
logger.error('Failed to send no-attachments notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
}
private async handleOcrFailure(
emailId: string,
userId: string,
userName: string,
userEmail: string,
reason: string
): Promise<void> {
logger.info('OCR extraction failed for all attachments', { emailId, reason });
await this.repository.updateQueueStatus(emailId, 'failed', {
errorMessage: reason,
});
await this.notificationHandler.notifyOcrFailure(userId, userName, userEmail, reason).catch(error => {
logger.error('Failed to send OCR failure notification', {
emailId,
error: error instanceof Error ? error.message : String(error),
});
});
}
}

View File

@@ -0,0 +1,114 @@
/**
* @ai-summary TypeScript types for the email ingestion feature
* @ai-context Covers database records, status enums, and Resend webhook payloads
*/
// ========================
// Status Enums
// ========================
export type EmailIngestionStatus = 'pending' | 'processing' | 'completed' | 'failed';
export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired';
export type EmailRecordType = 'fuel_log' | 'maintenance_record';
// ========================
// Receipt Classification
// ========================
export type ReceiptClassificationType = 'fuel' | 'maintenance' | 'unclassified';
export interface ClassificationResult {
type: ReceiptClassificationType;
confidence: number;
}
// ========================
// Database Records
// ========================
export interface EmailIngestionQueueRecord {
id: string;
emailId: string;
senderEmail: string;
userId: string;
receivedAt: string;
subject: string | null;
status: EmailIngestionStatus;
processingResult: EmailProcessingResult | null;
errorMessage: string | null;
retryCount: number;
createdAt: string;
updatedAt: string;
}
export interface PendingVehicleAssociation {
id: string;
userId: string;
recordType: EmailRecordType;
extractedData: ExtractedReceiptData;
documentId: string | null;
status: PendingAssociationStatus;
createdAt: string;
resolvedAt: string | null;
}
// ========================
// Processing Results
// ========================
export interface EmailProcessingResult {
recordType: EmailRecordType;
vehicleId: string | null;
recordId: string | null;
documentId: string | null;
pendingAssociationId: string | null;
extractedData: ExtractedReceiptData;
}
export interface ExtractedReceiptData {
vendor: string | null;
date: string | null;
total: number | null;
odometerReading: number | null;
/** Fuel-specific fields */
gallons: number | null;
pricePerGallon: number | null;
fuelType: string | null;
/** Maintenance-specific fields */
category: string | null;
subtypes: string[] | null;
shopName: string | null;
description: string | null;
}
// ========================
// Resend Webhook Payloads
// ========================
/** Top-level Resend webhook event envelope */
export interface ResendWebhookEvent {
type: string;
created_at: string;
data: ResendWebhookEventData;
}
/** Resend email.received webhook event data */
export interface ResendWebhookEventData {
email_id: string;
from: string;
to: string[];
subject: string;
text: string | null;
html: string | null;
created_at: string;
attachments: ResendEmailAttachment[];
}
/** Attachment metadata from Resend inbound email */
export interface ResendEmailAttachment {
filename: string;
content_type: string;
content: string;
}

View File

@@ -0,0 +1,333 @@
/**
* @ai-summary Notification handler for the email ingestion pipeline
* @ai-context Encapsulates all email replies, in-app notifications, and notification logging
* for the email-to-record flow. Every email sent is logged to notification_logs.
*/
import { logger } from '../../../core/logging/logger';
import { NotificationsRepository } from '../../notifications/data/notifications.repository';
import { TemplateService } from '../../notifications/domain/template.service';
import { EmailService } from '../../notifications/domain/email.service';
import type { TemplateKey } from '../../notifications/domain/notifications.types';
import type { EmailRecordType, ExtractedReceiptData } from './email-ingestion.types';
export class EmailIngestionNotificationHandler {
constructor(
private notificationsRepository: NotificationsRepository,
private templateService: TemplateService,
private emailService: EmailService,
) {}
// ========================
// Success Notifications
// ========================
/**
* Notify user that their emailed receipt was successfully processed.
* Sends confirmation email + creates in-app notification + logs to notification_logs.
*/
async notifyReceiptProcessed(params: {
userId: string;
userName: string;
userEmail: string;
vehicleName: string;
recordType: EmailRecordType;
recordId: string | null;
vehicleId: string;
extractedData: ExtractedReceiptData;
}): Promise<void> {
const { userId, userName, userEmail, vehicleName, recordType, recordId, vehicleId, extractedData } = params;
const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown';
// In-app notification
const message = recordId
? `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} has been processed and recorded for ${vehicleName}.`
: `Your emailed ${recordLabel.toLowerCase()} receipt from ${merchantName} was processed but the record could not be created automatically. Please add it manually.`;
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: recordId ? 'Receipt Processed' : 'Receipt Partially Processed',
message,
referenceType: recordType,
referenceId: recordId ?? undefined,
vehicleId,
});
// Confirmation email
await this.sendTemplateEmail({
templateKey: 'receipt_processed',
userId,
userEmail,
variables: {
userName,
vehicleName,
recordType: recordLabel,
merchantName,
totalAmount: extractedData.total?.toFixed(2) || 'N/A',
date: extractedData.date || 'N/A',
},
referenceType: 'email_ingestion',
referenceId: recordId ?? undefined,
});
}
// ========================
// Pending Vehicle Notification
// ========================
/**
* Notify multi-vehicle user that their receipt needs vehicle selection.
* Sends pending-vehicle email + creates in-app notification + logs to notification_logs.
*/
async notifyPendingVehicleSelection(params: {
userId: string;
userName: string;
userEmail: string;
recordType: EmailRecordType;
pendingAssociationId: string;
extractedData: ExtractedReceiptData;
}): Promise<void> {
const { userId, userName, userEmail, recordType, pendingAssociationId, extractedData } = params;
const recordLabel = recordType === 'fuel_log' ? 'Fuel Log' : 'Maintenance Record';
const merchantName = extractedData.vendor || extractedData.shopName || 'Unknown';
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Vehicle Selection Required',
message: `Your emailed receipt from ${merchantName} has been processed. Please select which vehicle this ${recordLabel.toLowerCase()} belongs to.`,
referenceType: 'pending_vehicle_association',
referenceId: pendingAssociationId,
});
// Pending vehicle email
await this.sendTemplateEmail({
templateKey: 'receipt_pending_vehicle',
userId,
userEmail,
variables: {
userName,
recordType: recordLabel,
merchantName,
totalAmount: extractedData.total?.toFixed(2) || 'N/A',
date: extractedData.date || 'N/A',
},
referenceType: 'email_ingestion',
referenceId: pendingAssociationId,
});
}
// ========================
// Error Notifications
// ========================
/**
* Notify unregistered sender that their email was rejected.
* Email reply only (no in-app notification since no user account).
*/
async notifyUnregisteredSender(userEmail: string): Promise<void> {
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userEmail,
variables: {
userName: 'MotoVaultPro User',
errorReason: 'This email address is not registered with MotoVaultPro.',
guidance: 'Please send receipts from the email address associated with your account. You can check your registered email in your MotoVaultPro profile settings.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user that they must add a vehicle before emailing receipts.
* Sends error email + creates in-app notification + logs to notification_logs.
*/
async notifyNoVehicles(userId: string, userName: string, userEmail: string): Promise<void> {
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: 'Your emailed receipt could not be processed because you have no vehicles registered. Please add a vehicle first, then re-send your receipt.',
});
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName,
errorReason: 'You do not have any vehicles registered in MotoVaultPro.',
guidance: 'Please add a vehicle first in the MotoVaultPro app, then re-send your receipt.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user that no valid attachments were found in their email.
* Sends error email + creates in-app notification + logs to notification_logs.
*/
async notifyNoValidAttachments(userId: string, userName: string, userEmail: string): Promise<void> {
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: 'No valid attachments were found in your email. Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB.',
});
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName,
errorReason: 'No valid attachments were found in your email.',
guidance: 'Please attach receipt images (PNG, JPG, JPEG, HEIC) or PDFs under 10MB. Make sure your receipt is clearly visible in the image.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user that OCR extraction failed after all attempts.
* Sends error email + creates in-app notification + logs to notification_logs.
*/
async notifyOcrFailure(userId: string, userName: string, userEmail: string, reason: string): Promise<void> {
// In-app notification
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: `We could not extract data from your receipt: ${reason}`,
});
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName,
errorReason: reason,
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
},
referenceType: 'email_ingestion',
});
}
/**
* Notify user of a general processing failure (after max retries exceeded).
* Sends error email + creates in-app notification (if userId available) + logs.
*/
async notifyProcessingFailure(params: {
userId?: string;
userName?: string;
userEmail: string;
errorReason: string;
}): Promise<void> {
const { userId, userName, userEmail, errorReason } = params;
// In-app notification (only if we have a userId)
if (userId) {
await this.notificationsRepository.insertUserNotification({
userId,
notificationType: 'email_ingestion',
title: 'Receipt Processing Failed',
message: `Your emailed receipt could not be processed: ${errorReason}`,
});
}
// Error email
await this.sendTemplateEmail({
templateKey: 'receipt_failed',
userId,
userEmail,
variables: {
userName: userName || 'MotoVaultPro User',
errorReason,
guidance: 'Please try again or upload the receipt directly through the MotoVaultPro app.',
},
referenceType: 'email_ingestion',
});
}
// ========================
// Internal Helpers
// ========================
/**
* Send a templated email and log to notification_logs.
* Swallows errors to prevent notification failures from breaking the pipeline.
*/
private async sendTemplateEmail(params: {
templateKey: TemplateKey;
userId?: string;
userEmail: string;
variables: Record<string, string | number | boolean | null | undefined>;
referenceType?: string;
referenceId?: string;
}): Promise<void> {
const { templateKey, userId, userEmail, variables, referenceType, referenceId } = params;
try {
const template = await this.notificationsRepository.getEmailTemplateByKey(templateKey);
if (!template || !template.isActive) {
logger.warn('Email template not found or inactive', { templateKey });
return;
}
const renderedSubject = this.templateService.render(template.subject, variables);
const renderedHtml = this.templateService.renderEmailHtml(template.body, variables);
await this.emailService.send(userEmail, renderedSubject, renderedHtml);
// Log successful send
if (userId) {
await this.notificationsRepository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
subject: renderedSubject,
reference_type: referenceType,
reference_id: referenceId,
status: 'sent',
});
}
logger.info('Email ingestion notification sent', { templateKey, userEmail });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Failed to send email ingestion notification', {
templateKey,
userEmail,
error: errorMessage,
});
// Log failed send
if (userId) {
await this.notificationsRepository.insertNotificationLog({
user_id: userId,
notification_type: 'email',
template_key: templateKey,
recipient_email: userEmail,
reference_type: referenceType,
reference_id: referenceId,
status: 'failed',
error_message: errorMessage,
}).catch(logErr => {
logger.error('Failed to log notification failure', {
error: logErr instanceof Error ? logErr.message : String(logErr),
});
});
}
}
}
}

View File

@@ -0,0 +1,130 @@
/**
* @ai-summary Classifies receipt type from email text or OCR raw text
* @ai-context Uses keyword matching to determine fuel vs maintenance receipts
* before falling back to OCR-based classification. Returns confidence score.
*/
import { logger } from '../../../core/logging/logger';
import type { ClassificationResult, ReceiptClassificationType } from './email-ingestion.types';
/** Fuel-related keywords (case-insensitive matching) */
const FUEL_KEYWORDS: string[] = [
'gas',
'fuel',
'gallons',
'octane',
'pump',
'diesel',
'unleaded',
'shell',
'chevron',
'exxon',
'bp',
];
/** Maintenance-related keywords (case-insensitive matching). Multi-word entries matched as phrases. */
const MAINTENANCE_KEYWORDS: string[] = [
'oil change',
'brake',
'alignment',
'tire',
'rotation',
'inspection',
'labor',
'parts',
'service',
'repair',
'transmission',
'coolant',
];
/** Minimum keyword matches required for a confident classification */
const CONFIDENCE_THRESHOLD = 2;
export class ReceiptClassifier {
/**
* Classify receipt type from email subject and body text.
* Returns a confident result if >= 2 keyword matches for one type.
*/
classifyFromText(subject: string | null, body: string | null): ClassificationResult {
const text = [subject || '', body || ''].join(' ');
return this.classifyText(text, 'email');
}
/**
* Classify receipt type from OCR raw text output.
* Uses same keyword matching as email text classification.
*/
classifyFromOcrRawText(rawText: string): ClassificationResult {
return this.classifyText(rawText, 'ocr');
}
/**
* Core keyword matching logic shared by email and OCR classification.
*/
private classifyText(text: string, source: 'email' | 'ocr'): ClassificationResult {
const normalizedText = text.toLowerCase();
const fuelMatches = this.countKeywordMatches(normalizedText, FUEL_KEYWORDS);
const maintenanceMatches = this.countKeywordMatches(normalizedText, MAINTENANCE_KEYWORDS);
logger.info('Receipt classification keyword analysis', {
source,
fuelMatches,
maintenanceMatches,
textLength: text.length,
});
// Both below threshold - unclassified
if (fuelMatches < CONFIDENCE_THRESHOLD && maintenanceMatches < CONFIDENCE_THRESHOLD) {
return { type: 'unclassified', confidence: 0 };
}
// Clear winner with threshold met
if (fuelMatches >= CONFIDENCE_THRESHOLD && fuelMatches > maintenanceMatches) {
return {
type: 'fuel',
confidence: Math.min(fuelMatches / (fuelMatches + maintenanceMatches), 1),
};
}
if (maintenanceMatches >= CONFIDENCE_THRESHOLD && maintenanceMatches > fuelMatches) {
return {
type: 'maintenance',
confidence: Math.min(maintenanceMatches / (fuelMatches + maintenanceMatches), 1),
};
}
// Tie with both meeting threshold - unclassified (ambiguous)
if (fuelMatches >= CONFIDENCE_THRESHOLD && maintenanceMatches >= CONFIDENCE_THRESHOLD) {
return { type: 'unclassified', confidence: 0 };
}
return { type: 'unclassified', confidence: 0 };
}
/**
* Count how many keywords from the list appear in the text.
* Multi-word keywords are matched as phrases.
*/
private countKeywordMatches(normalizedText: string, keywords: string[]): number {
let matches = 0;
for (const keyword of keywords) {
if (normalizedText.includes(keyword)) {
matches++;
}
}
return matches;
}
/**
* Map classifier type to the EmailRecordType used in the processing pipeline.
*/
static toRecordType(classificationType: ReceiptClassificationType): 'fuel_log' | 'maintenance_record' | null {
switch (classificationType) {
case 'fuel': return 'fuel_log';
case 'maintenance': return 'maintenance_record';
case 'unclassified': return null;
}
}
}

View File

@@ -0,0 +1,110 @@
/**
* @ai-summary Resend inbound email client for webhook verification and email parsing
* @ai-context Verifies Resend webhook signatures via Svix, fetches raw emails, parses with mailparser
*/
import { Webhook } from 'svix';
import { simpleParser } from 'mailparser';
import { logger } from '../../../core/logging/logger';
import type { ResendWebhookEvent } from '../domain/email-ingestion.types';
export interface ParsedEmailResult {
text: string | null;
html: string | null;
attachments: ParsedEmailAttachment[];
}
export interface ParsedEmailAttachment {
filename: string;
contentType: string;
content: Buffer;
size: number;
}
export class ResendInboundClient {
private webhookSecret: string | undefined;
private apiKey: string;
constructor() {
this.apiKey = process.env['RESEND_API_KEY'] || '';
this.webhookSecret = process.env['RESEND_WEBHOOK_SECRET'];
}
/**
* Verify Resend webhook signature using Svix
* @throws Error if signature is invalid or secret is not configured
*/
verifyWebhookSignature(rawBody: string | Buffer, headers: Record<string, string>): ResendWebhookEvent {
if (!this.webhookSecret) {
throw new Error('RESEND_WEBHOOK_SECRET is not configured');
}
const wh = new Webhook(this.webhookSecret);
const verified = wh.verify(
typeof rawBody === 'string' ? rawBody : rawBody.toString(),
{
'svix-id': headers['svix-id'] || '',
'svix-timestamp': headers['svix-timestamp'] || '',
'svix-signature': headers['svix-signature'] || '',
}
);
return verified as unknown as ResendWebhookEvent;
}
/**
* Fetch email metadata from Resend API including raw download URL
*/
async getEmail(emailId: string): Promise<{ downloadUrl: string }> {
const response = await fetch(`https://api.resend.com/emails/${emailId}`, {
headers: { 'Authorization': `Bearer ${this.apiKey}` },
});
if (!response.ok) {
throw new Error(`Failed to fetch email ${emailId}: ${response.status} ${response.statusText}`);
}
const data = await response.json() as { raw?: { download_url?: string } };
const downloadUrl = data.raw?.download_url;
if (!downloadUrl) {
throw new Error(`No download URL for email ${emailId}`);
}
logger.info('Fetched email metadata from Resend', { emailId });
return { downloadUrl };
}
/**
* Download raw RFC 5322 email content from Resend download URL
*/
async downloadRawEmail(downloadUrl: string): Promise<string> {
const response = await fetch(downloadUrl);
if (!response.ok) {
throw new Error(`Failed to download raw email: ${response.status} ${response.statusText}`);
}
const rawEmail = await response.text();
logger.info('Downloaded raw email', { size: rawEmail.length });
return rawEmail;
}
/**
* Parse raw RFC 5322 email into structured text/html body and attachments
*/
async parseEmail(rawEmail: string): Promise<ParsedEmailResult> {
const parsed = await simpleParser(rawEmail);
return {
text: parsed.text || null,
html: typeof parsed.html === 'string' ? parsed.html : null,
attachments: (parsed.attachments || []).map((att) => ({
filename: att.filename || 'unnamed',
contentType: att.contentType || 'application/octet-stream',
content: att.content,
size: att.size,
})),
};
}
}

View File

@@ -0,0 +1,22 @@
/**
* @ai-summary Email ingestion feature barrel export
* @ai-context Exports webhook routes, services, and types for Resend inbound email processing
*/
export { emailIngestionWebhookRoutes, emailIngestionRoutes } from './api/email-ingestion.routes';
export { EmailIngestionService } from './domain/email-ingestion.service';
export { EmailIngestionRepository } from './data/email-ingestion.repository';
export { ReceiptClassifier } from './domain/receipt-classifier';
export { ResendInboundClient } from './external/resend-inbound.client';
export type { ParsedEmailResult, ParsedEmailAttachment } from './external/resend-inbound.client';
export type {
ClassificationResult,
EmailIngestionQueueRecord,
EmailIngestionStatus,
EmailProcessingResult,
ExtractedReceiptData,
PendingVehicleAssociation,
ReceiptClassificationType,
ResendWebhookEvent,
ResendWebhookEventData,
} from './domain/email-ingestion.types';

View File

@@ -0,0 +1,71 @@
/**
* Migration: Create email ingestion tables
* @ai-summary Creates email_ingestion_queue and pending_vehicle_associations tables
* @ai-context Supports inbound email receipt processing via Resend webhooks
*/
-- email_ingestion_queue: Tracks inbound emails from Resend webhooks
CREATE TABLE IF NOT EXISTS email_ingestion_queue (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email_id VARCHAR(255) NOT NULL,
sender_email VARCHAR(255) NOT NULL,
user_id VARCHAR(255) NOT NULL,
received_at TIMESTAMP WITH TIME ZONE NOT NULL,
subject VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'processing', 'completed', 'failed'
)),
processing_result JSONB,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Unique constraint on email_id to prevent duplicate processing
ALTER TABLE email_ingestion_queue
ADD CONSTRAINT uq_email_ingestion_queue_email_id UNIQUE (email_id);
-- Trigger for updated_at (reuses update_updated_at_column() from vehicles feature)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'set_timestamp_email_ingestion_queue'
) THEN
CREATE TRIGGER set_timestamp_email_ingestion_queue
BEFORE UPDATE ON email_ingestion_queue
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;
-- Indexes
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_user_id ON email_ingestion_queue(user_id);
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_status ON email_ingestion_queue(status);
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_sender ON email_ingestion_queue(sender_email);
CREATE INDEX IF NOT EXISTS idx_email_ingestion_queue_received_at ON email_ingestion_queue(received_at DESC);
-- pending_vehicle_associations: Holds records needing vehicle selection (multi-vehicle users)
CREATE TABLE IF NOT EXISTS pending_vehicle_associations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id VARCHAR(255) NOT NULL,
record_type VARCHAR(30) NOT NULL CHECK (record_type IN (
'fuel_log', 'maintenance_record'
)),
extracted_data JSONB NOT NULL,
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'resolved', 'expired'
)),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP WITH TIME ZONE
);
-- Trigger for pending_vehicle_associations does not need updated_at (uses resolved_at instead)
-- Indexes
CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_user_id ON pending_vehicle_associations(user_id);
CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_status ON pending_vehicle_associations(status)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_pending_vehicle_assoc_document_id ON pending_vehicle_associations(document_id)
WHERE document_id IS NOT NULL;

View File

@@ -0,0 +1,233 @@
/**
* Migration: Add email ingestion email templates
* @ai-summary Extends email_templates CHECK constraint and seeds 3 receipt templates
* @ai-context Templates for receipt processing confirmations, failures, and pending vehicle selection
*/
-- Extend template_key CHECK constraint to include email ingestion templates
ALTER TABLE email_templates
DROP CONSTRAINT IF EXISTS email_templates_template_key_check;
ALTER TABLE email_templates
ADD CONSTRAINT email_templates_template_key_check
CHECK (template_key IN (
'maintenance_due_soon', 'maintenance_overdue',
'document_expiring', 'document_expired',
'payment_failed_immediate', 'payment_failed_7day', 'payment_failed_1day',
'subscription_tier_change',
'receipt_processed', 'receipt_failed', 'receipt_pending_vehicle'
));
-- Insert email ingestion templates
INSERT INTO email_templates (template_key, name, description, subject, body, variables, html_body) VALUES
(
'receipt_processed',
'Receipt Processed Successfully',
'Sent when an emailed receipt is successfully processed and recorded',
'MotoVaultPro: Receipt Processed for {{vehicleName}}',
'Hi {{userName}},
Your emailed receipt has been successfully processed.
Vehicle: {{vehicleName}}
Record Type: {{recordType}}
Merchant: {{merchantName}}
Date: {{date}}
Amount: ${{totalAmount}}
The record has been added to your vehicle history.
Best regards,
MotoVaultPro Team',
'["userName", "vehicleName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Processed</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #2e7d32; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Receipt Processed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been successfully processed.</p>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #2e7d32;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Vehicle:</strong> {{vehicleName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
</td>
</tr>
</table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">The record has been added to your vehicle history.</p>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
),
(
'receipt_failed',
'Receipt Processing Failed',
'Sent when an emailed receipt fails OCR processing or validation',
'MotoVaultPro: Unable to Process Your Receipt',
'Hi {{userName}},
We were unable to process the receipt you emailed to us.
Error: {{errorReason}}
{{guidance}}
Best regards,
MotoVaultPro Team',
'["userName", "errorReason", "guidance"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Receipt Processing Failed</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #d32f2f; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Processing Failed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">We were unable to process the receipt you emailed to us.</p>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #d32f2f;">
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Error:</strong> {{errorReason}}</p>
</td>
</tr>
</table>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 4px; margin: 20px 0;">
<p style="color: #333333; font-size: 14px; font-weight: bold; margin: 0 0 10px 0;">What to do next:</p>
<p style="color: #666666; font-size: 14px; line-height: 1.8; margin: 0;">{{guidance}}</p>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
),
(
'receipt_pending_vehicle',
'Receipt Pending Vehicle Selection',
'Sent when a multi-vehicle user needs to select which vehicle a receipt belongs to',
'MotoVaultPro: Select Vehicle for Your Receipt',
'Hi {{userName}},
Your emailed receipt has been processed, but we need your help to complete the record.
Since you have multiple vehicles, please log in to MotoVaultPro and select which vehicle this receipt belongs to.
Record Type: {{recordType}}
Merchant: {{merchantName}}
Date: {{date}}
Amount: ${{totalAmount}}
You can find the pending receipt in your notifications.
Best regards,
MotoVaultPro Team',
'["userName", "recordType", "merchantName", "totalAmount", "date"]',
'<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Select Vehicle for Receipt</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #f57c00; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 24px;">Vehicle Selection Needed</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 30px;">
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Hi {{userName}},</p>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 0 0 20px 0;">Your emailed receipt has been processed, but we need your help to complete the record.</p>
<div style="background-color: #fff3e0; border-left: 4px solid #f57c00; padding: 20px; margin: 20px 0;">
<p style="color: #e65100; font-size: 16px; font-weight: bold; margin: 0 0 10px 0;">Action Required</p>
<p style="color: #333333; font-size: 14px; margin: 0;">Since you have multiple vehicles, please select which vehicle this receipt belongs to.</p>
</div>
<table cellpadding="0" cellspacing="0" style="margin: 20px 0; width: 100%;">
<tr>
<td style="padding: 15px; background-color: #f9f9f9; border-left: 4px solid #f57c00;">
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Record Type:</strong> {{recordType}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Merchant:</strong> {{merchantName}}</p>
<p style="margin: 0 0 10px 0; color: #666666; font-size: 14px;"><strong>Date:</strong> {{date}}</p>
<p style="margin: 0; color: #666666; font-size: 14px;"><strong>Amount:</strong> ${{totalAmount}}</p>
</td>
</tr>
</table>
<p style="color: #333333; font-size: 16px; line-height: 1.6; margin: 20px 0;">You can find the pending receipt in your notifications.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="https://motovaultpro.com/notifications" style="display: inline-block; padding: 14px 28px; background-color: #f57c00; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: bold;">Select Vehicle</a>
</div>
</td>
</tr>
<tr>
<td style="padding: 20px 30px; background-color: #f9f9f9; border-top: 1px solid #e0e0e0;">
<p style="color: #666666; font-size: 14px; line-height: 1.6; margin: 0;">Best regards,<br>MotoVaultPro Team</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>'
)
ON CONFLICT (template_key) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
subject = EXCLUDED.subject,
body = EXCLUDED.body,
variables = EXCLUDED.variables,
html_body = EXCLUDED.html_body,
updated_at = NOW();

View File

@@ -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 }

View File

@@ -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,
};
}

View File

@@ -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

View File

@@ -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;

View File

@@ -445,6 +445,29 @@ export class NotificationsService {
reason: 'Subscription upgrade',
additionalInfo: 'You now have access to all the features included in the Pro tier. Enjoy your enhanced MotoVaultPro experience!',
};
case 'receipt_processed':
return {
...baseVariables,
vehicleName: '2024 Toyota Camry',
recordType: 'Fuel Log',
merchantName: 'Shell Gas Station',
totalAmount: '45.50',
date: new Date().toLocaleDateString(),
};
case 'receipt_failed':
return {
...baseVariables,
errorReason: 'Unable to extract receipt data from the attached image.',
guidance: 'Please try again with a clearer image or PDF. Tips: use a well-lit, high-contrast photo; ensure text is legible and not cut off; PDF receipts work best.',
};
case 'receipt_pending_vehicle':
return {
...baseVariables,
recordType: 'Maintenance Record',
merchantName: 'AutoZone',
totalAmount: '89.99',
date: new Date().toLocaleDateString(),
};
default:
return baseVariables;
}

View File

@@ -11,7 +11,10 @@ export type TemplateKey =
| 'maintenance_overdue'
| 'document_expiring'
| 'document_expired'
| 'subscription_tier_change';
| 'subscription_tier_change'
| 'receipt_processed'
| 'receipt_failed'
| 'receipt_pending_vehicle';
// Email template API response type (camelCase for frontend)
export interface EmailTemplate {
@@ -86,7 +89,10 @@ export const TemplateKeySchema = z.enum([
'maintenance_overdue',
'document_expiring',
'document_expired',
'subscription_tier_change'
'subscription_tier_change',
'receipt_processed',
'receipt_failed',
'receipt_pending_vehicle'
]);
export const UpdateEmailTemplateSchema = z.object({

View File

@@ -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.

View File

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

View File

@@ -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.
*

View File

@@ -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;

View File

@@ -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.
*

View File

@@ -2,16 +2,19 @@
* @ai-summary Main dashboard screen component showing fleet overview
*/
import React from 'react';
import { Box } from '@mui/material';
import React, { useState } from 'react';
import { Box, Dialog, DialogTitle, DialogContent, IconButton, useMediaQuery, useTheme } from '@mui/material';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import CloseIcon from '@mui/icons-material/Close';
import { SummaryCards, SummaryCardsSkeleton } from './SummaryCards';
import { VehicleAttention, VehicleAttentionSkeleton } from './VehicleAttention';
import { QuickActions, QuickActionsSkeleton } from './QuickActions';
import { useDashboardSummary, useVehiclesNeedingAttention } from '../hooks/useDashboardData';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button';
import { PendingAssociationBanner } from '../../email-ingestion/components/PendingAssociationBanner';
import { PendingAssociationList } from '../../email-ingestion/components/PendingAssociationList';
import { MobileScreen } from '../../../core/store';
import { Vehicle } from '../../vehicles/types/vehicles.types';
@@ -29,6 +32,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
onViewMaintenance,
onAddVehicle
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const [showPendingReceipts, setShowPendingReceipts] = useState(false);
const { data: summary, isLoading: summaryLoading, error: summaryError } = useDashboardSummary();
const { data: vehiclesNeedingAttention, isLoading: attentionLoading, error: attentionError } = useVehiclesNeedingAttention();
@@ -102,6 +108,9 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
// Main dashboard view
return (
<div className="space-y-6">
{/* Pending Receipts Banner */}
<PendingAssociationBanner onViewPending={() => setShowPendingReceipts(true)} />
{/* Summary Cards */}
<SummaryCards summary={summary} />
@@ -132,6 +141,35 @@ export const DashboardScreen: React.FC<DashboardScreenProps> = ({
Dashboard updates every 2 minutes
</p>
</div>
{/* Pending Receipts Dialog */}
<Dialog
open={showPendingReceipts}
onClose={() => setShowPendingReceipts(false)}
fullScreen={isSmall}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
maxHeight: isSmall ? '100%' : '90vh',
m: isSmall ? 0 : 2,
},
}}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
Pending Receipts
<IconButton
aria-label="close"
onClick={() => setShowPendingReceipts(false)}
sx={{ minWidth: 44, minHeight: 44 }}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: { xs: 1, sm: 2 } }}>
<PendingAssociationList />
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -0,0 +1,33 @@
/**
* @ai-summary API calls for email ingestion pending associations
*/
import { apiClient } from '../../../core/api/client';
import type {
PendingVehicleAssociation,
PendingAssociationCount,
ResolveAssociationResult,
} from '../types/email-ingestion.types';
export const emailIngestionApi = {
getPending: async (): Promise<PendingVehicleAssociation[]> => {
const response = await apiClient.get('/email-ingestion/pending');
return response.data;
},
getPendingCount: async (): Promise<PendingAssociationCount> => {
const response = await apiClient.get('/email-ingestion/pending/count');
return response.data;
},
resolve: async (associationId: string, vehicleId: string): Promise<ResolveAssociationResult> => {
const response = await apiClient.post(`/email-ingestion/pending/${associationId}/resolve`, {
vehicleId,
});
return response.data;
},
dismiss: async (associationId: string): Promise<void> => {
await apiClient.delete(`/email-ingestion/pending/${associationId}`);
},
};

View File

@@ -0,0 +1,76 @@
/**
* @ai-summary Banner shown on dashboard when user has pending vehicle associations from emailed receipts
*/
import React from 'react';
import { Box } from '@mui/material';
import EmailRoundedIcon from '@mui/icons-material/EmailRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button';
import { usePendingAssociationCount } from '../hooks/usePendingAssociations';
interface PendingAssociationBannerProps {
onViewPending: () => void;
}
export const PendingAssociationBanner: React.FC<PendingAssociationBannerProps> = ({ onViewPending }) => {
const { data, isLoading } = usePendingAssociationCount();
if (isLoading || !data || data.count === 0) {
return null;
}
const count = data.count;
const label = count === 1 ? '1 emailed receipt' : `${count} emailed receipts`;
return (
<GlassCard padding="md">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: { xs: 'wrap', sm: 'nowrap' },
}}
>
<Box
sx={{
flexShrink: 0,
width: 44,
height: 44,
borderRadius: 3,
bgcolor: 'warning.light',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<EmailRoundedIcon sx={{ color: 'warning.dark', fontSize: 24 }} />
</Box>
<div className="flex-1 min-w-0">
<Box
component="h4"
sx={{ fontWeight: 600, color: 'text.primary', fontSize: '0.95rem', mb: 0.25 }}
>
Pending Receipts
</Box>
<p className="text-sm text-slate-500 dark:text-titanio">
{label} need a vehicle assigned
</p>
</div>
<Box sx={{ flexShrink: 0, width: { xs: '100%', sm: 'auto' } }}>
<Button
variant="primary"
size="sm"
onClick={onViewPending}
style={{ minHeight: 44, width: '100%' }}
>
Review
</Button>
</Box>
</Box>
</GlassCard>
);
};

View File

@@ -0,0 +1,210 @@
/**
* @ai-summary List of pending vehicle associations with receipt details and action buttons
*/
import React, { useState } from 'react';
import { Box } from '@mui/material';
import EmailRoundedIcon from '@mui/icons-material/EmailRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded';
import { GlassCard } from '../../../shared-minimal/components/mobile/GlassCard';
import { Button } from '../../../shared-minimal/components/Button';
import { usePendingAssociations, useDismissAssociation } from '../hooks/usePendingAssociations';
import { ResolveAssociationDialog } from './ResolveAssociationDialog';
import type { PendingVehicleAssociation } from '../types/email-ingestion.types';
export const PendingAssociationList: React.FC = () => {
const { data: associations, isLoading, error } = usePendingAssociations();
const dismissMutation = useDismissAssociation();
const [resolving, setResolving] = useState<PendingVehicleAssociation | null>(null);
if (isLoading) {
return <PendingAssociationListSkeleton />;
}
if (error) {
return (
<GlassCard padding="md">
<div className="text-center py-8">
<p className="text-slate-500 dark:text-titanio">Failed to load pending receipts</p>
</div>
</GlassCard>
);
}
if (!associations || associations.length === 0) {
return (
<GlassCard padding="md">
<div className="text-center py-12">
<Box sx={{ color: 'success.main', mb: 1.5 }}>
<EmailRoundedIcon sx={{ fontSize: 48 }} />
</Box>
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus mb-2">
No Pending Receipts
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
All emailed receipts have been assigned to vehicles
</p>
</div>
</GlassCard>
);
}
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return 'Unknown date';
try {
return new Date(dateStr).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return dateStr;
}
};
const formatCurrency = (amount: number | null): string => {
if (amount === null || amount === undefined) return '';
return `$${amount.toFixed(2)}`;
};
return (
<>
<GlassCard padding="md">
<div className="mb-4">
<h3 className="text-lg font-semibold text-slate-800 dark:text-avus">
Pending Receipts
</h3>
<p className="text-sm text-slate-500 dark:text-titanio">
Assign a vehicle to each emailed receipt
</p>
</div>
<div className="space-y-3">
{associations.map((association) => {
const isFuel = association.recordType === 'fuel_log';
const IconComponent = isFuel ? LocalGasStationRoundedIcon : BuildRoundedIcon;
const typeLabel = isFuel ? 'Fuel Receipt' : 'Maintenance Receipt';
const { extractedData } = association;
const merchant = extractedData.vendor || extractedData.shopName || 'Unknown merchant';
return (
<Box
key={association.id}
sx={{
p: 2,
borderRadius: 3,
bgcolor: 'action.hover',
border: '1px solid',
borderColor: 'divider',
}}
>
<div className="flex items-start gap-3">
<Box
sx={{
flexShrink: 0,
color: isFuel ? 'info.main' : 'warning.main',
}}
>
<IconComponent sx={{ fontSize: 24 }} />
</Box>
<div className="flex-1 min-w-0">
<Box
component="h4"
sx={{ fontWeight: 600, color: 'text.primary', fontSize: '0.95rem', mb: 0.5 }}
>
{merchant}
</Box>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-slate-600 dark:text-titanio">
<span>{typeLabel}</span>
<span>{formatDate(extractedData.date)}</span>
{extractedData.total != null && (
<span className="font-medium">{formatCurrency(extractedData.total)}</span>
)}
</div>
{isFuel && extractedData.gallons != null && (
<p className="text-xs text-slate-400 dark:text-canna mt-1">
{extractedData.gallons} gal
{extractedData.pricePerGallon != null && ` @ $${extractedData.pricePerGallon.toFixed(3)}/gal`}
</p>
)}
{!isFuel && extractedData.category && (
<p className="text-xs text-slate-400 dark:text-canna mt-1">
{extractedData.category}
{extractedData.description && ` - ${extractedData.description}`}
</p>
)}
<p className="text-xs text-slate-400 dark:text-canna mt-1">
Received {formatDate(association.createdAt)}
</p>
<div className="flex gap-2 mt-3">
<Button
variant="primary"
size="sm"
onClick={() => setResolving(association)}
style={{ minHeight: 44 }}
>
Assign Vehicle
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => dismissMutation.mutate(association.id)}
disabled={dismissMutation.isPending}
style={{ minHeight: 44 }}
>
<DeleteOutlineRoundedIcon sx={{ fontSize: 18, mr: 0.5 }} />
Dismiss
</Button>
</div>
</div>
</div>
</Box>
);
})}
</div>
</GlassCard>
{resolving && (
<ResolveAssociationDialog
open={!!resolving}
association={resolving}
onClose={() => setResolving(null)}
/>
)}
</>
);
};
const PendingAssociationListSkeleton: React.FC = () => (
<GlassCard padding="md">
<div className="mb-4">
<div className="h-6 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-40 mb-2" />
<div className="h-4 bg-slate-100 dark:bg-slate-800 rounded animate-pulse w-56" />
</div>
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-6 h-6 bg-slate-100 dark:bg-slate-700 rounded animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-5 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-3/4" />
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/2" />
<div className="h-4 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-1/3" />
<div className="flex gap-2 mt-3">
<div className="h-10 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-28" />
<div className="h-10 bg-slate-100 dark:bg-slate-700 rounded animate-pulse w-20" />
</div>
</div>
</div>
</div>
))}
</div>
</GlassCard>
);

View File

@@ -0,0 +1,260 @@
/**
* @ai-summary Dialog to select a vehicle and resolve a pending association from an emailed receipt
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Typography,
Box,
IconButton,
useMediaQuery,
useTheme,
CircularProgress,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
import BuildRoundedIcon from '@mui/icons-material/BuildRounded';
import CheckCircleRoundedIcon from '@mui/icons-material/CheckCircleRounded';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { useResolveAssociation } from '../hooks/usePendingAssociations';
import type { PendingVehicleAssociation } from '../types/email-ingestion.types';
interface ResolveAssociationDialogProps {
open: boolean;
association: PendingVehicleAssociation;
onClose: () => void;
}
export const ResolveAssociationDialog: React.FC<ResolveAssociationDialogProps> = ({
open,
association,
onClose,
}) => {
const theme = useTheme();
const isSmall = useMediaQuery(theme.breakpoints.down('sm'));
const { data: vehicles, isLoading: vehiclesLoading } = useVehicles();
const resolveMutation = useResolveAssociation();
const [selectedVehicleId, setSelectedVehicleId] = useState<string | null>(null);
const isFuel = association.recordType === 'fuel_log';
const { extractedData } = association;
const merchant = extractedData.vendor || extractedData.shopName || 'Unknown merchant';
const handleResolve = () => {
if (!selectedVehicleId) return;
resolveMutation.mutate(
{ associationId: association.id, vehicleId: selectedVehicleId },
{ onSuccess: () => onClose() }
);
};
const formatDate = (dateStr: string | null): string => {
if (!dateStr) return '';
try {
return new Date(dateStr).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
} catch {
return dateStr;
}
};
return (
<Dialog
open={open}
onClose={onClose}
fullScreen={isSmall}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
maxHeight: isSmall ? '100%' : '90vh',
m: isSmall ? 0 : 2,
},
}}
>
{isSmall && (
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
minWidth: 44,
minHeight: 44,
color: theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
)}
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isFuel ? (
<LocalGasStationRoundedIcon color="info" />
) : (
<BuildRoundedIcon color="warning" />
)}
Assign Vehicle
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{/* Receipt summary */}
<Box
sx={{
p: 2,
bgcolor: 'action.hover',
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="subtitle2" gutterBottom>
{isFuel ? 'Fuel Receipt' : 'Maintenance Receipt'}
</Typography>
<Typography variant="body2" color="text.primary" fontWeight={600}>
{merchant}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mt: 0.5, flexWrap: 'wrap' }}>
{extractedData.date && (
<Typography variant="body2" color="text.secondary">
{formatDate(extractedData.date)}
</Typography>
)}
{extractedData.total != null && (
<Typography variant="body2" color="text.secondary" fontWeight={500}>
${extractedData.total.toFixed(2)}
</Typography>
)}
{isFuel && extractedData.gallons != null && (
<Typography variant="body2" color="text.secondary">
{extractedData.gallons} gal
</Typography>
)}
{!isFuel && extractedData.category && (
<Typography variant="body2" color="text.secondary">
{extractedData.category}
</Typography>
)}
</Box>
</Box>
{/* Vehicle selection */}
<Box>
<Typography variant="subtitle2" gutterBottom>
Select a vehicle
</Typography>
{vehiclesLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={24} />
</Box>
) : !vehicles || vehicles.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No vehicles found. Add a vehicle first.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{vehicles.map((vehicle) => {
const isSelected = selectedVehicleId === vehicle.id;
const vehicleName = vehicle.nickname
|| [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ')
|| 'Unnamed Vehicle';
return (
<Box
key={vehicle.id}
role="button"
tabIndex={0}
onClick={() => setSelectedVehicleId(vehicle.id)}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setSelectedVehicleId(vehicle.id);
}
}}
sx={{
p: 2,
borderRadius: 2,
border: '2px solid',
borderColor: isSelected ? 'primary.main' : 'divider',
bgcolor: isSelected ? 'primary.50' : 'transparent',
cursor: 'pointer',
transition: 'all 0.15s',
display: 'flex',
alignItems: 'center',
gap: 1.5,
minHeight: 44,
'&:hover': {
borderColor: isSelected ? 'primary.main' : 'primary.light',
bgcolor: isSelected ? 'primary.50' : 'action.hover',
},
}}
>
{isSelected && (
<CheckCircleRoundedIcon color="primary" sx={{ fontSize: 20 }} />
)}
<Box sx={{ flex: 1 }}>
<Typography variant="body2" fontWeight={600}>
{vehicleName}
</Typography>
{vehicle.licensePlate && (
<Typography variant="caption" color="text.secondary">
{vehicle.licensePlate}
</Typography>
)}
</Box>
</Box>
);
})}
</Box>
)}
</Box>
</Box>
</DialogContent>
<DialogActions
sx={{
px: 3,
pb: 3,
pt: 1,
flexDirection: isSmall ? 'column' : 'row',
gap: 1,
}}
>
<Button
onClick={onClose}
variant="outlined"
fullWidth={isSmall}
sx={{ order: isSmall ? 2 : 1, minHeight: 44 }}
disabled={resolveMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleResolve}
variant="contained"
color="primary"
fullWidth={isSmall}
sx={{ order: isSmall ? 1 : 2, minHeight: 44 }}
disabled={!selectedVehicleId || resolveMutation.isPending}
>
{resolveMutation.isPending ? (
<CircularProgress size={20} color="inherit" />
) : (
'Assign & Create Record'
)}
</Button>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,65 @@
/**
* @ai-summary React Query hooks for pending vehicle association management
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth0 } from '@auth0/auth0-react';
import { emailIngestionApi } from '../api/email-ingestion.api';
import toast from 'react-hot-toast';
export const usePendingAssociationCount = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['pendingAssociations', 'count'],
queryFn: emailIngestionApi.getPendingCount,
enabled: isAuthenticated && !isLoading,
staleTime: 60 * 1000,
refetchInterval: 2 * 60 * 1000,
});
};
export const usePendingAssociations = () => {
const { isAuthenticated, isLoading } = useAuth0();
return useQuery({
queryKey: ['pendingAssociations'],
queryFn: emailIngestionApi.getPending,
enabled: isAuthenticated && !isLoading,
staleTime: 30 * 1000,
});
};
export const useResolveAssociation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ associationId, vehicleId }: { associationId: string; vehicleId: string }) =>
emailIngestionApi.resolve(associationId, vehicleId),
onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: ['pendingAssociations'] });
queryClient.invalidateQueries({ queryKey: ['fuelLogs'] });
queryClient.invalidateQueries({ queryKey: ['maintenance'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success('Receipt assigned to vehicle');
},
onError: () => {
toast.error('Failed to assign receipt');
},
});
};
export const useDismissAssociation = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (associationId: string) => emailIngestionApi.dismiss(associationId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pendingAssociations'] });
toast.success('Receipt dismissed');
},
onError: () => {
toast.error('Failed to dismiss receipt');
},
});
};

View File

@@ -0,0 +1,10 @@
/**
* @ai-summary Email ingestion feature barrel export
*/
export { PendingAssociationBanner } from './components/PendingAssociationBanner';
export { PendingAssociationList } from './components/PendingAssociationList';
export { ResolveAssociationDialog } from './components/ResolveAssociationDialog';
export { usePendingAssociationCount, usePendingAssociations, useResolveAssociation, useDismissAssociation } from './hooks/usePendingAssociations';
export { emailIngestionApi } from './api/email-ingestion.api';
export type { PendingVehicleAssociation, ExtractedReceiptData, ResolveAssociationResult } from './types/email-ingestion.types';

View File

@@ -0,0 +1,41 @@
/**
* @ai-summary TypeScript types for email ingestion frontend feature
*/
export type EmailRecordType = 'fuel_log' | 'maintenance_record';
export type PendingAssociationStatus = 'pending' | 'resolved' | 'expired';
export interface ExtractedReceiptData {
vendor: string | null;
date: string | null;
total: number | null;
odometerReading: number | null;
gallons: number | null;
pricePerGallon: number | null;
fuelType: string | null;
category: string | null;
subtypes: string[] | null;
shopName: string | null;
description: string | null;
}
export interface PendingVehicleAssociation {
id: string;
userId: string;
recordType: EmailRecordType;
extractedData: ExtractedReceiptData;
documentId: string | null;
status: PendingAssociationStatus;
createdAt: string;
resolvedAt: string | null;
}
export interface PendingAssociationCount {
count: number;
}
export interface ResolveAssociationResult {
recordId: string;
recordType: EmailRecordType;
}

View File

@@ -0,0 +1,427 @@
/**
* @ai-summary Modal for reviewing and editing OCR-extracted maintenance receipt fields
* @ai-context Mirrors ReceiptOcrReviewModal: confidence indicators, inline editing, category suggestion display
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Box,
Typography,
TextField,
Grid,
useTheme,
useMediaQuery,
IconButton,
Collapse,
Alert,
Chip,
} from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import CameraAltIcon from '@mui/icons-material/CameraAlt';
import BuildIcon from '@mui/icons-material/Build';
import {
ExtractedMaintenanceReceiptFields,
ExtractedMaintenanceField,
CategorySuggestion,
} from '../types/maintenance-receipt.types';
import { LOW_CONFIDENCE_THRESHOLD } from '../hooks/useMaintenanceReceiptOcr';
import { getCategoryDisplayName } from '../types/maintenance.types';
import { ReceiptPreview } from '../../fuel-logs/components/ReceiptPreview';
export interface MaintenanceReceiptReviewModalProps {
open: boolean;
extractedFields: ExtractedMaintenanceReceiptFields;
receiptImageUrl: string | null;
categorySuggestion: CategorySuggestion | null;
onAccept: () => void;
onRetake: () => void;
onCancel: () => void;
onFieldEdit: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void;
}
/** Confidence indicator component (4-dot system) */
const ConfidenceIndicator: React.FC<{ confidence: number }> = ({ confidence }) => {
const filledDots = Math.round(confidence * 4);
const isLow = confidence < LOW_CONFIDENCE_THRESHOLD;
return (
<Box
sx={{ display: 'flex', gap: 0.25, ml: 1 }}
aria-label={`Confidence: ${Math.round(confidence * 100)}%`}
>
{[0, 1, 2, 3].map((i) => (
<Box
key={i}
sx={{
width: 6,
height: 6,
borderRadius: '50%',
backgroundColor: i < filledDots
? (isLow ? 'warning.main' : 'success.main')
: 'grey.300',
}}
/>
))}
</Box>
);
};
/** Field row component with inline editing */
const FieldRow: React.FC<{
label: string;
field: ExtractedMaintenanceField;
onEdit: (value: string | number | null) => void;
type?: 'text' | 'number';
formatDisplay?: (value: string | number | null) => string;
}> = ({ label, field, onEdit, type = 'text', formatDisplay }) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState<string>(
field.value !== null ? String(field.value) : ''
);
const isLowConfidence = field.confidence < LOW_CONFIDENCE_THRESHOLD && field.value !== null;
const displayValue = formatDisplay
? formatDisplay(field.value)
: field.value !== null
? String(field.value)
: '-';
const handleSave = () => {
let parsedValue: string | number | null = editValue || null;
if (type === 'number' && editValue) {
const num = parseFloat(editValue);
parsedValue = isNaN(num) ? null : num;
}
onEdit(parsedValue);
setIsEditing(false);
};
const handleCancel = () => {
setEditValue(field.value !== null ? String(field.value) : '');
setIsEditing(false);
};
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
py: 1.5,
px: 1,
borderRadius: 1,
backgroundColor: isLowConfidence ? 'warning.light' : 'transparent',
'&:hover': {
backgroundColor: isLowConfidence ? 'warning.light' : 'action.hover',
},
}}
>
<Typography
variant="body2"
sx={{ width: 100, flexShrink: 0, color: 'text.secondary', fontWeight: 500 }}
>
{label}
</Typography>
{isEditing ? (
<Box sx={{ display: 'flex', alignItems: 'center', flex: 1, gap: 1 }}>
<TextField
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
type={type === 'number' ? 'number' : 'text'}
inputProps={{
step: type === 'number' ? 0.01 : undefined,
}}
autoFocus
sx={{ flex: 1 }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel();
}}
/>
<IconButton size="small" onClick={handleSave} color="primary">
<CheckIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={handleCancel}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Box
sx={{ display: 'flex', alignItems: 'center', flex: 1, cursor: 'pointer' }}
onClick={() => setIsEditing(true)}
role="button"
tabIndex={0}
aria-label={`Edit ${label}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setIsEditing(true);
}
}}
>
<Typography
variant="body1"
sx={{
flex: 1,
fontWeight: field.value !== null ? 500 : 400,
color: field.value !== null ? 'text.primary' : 'text.disabled',
}}
>
{displayValue}
</Typography>
{field.value !== null && <ConfidenceIndicator confidence={field.confidence} />}
<IconButton size="small" sx={{ ml: 1 }}>
<EditIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
};
export const MaintenanceReceiptReviewModal: React.FC<MaintenanceReceiptReviewModalProps> = ({
open,
extractedFields,
receiptImageUrl,
categorySuggestion,
onAccept,
onRetake,
onCancel,
onFieldEdit,
}) => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [showAllFields, setShowAllFields] = useState(false);
const hasLowConfidenceFields = Object.values(extractedFields).some(
(field) => field.value !== null && field.confidence < LOW_CONFIDENCE_THRESHOLD
);
const formatCurrency = (value: string | number | null): string => {
if (value === null) return '-';
const num = typeof value === 'string' ? parseFloat(value) : value;
return isNaN(num) ? String(value) : `$${num.toFixed(2)}`;
};
const formatDate = (value: string | number | null): string => {
if (value === null) return '-';
const dateStr = String(value);
try {
const date = new Date(dateStr + 'T00:00:00');
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
} catch {
// Return as-is if parsing fails
}
return dateStr;
};
return (
<Dialog
open={open}
onClose={onCancel}
maxWidth="sm"
fullWidth
fullScreen={isMobile}
PaperProps={{
sx: { maxHeight: isMobile ? '100vh' : '90vh' },
}}
>
<DialogTitle
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Typography variant="h6" component="span">
Maintenance Receipt Extracted
</Typography>
<IconButton onClick={onCancel} size="small" aria-label="Close">
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent dividers>
{hasLowConfidenceFields && (
<Alert severity="warning" sx={{ mb: 2 }}>
Some fields have low confidence. Please review and edit if needed.
</Alert>
)}
<Grid container spacing={2}>
{/* Receipt thumbnail */}
{receiptImageUrl && (
<Grid item xs={12} sm={4}>
<Box
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}
>
<ReceiptPreview
imageUrl={receiptImageUrl}
maxWidth={isMobile ? 100 : 120}
maxHeight={isMobile ? 150 : 180}
/>
<Typography variant="caption" color="text.secondary">
Tap to zoom
</Typography>
</Box>
</Grid>
)}
{/* Extracted fields */}
<Grid item xs={12} sm={receiptImageUrl ? 8 : 12}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{/* Primary fields */}
<FieldRow
label="Service"
field={extractedFields.serviceName}
onEdit={(value) => onFieldEdit('serviceName', value)}
/>
<FieldRow
label="Date"
field={extractedFields.serviceDate}
onEdit={(value) => onFieldEdit('serviceDate', value)}
formatDisplay={formatDate}
/>
<FieldRow
label="Total Cost"
field={extractedFields.totalCost}
onEdit={(value) => onFieldEdit('totalCost', value)}
type="number"
formatDisplay={formatCurrency}
/>
<FieldRow
label="Shop"
field={extractedFields.shopName}
onEdit={(value) => onFieldEdit('shopName', value)}
/>
{/* Category suggestion */}
{categorySuggestion && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
py: 1,
px: 1,
gap: 1,
backgroundColor: 'success.light',
borderRadius: 1,
my: 0.5,
}}
>
<BuildIcon fontSize="small" color="success" />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" fontWeight={500}>
{getCategoryDisplayName(categorySuggestion.category)}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
{categorySuggestion.subtypes.map((subtype) => (
<Chip
key={subtype}
label={subtype}
size="small"
color="success"
variant="outlined"
/>
))}
</Box>
</Box>
</Box>
)}
{/* Secondary fields (collapsible on mobile) */}
<Collapse in={!isMobile || showAllFields}>
<FieldRow
label="Odometer"
field={extractedFields.odometerReading}
onEdit={(value) => onFieldEdit('odometerReading', value)}
type="number"
/>
<FieldRow
label="Labor"
field={extractedFields.laborCost}
onEdit={(value) => onFieldEdit('laborCost', value)}
type="number"
formatDisplay={formatCurrency}
/>
<FieldRow
label="Parts"
field={extractedFields.partsCost}
onEdit={(value) => onFieldEdit('partsCost', value)}
type="number"
formatDisplay={formatCurrency}
/>
<FieldRow
label="Vehicle"
field={extractedFields.vehicleInfo}
onEdit={(value) => onFieldEdit('vehicleInfo', value)}
/>
</Collapse>
{isMobile && (
<Button
size="small"
onClick={() => setShowAllFields(!showAllFields)}
sx={{ mt: 1, alignSelf: 'flex-start' }}
>
{showAllFields ? 'Show Less' : 'Show More Fields'}
</Button>
)}
</Box>
</Grid>
</Grid>
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 2, textAlign: 'center' }}
>
Tap any field to edit before saving.
</Typography>
</DialogContent>
<DialogActions
sx={{
flexDirection: isMobile ? 'column' : 'row',
gap: 1,
p: 2,
}}
>
<Button
onClick={onRetake}
startIcon={<CameraAltIcon />}
sx={{ order: isMobile ? 2 : 1, minHeight: 44 }}
>
Retake Photo
</Button>
<Box sx={{ flex: 1, display: isMobile ? 'none' : 'block' }} />
<Button
onClick={onCancel}
sx={{ order: isMobile ? 3 : 2, minHeight: 44 }}
>
Cancel
</Button>
<Button
variant="contained"
onClick={onAccept}
startIcon={<CheckIcon />}
sx={{ order: isMobile ? 1 : 3, width: isMobile ? '100%' : 'auto', minHeight: 44 }}
>
Accept
</Button>
</DialogActions>
</Dialog>
);
};
export default MaintenanceReceiptReviewModal;

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Edit dialog for maintenance records
* @ai-context Mobile-friendly dialog with proper form handling
* @ai-summary Edit dialog for maintenance records with linked receipt display
* @ai-context Mobile-friendly dialog with proper form handling and receipt thumbnail/view
*/
import React, { useState, useEffect } from 'react';
@@ -19,7 +19,9 @@ import {
MenuItem,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import ReceiptIcon from '@mui/icons-material/Receipt';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
@@ -32,6 +34,7 @@ import {
} from '../types/maintenance.types';
import { SubtypeCheckboxGroup } from './SubtypeCheckboxGroup';
import { useVehicles } from '../../vehicles/hooks/useVehicles';
import { documentsApi } from '../../documents/api/documents.api';
import type { Vehicle } from '../../vehicles/types/vehicles.types';
interface MaintenanceRecordEditDialogProps {
@@ -53,7 +56,10 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
const vehiclesQuery = useVehicles();
const vehicles = vehiclesQuery.data;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery('(max-width:600px)');
const [receiptThumbnailUrl, setReceiptThumbnailUrl] = useState<string | null>(null);
// Reset form when record changes
useEffect(() => {
@@ -76,6 +82,45 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
}
}, [record]);
// Load receipt thumbnail when record has a linked receipt document
useEffect(() => {
if (!record?.receiptDocument?.documentId) {
setReceiptThumbnailUrl(null);
return;
}
let revoked = false;
documentsApi.download(record.receiptDocument.documentId).then((blob) => {
if (!revoked) {
const url = URL.createObjectURL(blob);
setReceiptThumbnailUrl(url);
}
}).catch((err) => {
console.error('[MaintenanceRecordEditDialog] Failed to load receipt thumbnail:', err);
});
return () => {
revoked = true;
if (receiptThumbnailUrl) {
URL.revokeObjectURL(receiptThumbnailUrl);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [record?.receiptDocument?.documentId]);
const handleViewReceipt = async () => {
if (!record?.receiptDocument?.documentId) return;
try {
const blob = await documentsApi.download(record.receiptDocument.documentId);
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
// Revoke after a delay to allow the new tab to load
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch (err) {
console.error('[MaintenanceRecordEditDialog] Failed to open receipt:', err);
}
};
const handleInputChange = (field: keyof UpdateMaintenanceRecordRequest, value: any) => {
setFormData((prev) => ({
...prev,
@@ -182,6 +227,76 @@ export const MaintenanceRecordEditDialog: React.FC<MaintenanceRecordEditDialogPr
/>
</Grid>
{/* Linked Receipt Display */}
{record.receiptDocument && (
<Grid item xs={12}>
<Box
sx={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'center' : 'center',
gap: 2,
p: 2,
borderRadius: 1,
backgroundColor: 'action.hover',
}}
>
{receiptThumbnailUrl ? (
<Box
component="img"
src={receiptThumbnailUrl}
alt="Receipt"
sx={{
width: isMobile ? 64 : 80,
height: isMobile ? 64 : 80,
borderRadius: 1,
objectFit: 'cover',
flexShrink: 0,
}}
/>
) : (
<Box
sx={{
width: isMobile ? 64 : 80,
height: isMobile ? 64 : 80,
borderRadius: 1,
backgroundColor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<ReceiptIcon color="action" />
</Box>
)}
<Box sx={{ flex: 1, minWidth: 0, textAlign: isMobile ? 'center' : 'left' }}>
<Typography variant="body2" fontWeight={500}>
Linked Receipt
</Typography>
{record.receiptDocument.fileName && (
<Typography variant="caption" color="text.secondary" noWrap>
{record.receiptDocument.fileName}
</Typography>
)}
</Box>
<Button
variant="outlined"
size="small"
startIcon={<ReceiptIcon />}
onClick={handleViewReceipt}
sx={{
minHeight: 44,
width: isMobile ? '100%' : 'auto',
flexShrink: 0,
}}
>
View Receipt
</Button>
</Box>
</Grid>
)}
{/* Category */}
<Grid item xs={12}>
<FormControl fullWidth>

View File

@@ -1,6 +1,6 @@
/**
* @ai-summary Form component for creating maintenance records
* @ai-context Mobile-first responsive design with proper validation
* @ai-summary Form component for creating maintenance records with receipt OCR integration
* @ai-context Mobile-first responsive design with tier-gated receipt scanning, mirrors FuelLogForm OCR pattern
*/
import React, { useState, useEffect } from 'react';
@@ -23,6 +23,8 @@ import {
CircularProgress,
Typography,
InputAdornment,
Dialog,
Backdrop,
} from '@mui/material';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
@@ -36,6 +38,13 @@ import {
CreateMaintenanceRecordRequest,
getCategoryDisplayName,
} from '../types/maintenance.types';
import { useMaintenanceReceiptOcr } from '../hooks/useMaintenanceReceiptOcr';
import { MaintenanceReceiptReviewModal } from './MaintenanceReceiptReviewModal';
import { ReceiptCameraButton } from '../../fuel-logs/components/ReceiptCameraButton';
import { CameraCapture } from '../../../shared/components/CameraCapture';
import { useTierAccess } from '../../../core/hooks/useTierAccess';
import { UpgradeRequiredDialog } from '../../../shared-minimal/components/UpgradeRequiredDialog';
import { documentsApi } from '../../documents/api/documents.api';
import toast from 'react-hot-toast';
const schema = z.object({
@@ -58,6 +67,29 @@ export const MaintenanceRecordForm: React.FC = () => {
const { createRecord, isRecordMutating } = useMaintenanceRecords();
const [selectedCategory, setSelectedCategory] = useState<MaintenanceCategory | null>(null);
// Tier access check for receipt scan feature
const { hasAccess } = useTierAccess();
const hasReceiptScanAccess = hasAccess('maintenance.receiptScan');
const [showUpgradeDialog, setShowUpgradeDialog] = useState(false);
// Receipt OCR integration
const {
isCapturing,
isProcessing,
result: ocrResult,
receiptImageUrl,
error: ocrError,
startCapture,
cancelCapture,
processImage,
acceptResult,
reset: resetOcr,
updateField,
} = useMaintenanceReceiptOcr();
// Store captured file for document upload on submit
const [capturedReceiptFile, setCapturedReceiptFile] = useState<File | null>(null);
const {
control,
handleSubmit,
@@ -89,8 +121,69 @@ export const MaintenanceRecordForm: React.FC = () => {
}
}, [watchedCategory, setValue]);
// Wrap processImage to also save file reference
const handleCaptureImage = async (file: File, croppedFile?: File) => {
setCapturedReceiptFile(croppedFile || file);
await processImage(file, croppedFile);
};
// Handle accepting OCR results and populating the form
const handleAcceptOcrResult = () => {
const mappedFields = acceptResult();
if (!mappedFields) return;
// Populate form fields from OCR result
if (mappedFields.category) {
setValue('category', mappedFields.category);
setSelectedCategory(mappedFields.category);
}
if (mappedFields.subtypes && mappedFields.subtypes.length > 0) {
setValue('subtypes', mappedFields.subtypes);
}
if (mappedFields.date) {
setValue('date', mappedFields.date);
}
if (mappedFields.cost !== undefined) {
setValue('cost', mappedFields.cost as any);
}
if (mappedFields.shopName) {
setValue('shop_name', mappedFields.shopName);
}
if (mappedFields.odometerReading !== undefined) {
setValue('odometer_reading', mappedFields.odometerReading as any);
}
if (mappedFields.notes) {
setValue('notes', mappedFields.notes);
}
};
// Handle retaking photo
const handleRetakePhoto = () => {
resetOcr();
setCapturedReceiptFile(null);
startCapture();
};
const onSubmit = async (data: FormData) => {
try {
let receiptDocumentId: string | undefined;
// Upload receipt as document if we have a captured file
if (capturedReceiptFile) {
try {
const doc = await documentsApi.create({
vehicleId: data.vehicle_id,
documentType: 'manual',
title: `Maintenance Receipt - ${new Date(data.date).toLocaleDateString()}`,
});
await documentsApi.upload(doc.id, capturedReceiptFile);
receiptDocumentId = doc.id;
} catch (uploadError) {
console.error('Failed to upload receipt document:', uploadError);
toast.error('Receipt upload failed, but the record will be saved without the receipt.');
}
}
const payload: CreateMaintenanceRecordRequest = {
vehicleId: data.vehicle_id,
category: data.category as MaintenanceCategory,
@@ -100,6 +193,7 @@ export const MaintenanceRecordForm: React.FC = () => {
cost: data.cost ? Number(data.cost) : undefined,
shopName: data.shop_name || undefined,
notes: data.notes || undefined,
receiptDocumentId,
};
await createRecord(payload);
@@ -117,6 +211,7 @@ export const MaintenanceRecordForm: React.FC = () => {
notes: '',
});
setSelectedCategory(null);
setCapturedReceiptFile(null);
} catch (error) {
console.error('Failed to create maintenance record:', error);
toast.error('Failed to add maintenance record');
@@ -140,6 +235,31 @@ export const MaintenanceRecordForm: React.FC = () => {
<Card>
<CardHeader title="Add Maintenance Record" />
<CardContent>
{/* Receipt Scan Button */}
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 3,
pb: 2,
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<ReceiptCameraButton
onClick={() => {
if (!hasReceiptScanAccess) {
setShowUpgradeDialog(true);
return;
}
startCapture();
}}
disabled={isProcessing || isRecordMutating}
variant="button"
locked={!hasReceiptScanAccess}
/>
</Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Grid container spacing={2}>
{/* Vehicle Selection */}
@@ -374,6 +494,89 @@ export const MaintenanceRecordForm: React.FC = () => {
</form>
</CardContent>
</Card>
{/* Camera Capture Modal */}
<Dialog
open={isCapturing}
onClose={cancelCapture}
fullScreen
PaperProps={{
sx: { backgroundColor: 'black' },
}}
>
<CameraCapture
onCapture={handleCaptureImage}
onCancel={cancelCapture}
guidanceType="receipt"
allowCrop={true}
/>
</Dialog>
{/* OCR Processing Overlay */}
<Backdrop
open={isProcessing}
sx={{
color: '#fff',
zIndex: (theme) => theme.zIndex.drawer + 1,
flexDirection: 'column',
gap: 2,
}}
>
<CircularProgress color="inherit" />
<Typography variant="body1">Extracting receipt data...</Typography>
</Backdrop>
{/* OCR Review Modal */}
{ocrResult && (
<MaintenanceReceiptReviewModal
open={!!ocrResult}
extractedFields={ocrResult.extractedFields}
receiptImageUrl={receiptImageUrl}
categorySuggestion={ocrResult.categorySuggestion}
onAccept={handleAcceptOcrResult}
onRetake={handleRetakePhoto}
onCancel={() => {
resetOcr();
setCapturedReceiptFile(null);
}}
onFieldEdit={updateField}
/>
)}
{/* Upgrade Required Dialog for Receipt Scan */}
<UpgradeRequiredDialog
featureKey="maintenance.receiptScan"
open={showUpgradeDialog}
onClose={() => setShowUpgradeDialog(false)}
/>
{/* OCR Error Display */}
{ocrError && (
<Dialog open={!!ocrError} onClose={resetOcr} maxWidth="xs">
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="h6" gutterBottom>
OCR Error
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{ocrError}
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center' }}>
<Button onClick={startCapture} variant="contained">
Try Again
</Button>
<Button
onClick={() => {
resetOcr();
setCapturedReceiptFile(null);
}}
variant="outlined"
>
Cancel
</Button>
</Box>
</Box>
</Dialog>
)}
</LocalizationProvider>
);
};

View File

@@ -20,7 +20,7 @@ import {
useTheme,
useMediaQuery,
} from '@mui/material';
import { Edit, Delete } from '@mui/icons-material';
import { Edit, Delete, Receipt } from '@mui/icons-material';
import {
MaintenanceRecordResponse,
getCategoryDisplayName,
@@ -136,6 +136,15 @@ export const MaintenanceRecordsList: React.FC<MaintenanceRecordsListProps> = ({
variant="outlined"
/>
)}
{record.receiptDocument && (
<Chip
icon={<Receipt fontSize="small" />}
label="Receipt"
size="small"
color="info"
variant="outlined"
/>
)}
</Stack>
{record.notes && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>

View File

@@ -0,0 +1,333 @@
/**
* @ai-summary Hook to orchestrate maintenance receipt OCR extraction
* @ai-context Mirrors useReceiptOcr pattern: capture -> OCR -> category suggestion -> review -> accept
*/
import { useState, useCallback } from 'react';
import { apiClient } from '../../../core/api/client';
import {
ExtractedMaintenanceReceiptFields,
ExtractedMaintenanceField,
MappedMaintenanceFields,
MaintenanceReceiptOcrResult,
CategorySuggestion,
UseMaintenanceReceiptOcrReturn,
} from '../types/maintenance-receipt.types';
import { MaintenanceCategory } from '../types/maintenance.types';
/** Confidence threshold for highlighting low-confidence fields */
export const LOW_CONFIDENCE_THRESHOLD = 0.7;
/** Keyword-to-category/subtype mapping for service name suggestion */
const SERVICE_KEYWORD_MAP: Array<{
keywords: string[];
category: MaintenanceCategory;
subtypes: string[];
}> = [
// Routine maintenance mappings
{ keywords: ['oil change', 'oil filter', 'engine oil', 'synthetic oil', 'conventional oil', 'oil & filter'],
category: 'routine_maintenance', subtypes: ['Engine Oil'] },
{ keywords: ['tire rotation', 'tire balance', 'wheel balance', 'tire alignment', 'alignment'],
category: 'routine_maintenance', subtypes: ['Tires'] },
{ keywords: ['brake pad', 'brake rotor', 'brake fluid', 'brake inspection', 'brake service', 'brakes'],
category: 'routine_maintenance', subtypes: ['Brakes and Traction Control'] },
{ keywords: ['air filter', 'engine air filter'],
category: 'routine_maintenance', subtypes: ['Air Filter Element'] },
{ keywords: ['cabin filter', 'cabin air', 'a/c filter'],
category: 'routine_maintenance', subtypes: ['Cabin Air Filter / Purifier'] },
{ keywords: ['spark plug', 'ignition'],
category: 'routine_maintenance', subtypes: ['Spark Plug'] },
{ keywords: ['coolant', 'antifreeze', 'radiator flush', 'cooling system'],
category: 'routine_maintenance', subtypes: ['Coolant'] },
{ keywords: ['transmission fluid', 'trans fluid', 'atf'],
category: 'routine_maintenance', subtypes: ['Fluid - A/T'] },
{ keywords: ['differential fluid', 'diff fluid'],
category: 'routine_maintenance', subtypes: ['Fluid - Differential'] },
{ keywords: ['wiper blade', 'wiper', 'windshield wiper'],
category: 'routine_maintenance', subtypes: ['Wiper Blade'] },
{ keywords: ['washer fluid', 'windshield washer'],
category: 'routine_maintenance', subtypes: ['Washer Fluid'] },
{ keywords: ['drive belt', 'serpentine belt', 'timing belt', 'belt replacement'],
category: 'routine_maintenance', subtypes: ['Drive Belt'] },
{ keywords: ['exhaust', 'muffler', 'catalytic'],
category: 'routine_maintenance', subtypes: ['Exhaust System'] },
{ keywords: ['suspension', 'shock', 'strut', 'ball joint', 'tie rod'],
category: 'routine_maintenance', subtypes: ['Steering and Suspension'] },
{ keywords: ['fuel filter', 'fuel injection', 'fuel system', 'fuel delivery'],
category: 'routine_maintenance', subtypes: ['Fuel Delivery and Air Induction'] },
{ keywords: ['parking brake', 'e-brake', 'emergency brake'],
category: 'routine_maintenance', subtypes: ['Parking Brake System'] },
// Repair mappings
{ keywords: ['engine repair', 'engine rebuild', 'head gasket', 'valve cover'],
category: 'repair', subtypes: ['Engine'] },
{ keywords: ['transmission repair', 'trans rebuild'],
category: 'repair', subtypes: ['Transmission'] },
{ keywords: ['axle', 'cv joint', 'driveshaft', 'drivetrain'],
category: 'repair', subtypes: ['Drivetrain'] },
{ keywords: ['body work', 'dent', 'paint', 'bumper', 'fender'],
category: 'repair', subtypes: ['Exterior'] },
{ keywords: ['upholstery', 'dashboard', 'seat repair', 'interior repair'],
category: 'repair', subtypes: ['Interior'] },
];
/** Suggest category and subtypes from service name using keyword matching */
function suggestCategory(serviceName: string | number | null): CategorySuggestion | null {
if (!serviceName) return null;
const normalized = String(serviceName).toLowerCase().trim();
if (!normalized) return null;
for (const mapping of SERVICE_KEYWORD_MAP) {
for (const keyword of mapping.keywords) {
if (normalized.includes(keyword)) {
return {
category: mapping.category,
subtypes: mapping.subtypes,
confidence: 0.8,
};
}
}
}
// No match found - default to routine_maintenance with no subtypes
return null;
}
/** Parse date string to YYYY-MM-DD format */
function parseServiceDate(value: string | number | null): string | undefined {
if (!value) return undefined;
const dateStr = String(value);
// Already in YYYY-MM-DD format
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
const date = new Date(dateStr + 'T00:00:00');
if (!isNaN(date.getTime())) return dateStr;
}
// Try standard parsing
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
return date.toISOString().split('T')[0];
}
// Try MM/DD/YYYY format
const mdyMatch = dateStr.match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
if (mdyMatch) {
const [, month, day, year] = mdyMatch;
const parsed = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
if (!isNaN(parsed.getTime())) {
return parsed.toISOString().split('T')[0];
}
}
return undefined;
}
/** Parse numeric value */
function parseNumber(value: string | number | null): number | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === 'number') return value;
const cleaned = value.replace(/[$,\s]/g, '');
const num = parseFloat(cleaned);
return isNaN(num) ? undefined : num;
}
/** Extract maintenance receipt data from image via OCR service */
async function extractMaintenanceReceiptFromImage(file: File): Promise<{
extractedFields: ExtractedMaintenanceReceiptFields;
rawText: string;
confidence: number;
}> {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post('/ocr/extract/maintenance-receipt', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30000,
});
const data = response.data;
if (!data.success) {
throw new Error('Maintenance receipt OCR extraction failed');
}
const fields = data.extractedFields || {};
const makeField = (key: string): ExtractedMaintenanceField => ({
value: fields[key]?.value ?? null,
confidence: fields[key]?.confidence ?? 0,
});
const extractedFields: ExtractedMaintenanceReceiptFields = {
serviceName: makeField('serviceName'),
serviceDate: makeField('serviceDate'),
totalCost: makeField('totalCost'),
shopName: makeField('shopName'),
laborCost: makeField('laborCost'),
partsCost: makeField('partsCost'),
odometerReading: makeField('odometerReading'),
vehicleInfo: makeField('vehicleInfo'),
};
return {
extractedFields,
rawText: data.rawText || '',
confidence: data.confidence || 0,
};
}
/** Map extracted fields to maintenance record form fields */
function mapFieldsToMaintenanceRecord(
fields: ExtractedMaintenanceReceiptFields,
categorySuggestion: CategorySuggestion | null
): MappedMaintenanceFields {
// Build notes from supplementary fields
const noteParts: string[] = [];
if (fields.laborCost.value !== null) {
noteParts.push(`Labor: $${parseNumber(fields.laborCost.value)?.toFixed(2) ?? fields.laborCost.value}`);
}
if (fields.partsCost.value !== null) {
noteParts.push(`Parts: $${parseNumber(fields.partsCost.value)?.toFixed(2) ?? fields.partsCost.value}`);
}
if (fields.vehicleInfo.value !== null) {
noteParts.push(`Vehicle: ${fields.vehicleInfo.value}`);
}
return {
date: parseServiceDate(fields.serviceDate.value),
cost: parseNumber(fields.totalCost.value),
shopName: fields.shopName.value ? String(fields.shopName.value) : undefined,
odometerReading: parseNumber(fields.odometerReading.value),
category: categorySuggestion?.category,
subtypes: categorySuggestion?.subtypes,
notes: noteParts.length > 0 ? noteParts.join(' | ') : undefined,
};
}
/**
* Hook to orchestrate maintenance receipt photo capture and OCR extraction.
* Mirrors useReceiptOcr pattern: startCapture -> processImage -> review -> acceptResult
*/
export function useMaintenanceReceiptOcr(): UseMaintenanceReceiptOcrReturn {
const [isCapturing, setIsCapturing] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [result, setResult] = useState<MaintenanceReceiptOcrResult | null>(null);
const [receiptImageUrl, setReceiptImageUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const startCapture = useCallback(() => {
setIsCapturing(true);
setError(null);
setResult(null);
}, []);
const cancelCapture = useCallback(() => {
setIsCapturing(false);
setError(null);
}, []);
const processImage = useCallback(async (file: File, croppedFile?: File) => {
setIsCapturing(false);
setIsProcessing(true);
setError(null);
setResult(null);
const imageToProcess = croppedFile || file;
const imageUrl = URL.createObjectURL(imageToProcess);
setReceiptImageUrl(imageUrl);
try {
const { extractedFields, rawText, confidence } = await extractMaintenanceReceiptFromImage(imageToProcess);
const categorySuggestion = suggestCategory(extractedFields.serviceName.value);
const mappedFields = mapFieldsToMaintenanceRecord(extractedFields, categorySuggestion);
setResult({
extractedFields,
mappedFields,
categorySuggestion,
rawText,
overallConfidence: confidence,
});
} catch (err: any) {
console.error('Maintenance receipt OCR processing failed:', err);
const message = err.response?.data?.message || err.message || 'Failed to process maintenance receipt image';
setError(message);
URL.revokeObjectURL(imageUrl);
setReceiptImageUrl(null);
} finally {
setIsProcessing(false);
}
}, []);
const updateField = useCallback((
fieldName: keyof ExtractedMaintenanceReceiptFields,
value: string | number | null
) => {
setResult((prev) => {
if (!prev) return null;
const updatedFields = {
...prev.extractedFields,
[fieldName]: {
...prev.extractedFields[fieldName],
value,
confidence: 1.0, // User-edited field has full confidence
},
};
// Re-run category suggestion if service name was edited
const categorySuggestion = fieldName === 'serviceName'
? suggestCategory(value)
: prev.categorySuggestion;
return {
...prev,
extractedFields: updatedFields,
categorySuggestion,
mappedFields: mapFieldsToMaintenanceRecord(updatedFields, categorySuggestion),
};
});
}, []);
const acceptResult = useCallback(() => {
if (!result) return null;
const mappedFields = result.mappedFields;
if (receiptImageUrl) {
URL.revokeObjectURL(receiptImageUrl);
}
setResult(null);
setReceiptImageUrl(null);
return mappedFields;
}, [result, receiptImageUrl]);
const reset = useCallback(() => {
setIsCapturing(false);
setIsProcessing(false);
if (receiptImageUrl) {
URL.revokeObjectURL(receiptImageUrl);
}
setResult(null);
setReceiptImageUrl(null);
setError(null);
}, [receiptImageUrl]);
return {
isCapturing,
isProcessing,
result,
receiptImageUrl,
error,
startCapture,
cancelCapture,
processImage,
acceptResult,
reset,
updateField,
};
}

View File

@@ -0,0 +1,70 @@
/**
* @ai-summary Type definitions for maintenance receipt OCR extraction
* @ai-context Mirrors fuel-logs ExtractedReceiptField pattern; maps OCR fields to maintenance record form values
*/
import { MaintenanceCategory } from './maintenance.types';
/** OCR-extracted field with confidence score */
export interface ExtractedMaintenanceField {
value: string | number | null;
confidence: number;
}
/** Fields extracted from a maintenance receipt via OCR */
export interface ExtractedMaintenanceReceiptFields {
serviceName: ExtractedMaintenanceField;
serviceDate: ExtractedMaintenanceField;
totalCost: ExtractedMaintenanceField;
shopName: ExtractedMaintenanceField;
laborCost: ExtractedMaintenanceField;
partsCost: ExtractedMaintenanceField;
odometerReading: ExtractedMaintenanceField;
vehicleInfo: ExtractedMaintenanceField;
}
/** Suggested category and subtypes from service name keyword matching */
export interface CategorySuggestion {
category: MaintenanceCategory;
subtypes: string[];
confidence: number;
}
/** Mapped fields ready for maintenance record form population */
export interface MappedMaintenanceFields {
date?: string;
cost?: number;
shopName?: string;
odometerReading?: number;
category?: MaintenanceCategory;
subtypes?: string[];
notes?: string;
}
/** Maintenance receipt OCR result */
export interface MaintenanceReceiptOcrResult {
extractedFields: ExtractedMaintenanceReceiptFields;
mappedFields: MappedMaintenanceFields;
categorySuggestion: CategorySuggestion | null;
rawText: string;
overallConfidence: number;
}
/** Hook state */
export interface UseMaintenanceReceiptOcrState {
isCapturing: boolean;
isProcessing: boolean;
result: MaintenanceReceiptOcrResult | null;
receiptImageUrl: string | null;
error: string | null;
}
/** Hook return type */
export interface UseMaintenanceReceiptOcrReturn extends UseMaintenanceReceiptOcrState {
startCapture: () => void;
cancelCapture: () => void;
processImage: (file: File, croppedFile?: File) => Promise<void>;
acceptResult: () => MappedMaintenanceFields | null;
reset: () => void;
updateField: (fieldName: keyof ExtractedMaintenanceReceiptFields, value: string | number | null) => void;
}

View File

@@ -68,6 +68,7 @@ export interface MaintenanceRecord {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string | null;
createdAt: string;
updatedAt: string;
}
@@ -105,6 +106,15 @@ export interface CreateMaintenanceRecordRequest {
cost?: number;
shopName?: string;
notes?: string;
receiptDocumentId?: string;
}
// Receipt document metadata returned on GET
export interface ReceiptDocumentMeta {
documentId: string;
fileName: string;
contentType: string;
storageKey: string;
}
export interface UpdateMaintenanceRecordRequest {
@@ -148,6 +158,7 @@ export interface UpdateScheduleRequest {
// Response types (camelCase)
export interface MaintenanceRecordResponse extends MaintenanceRecord {
subtypeCount: number;
receiptDocument?: ReceiptDocumentMeta | null;
}
export interface MaintenanceScheduleResponse extends MaintenanceSchedule {

View File

@@ -8,6 +8,10 @@ from app.extractors.receipt_extractor import (
ExtractedField,
)
from app.extractors.fuel_receipt import FuelReceiptExtractor, fuel_receipt_extractor
from app.extractors.maintenance_receipt_extractor import (
MaintenanceReceiptExtractor,
maintenance_receipt_extractor,
)
from app.extractors.manual_extractor import (
ManualExtractor,
manual_extractor,
@@ -27,6 +31,8 @@ __all__ = [
"ExtractedField",
"FuelReceiptExtractor",
"fuel_receipt_extractor",
"MaintenanceReceiptExtractor",
"maintenance_receipt_extractor",
"ManualExtractor",
"manual_extractor",
"ManualExtractionResult",

View File

@@ -0,0 +1,312 @@
"""Maintenance receipt extraction with Gemini-primary and regex cross-validation.
Flow:
1. Preprocess image and OCR via receipt_extractor (PaddleOCR)
2. Send OCR text to Gemini text API for semantic field extraction
3. Cross-validate structured fields (date, cost, odometer) with regex
4. Return ReceiptExtractionResult with per-field confidence scores
"""
import json
import logging
import os
import time
from typing import Any, Optional
from app.config import settings
from app.extractors.receipt_extractor import (
ExtractedField,
ReceiptExtractionResult,
receipt_extractor,
)
from app.patterns.maintenance_receipt_validation import (
MaintenanceReceiptValidation,
maintenance_receipt_validator,
)
logger = logging.getLogger(__name__)
# Default confidence for Gemini-extracted fields before cross-validation
DEFAULT_GEMINI_CONFIDENCE = 0.85
# Gemini prompt for maintenance receipt field extraction
_RECEIPT_EXTRACTION_PROMPT = """\
Extract maintenance service receipt fields from the following OCR text.
For each field, extract the value if present. Return null for fields not found.
Fields to extract:
- serviceName: The maintenance service performed (e.g., "Oil Change", "Brake Pad Replacement", "Tire Rotation")
- serviceDate: Date of service in YYYY-MM-DD format
- totalCost: Total cost as a number (e.g., 89.95)
- shopName: Name of the shop or business
- laborCost: Labor cost as a number, or null if not broken out
- partsCost: Parts cost as a number, or null if not broken out
- odometerReading: Odometer/mileage reading as a number, or null if not present
- vehicleInfo: Vehicle description if present (e.g., "2022 Toyota Camry"), or null
Return a JSON object with these field names and their extracted values.
OCR Text:
---
{ocr_text}
---\
"""
_RECEIPT_RESPONSE_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"serviceName": {"type": "string", "nullable": True},
"serviceDate": {"type": "string", "nullable": True},
"totalCost": {"type": "number", "nullable": True},
"shopName": {"type": "string", "nullable": True},
"laborCost": {"type": "number", "nullable": True},
"partsCost": {"type": "number", "nullable": True},
"odometerReading": {"type": "number", "nullable": True},
"vehicleInfo": {"type": "string", "nullable": True},
},
"required": [
"serviceName",
"serviceDate",
"totalCost",
"shopName",
"laborCost",
"partsCost",
"odometerReading",
"vehicleInfo",
],
}
class MaintenanceReceiptExtractor:
"""Maintenance receipt extractor using Gemini for semantic extraction.
Wraps receipt_extractor for OCR preprocessing, then sends raw text to
Gemini for field extraction. Structured fields (dates, amounts, odometer)
are cross-validated against regex patterns for confidence adjustment.
"""
def __init__(self) -> None:
self._model: Any | None = None
self._generation_config: Any | None = None
def extract(
self,
image_bytes: bytes,
content_type: Optional[str] = None,
) -> ReceiptExtractionResult:
"""Extract maintenance receipt fields from an image.
Args:
image_bytes: Raw image bytes (HEIC, JPEG, PNG).
content_type: MIME type (auto-detected if not provided).
Returns:
ReceiptExtractionResult with maintenance-specific fields.
"""
start_time = time.time()
# Step 1: OCR the image via receipt_extractor
ocr_result = receipt_extractor.extract(
image_bytes=image_bytes,
content_type=content_type,
)
if not ocr_result.success:
return ocr_result
raw_text = ocr_result.raw_text
if not raw_text.strip():
return ReceiptExtractionResult(
success=False,
error="No text found in image",
processing_time_ms=int((time.time() - start_time) * 1000),
)
# Step 2: Extract fields with Gemini
try:
gemini_fields = self._extract_with_gemini(raw_text)
except Exception as e:
logger.warning(f"Gemini extraction failed, falling back to OCR-only: {e}")
gemini_fields = {}
# Step 3: Build extracted fields with base confidence
extracted_fields = self._build_fields(gemini_fields)
if not extracted_fields:
return ReceiptExtractionResult(
success=False,
receipt_type="maintenance",
error="No maintenance receipt fields could be extracted",
raw_text=raw_text,
processing_time_ms=int((time.time() - start_time) * 1000),
)
# Step 4: Cross-validate structured fields with regex
validation = maintenance_receipt_validator.validate(gemini_fields, raw_text)
if validation.issues:
logger.info(f"Maintenance receipt validation issues: {validation.issues}")
# Step 5: Adjust confidences based on cross-validation
adjusted_fields = self._adjust_confidences(extracted_fields, validation)
processing_time_ms = int((time.time() - start_time) * 1000)
logger.info(
f"Maintenance receipt extraction: "
f"fields={len(adjusted_fields)}, "
f"validated={validation.is_valid}, "
f"time={processing_time_ms}ms"
)
return ReceiptExtractionResult(
success=True,
receipt_type="maintenance",
extracted_fields=adjusted_fields,
raw_text=raw_text,
processing_time_ms=processing_time_ms,
)
def _get_model(self) -> Any:
"""Lazy-initialize Vertex AI Gemini model.
Uses the same authentication pattern as GeminiEngine.
"""
if self._model is not None:
return self._model
key_path = settings.google_vision_key_path
if not os.path.isfile(key_path):
raise RuntimeError(
f"Google credential config not found at {key_path}. "
"Set GOOGLE_VISION_KEY_PATH or mount the secret."
)
from google.cloud import aiplatform # type: ignore[import-untyped]
from vertexai.generative_models import ( # type: ignore[import-untyped]
GenerationConfig,
GenerativeModel,
)
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = key_path
os.environ["GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"] = "1"
aiplatform.init(
project=settings.vertex_ai_project,
location=settings.vertex_ai_location,
)
model_name = settings.gemini_model
self._model = GenerativeModel(model_name)
self._generation_config = GenerationConfig(
response_mime_type="application/json",
response_schema=_RECEIPT_RESPONSE_SCHEMA,
)
logger.info(
"Maintenance receipt Gemini model initialized (model=%s)",
model_name,
)
return self._model
def _extract_with_gemini(self, ocr_text: str) -> dict:
"""Send OCR text to Gemini for semantic field extraction.
Args:
ocr_text: Raw OCR text from receipt image.
Returns:
Dictionary of field_name -> extracted_value from Gemini.
"""
model = self._get_model()
prompt = _RECEIPT_EXTRACTION_PROMPT.format(ocr_text=ocr_text)
response = model.generate_content(
[prompt],
generation_config=self._generation_config,
)
raw = json.loads(response.text)
logger.info(
"Gemini extracted maintenance fields: %s",
[k for k, v in raw.items() if v is not None],
)
return raw
def _build_fields(self, gemini_fields: dict) -> dict[str, ExtractedField]:
"""Convert Gemini response to ExtractedField dict with base confidence.
Args:
gemini_fields: Raw Gemini response dict.
Returns:
Dictionary of field_name -> ExtractedField.
"""
fields: dict[str, ExtractedField] = {}
for field_name, value in gemini_fields.items():
if value is None:
continue
# Convert numeric values to appropriate types
if field_name in ("totalCost", "laborCost", "partsCost"):
try:
value = round(float(value), 2)
except (ValueError, TypeError):
continue
elif field_name == "odometerReading":
try:
value = int(float(value))
except (ValueError, TypeError):
continue
elif isinstance(value, str) and not value.strip():
continue
fields[field_name] = ExtractedField(
value=value,
confidence=DEFAULT_GEMINI_CONFIDENCE,
)
return fields
def _adjust_confidences(
self,
fields: dict[str, ExtractedField],
validation: MaintenanceReceiptValidation,
) -> dict[str, ExtractedField]:
"""Adjust field confidences based on cross-validation results.
Args:
fields: Extracted fields with base confidence.
validation: Cross-validation results.
Returns:
Fields with adjusted confidences.
"""
adjusted: dict[str, ExtractedField] = {}
for name, extracted_field in fields.items():
if name in validation.field_validations:
fv = validation.field_validations[name]
new_confidence = min(
1.0, extracted_field.confidence * fv.confidence_adjustment
)
else:
# Semantic fields (no regex validation) keep base confidence
new_confidence = extracted_field.confidence
adjusted[name] = ExtractedField(
value=extracted_field.value,
confidence=round(new_confidence, 3),
)
return adjusted
# Singleton instance
maintenance_receipt_extractor = MaintenanceReceiptExtractor()

View File

@@ -4,6 +4,10 @@ from app.patterns.currency_patterns import CurrencyPatternMatcher, currency_matc
from app.patterns.fuel_patterns import FuelPatternMatcher, fuel_matcher
from app.patterns.maintenance_patterns import MaintenancePatternMatcher, maintenance_matcher
from app.patterns.service_mapping import ServiceMapper, service_mapper
from app.patterns.maintenance_receipt_validation import (
MaintenanceReceiptValidator,
maintenance_receipt_validator,
)
__all__ = [
"DatePatternMatcher",
@@ -16,4 +20,6 @@ __all__ = [
"maintenance_matcher",
"ServiceMapper",
"service_mapper",
"MaintenanceReceiptValidator",
"maintenance_receipt_validator",
]

View File

@@ -0,0 +1,299 @@
"""Cross-validation patterns for maintenance receipt field extraction.
Validates structured fields (dates, amounts, odometer) extracted by Gemini
against regex patterns found in the OCR raw text. Boosts or reduces confidence
based on regex agreement.
"""
import re
from dataclasses import dataclass, field
from typing import Optional
from app.patterns.currency_patterns import currency_matcher
from app.patterns.date_patterns import date_matcher
@dataclass
class FieldValidation:
"""Validation result for a single extracted field."""
field_name: str
regex_confirmed: bool
confidence_adjustment: float # Multiplier: >1.0 boosts, <1.0 reduces
regex_value: Optional[str] = None # Value found by regex, if any
@dataclass
class MaintenanceReceiptValidation:
"""Aggregated validation result for a maintenance receipt."""
is_valid: bool
issues: list[str]
field_validations: dict[str, FieldValidation] = field(default_factory=dict)
overall_confidence: float = 1.0
# Odometer patterns: 5-7 digit numbers near odometer keywords
ODOMETER_PATTERNS = [
# "Odometer: 45,231" or "Mileage: 45231"
(
r"(?:ODOMETER|MILEAGE|MILES|ODO|MI)\s*[:\s]\s*(\d{1,3}[,.]?\d{3,4})",
"labeled_odometer",
0.95,
),
# "45,231 mi" or "45231 miles"
(
r"(\d{1,3}[,.]?\d{3,4})\s*(?:MI|MILES|KM)",
"unit_odometer",
0.90,
),
# Standalone 5-6 digit number (lower confidence)
(
r"(?<!\d)(\d{5,6})(?!\d)",
"standalone_odometer",
0.60,
),
]
@dataclass
class OdometerMatch:
"""Result of odometer pattern matching."""
value: int
raw_match: str
confidence: float
pattern_name: str
class MaintenanceReceiptValidator:
"""Cross-validates Gemini-extracted maintenance receipt fields against regex patterns."""
def validate(
self,
gemini_fields: dict,
raw_text: str,
) -> MaintenanceReceiptValidation:
"""Validate Gemini-extracted fields against regex patterns in raw OCR text.
Args:
gemini_fields: Fields extracted by Gemini (field_name -> value).
raw_text: Raw OCR text for regex cross-validation.
Returns:
MaintenanceReceiptValidation with per-field results.
"""
issues: list[str] = []
field_validations: dict[str, FieldValidation] = {}
overall_confidence = 1.0
# Validate date field
if "serviceDate" in gemini_fields:
date_validation = self._validate_date(
gemini_fields["serviceDate"], raw_text
)
field_validations["serviceDate"] = date_validation
if not date_validation.regex_confirmed:
issues.append(
f"Service date '{gemini_fields['serviceDate']}' not confirmed by regex"
)
overall_confidence *= 0.85
# Validate total cost
if "totalCost" in gemini_fields:
cost_validation = self._validate_amount(
"totalCost", gemini_fields["totalCost"], raw_text
)
field_validations["totalCost"] = cost_validation
if not cost_validation.regex_confirmed:
issues.append(
f"Total cost '{gemini_fields['totalCost']}' not confirmed by regex"
)
overall_confidence *= 0.85
# Validate labor cost
if "laborCost" in gemini_fields:
labor_validation = self._validate_amount(
"laborCost", gemini_fields["laborCost"], raw_text
)
field_validations["laborCost"] = labor_validation
if not labor_validation.regex_confirmed:
issues.append(
f"Labor cost '{gemini_fields['laborCost']}' not confirmed by regex"
)
overall_confidence *= 0.90
# Validate odometer
if "odometerReading" in gemini_fields:
odo_validation = self._validate_odometer(
gemini_fields["odometerReading"], raw_text
)
field_validations["odometerReading"] = odo_validation
if not odo_validation.regex_confirmed:
issues.append(
f"Odometer '{gemini_fields['odometerReading']}' not confirmed by regex"
)
overall_confidence *= 0.90
is_valid = len(issues) == 0
return MaintenanceReceiptValidation(
is_valid=is_valid,
issues=issues,
field_validations=field_validations,
overall_confidence=overall_confidence,
)
def extract_odometer(self, text: str) -> Optional[OdometerMatch]:
"""Extract odometer reading from text using regex patterns.
Args:
text: OCR text to search.
Returns:
Best OdometerMatch or None.
"""
text_upper = text.upper()
best_match: Optional[OdometerMatch] = None
for pattern, name, confidence in ODOMETER_PATTERNS:
for match in re.finditer(pattern, text_upper):
raw_value = match.group(1)
parsed = self._parse_odometer(raw_value)
if parsed is not None and self._is_reasonable_odometer(parsed):
candidate = OdometerMatch(
value=parsed,
raw_match=match.group(0).strip(),
confidence=confidence,
pattern_name=name,
)
if best_match is None or candidate.confidence > best_match.confidence:
best_match = candidate
return best_match
def _validate_date(self, gemini_date: str, raw_text: str) -> FieldValidation:
"""Check if Gemini-extracted date matches a regex-found date."""
regex_dates = date_matcher.extract_dates(raw_text)
if not regex_dates:
# No dates found by regex -- cannot confirm or deny
return FieldValidation(
field_name="serviceDate",
regex_confirmed=False,
confidence_adjustment=0.95,
)
# Normalize Gemini date for comparison
gemini_normalized = gemini_date.strip().replace("/", "-")
for regex_date in regex_dates:
if regex_date.value == gemini_normalized:
return FieldValidation(
field_name="serviceDate",
regex_confirmed=True,
confidence_adjustment=1.10,
regex_value=regex_date.value,
)
# Gemini found a date but it doesn't match regex dates
return FieldValidation(
field_name="serviceDate",
regex_confirmed=False,
confidence_adjustment=0.80,
regex_value=regex_dates[0].value if regex_dates else None,
)
def _validate_amount(
self, field_name: str, gemini_amount: str | float, raw_text: str
) -> FieldValidation:
"""Check if Gemini-extracted amount matches a regex-found amount."""
try:
gemini_value = float(str(gemini_amount).replace("$", "").replace(",", ""))
except (ValueError, TypeError):
return FieldValidation(
field_name=field_name,
regex_confirmed=False,
confidence_adjustment=0.70,
)
regex_amounts = currency_matcher.extract_all_amounts(raw_text)
if not regex_amounts:
return FieldValidation(
field_name=field_name,
regex_confirmed=False,
confidence_adjustment=0.95,
)
# Check if any regex amount matches within 5% tolerance
for regex_amount in regex_amounts:
if gemini_value > 0 and abs(regex_amount.value - gemini_value) / gemini_value < 0.05:
return FieldValidation(
field_name=field_name,
regex_confirmed=True,
confidence_adjustment=1.10,
regex_value=str(regex_amount.value),
)
return FieldValidation(
field_name=field_name,
regex_confirmed=False,
confidence_adjustment=0.80,
regex_value=str(regex_amounts[0].value) if regex_amounts else None,
)
def _validate_odometer(
self, gemini_odometer: str | int, raw_text: str
) -> FieldValidation:
"""Check if Gemini-extracted odometer matches a regex-found reading."""
try:
gemini_value = int(
str(gemini_odometer).replace(",", "").replace(".", "").strip()
)
except (ValueError, TypeError):
return FieldValidation(
field_name="odometerReading",
regex_confirmed=False,
confidence_adjustment=0.70,
)
regex_match = self.extract_odometer(raw_text)
if not regex_match:
return FieldValidation(
field_name="odometerReading",
regex_confirmed=False,
confidence_adjustment=0.95,
)
# Check if values match within 1% tolerance (OCR might misread a digit)
if gemini_value > 0 and abs(regex_match.value - gemini_value) / gemini_value < 0.01:
return FieldValidation(
field_name="odometerReading",
regex_confirmed=True,
confidence_adjustment=1.10,
regex_value=str(regex_match.value),
)
return FieldValidation(
field_name="odometerReading",
regex_confirmed=False,
confidence_adjustment=0.80,
regex_value=str(regex_match.value),
)
def _parse_odometer(self, raw: str) -> Optional[int]:
"""Parse odometer string to integer."""
cleaned = raw.replace(",", "").replace(".", "").strip()
try:
return int(cleaned)
except ValueError:
return None
def _is_reasonable_odometer(self, value: int) -> bool:
"""Check if odometer reading is in a reasonable range."""
return 100 <= value <= 999_999
# Singleton instance
maintenance_receipt_validator = MaintenanceReceiptValidator()

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, Query
from app.extractors.vin_extractor import vin_extractor
from app.extractors.receipt_extractor import receipt_extractor
from app.extractors.maintenance_receipt_extractor import maintenance_receipt_extractor
from app.extractors.manual_extractor import manual_extractor
from app.models import (
BoundingBox,
@@ -267,6 +268,95 @@ async def extract_receipt(
)
@router.post("/maintenance-receipt", response_model=ReceiptExtractionResponse)
async def extract_maintenance_receipt(
file: UploadFile = File(..., description="Maintenance receipt image file"),
) -> ReceiptExtractionResponse:
"""
Extract data from a maintenance receipt image using OCR + Gemini.
Gemini-primary extraction with regex cross-validation:
- OCR preprocessing (HEIC conversion, contrast, thresholding)
- PaddleOCR text extraction
- Gemini semantic field extraction from OCR text
- Regex cross-validation for dates, amounts, odometer
Supports HEIC, JPEG, PNG formats.
- **file**: Maintenance receipt image file (max 10MB)
Returns:
- **receiptType**: "maintenance"
- **extractedFields**: Dictionary of extracted fields with confidence scores
- serviceName: Service performed (e.g., "Oil Change")
- serviceDate: Date in YYYY-MM-DD format
- totalCost: Total cost
- shopName: Shop or business name
- laborCost: Labor cost (if broken out)
- partsCost: Parts cost (if broken out)
- odometerReading: Odometer reading (if present)
- vehicleInfo: Vehicle description (if present)
- **rawText**: Full OCR text
- **processingTimeMs**: Processing time in milliseconds
"""
# Validate file presence
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
# Read file content
content = await file.read()
file_size = len(content)
# Validate file size
if file_size > MAX_SYNC_SIZE:
raise HTTPException(
status_code=413,
detail=f"File too large. Max: {MAX_SYNC_SIZE // (1024*1024)}MB",
)
if file_size == 0:
raise HTTPException(status_code=400, detail="Empty file provided")
logger.info(
f"Maintenance receipt extraction: {file.filename}, "
f"size: {file_size} bytes, "
f"content_type: {file.content_type}"
)
# Perform maintenance receipt extraction
result = maintenance_receipt_extractor.extract(
image_bytes=content,
content_type=file.content_type,
)
if not result.success:
logger.warning(
f"Maintenance receipt extraction failed for {file.filename}: {result.error}"
)
raise HTTPException(
status_code=422,
detail=result.error or "Failed to extract data from maintenance receipt",
)
# Convert internal fields to API response format
extracted_fields = {
name: ReceiptExtractedField(
value=field.value,
confidence=field.confidence,
)
for name, field in result.extracted_fields.items()
}
return ReceiptExtractionResponse(
success=result.success,
receiptType=result.receipt_type,
extractedFields=extracted_fields,
rawText=result.raw_text,
processingTimeMs=result.processing_time_ms,
error=result.error,
)
@router.post("/manual", response_model=ManualJobResponse)
async def extract_manual(
background_tasks: BackgroundTasks,