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

@@ -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<CreateFuelLogRequest>({
@@ -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}
/>
)}

View File

@@ -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<ReceiptOcrReviewModalProps> = ({
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<ReceiptOcrReviewModalProps> = ({
onEdit={(value) => onFieldEdit('merchantName', value)}
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>
{isMobile && (

View File

@@ -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<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 */
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,
};
}