feat: add station matching from receipt merchant name (refs #132)

Add Google Places Text Search to match receipt merchant names (e.g.
"Shell", "COSTCO #123") to real gas stations. Backend exposes
POST /api/stations/match endpoint. Frontend calls it after OCR
extraction and pre-fills locationData with matched station's placeId,
name, and address. Users can clear the match in the review modal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-11 09:45:13 -06:00
parent bc91fbad79
commit d8dec64538
10 changed files with 530 additions and 10 deletions

View File

@@ -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<Station | null> {
const query = `${merchantName} gas station`;
const cacheKey = `station-match:${query.toLowerCase().trim()}`;
try {
const cached = await cacheService.get<Station | null>(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<GoogleTextSearchResponse>(
`${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