import db from './sqlite'; const BASE_CURRENCY = 'CNY'; interface TransactionRow { id: number; type: string; amount: number; currency: string; exchange_rate_to_base: number; amount_in_base: number; 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: number; 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 = | 'draft' | 'pending' | 'approved' | 'rejected' | 'paid'; function getExchangeRateToBase(currency: string) { if (currency === BASE_CURRENCY) { return 1; } const stmt = db.prepare( `SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = ? ORDER BY date DESC LIMIT 1`, ); const row = stmt.get(currency, BASE_CURRENCY) as undefined | { rate: number }; return row?.rate ?? 1; } function mapTransaction(row: TransactionRow) { return { id: row.id, userId: 1, type: 'expense' as const, amount: Math.abs(row.amount), currency: row.currency, exchangeRateToBase: row.exchange_rate_to_base, amountInBase: Math.abs(row.amount_in_base), 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, }; } export function fetchTransactions( options: { includeDeleted?: boolean; type?: string; statuses?: TransactionStatus[]; } = {}, ) { const clauses: string[] = []; const params: Record = {}; if (!options.includeDeleted) { clauses.push('is_deleted = 0'); } if (options.type) { clauses.push('type = @type'); params.type = options.type; } if (options.statuses && options.statuses.length > 0) { clauses.push( `status IN (${options.statuses.map((_, index) => `@status${index}`).join(', ')})`, ); options.statuses.forEach((status, index) => { params[`status${index}`] = status; }); } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''; const stmt = db.prepare( `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`, ); return stmt.all(params).map(mapTransaction); } export function getTransactionById(id: number) { const stmt = db.prepare( `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 = ?`, ); const row = stmt.get(id); return row ? mapTransaction(row) : null; } export function createTransaction(payload: TransactionPayload) { const exchangeRate = getExchangeRateToBase(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; const approvedAt = payload.approvedAt && payload.approvedAt.length > 0 ? payload.approvedAt : status === 'approved' || status === 'paid' ? statusUpdatedAt : null; const stmt = db.prepare( `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 (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, 0)`, ); const info = stmt.run({ type: payload.type, amount: payload.amount, currency: payload.currency, exchangeRateToBase: exchangeRate, amountInBase, categoryId: payload.categoryId ?? null, accountId: payload.accountId ?? null, transactionDate: payload.transactionDate, description: payload.description ?? '', project: payload.project ?? null, memo: payload.memo ?? null, createdAt, status, statusUpdatedAt, reimbursementBatch: payload.reimbursementBatch ?? null, reviewNotes: payload.reviewNotes ?? null, submittedBy: payload.submittedBy ?? null, approvedBy: payload.approvedBy ?? null, approvedAt, }); return getTransactionById(Number(info.lastInsertRowid)); } export function updateTransaction(id: number, payload: TransactionPayload) { const current = getTransactionById(id); if (!current) { return null; } const nextStatus = (payload.status ?? current.status ?? 'approved') as TransactionStatus; const statusChanged = nextStatus !== current.status; const statusUpdatedAt = payload.statusUpdatedAt && payload.statusUpdatedAt.length > 0 ? payload.statusUpdatedAt : statusChanged ? new Date().toISOString() : current.statusUpdatedAt ?? current.createdAt; const approvedAt = payload.approvedAt && payload.approvedAt.length > 0 ? payload.approvedAt : nextStatus === 'approved' || nextStatus === 'paid' ? current.approvedAt ?? (statusChanged ? statusUpdatedAt : null) : 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 = getExchangeRateToBase(next.currency); const amountInBase = +(next.amount * exchangeRate).toFixed(2); const stmt = db.prepare( `UPDATE finance_transactions SET type = @type, amount = @amount, currency = @currency, exchange_rate_to_base = @exchangeRateToBase, amount_in_base = @amountInBase, category_id = @categoryId, account_id = @accountId, transaction_date = @transactionDate, description = @description, project = @project, memo = @memo, status = @status, status_updated_at = @statusUpdatedAt, reimbursement_batch = @reimbursementBatch, review_notes = @reviewNotes, submitted_by = @submittedBy, approved_by = @approvedBy, approved_at = @approvedAt, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`, ); const deletedAt = next.isDeleted ? new Date().toISOString() : null; stmt.run({ id, type: next.type, amount: next.amount, currency: next.currency, exchangeRateToBase: exchangeRate, amountInBase, categoryId: next.categoryId, accountId: next.accountId, transactionDate: next.transactionDate, description: next.description, project: next.project, memo: next.memo, status: next.status, statusUpdatedAt: next.statusUpdatedAt, reimbursementBatch: next.reimbursementBatch, reviewNotes: next.reviewNotes, submittedBy: next.submittedBy, approvedBy: next.approvedBy, approvedAt: next.approvedAt, isDeleted: next.isDeleted ? 1 : 0, deletedAt, }); return getTransactionById(id); } export function softDeleteTransaction(id: number) { const stmt = db.prepare( `UPDATE finance_transactions SET is_deleted = 1, deleted_at = @deletedAt WHERE id = @id`, ); stmt.run({ id, deletedAt: new Date().toISOString() }); return getTransactionById(id); } export function restoreTransaction(id: number) { const stmt = db.prepare( `UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`, ); stmt.run({ id }); return getTransactionById(id); } export function replaceAllTransactions( rows: Array<{ accountId: null | number; amount: number; categoryId: null | number; createdAt?: string; currency: string; description: string; memo?: null | string; project?: null | string; transactionDate: string; type: string; status?: TransactionStatus; statusUpdatedAt?: string; reimbursementBatch?: null | string; reviewNotes?: null | string; submittedBy?: null | string; approvedBy?: null | string; approvedAt?: null | string; isDeleted?: boolean; }>, ) { db.prepare('DELETE FROM finance_transactions').run(); const insert = db.prepare( `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 (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, @status, @statusUpdatedAt, @reimbursementBatch, @reviewNotes, @submittedBy, @approvedBy, @approvedAt, @isDeleted)`, ); const getRate = db.prepare( `SELECT rate FROM finance_exchange_rates WHERE from_currency = ? AND to_currency = 'CNY' ORDER BY date DESC LIMIT 1`, ); const insertMany = db.transaction((items: Array) => { for (const item of items) { const row = getRate.get(item.currency) as undefined | { rate: number }; const rate = row?.rate ?? 1; 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); insert.run({ ...item, exchangeRateToBase: rate, amountInBase, project: item.project ?? null, memo: item.memo ?? null, createdAt, status, statusUpdatedAt, reimbursementBatch: item.reimbursementBatch ?? null, reviewNotes: item.reviewNotes ?? null, submittedBy: item.submittedBy ?? null, approvedBy: status === 'approved' || status === 'paid' ? item.approvedBy ?? null : null, approvedAt, isDeleted: item.isDeleted ? 1 : 0, }); } }); insertMany(rows); } // 分类相关函数 interface CategoryRow { id: number; name: string; type: string; icon: null | string; color: null | string; user_id: null | number; is_active: number; } 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 function fetchCategories(options: { type?: 'expense' | 'income' } = {}) { const where = options.type ? `WHERE type = @type AND is_active = 1` : 'WHERE is_active = 1'; const params = options.type ? { type: options.type } : {}; const stmt = db.prepare( `SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`, ); return stmt.all(params).map(mapCategory); }