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:
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<StationMatchResponse> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
|
||||
has93Octane: boolean;
|
||||
has93OctaneEthanolFree: boolean;
|
||||
}
|
||||
|
||||
export interface StationMatchBody {
|
||||
merchantName: string;
|
||||
}
|
||||
|
||||
export interface StationMatchResponse {
|
||||
matched: boolean;
|
||||
station: Station | null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -52,4 +52,10 @@ export interface GooglePlaceDetails {
|
||||
website?: string;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface GoogleTextSearchResponse {
|
||||
results: GooglePlace[];
|
||||
status: string;
|
||||
next_page_token?: string;
|
||||
}
|
||||
@@ -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<typeof axios>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockAxios = axios as jest.Mocked<typeof axios>;
|
||||
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<StationsRepository>;
|
||||
|
||||
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<StationsRepository>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user