diff --git a/backend/src/features/stations/api/stations.controller.ts b/backend/src/features/stations/api/stations.controller.ts index e4afaf5..271b1f4 100644 --- a/backend/src/features/stations/api/stations.controller.ts +++ b/backend/src/features/stations/api/stations.controller.ts @@ -10,6 +10,7 @@ import { pool } from '../../../core/config/database'; import { logger } from '../../../core/logging/logger'; import { StationSearchBody, + StationMatchBody, SaveStationBody, StationParams, UpdateSavedStationBody @@ -53,6 +54,29 @@ export class StationsController { } } + async matchStation(request: FastifyRequest<{ Body: StationMatchBody }>, reply: FastifyReply) { + try { + const { merchantName } = request.body; + + if (!merchantName || !merchantName.trim()) { + return reply.code(400).send({ + error: 'Bad Request', + message: 'Merchant name is required', + }); + } + + const result = await this.stationsService.matchStationFromReceipt(merchantName); + + return reply.code(200).send(result); + } catch (error: any) { + logger.error('Error matching station from receipt', { error, merchantName: request.body?.merchantName }); + return reply.code(500).send({ + error: 'Internal server error', + message: 'Failed to match station', + }); + } + } + async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) { try { const userId = (request as any).user.sub; diff --git a/backend/src/features/stations/api/stations.routes.ts b/backend/src/features/stations/api/stations.routes.ts index a744d7d..5cc9ce1 100644 --- a/backend/src/features/stations/api/stations.routes.ts +++ b/backend/src/features/stations/api/stations.routes.ts @@ -7,6 +7,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify'; import { FastifyPluginAsync } from 'fastify'; import { StationSearchBody, + StationMatchBody, SaveStationBody, StationParams, UpdateSavedStationBody @@ -25,6 +26,12 @@ export const stationsRoutes: FastifyPluginAsync = async ( handler: stationsController.searchStations.bind(stationsController) }); + // POST /api/stations/match - Match station from receipt merchant name + fastify.post<{ Body: StationMatchBody }>('/stations/match', { + preHandler: [fastify.authenticate], + handler: stationsController.matchStation.bind(stationsController) + }); + // POST /api/stations/save - Save a station to user's favorites fastify.post<{ Body: SaveStationBody }>('/stations/save', { preHandler: [fastify.authenticate], diff --git a/backend/src/features/stations/domain/stations.service.ts b/backend/src/features/stations/domain/stations.service.ts index 56746e0..2aafa35 100644 --- a/backend/src/features/stations/domain/stations.service.ts +++ b/backend/src/features/stations/domain/stations.service.ts @@ -7,6 +7,7 @@ import { googleMapsClient } from '../external/google-maps/google-maps.client'; import { StationSearchRequest, StationSearchResponse, + StationMatchResponse, SavedStation, StationSavedMetadata, UpdateSavedStationBody @@ -154,6 +155,27 @@ export class StationsService { return enriched; } + async matchStationFromReceipt(merchantName: string): Promise { + const trimmed = merchantName.trim(); + if (!trimmed) { + return { matched: false, station: null }; + } + + logger.info('Matching station from receipt merchant name', { merchantName: trimmed }); + + const station = await googleMapsClient.searchStationByName(trimmed); + + if (station) { + // Cache matched station for future reference (e.g. saveStation) + await this.repository.cacheStation(station); + } + + return { + matched: station !== null, + station, + }; + } + async removeSavedStation(placeId: string, userId: string) { const removed = await this.repository.deleteSavedStation(userId, placeId); diff --git a/backend/src/features/stations/domain/stations.types.ts b/backend/src/features/stations/domain/stations.types.ts index 435ab5e..0521067 100644 --- a/backend/src/features/stations/domain/stations.types.ts +++ b/backend/src/features/stations/domain/stations.types.ts @@ -89,3 +89,12 @@ export interface StationSavedMetadata { has93Octane: boolean; has93OctaneEthanolFree: boolean; } + +export interface StationMatchBody { + merchantName: string; +} + +export interface StationMatchResponse { + matched: boolean; + station: Station | null; +} diff --git a/backend/src/features/stations/external/google-maps/google-maps.client.ts b/backend/src/features/stations/external/google-maps/google-maps.client.ts index 780375d..4336065 100644 --- a/backend/src/features/stations/external/google-maps/google-maps.client.ts +++ b/backend/src/features/stations/external/google-maps/google-maps.client.ts @@ -7,7 +7,7 @@ import axios from 'axios'; import { appConfig } from '../../../../core/config/config-loader'; import { logger } from '../../../../core/logging/logger'; import { cacheService } from '../../../../core/config/redis'; -import { GooglePlacesResponse, GooglePlace } from './google-maps.types'; +import { GooglePlacesResponse, GoogleTextSearchResponse, GooglePlace } from './google-maps.types'; import { Station } from '../../domain/stations.types'; export class GoogleMapsClient { @@ -103,6 +103,87 @@ export class GoogleMapsClient { return station; } + /** + * Search for a gas station by merchant name using Google Places Text Search API. + * Used to match receipt merchant names (e.g. "Shell", "COSTCO #123") to actual stations. + */ + async searchStationByName(merchantName: string): Promise { + const query = `${merchantName} gas station`; + const cacheKey = `station-match:${query.toLowerCase().trim()}`; + + try { + const cached = await cacheService.get(cacheKey); + if (cached !== undefined && cached !== null) { + logger.debug('Station name match cache hit', { merchantName }); + return cached; + } + + logger.info('Searching Google Places Text Search for station', { merchantName, query }); + + const response = await axios.get( + `${this.baseURL}/textsearch/json`, + { + params: { + query, + type: 'gas_station', + key: this.apiKey, + }, + } + ); + + if (response.data.status !== 'OK' && response.data.status !== 'ZERO_RESULTS') { + throw new Error(`Google Places Text Search API error: ${response.data.status}`); + } + + if (response.data.results.length === 0) { + await cacheService.set(cacheKey, null, this.cacheTTL); + return null; + } + + const topResult = response.data.results[0]; + const station = this.transformTextSearchResult(topResult); + + await cacheService.set(cacheKey, station, this.cacheTTL); + return station; + } catch (error) { + logger.error('Station name search failed', { error, merchantName }); + return null; + } + } + + private transformTextSearchResult(place: GooglePlace): Station { + let photoReference: string | undefined; + if (place.photos && place.photos.length > 0 && place.photos[0]) { + photoReference = place.photos[0].photo_reference; + } + + // Text Search returns formatted_address instead of vicinity + const address = (place as any).formatted_address || place.vicinity || ''; + + const station: Station = { + id: place.place_id, + placeId: place.place_id, + name: place.name, + address, + latitude: place.geometry.location.lat, + longitude: place.geometry.location.lng, + }; + + if (photoReference !== undefined) { + station.photoReference = photoReference; + } + + if (place.opening_hours?.open_now !== undefined) { + station.isOpen = place.opening_hours.open_now; + } + + if (place.rating !== undefined) { + station.rating = place.rating; + } + + return station; + } + /** * Fetch photo from Google Maps API using photo reference * Used by photo proxy endpoint to serve photos without exposing API key diff --git a/backend/src/features/stations/external/google-maps/google-maps.types.ts b/backend/src/features/stations/external/google-maps/google-maps.types.ts index 39a02d4..e85d9db 100644 --- a/backend/src/features/stations/external/google-maps/google-maps.types.ts +++ b/backend/src/features/stations/external/google-maps/google-maps.types.ts @@ -52,4 +52,10 @@ export interface GooglePlaceDetails { website?: string; }; status: string; +} + +export interface GoogleTextSearchResponse { + results: GooglePlace[]; + status: string; + next_page_token?: string; } \ No newline at end of file diff --git a/backend/src/features/stations/tests/unit/station-matching.test.ts b/backend/src/features/stations/tests/unit/station-matching.test.ts new file mode 100644 index 0000000..20d9569 --- /dev/null +++ b/backend/src/features/stations/tests/unit/station-matching.test.ts @@ -0,0 +1,258 @@ +/** + * @ai-summary Unit tests for station matching from receipt merchant names + */ + +// Mock config-loader before any imports that use it +jest.mock('../../../../core/config/config-loader', () => ({ + appConfig: { + secrets: { google_maps_api_key: 'mock-api-key' }, + getDatabaseUrl: () => 'postgresql://mock:mock@localhost/mock', + getRedisUrl: () => 'redis://localhost', + get: () => ({}), + }, +})); +jest.mock('axios'); +jest.mock('../../../../core/config/redis'); +jest.mock('../../../../core/logging/logger'); +jest.mock('../../data/stations.repository'); +jest.mock('../../external/google-maps/google-maps.client', () => { + const { GoogleMapsClient } = jest.requireActual('../../external/google-maps/google-maps.client'); + return { + GoogleMapsClient, + googleMapsClient: { + searchNearbyStations: jest.fn(), + searchStationByName: jest.fn(), + fetchPhoto: jest.fn(), + }, + }; +}); + +import axios from 'axios'; +import { GoogleMapsClient } from '../../external/google-maps/google-maps.client'; +import { StationsService } from '../../domain/stations.service'; +import { StationsRepository } from '../../data/stations.repository'; +import { googleMapsClient } from '../../external/google-maps/google-maps.client'; +import { mockStations } from '../fixtures/mock-stations'; + +describe('Station Matching from Receipt', () => { + describe('GoogleMapsClient.searchStationByName', () => { + let client: GoogleMapsClient; + let mockAxios: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockAxios = axios as jest.Mocked; + client = new GoogleMapsClient(); + }); + + it('should match a known station name like "Shell"', async () => { + mockAxios.get.mockResolvedValue({ + data: { + results: [ + { + place_id: 'ChIJ_shell_match', + name: 'Shell Gas Station', + formatted_address: '123 Main St, San Francisco, CA 94105', + geometry: { location: { lat: 37.7749, lng: -122.4194 } }, + rating: 4.2, + photos: [{ photo_reference: 'shell-photo-ref' }], + opening_hours: { open_now: true }, + types: ['gas_station'], + }, + ], + status: 'OK', + }, + }); + + const result = await client.searchStationByName('Shell'); + + expect(result).not.toBeNull(); + expect(result?.placeId).toBe('ChIJ_shell_match'); + expect(result?.name).toBe('Shell Gas Station'); + expect(result?.address).toBe('123 Main St, San Francisco, CA 94105'); + expect(mockAxios.get).toHaveBeenCalledWith( + expect.stringContaining('textsearch/json'), + expect.objectContaining({ + params: expect.objectContaining({ + query: 'Shell gas station', + type: 'gas_station', + }), + }) + ); + }); + + it('should match abbreviated names like "COSTCO #123"', async () => { + mockAxios.get.mockResolvedValue({ + data: { + results: [ + { + place_id: 'ChIJ_costco_match', + name: 'Costco Gasoline', + formatted_address: '2000 El Camino Real, Redwood City, CA', + geometry: { location: { lat: 37.4849, lng: -122.2278 } }, + rating: 4.5, + types: ['gas_station'], + }, + ], + status: 'OK', + }, + }); + + const result = await client.searchStationByName('COSTCO #123'); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('Costco Gasoline'); + expect(result?.placeId).toBe('ChIJ_costco_match'); + }); + + it('should match "BP" station name', async () => { + mockAxios.get.mockResolvedValue({ + data: { + results: [ + { + place_id: 'ChIJ_bp_match', + name: 'BP', + formatted_address: '500 Market St, San Francisco, CA', + geometry: { location: { lat: 37.79, lng: -122.40 } }, + types: ['gas_station'], + }, + ], + status: 'OK', + }, + }); + + const result = await client.searchStationByName('BP'); + + expect(result).not.toBeNull(); + expect(result?.name).toBe('BP'); + }); + + it('should return null when no match is found', async () => { + mockAxios.get.mockResolvedValue({ + data: { + results: [], + status: 'ZERO_RESULTS', + }, + }); + + const result = await client.searchStationByName('Unknown Station XYZ123'); + + expect(result).toBeNull(); + }); + + it('should return null gracefully on API error', async () => { + mockAxios.get.mockRejectedValue(new Error('Network error')); + + const result = await client.searchStationByName('Shell'); + + expect(result).toBeNull(); + }); + + it('should return null on API denial', async () => { + mockAxios.get.mockResolvedValue({ + data: { + results: [], + status: 'REQUEST_DENIED', + error_message: 'Invalid key', + }, + }); + + const result = await client.searchStationByName('Shell'); + + expect(result).toBeNull(); + }); + + it('should include rating and photo reference when available', async () => { + mockAxios.get.mockResolvedValue({ + data: { + results: [ + { + place_id: 'ChIJ_rated', + name: 'Chevron', + formatted_address: '789 Oak Ave, Portland, OR', + geometry: { location: { lat: 45.52, lng: -122.68 } }, + rating: 4.7, + photos: [{ photo_reference: 'chevron-photo' }], + opening_hours: { open_now: false }, + types: ['gas_station'], + }, + ], + status: 'OK', + }, + }); + + const result = await client.searchStationByName('Chevron'); + + expect(result?.rating).toBe(4.7); + expect(result?.photoReference).toBe('chevron-photo'); + expect(result?.isOpen).toBe(false); + }); + }); + + describe('StationsService.matchStationFromReceipt', () => { + let service: StationsService; + let mockRepository: jest.Mocked; + + const mockSearchByName = googleMapsClient.searchStationByName as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRepository = { + cacheStation: jest.fn().mockResolvedValue(undefined), + getCachedStation: jest.fn(), + saveStation: jest.fn(), + getUserSavedStations: jest.fn().mockResolvedValue([]), + updateSavedStation: jest.fn(), + deleteSavedStation: jest.fn(), + } as unknown as jest.Mocked; + + service = new StationsService(mockRepository); + }); + + it('should return matched station for known merchant name', async () => { + const matchedStation = mockStations[0]!; + mockSearchByName.mockResolvedValue(matchedStation); + + const result = await service.matchStationFromReceipt('Shell'); + + expect(result.matched).toBe(true); + expect(result.station).not.toBeNull(); + expect(result.station?.name).toBe('Shell Gas Station - Downtown'); + expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation); + }); + + it('should return no match for unknown merchant', async () => { + mockSearchByName.mockResolvedValue(null); + + const result = await service.matchStationFromReceipt('Unknown Store'); + + expect(result.matched).toBe(false); + expect(result.station).toBeNull(); + expect(mockRepository.cacheStation).not.toHaveBeenCalled(); + }); + + it('should handle empty merchant name', async () => { + const result = await service.matchStationFromReceipt(''); + + expect(result.matched).toBe(false); + expect(result.station).toBeNull(); + }); + + it('should handle whitespace-only merchant name', async () => { + const result = await service.matchStationFromReceipt(' '); + + expect(result.matched).toBe(false); + expect(result.station).toBeNull(); + }); + + it('should cache matched station for future saveStation calls', async () => { + const matchedStation = mockStations[1]!; + mockSearchByName.mockResolvedValue(matchedStation); + + await service.matchStationFromReceipt('Chevron'); + + expect(mockRepository.cacheStation).toHaveBeenCalledWith(matchedStation); + }); + }); +}); diff --git a/frontend/src/features/fuel-logs/components/FuelLogForm.tsx b/frontend/src/features/fuel-logs/components/FuelLogForm.tsx index 2b5cd34..a60085b 100644 --- a/frontend/src/features/fuel-logs/components/FuelLogForm.tsx +++ b/frontend/src/features/fuel-logs/components/FuelLogForm.tsx @@ -68,6 +68,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial acceptResult, reset: resetOcr, updateField, + clearMatchedStation, } = useReceiptOcr(); const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm({ @@ -159,13 +160,13 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial if (mappedFields.fuelGrade) { setValue('fuelGrade', mappedFields.fuelGrade); } - if (mappedFields.locationData?.stationName) { - // Set station name in locationData if no station is already selected + if (mappedFields.locationData) { + // Set location data from OCR + station matching if no station is already selected const currentLocation = watch('locationData'); if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) { setValue('locationData', { ...currentLocation, - stationName: mappedFields.locationData.stationName, + ...mappedFields.locationData, }); } } @@ -443,10 +444,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial open={!!ocrResult} extractedFields={ocrResult.extractedFields} receiptImageUrl={receiptImageUrl} + matchedStation={ocrResult.matchedStation} onAccept={handleAcceptOcrResult} onRetake={handleRetakePhoto} onCancel={resetOcr} onFieldEdit={updateField} + onClearMatchedStation={clearMatchedStation} /> )} diff --git a/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx b/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx index f2c997e..c8ae025 100644 --- a/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx +++ b/frontend/src/features/fuel-logs/components/ReceiptOcrReviewModal.tsx @@ -24,9 +24,11 @@ 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 PlaceIcon from '@mui/icons-material/Place'; import { ExtractedReceiptFields, ExtractedReceiptField, + MatchedStation, LOW_CONFIDENCE_THRESHOLD, } from '../hooks/useReceiptOcr'; import { ReceiptPreview } from './ReceiptPreview'; @@ -38,6 +40,8 @@ export interface ReceiptOcrReviewModalProps { extractedFields: ExtractedReceiptFields; /** Receipt image URL for preview */ receiptImageUrl: string | null; + /** Matched station from merchant name (if any) */ + matchedStation?: MatchedStation | null; /** Called when user accepts the fields */ onAccept: () => void; /** Called when user wants to retake the photo */ @@ -46,6 +50,8 @@ export interface ReceiptOcrReviewModalProps { onCancel: () => void; /** Called when user edits a field */ onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void; + /** Called when user clears the matched station */ + onClearMatchedStation?: () => void; } /** Confidence indicator component */ @@ -209,10 +215,12 @@ export const ReceiptOcrReviewModal: React.FC = ({ open, extractedFields, receiptImageUrl, + matchedStation, onAccept, onRetake, onCancel, onFieldEdit, + onClearMatchedStation, }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -354,6 +362,40 @@ export const ReceiptOcrReviewModal: React.FC = ({ onEdit={(value) => onFieldEdit('merchantName', value)} type="text" /> + {matchedStation && ( + + + + + {matchedStation.name} + + + {matchedStation.address} + + + {onClearMatchedStation && ( + + + + )} + + )} {isMobile && ( diff --git a/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts b/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts index 6ebe401..dfd051a 100644 --- a/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts +++ b/frontend/src/features/fuel-logs/hooks/useReceiptOcr.ts @@ -31,15 +31,25 @@ export interface MappedFuelLogFields { fuelGrade?: FuelGrade; locationData?: { stationName?: string; + googlePlaceId?: string; + address?: string; }; } +/** Matched station from receipt merchant name */ +export interface MatchedStation { + placeId: string; + name: string; + address: string; +} + /** Receipt OCR result */ export interface ReceiptOcrResult { extractedFields: ExtractedReceiptFields; mappedFields: MappedFuelLogFields; rawText: string; overallConfidence: number; + matchedStation: MatchedStation | null; } /** Hook state */ @@ -59,6 +69,7 @@ export interface UseReceiptOcrReturn extends UseReceiptOcrState { acceptResult: () => MappedFuelLogFields | null; reset: () => void; updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void; + clearMatchedStation: () => void; } /** Confidence threshold for highlighting low-confidence fields */ @@ -185,16 +196,48 @@ async function extractReceiptFromImage(file: File): Promise<{ }; } +/** Match station from merchant name via backend */ +async function matchStationFromMerchant(merchantName: string): Promise { + try { + const response = await apiClient.post('/stations/match', { merchantName }); + const data = response.data; + + if (data.matched && data.station) { + return { + placeId: data.station.placeId, + name: data.station.name, + address: data.station.address, + }; + } + return null; + } catch (err) { + console.error('Station matching failed (non-blocking):', err); + return null; + } +} + /** Map extracted fields to fuel log form fields */ -function mapFieldsToFuelLog(fields: ExtractedReceiptFields): MappedFuelLogFields { +function mapFieldsToFuelLog( + fields: ExtractedReceiptFields, + matchedStation?: MatchedStation | null +): MappedFuelLogFields { + // If station was matched, use matched data; otherwise fall back to merchant name + const locationData = matchedStation + ? { + stationName: matchedStation.name, + googlePlaceId: matchedStation.placeId, + address: matchedStation.address, + } + : fields.merchantName.value + ? { stationName: String(fields.merchantName.value) } + : undefined; + return { dateTime: parseTransactionDate(fields.transactionDate.value), fuelUnits: parseNumber(fields.fuelQuantity.value), costPerUnit: parseNumber(fields.pricePerUnit.value), fuelGrade: mapFuelGrade(fields.fuelGrade.value), - locationData: fields.merchantName.value - ? { stationName: String(fields.merchantName.value) } - : undefined, + locationData, }; } @@ -232,13 +275,22 @@ export function useReceiptOcr(): UseReceiptOcrReturn { try { const { extractedFields, rawText, confidence } = await extractReceiptFromImage(imageToProcess); - const mappedFields = mapFieldsToFuelLog(extractedFields); + + // Attempt station matching from merchant name (non-blocking) + let matchedStation: MatchedStation | null = null; + const merchantName = extractedFields.merchantName.value; + if (merchantName && String(merchantName).trim()) { + matchedStation = await matchStationFromMerchant(String(merchantName)); + } + + const mappedFields = mapFieldsToFuelLog(extractedFields, matchedStation); setResult({ extractedFields, mappedFields, rawText, overallConfidence: confidence, + matchedStation, }); } catch (err: any) { console.error('Receipt OCR processing failed:', err); @@ -268,10 +320,14 @@ export function useReceiptOcr(): UseReceiptOcrReturn { }, }; + // Clear matched station if merchant name was edited (user override) + const station = fieldName === 'merchantName' ? null : prev.matchedStation; + return { ...prev, extractedFields: updatedFields, - mappedFields: mapFieldsToFuelLog(updatedFields), + mappedFields: mapFieldsToFuelLog(updatedFields, station), + matchedStation: station, }; }); }, []); @@ -291,6 +347,17 @@ export function useReceiptOcr(): UseReceiptOcrReturn { return mappedFields; }, [result, receiptImageUrl]); + const clearMatchedStation = useCallback(() => { + setResult((prev) => { + if (!prev) return null; + return { + ...prev, + matchedStation: null, + mappedFields: mapFieldsToFuelLog(prev.extractedFields, null), + }; + }); + }, []); + const reset = useCallback(() => { setIsCapturing(false); setIsProcessing(false); @@ -314,5 +381,6 @@ export function useReceiptOcr(): UseReceiptOcrReturn { acceptResult, reset, updateField, + clearMatchedStation, }; }