All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 15m30s
1076 lines
30 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|