import type { PoolClient } from 'pg'; import { query, withTransaction } from './db'; const BASE_CURRENCY = 'CNY'; interface TransactionRow { id: number; type: string; amount: number | string; currency: string; exchange_rate_to_base: number | string; amount_in_base: number | string; category_id: null | number; account_id: null | number; transaction_date: string; description: null | string; project: null | string; memo: null | string; created_at: string; status: string; status_updated_at: null | string; reimbursement_batch: null | string; review_notes: null | string; submitted_by: null | string; approved_by: null | string; approved_at: null | string; is_deleted: boolean; deleted_at: null | string; } interface TransactionPayload { type: string; amount: number; currency: string; categoryId?: null | number; accountId?: null | number; transactionDate: string; description?: string; project?: null | string; memo?: null | string; createdAt?: string; isDeleted?: boolean; status?: TransactionStatus; statusUpdatedAt?: string; reimbursementBatch?: null | string; reviewNotes?: null | string; submittedBy?: null | string; approvedBy?: null | string; approvedAt?: null | string; } export type TransactionStatus = | 'approved' | 'draft' | 'paid' | 'pending' | 'rejected'; function mapTransaction(row: TransactionRow) { const amount = Number(row.amount); const exchangeRateToBase = Number(row.exchange_rate_to_base); const amountInBase = Number(row.amount_in_base); return { id: row.id, userId: 1, type: row.type as 'expense' | 'income' | 'transfer', amount: Math.abs(amount), currency: row.currency, exchangeRateToBase, amountInBase: Math.abs(amountInBase), categoryId: row.category_id ?? undefined, accountId: row.account_id ?? undefined, transactionDate: row.transaction_date, description: row.description ?? '', project: row.project ?? undefined, memo: row.memo ?? undefined, createdAt: row.created_at, status: row.status as TransactionStatus, statusUpdatedAt: row.status_updated_at ?? undefined, reimbursementBatch: row.reimbursement_batch ?? undefined, reviewNotes: row.review_notes ?? undefined, submittedBy: row.submitted_by ?? undefined, approvedBy: row.approved_by ?? undefined, approvedAt: row.approved_at ?? undefined, isDeleted: Boolean(row.is_deleted), deletedAt: row.deleted_at ?? undefined, }; } async function getExchangeRateToBase(client: PoolClient, currency: string) { if (currency === BASE_CURRENCY) { return 1; } const result = await client.query<{ rate: number | string }>( `SELECT rate FROM finance_exchange_rates WHERE from_currency = $1 AND to_currency = $2 ORDER BY date DESC LIMIT 1`, [currency, BASE_CURRENCY], ); const raw = result.rows[0]?.rate; return raw ? Number(raw) : 1; } export async function fetchTransactions( options: { includeDeleted?: boolean; statuses?: TransactionStatus[]; type?: string; } = {}, ) { const clauses: string[] = []; const params: any[] = []; if (!options.includeDeleted) { clauses.push('is_deleted = FALSE'); } if (options.type) { params.push(options.type); clauses.push(`type = $${params.length}`); } if (options.statuses && options.statuses.length > 0) { const statusPlaceholders = options.statuses.map((status) => { params.push(status); return `$${params.length}`; }); clauses.push(`status IN (${statusPlaceholders.join(', ')})`); } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; const { rows } = await query( `SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`, params, ); return rows.map((row) => mapTransaction(row)); } export async function getTransactionById(id: number) { const { rows } = await query( `SELECT id, type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted, deleted_at FROM finance_transactions WHERE id = $1`, [id], ); const row = rows[0]; return row ? mapTransaction(row) : null; } export async function createTransaction(payload: TransactionPayload) { return withTransaction(async (client) => { const exchangeRate = await getExchangeRateToBase(client, payload.currency); const amountInBase = +(payload.amount * exchangeRate).toFixed(2); const createdAt = payload.createdAt && payload.createdAt.length > 0 ? payload.createdAt : new Date().toISOString(); const status: TransactionStatus = payload.status ?? 'approved'; const statusUpdatedAt = payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0 ? payload.statusUpdatedAt : createdAt; let approvedAt: string | null = null; if (payload.approvedAt && payload.approvedAt.length > 0) { approvedAt = payload.approvedAt; } else if (status === 'approved' || status === 'paid') { approvedAt = statusUpdatedAt; } const { rows } = await client.query( `INSERT INTO finance_transactions ( type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, FALSE ) RETURNING *`, [ payload.type, payload.amount, payload.currency, exchangeRate, amountInBase, payload.categoryId ?? null, payload.accountId ?? null, payload.transactionDate, payload.description ?? '', payload.project ?? null, payload.memo ?? null, createdAt, status, statusUpdatedAt, payload.reimbursementBatch ?? null, payload.reviewNotes ?? null, payload.submittedBy ?? null, payload.approvedBy ?? null, approvedAt, ], ); return mapTransaction(rows[0]); }); } export async function updateTransaction( id: number, payload: TransactionPayload, ) { const current = await getTransactionById(id); if (!current) { return null; } return withTransaction(async (client) => { const nextStatus = (payload.status ?? current.status ?? 'approved') as TransactionStatus; const statusChanged = nextStatus !== current.status; let statusUpdatedAt: string; if (payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0) { statusUpdatedAt = payload.statusUpdatedAt; } else if (statusChanged) { statusUpdatedAt = new Date().toISOString(); } else { statusUpdatedAt = current.statusUpdatedAt ?? current.createdAt; } let approvedAt: string | null = null; if (payload.approvedAt && payload.approvedAt.length > 0) { approvedAt = payload.approvedAt; } else if (nextStatus === 'approved' || nextStatus === 'paid') { approvedAt = current.approvedAt ?? (statusChanged ? statusUpdatedAt : null); } const approvedBy = nextStatus === 'approved' || nextStatus === 'paid' ? payload.approvedBy ?? current.approvedBy ?? null : payload.approvedBy ?? null; const next = { type: payload.type ?? current.type, amount: payload.amount ?? current.amount, currency: payload.currency ?? current.currency, categoryId: payload.categoryId ?? current.categoryId ?? null, accountId: payload.accountId ?? current.accountId ?? null, transactionDate: payload.transactionDate ?? current.transactionDate, description: payload.description ?? current.description ?? '', project: payload.project ?? current.project ?? null, memo: payload.memo ?? current.memo ?? null, isDeleted: payload.isDeleted ?? current.isDeleted, status: nextStatus, statusUpdatedAt, reimbursementBatch: payload.reimbursementBatch ?? current.reimbursementBatch ?? null, reviewNotes: payload.reviewNotes ?? current.reviewNotes ?? null, submittedBy: payload.submittedBy ?? current.submittedBy ?? null, approvedBy, approvedAt, }; const exchangeRate = await getExchangeRateToBase(client, next.currency); const amountInBase = +(next.amount * exchangeRate).toFixed(2); const deletedAt = next.isDeleted ? new Date().toISOString() : null; const { rows } = await client.query( `UPDATE finance_transactions SET type = $1, amount = $2, currency = $3, exchange_rate_to_base = $4, amount_in_base = $5, category_id = $6, account_id = $7, transaction_date = $8, description = $9, project = $10, memo = $11, status = $12, status_updated_at = $13, reimbursement_batch = $14, review_notes = $15, submitted_by = $16, approved_by = $17, approved_at = $18, is_deleted = $19, deleted_at = $20 WHERE id = $21 RETURNING *`, [ next.type, next.amount, next.currency, exchangeRate, amountInBase, next.categoryId, next.accountId, next.transactionDate, next.description, next.project, next.memo, next.status, next.statusUpdatedAt, next.reimbursementBatch, next.reviewNotes, next.submittedBy, next.approvedBy, next.approvedAt, next.isDeleted, deletedAt, id, ], ); return mapTransaction(rows[0]); }); } export async function softDeleteTransaction(id: number) { const deletedAt = new Date().toISOString(); const { rows } = await query( `UPDATE finance_transactions SET is_deleted = TRUE, deleted_at = $1 WHERE id = $2 RETURNING *`, [deletedAt, id], ); const row = rows[0]; return row ? mapTransaction(row) : null; } export async function restoreTransaction(id: number) { const { rows } = await query( `UPDATE finance_transactions SET is_deleted = FALSE, deleted_at = NULL WHERE id = $1 RETURNING *`, [id], ); const row = rows[0]; return row ? mapTransaction(row) : null; } export async function replaceAllTransactions( rows: Array<{ accountId: null | number; amount: number; approvedAt?: null | string; approvedBy?: null | string; categoryId: null | number; createdAt?: string; currency: string; description: string; isDeleted?: boolean; memo?: null | string; project?: null | string; reimbursementBatch?: null | string; reviewNotes?: null | string; status?: TransactionStatus; statusUpdatedAt?: string; submittedBy?: null | string; transactionDate: string; type: string; }>, ) { await withTransaction(async (client) => { await client.query( 'TRUNCATE TABLE finance_transactions RESTART IDENTITY CASCADE', ); for (const item of rows) { const rate = await getExchangeRateToBase(client, item.currency); const amountInBase = +(item.amount * rate).toFixed(2); const createdAt = item.createdAt ?? new Date(`${item.transactionDate}T00:00:00Z`).toISOString(); const status = item.status ?? 'approved'; const statusUpdatedAt = item.statusUpdatedAt ?? new Date(`${item.transactionDate}T00:00:00Z`).toISOString(); const approvedAt = item.approvedAt ?? (status === 'approved' || status === 'paid' ? statusUpdatedAt : null); await client.query( `INSERT INTO finance_transactions ( type, amount, currency, exchange_rate_to_base, amount_in_base, category_id, account_id, transaction_date, description, project, memo, created_at, status, status_updated_at, reimbursement_batch, review_notes, submitted_by, approved_by, approved_at, is_deleted ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20 )`, [ item.type, item.amount, item.currency, rate, amountInBase, item.categoryId ?? null, item.accountId ?? null, item.transactionDate, item.description ?? '', item.project ?? null, item.memo ?? null, createdAt, status, statusUpdatedAt, item.reimbursementBatch ?? null, item.reviewNotes ?? null, item.submittedBy ?? null, status === 'approved' || status === 'paid' ? (item.approvedBy ?? null) : null, approvedAt, item.isDeleted ?? false, ], ); } }); } interface CategoryRow { id: number; name: string; type: string; icon: null | string; color: null | string; user_id: null | number; is_active: boolean; } function mapCategory(row: CategoryRow) { return { id: row.id, userId: row.user_id ?? null, name: row.name, type: row.type as 'expense' | 'income', icon: row.icon ?? '📝', color: row.color ?? '#dfe4ea', sortOrder: row.id, isSystem: row.user_id === null, isActive: Boolean(row.is_active), }; } export async function fetchCategories( options: { type?: 'expense' | 'income' } = {}, ) { const params: any[] = []; const clauses: string[] = ['is_active = TRUE']; if (options.type) { params.push(options.type); clauses.push(`type = $${params.length}`); } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; const { rows } = await query( `SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`, params, ); return rows.map((row) => mapCategory(row)); } export async function getAccountById(id: number) { const { rows } = await query<{ currency: string; id: number; name: string; }>( `SELECT id, name, currency FROM finance_accounts WHERE id = $1`, [id], ); return rows[0] ?? null; } export async function getCategoryById(id: number) { const { rows } = await query( `SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories WHERE id = $1`, [id], ); const row = rows[0]; return row ? mapCategory(row) : null; }