Files
flyer-crawler.projectium.com/src/services/db/receipt.db.ts
Torben Sorensen 4e06dde9e1
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m30s
logging work - almost there
2026-01-12 16:57:18 -08:00

1076 lines
30 KiB
TypeScript

// src/services/db/receipt.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
import { NotFoundError, handleDbError } from './errors.db';
import type { Logger } from 'pino';
import type {
ReceiptStatus,
ReceiptItemStatus,
ReceiptProcessingStep,
ReceiptProcessingStatus,
OcrProvider,
ReceiptScan,
ReceiptItem,
ReceiptProcessingLogRecord,
} from '../../types/expiry';
/**
* Database row type for receipts table.
*/
interface ReceiptRow {
receipt_id: number;
user_id: string;
store_id: number | null;
receipt_image_url: string;
transaction_date: string | null;
total_amount_cents: number | null;
status: ReceiptStatus;
raw_text: string | null;
store_confidence: number | null;
ocr_provider: OcrProvider | null;
// JSONB columns are automatically parsed by pg driver
error_details: Record<string, unknown> | null;
retry_count: number;
ocr_confidence: number | null;
currency: string;
created_at: string;
processed_at: string | null;
updated_at: string;
}
/**
* Database row type for receipt_items table.
*/
interface ReceiptItemRow {
receipt_item_id: number;
receipt_id: number;
raw_item_description: string;
quantity: number;
price_paid_cents: number;
master_item_id: number | null;
product_id: number | null;
status: ReceiptItemStatus;
line_number: number | null;
match_confidence: number | null;
is_discount: boolean;
unit_price_cents: number | null;
unit_type: string | null;
added_to_pantry: boolean;
pantry_item_id: number | null;
upc_code: string | null;
created_at: string;
updated_at: string;
}
/**
* Database row type for store_receipt_patterns table.
*/
interface StoreReceiptPatternRow {
pattern_id: number;
store_id: number;
pattern_type: string;
pattern_value: string;
priority: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
/**
* Request to create a new receipt scan.
*/
export interface CreateReceiptRequest {
user_id: string;
receipt_image_url: string;
store_id?: number;
transaction_date?: string;
}
/**
* Request to update receipt processing status.
*/
export interface UpdateReceiptStatusRequest {
status?: ReceiptStatus;
raw_text?: string;
store_id?: number;
store_confidence?: number;
total_amount_cents?: number;
transaction_date?: string;
ocr_provider?: OcrProvider;
ocr_confidence?: number;
error_details?: Record<string, unknown>;
processed_at?: string;
}
/**
* Request to add a receipt item.
*/
export interface AddReceiptItemRequest {
receipt_id: number;
raw_item_description: string;
quantity?: number;
price_paid_cents: number;
line_number?: number;
is_discount?: boolean;
unit_price_cents?: number;
unit_type?: string;
upc_code?: string;
}
/**
* Request to update a receipt item.
*/
export interface UpdateReceiptItemRequest {
status?: ReceiptItemStatus;
master_item_id?: number | null;
product_id?: number | null;
match_confidence?: number;
added_to_pantry?: boolean;
pantry_item_id?: number | null;
}
/**
* Options for querying receipts.
*/
export interface ReceiptQueryOptions {
user_id: string;
status?: ReceiptStatus;
store_id?: number;
from_date?: string;
to_date?: string;
limit?: number;
offset?: number;
}
/**
* Repository for receipt scanning database operations.
* Handles receipts, receipt items, processing logs, and store patterns.
*/
export class ReceiptRepository {
private db: Pick<Pool | PoolClient, 'query'>;
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
this.db = db;
}
// ============================================================================
// RECEIPTS
// ============================================================================
/**
* Creates a new receipt scan record.
*/
async createReceipt(request: CreateReceiptRequest, logger: Logger): Promise<ReceiptScan> {
try {
logger.debug({ request }, 'Creating new receipt record');
const res = await this.db.query<ReceiptRow>(
`INSERT INTO public.receipts
(user_id, receipt_image_url, store_id, transaction_date, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING *`,
[
request.user_id,
request.receipt_image_url,
request.store_id || null,
request.transaction_date || null,
],
);
logger.info({ receiptId: res.rows[0].receipt_id }, 'Receipt record created');
return this.mapReceiptRowToReceipt(res.rows[0]);
} catch (error) {
handleDbError(
error,
logger,
'Database error in createReceipt',
{ request },
{
fkMessage: 'The specified store does not exist.',
defaultMessage: 'Failed to create receipt record.',
},
);
}
}
/**
* Gets a receipt by ID.
*/
async getReceiptById(receiptId: number, userId: string, logger: Logger): Promise<ReceiptScan> {
try {
const res = await this.db.query<ReceiptRow>(
`SELECT * FROM public.receipts WHERE receipt_id = $1 AND user_id = $2`,
[receiptId, userId],
);
if (res.rowCount === 0) {
throw new NotFoundError('Receipt not found.');
}
return this.mapReceiptRowToReceipt(res.rows[0]);
} catch (error) {
handleDbError(
error,
logger,
'Database error in getReceiptById',
{ receiptId, userId },
{
defaultMessage: 'Failed to get receipt.',
},
);
}
}
/**
* Gets receipts for a user with optional filtering.
*/
async getReceipts(
options: ReceiptQueryOptions,
logger: Logger,
): Promise<{ receipts: ReceiptScan[]; total: number }> {
const { user_id, status, store_id, from_date, to_date, limit = 50, offset = 0 } = options;
try {
// Build dynamic WHERE clause
const conditions: string[] = ['user_id = $1'];
const params: (string | number)[] = [user_id];
let paramIndex = 2;
if (status) {
conditions.push(`status = $${paramIndex++}`);
params.push(status);
}
if (store_id) {
conditions.push(`store_id = $${paramIndex++}`);
params.push(store_id);
}
if (from_date) {
conditions.push(`created_at >= $${paramIndex++}`);
params.push(from_date);
}
if (to_date) {
conditions.push(`created_at <= $${paramIndex++}`);
params.push(to_date);
}
const whereClause = conditions.join(' AND ');
// Get total count
const countRes = await this.db.query<{ count: string }>(
`SELECT COUNT(*) FROM public.receipts WHERE ${whereClause}`,
params,
);
const total = parseInt(countRes.rows[0].count, 10);
// Get paginated results
const dataParams = [...params, limit, offset];
const dataRes = await this.db.query<ReceiptRow>(
`SELECT * FROM public.receipts
WHERE ${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
receipts: dataRes.rows.map((row) => this.mapReceiptRowToReceipt(row)),
total,
};
} catch (error) {
handleDbError(
error,
logger,
'Database error in getReceipts',
{ options },
{
defaultMessage: 'Failed to get receipts.',
},
);
}
}
/**
* Updates a receipt's processing status and data.
*/
async updateReceipt(
receiptId: number,
updates: UpdateReceiptStatusRequest,
logger: Logger,
): Promise<ReceiptScan> {
try {
logger.debug({ receiptId, updates }, 'Updating receipt');
// Build dynamic SET clause
const setClauses: string[] = ['updated_at = NOW()'];
const values: (string | number | null)[] = [];
let paramIndex = 1;
if (updates.status !== undefined) {
setClauses.push(`status = $${paramIndex++}`);
values.push(updates.status);
}
if (updates.raw_text !== undefined) {
setClauses.push(`raw_text = $${paramIndex++}`);
values.push(updates.raw_text);
}
if (updates.store_id !== undefined) {
setClauses.push(`store_id = $${paramIndex++}`);
values.push(updates.store_id);
}
if (updates.store_confidence !== undefined) {
setClauses.push(`store_confidence = $${paramIndex++}`);
values.push(updates.store_confidence);
}
if (updates.total_amount_cents !== undefined) {
setClauses.push(`total_amount_cents = $${paramIndex++}`);
values.push(updates.total_amount_cents);
}
if (updates.transaction_date !== undefined) {
setClauses.push(`transaction_date = $${paramIndex++}`);
values.push(updates.transaction_date);
}
if (updates.ocr_provider !== undefined) {
setClauses.push(`ocr_provider = $${paramIndex++}`);
values.push(updates.ocr_provider);
}
if (updates.ocr_confidence !== undefined) {
setClauses.push(`ocr_confidence = $${paramIndex++}`);
values.push(updates.ocr_confidence);
}
if (updates.error_details !== undefined) {
setClauses.push(`error_details = $${paramIndex++}`);
values.push(JSON.stringify(updates.error_details));
}
if (updates.processed_at !== undefined) {
setClauses.push(`processed_at = $${paramIndex++}`);
values.push(updates.processed_at);
}
values.push(receiptId);
const res = await this.db.query<ReceiptRow>(
`UPDATE public.receipts SET ${setClauses.join(', ')} WHERE receipt_id = $${paramIndex} RETURNING *`,
values,
);
if (res.rowCount === 0) {
throw new NotFoundError('Receipt not found.');
}
logger.info({ receiptId, status: res.rows[0].status }, 'Receipt updated');
return this.mapReceiptRowToReceipt(res.rows[0]);
} catch (error) {
handleDbError(
error,
logger,
'Database error in updateReceipt',
{ receiptId, updates },
{
defaultMessage: 'Failed to update receipt.',
},
);
}
}
/**
* Increments the retry count for a failed receipt.
*/
async incrementRetryCount(receiptId: number, logger: Logger): Promise<number> {
try {
const res = await this.db.query<{ retry_count: number }>(
`UPDATE public.receipts
SET retry_count = retry_count + 1, updated_at = NOW()
WHERE receipt_id = $1
RETURNING retry_count`,
[receiptId],
);
if (res.rowCount === 0) {
throw new NotFoundError('Receipt not found.');
}
logger.debug({ receiptId, retryCount: res.rows[0].retry_count }, 'Retry count incremented');
return res.rows[0].retry_count;
} catch (error) {
handleDbError(
error,
logger,
'Database error in incrementRetryCount',
{ receiptId },
{
defaultMessage: 'Failed to increment retry count.',
},
);
}
}
/**
* Gets receipts that need processing (pending or failed with retries remaining).
*/
async getReceiptsNeedingProcessing(
maxRetries: number,
limit: number,
logger: Logger,
): Promise<ReceiptScan[]> {
try {
const res = await this.db.query<ReceiptRow>(
`SELECT * FROM public.receipts
WHERE (status = 'pending' OR (status = 'failed' AND retry_count < $1))
ORDER BY created_at ASC
LIMIT $2`,
[maxRetries, limit],
);
logger.debug({ count: res.rowCount }, 'Fetched receipts needing processing');
return res.rows.map((row) => this.mapReceiptRowToReceipt(row));
} catch (error) {
handleDbError(
error,
logger,
'Database error in getReceiptsNeedingProcessing',
{ maxRetries, limit },
{
defaultMessage: 'Failed to get receipts needing processing.',
},
);
}
}
/**
* Deletes a receipt and all associated items.
*/
async deleteReceipt(receiptId: number, userId: string, logger: Logger): Promise<void> {
try {
const res = await this.db.query(
`DELETE FROM public.receipts WHERE receipt_id = $1 AND user_id = $2`,
[receiptId, userId],
);
if (res.rowCount === 0) {
throw new NotFoundError('Receipt not found or user does not have permission.');
}
logger.info({ receiptId }, 'Receipt deleted');
} catch (error) {
handleDbError(
error,
logger,
'Database error in deleteReceipt',
{ receiptId, userId },
{
defaultMessage: 'Failed to delete receipt.',
},
);
}
}
// ============================================================================
// RECEIPT ITEMS
// ============================================================================
/**
* Adds items extracted from a receipt.
*/
async addReceiptItems(items: AddReceiptItemRequest[], logger: Logger): Promise<ReceiptItem[]> {
if (items.length === 0) {
return [];
}
try {
logger.debug({ count: items.length, receiptId: items[0].receipt_id }, 'Adding receipt items');
// Build batch insert
const values: (string | number | boolean | null)[] = [];
const valuePlaceholders: string[] = [];
let paramIndex = 1;
for (const item of items) {
valuePlaceholders.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`,
);
values.push(
item.receipt_id,
item.raw_item_description,
item.quantity ?? 1,
item.price_paid_cents,
item.line_number ?? null,
item.is_discount ?? false,
item.unit_price_cents ?? null,
item.unit_type ?? null,
item.upc_code ?? null,
);
}
const res = await this.db.query<ReceiptItemRow>(
`INSERT INTO public.receipt_items
(receipt_id, raw_item_description, quantity, price_paid_cents, line_number, is_discount, unit_price_cents, unit_type, upc_code)
VALUES ${valuePlaceholders.join(', ')}
RETURNING *`,
values,
);
logger.info({ count: res.rowCount, receiptId: items[0].receipt_id }, 'Receipt items added');
return res.rows.map((row) => this.mapReceiptItemRowToReceiptItem(row));
} catch (error) {
handleDbError(
error,
logger,
'Database error in addReceiptItems',
{ itemCount: items.length },
{
fkMessage: 'The specified receipt does not exist.',
defaultMessage: 'Failed to add receipt items.',
},
);
}
}
/**
* Gets all items for a receipt.
*/
async getReceiptItems(receiptId: number, logger: Logger): Promise<ReceiptItem[]> {
try {
const res = await this.db.query<ReceiptItemRow>(
`SELECT * FROM public.receipt_items
WHERE receipt_id = $1
ORDER BY line_number ASC NULLS LAST, receipt_item_id ASC`,
[receiptId],
);
return res.rows.map((row) => this.mapReceiptItemRowToReceiptItem(row));
} catch (error) {
handleDbError(
error,
logger,
'Database error in getReceiptItems',
{ receiptId },
{
defaultMessage: 'Failed to get receipt items.',
},
);
}
}
/**
* Updates a receipt item (matching, pantry linking, etc.).
*/
async updateReceiptItem(
receiptItemId: number,
updates: UpdateReceiptItemRequest,
logger: Logger,
): Promise<ReceiptItem> {
try {
const setClauses: string[] = ['updated_at = NOW()'];
const values: (string | number | boolean | null)[] = [];
let paramIndex = 1;
if (updates.status !== undefined) {
setClauses.push(`status = $${paramIndex++}`);
values.push(updates.status);
}
if (updates.master_item_id !== undefined) {
setClauses.push(`master_item_id = $${paramIndex++}`);
values.push(updates.master_item_id);
}
if (updates.product_id !== undefined) {
setClauses.push(`product_id = $${paramIndex++}`);
values.push(updates.product_id);
}
if (updates.match_confidence !== undefined) {
setClauses.push(`match_confidence = $${paramIndex++}`);
values.push(updates.match_confidence);
}
if (updates.added_to_pantry !== undefined) {
setClauses.push(`added_to_pantry = $${paramIndex++}`);
values.push(updates.added_to_pantry);
}
if (updates.pantry_item_id !== undefined) {
setClauses.push(`pantry_item_id = $${paramIndex++}`);
values.push(updates.pantry_item_id);
}
values.push(receiptItemId);
const res = await this.db.query<ReceiptItemRow>(
`UPDATE public.receipt_items SET ${setClauses.join(', ')} WHERE receipt_item_id = $${paramIndex} RETURNING *`,
values,
);
if (res.rowCount === 0) {
throw new NotFoundError('Receipt item not found.');
}
return this.mapReceiptItemRowToReceiptItem(res.rows[0]);
} catch (error) {
handleDbError(
error,
logger,
'Database error in updateReceiptItem',
{ receiptItemId, updates },
{
defaultMessage: 'Failed to update receipt item.',
},
);
}
}
/**
* Gets receipt items that haven't been added to pantry.
*/
async getUnaddedReceiptItems(receiptId: number, logger: Logger): Promise<ReceiptItem[]> {
try {
const res = await this.db.query<ReceiptItemRow>(
`SELECT * FROM public.receipt_items
WHERE receipt_id = $1
AND added_to_pantry = false
AND is_discount = false
ORDER BY line_number ASC NULLS LAST`,
[receiptId],
);
return res.rows.map((row) => this.mapReceiptItemRowToReceiptItem(row));
} catch (error) {
handleDbError(
error,
logger,
'Database error in getUnaddedReceiptItems',
{ receiptId },
{
defaultMessage: 'Failed to get unadded receipt items.',
},
);
}
}
// ============================================================================
// PROCESSING LOG
// ============================================================================
/**
* Logs a processing step for a receipt.
*/
async logProcessingStep(
receiptId: number,
step: ReceiptProcessingStep,
status: ReceiptProcessingStatus,
logger: Logger,
options: {
provider?: OcrProvider;
durationMs?: number;
tokensUsed?: number;
costCents?: number;
inputData?: Record<string, unknown>;
outputData?: Record<string, unknown>;
errorMessage?: string;
} = {},
): Promise<ReceiptProcessingLogRecord> {
try {
logger.debug({ receiptId, step, status }, 'Logging processing step');
const res = await this.db.query<ReceiptProcessingLogRecord>(
`INSERT INTO public.receipt_processing_log
(receipt_id, processing_step, status, provider, duration_ms, tokens_used, cost_cents, input_data, output_data, error_message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
receiptId,
step,
status,
options.provider || null,
options.durationMs ?? null,
options.tokensUsed ?? null,
options.costCents ?? null,
options.inputData ? JSON.stringify(options.inputData) : null,
options.outputData ? JSON.stringify(options.outputData) : null,
options.errorMessage || null,
],
);
return res.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Database error in logProcessingStep',
{ receiptId, step, status },
{
defaultMessage: 'Failed to log processing step.',
},
);
}
}
/**
* Gets processing logs for a receipt.
*/
async getProcessingLogs(
receiptId: number,
logger: Logger,
): Promise<ReceiptProcessingLogRecord[]> {
try {
const res = await this.db.query<ReceiptProcessingLogRecord>(
`SELECT * FROM public.receipt_processing_log
WHERE receipt_id = $1
ORDER BY created_at ASC`,
[receiptId],
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getProcessingLogs',
{ receiptId },
{
defaultMessage: 'Failed to get processing logs.',
},
);
}
}
/**
* Gets processing statistics for monitoring.
*/
async getProcessingStats(
logger: Logger,
options: { fromDate?: string; toDate?: string } = {},
): Promise<{
total_receipts: number;
completed: number;
failed: number;
pending: number;
avg_processing_time_ms: number;
total_cost_cents: number;
}> {
try {
const conditions: string[] = [];
const params: string[] = [];
let paramIndex = 1;
if (options.fromDate) {
conditions.push(`created_at >= $${paramIndex++}`);
params.push(options.fromDate);
}
if (options.toDate) {
conditions.push(`created_at <= $${paramIndex++}`);
params.push(options.toDate);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const receiptStatsRes = await this.db.query<{
total_receipts: string;
completed: string;
failed: string;
pending: string;
}>(
`SELECT
COUNT(*) AS total_receipts,
COUNT(*) FILTER (WHERE status = 'completed') AS completed,
COUNT(*) FILTER (WHERE status = 'failed') AS failed,
COUNT(*) FILTER (WHERE status = 'pending') AS pending
FROM public.receipts ${whereClause}`,
params,
);
const processingStatsRes = await this.db.query<{
avg_duration_ms: string;
total_cost_cents: string;
}>(
`SELECT
COALESCE(AVG(duration_ms), 0) AS avg_duration_ms,
COALESCE(SUM(cost_cents), 0) AS total_cost_cents
FROM public.receipt_processing_log ${whereClause}`,
params,
);
const receiptStats = receiptStatsRes.rows[0];
const processingStats = processingStatsRes.rows[0];
return {
total_receipts: parseInt(receiptStats.total_receipts, 10),
completed: parseInt(receiptStats.completed, 10),
failed: parseInt(receiptStats.failed, 10),
pending: parseInt(receiptStats.pending, 10),
avg_processing_time_ms: parseFloat(processingStats.avg_duration_ms),
total_cost_cents: parseInt(processingStats.total_cost_cents, 10),
};
} catch (error) {
handleDbError(
error,
logger,
'Database error in getProcessingStats',
{ options },
{
defaultMessage: 'Failed to get processing statistics.',
},
);
}
}
// ============================================================================
// STORE RECEIPT PATTERNS
// ============================================================================
/**
* Gets all active patterns for store detection.
*/
async getActiveStorePatterns(logger: Logger): Promise<StoreReceiptPatternRow[]> {
try {
const res = await this.db.query<StoreReceiptPatternRow>(
`SELECT * FROM public.store_receipt_patterns
WHERE is_active = true
ORDER BY priority DESC, pattern_type`,
);
return res.rows;
} catch (error) {
handleDbError(
error,
logger,
'Database error in getActiveStorePatterns',
{},
{
defaultMessage: 'Failed to get store patterns.',
},
);
}
}
/**
* Adds a new store receipt pattern.
*/
async addStorePattern(
storeId: number,
patternType: string,
patternValue: string,
logger: Logger,
options: { priority?: number } = {},
): Promise<StoreReceiptPatternRow> {
try {
const res = await this.db.query<StoreReceiptPatternRow>(
`INSERT INTO public.store_receipt_patterns
(store_id, pattern_type, pattern_value, priority)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[storeId, patternType, patternValue, options.priority ?? 0],
);
logger.info({ storeId, patternType }, 'Store pattern added');
return res.rows[0];
} catch (error) {
handleDbError(
error,
logger,
'Database error in addStorePattern',
{ storeId, patternType, patternValue },
{
fkMessage: 'The specified store does not exist.',
uniqueMessage: 'This pattern already exists for this store.',
checkMessage: 'Invalid pattern type or empty pattern value.',
defaultMessage: 'Failed to add store pattern.',
},
);
}
}
/**
* Deactivates a store pattern.
*/
async deactivateStorePattern(patternId: number, logger: Logger): Promise<void> {
try {
const res = await this.db.query(
`UPDATE public.store_receipt_patterns
SET is_active = false, updated_at = NOW()
WHERE pattern_id = $1`,
[patternId],
);
if (res.rowCount === 0) {
throw new NotFoundError('Pattern not found.');
}
logger.info({ patternId }, 'Store pattern deactivated');
} catch (error) {
handleDbError(
error,
logger,
'Database error in deactivateStorePattern',
{ patternId },
{
defaultMessage: 'Failed to deactivate store pattern.',
},
);
}
}
/**
* Detects store from receipt text using patterns.
*/
async detectStoreFromText(
receiptText: string,
logger: Logger,
): Promise<{ store_id: number; confidence: number } | null> {
try {
logger.debug('Attempting to detect store from receipt text');
const patterns = await this.getActiveStorePatterns(logger);
// Group patterns by store
const storePatternMap = new Map<number, StoreReceiptPatternRow[]>();
for (const pattern of patterns) {
const existing = storePatternMap.get(pattern.store_id) || [];
existing.push(pattern);
storePatternMap.set(pattern.store_id, existing);
}
// Try to match each store's patterns
let bestMatch: { store_id: number; confidence: number; matchCount: number } | null = null;
for (const [storeId, storePatterns] of storePatternMap) {
let matchCount = 0;
let totalPriority = 0;
for (const pattern of storePatterns) {
try {
let matches = false;
if (
pattern.pattern_type === 'header_regex' ||
pattern.pattern_type === 'footer_regex'
) {
const regex = new RegExp(pattern.pattern_value, 'i');
matches = regex.test(receiptText);
} else {
// Literal text match for phone_number, address_fragment, etc.
matches = receiptText.toLowerCase().includes(pattern.pattern_value.toLowerCase());
}
if (matches) {
matchCount++;
totalPriority += pattern.priority;
}
} catch (regexError) {
// Invalid regex pattern, skip it
logger.warn(
{ patternId: pattern.pattern_id, error: regexError },
'Invalid regex pattern',
);
}
}
if (matchCount > 0) {
// Calculate confidence based on match count and priority
const confidence = Math.min(
1,
(matchCount / storePatterns.length) * 0.7 + (totalPriority / 100) * 0.3,
);
if (
!bestMatch ||
matchCount > bestMatch.matchCount ||
(matchCount === bestMatch.matchCount && confidence > bestMatch.confidence)
) {
bestMatch = { store_id: storeId, confidence, matchCount };
}
}
}
if (bestMatch) {
logger.info(
{ storeId: bestMatch.store_id, confidence: bestMatch.confidence },
'Store detected from receipt text',
);
return { store_id: bestMatch.store_id, confidence: bestMatch.confidence };
}
logger.debug('No store match found from receipt text');
return null;
} catch (error) {
handleDbError(
error,
logger,
'Database error in detectStoreFromText',
{},
{
defaultMessage: 'Failed to detect store from receipt text.',
},
);
}
}
// ============================================================================
// HELPER METHODS
// ============================================================================
/**
* Maps a receipt database row to ReceiptScan type.
*/
private mapReceiptRowToReceipt(row: ReceiptRow): ReceiptScan {
return {
receipt_id: row.receipt_id,
user_id: row.user_id,
store_id: row.store_id,
receipt_image_url: row.receipt_image_url,
transaction_date: row.transaction_date,
total_amount_cents: row.total_amount_cents,
status: row.status,
raw_text: row.raw_text,
store_confidence: row.store_confidence !== null ? Number(row.store_confidence) : null,
ocr_provider: row.ocr_provider,
error_details: row.error_details ?? null,
retry_count: row.retry_count,
ocr_confidence: row.ocr_confidence !== null ? Number(row.ocr_confidence) : null,
currency: row.currency,
created_at: row.created_at,
processed_at: row.processed_at,
updated_at: row.updated_at,
};
}
/**
* Maps a receipt item database row to ReceiptItem type.
*/
private mapReceiptItemRowToReceiptItem(row: ReceiptItemRow): ReceiptItem {
return {
receipt_item_id: row.receipt_item_id,
receipt_id: row.receipt_id,
raw_item_description: row.raw_item_description,
quantity: Number(row.quantity),
price_paid_cents: row.price_paid_cents,
master_item_id: row.master_item_id,
product_id: row.product_id,
status: row.status,
line_number: row.line_number,
match_confidence: row.match_confidence !== null ? Number(row.match_confidence) : null,
is_discount: row.is_discount,
unit_price_cents: row.unit_price_cents,
unit_type: row.unit_type,
added_to_pantry: row.added_to_pantry,
pantry_item_id: row.pantry_item_id,
upc_code: row.upc_code,
created_at: row.created_at,
updated_at: row.updated_at,
};
}
}