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 { logger } from '../../../core/logging/logger';
import { import {
StationSearchBody, StationSearchBody,
StationMatchBody,
SaveStationBody, SaveStationBody,
StationParams, StationParams,
UpdateSavedStationBody 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) { async saveStation(request: FastifyRequest<{ Body: SaveStationBody }>, reply: FastifyReply) {
try { try {
const userId = (request as any).user.sub; const userId = (request as any).user.sub;

View File

@@ -7,6 +7,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify';
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { import {
StationSearchBody, StationSearchBody,
StationMatchBody,
SaveStationBody, SaveStationBody,
StationParams, StationParams,
UpdateSavedStationBody UpdateSavedStationBody
@@ -25,6 +26,12 @@ export const stationsRoutes: FastifyPluginAsync = async (
handler: stationsController.searchStations.bind(stationsController) 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 // POST /api/stations/save - Save a station to user's favorites
fastify.post<{ Body: SaveStationBody }>('/stations/save', { fastify.post<{ Body: SaveStationBody }>('/stations/save', {
preHandler: [fastify.authenticate], preHandler: [fastify.authenticate],

View File

@@ -7,6 +7,7 @@ import { googleMapsClient } from '../external/google-maps/google-maps.client';
import { import {
StationSearchRequest, StationSearchRequest,
StationSearchResponse, StationSearchResponse,
StationMatchResponse,
SavedStation, SavedStation,
StationSavedMetadata, StationSavedMetadata,
UpdateSavedStationBody UpdateSavedStationBody
@@ -154,6 +155,27 @@ export class StationsService {
return enriched; 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) { async removeSavedStation(placeId: string, userId: string) {
const removed = await this.repository.deleteSavedStation(userId, placeId); const removed = await this.repository.deleteSavedStation(userId, placeId);

View File

@@ -89,3 +89,12 @@ export interface StationSavedMetadata {
has93Octane: boolean; has93Octane: boolean;
has93OctaneEthanolFree: 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 { appConfig } from '../../../../core/config/config-loader';
import { logger } from '../../../../core/logging/logger'; import { logger } from '../../../../core/logging/logger';
import { cacheService } from '../../../../core/config/redis'; 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'; import { Station } from '../../domain/stations.types';
export class GoogleMapsClient { export class GoogleMapsClient {
@@ -103,6 +103,87 @@ export class GoogleMapsClient {
return station; 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 * Fetch photo from Google Maps API using photo reference
* Used by photo proxy endpoint to serve photos without exposing API key * Used by photo proxy endpoint to serve photos without exposing API key

View File

@@ -52,4 +52,10 @@ export interface GooglePlaceDetails {
website?: string; website?: string;
}; };
status: 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);
});
});
});

View File

@@ -68,6 +68,7 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
acceptResult, acceptResult,
reset: resetOcr, reset: resetOcr,
updateField, updateField,
clearMatchedStation,
} = useReceiptOcr(); } = useReceiptOcr();
const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({ const { control, handleSubmit, watch, setValue, reset, formState: { errors, isValid } } = useForm<CreateFuelLogRequest>({
@@ -159,13 +160,13 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
if (mappedFields.fuelGrade) { if (mappedFields.fuelGrade) {
setValue('fuelGrade', mappedFields.fuelGrade); setValue('fuelGrade', mappedFields.fuelGrade);
} }
if (mappedFields.locationData?.stationName) { if (mappedFields.locationData) {
// Set station name in locationData if no station is already selected // Set location data from OCR + station matching if no station is already selected
const currentLocation = watch('locationData'); const currentLocation = watch('locationData');
if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) { if (!currentLocation?.stationName && !currentLocation?.googlePlaceId) {
setValue('locationData', { setValue('locationData', {
...currentLocation, ...currentLocation,
stationName: mappedFields.locationData.stationName, ...mappedFields.locationData,
}); });
} }
} }
@@ -443,10 +444,12 @@ const FuelLogFormComponent: React.FC<{ onSuccess?: () => void; initial?: Partial
open={!!ocrResult} open={!!ocrResult}
extractedFields={ocrResult.extractedFields} extractedFields={ocrResult.extractedFields}
receiptImageUrl={receiptImageUrl} receiptImageUrl={receiptImageUrl}
matchedStation={ocrResult.matchedStation}
onAccept={handleAcceptOcrResult} onAccept={handleAcceptOcrResult}
onRetake={handleRetakePhoto} onRetake={handleRetakePhoto}
onCancel={resetOcr} onCancel={resetOcr}
onFieldEdit={updateField} onFieldEdit={updateField}
onClearMatchedStation={clearMatchedStation}
/> />
)} )}

View File

@@ -24,9 +24,11 @@ import EditIcon from '@mui/icons-material/Edit';
import CheckIcon from '@mui/icons-material/Check'; import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import CameraAltIcon from '@mui/icons-material/CameraAlt'; import CameraAltIcon from '@mui/icons-material/CameraAlt';
import PlaceIcon from '@mui/icons-material/Place';
import { import {
ExtractedReceiptFields, ExtractedReceiptFields,
ExtractedReceiptField, ExtractedReceiptField,
MatchedStation,
LOW_CONFIDENCE_THRESHOLD, LOW_CONFIDENCE_THRESHOLD,
} from '../hooks/useReceiptOcr'; } from '../hooks/useReceiptOcr';
import { ReceiptPreview } from './ReceiptPreview'; import { ReceiptPreview } from './ReceiptPreview';
@@ -38,6 +40,8 @@ export interface ReceiptOcrReviewModalProps {
extractedFields: ExtractedReceiptFields; extractedFields: ExtractedReceiptFields;
/** Receipt image URL for preview */ /** Receipt image URL for preview */
receiptImageUrl: string | null; receiptImageUrl: string | null;
/** Matched station from merchant name (if any) */
matchedStation?: MatchedStation | null;
/** Called when user accepts the fields */ /** Called when user accepts the fields */
onAccept: () => void; onAccept: () => void;
/** Called when user wants to retake the photo */ /** Called when user wants to retake the photo */
@@ -46,6 +50,8 @@ export interface ReceiptOcrReviewModalProps {
onCancel: () => void; onCancel: () => void;
/** Called when user edits a field */ /** Called when user edits a field */
onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void; onFieldEdit: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
/** Called when user clears the matched station */
onClearMatchedStation?: () => void;
} }
/** Confidence indicator component */ /** Confidence indicator component */
@@ -209,10 +215,12 @@ export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
open, open,
extractedFields, extractedFields,
receiptImageUrl, receiptImageUrl,
matchedStation,
onAccept, onAccept,
onRetake, onRetake,
onCancel, onCancel,
onFieldEdit, onFieldEdit,
onClearMatchedStation,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
@@ -354,6 +362,40 @@ export const ReceiptOcrReviewModal: React.FC<ReceiptOcrReviewModalProps> = ({
onEdit={(value) => onFieldEdit('merchantName', value)} onEdit={(value) => onFieldEdit('merchantName', value)}
type="text" type="text"
/> />
{matchedStation && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
py: 1,
px: 1,
ml: '100px',
gap: 1,
backgroundColor: 'success.light',
borderRadius: 1,
mb: 0.5,
}}
>
<PlaceIcon fontSize="small" color="success" />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" fontWeight={500} noWrap>
{matchedStation.name}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
{matchedStation.address}
</Typography>
</Box>
{onClearMatchedStation && (
<IconButton
size="small"
onClick={onClearMatchedStation}
aria-label="Clear matched station"
>
<CloseIcon fontSize="small" />
</IconButton>
)}
</Box>
)}
</Collapse> </Collapse>
{isMobile && ( {isMobile && (

View File

@@ -31,15 +31,25 @@ export interface MappedFuelLogFields {
fuelGrade?: FuelGrade; fuelGrade?: FuelGrade;
locationData?: { locationData?: {
stationName?: string; stationName?: string;
googlePlaceId?: string;
address?: string;
}; };
} }
/** Matched station from receipt merchant name */
export interface MatchedStation {
placeId: string;
name: string;
address: string;
}
/** Receipt OCR result */ /** Receipt OCR result */
export interface ReceiptOcrResult { export interface ReceiptOcrResult {
extractedFields: ExtractedReceiptFields; extractedFields: ExtractedReceiptFields;
mappedFields: MappedFuelLogFields; mappedFields: MappedFuelLogFields;
rawText: string; rawText: string;
overallConfidence: number; overallConfidence: number;
matchedStation: MatchedStation | null;
} }
/** Hook state */ /** Hook state */
@@ -59,6 +69,7 @@ export interface UseReceiptOcrReturn extends UseReceiptOcrState {
acceptResult: () => MappedFuelLogFields | null; acceptResult: () => MappedFuelLogFields | null;
reset: () => void; reset: () => void;
updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void; updateField: (fieldName: keyof ExtractedReceiptFields, value: string | number | null) => void;
clearMatchedStation: () => void;
} }
/** Confidence threshold for highlighting low-confidence fields */ /** 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<MatchedStation | null> {
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 */ /** 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 { return {
dateTime: parseTransactionDate(fields.transactionDate.value), dateTime: parseTransactionDate(fields.transactionDate.value),
fuelUnits: parseNumber(fields.fuelQuantity.value), fuelUnits: parseNumber(fields.fuelQuantity.value),
costPerUnit: parseNumber(fields.pricePerUnit.value), costPerUnit: parseNumber(fields.pricePerUnit.value),
fuelGrade: mapFuelGrade(fields.fuelGrade.value), fuelGrade: mapFuelGrade(fields.fuelGrade.value),
locationData: fields.merchantName.value locationData,
? { stationName: String(fields.merchantName.value) }
: undefined,
}; };
} }
@@ -232,13 +275,22 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
try { try {
const { extractedFields, rawText, confidence } = await extractReceiptFromImage(imageToProcess); 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({ setResult({
extractedFields, extractedFields,
mappedFields, mappedFields,
rawText, rawText,
overallConfidence: confidence, overallConfidence: confidence,
matchedStation,
}); });
} catch (err: any) { } catch (err: any) {
console.error('Receipt OCR processing failed:', err); 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 { return {
...prev, ...prev,
extractedFields: updatedFields, extractedFields: updatedFields,
mappedFields: mapFieldsToFuelLog(updatedFields), mappedFields: mapFieldsToFuelLog(updatedFields, station),
matchedStation: station,
}; };
}); });
}, []); }, []);
@@ -291,6 +347,17 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
return mappedFields; return mappedFields;
}, [result, receiptImageUrl]); }, [result, receiptImageUrl]);
const clearMatchedStation = useCallback(() => {
setResult((prev) => {
if (!prev) return null;
return {
...prev,
matchedStation: null,
mappedFields: mapFieldsToFuelLog(prev.extractedFields, null),
};
});
}, []);
const reset = useCallback(() => { const reset = useCallback(() => {
setIsCapturing(false); setIsCapturing(false);
setIsProcessing(false); setIsProcessing(false);
@@ -314,5 +381,6 @@ export function useReceiptOcr(): UseReceiptOcrReturn {
acceptResult, acceptResult,
reset, reset,
updateField, updateField,
clearMatchedStation,
}; };
} }