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

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

View File

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

View File

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

View File

@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
has93Octane: boolean;
has93OctaneEthanolFree: boolean;
}
export interface StationMatchBody {
merchantName: string;
}
export interface StationMatchResponse {
matched: boolean;
station: Station | null;
}

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

View File

@@ -52,4 +52,10 @@ export interface GooglePlaceDetails {
website?: string;
};
status: string;
}
export interface GoogleTextSearchResponse {
results: GooglePlace[];
status: string;
next_page_token?: string;
}

View File

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