Files
kt-financial-system/apps/backend/utils/finance-repository.ts
2025-11-04 16:06:44 +08:00

397 lines
14 KiB
TypeScript

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<string, unknown> = {};
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<TransactionRow>(
`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<TransactionRow>(
`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<any>) => {
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<CategoryRow>(
`SELECT id, name, type, icon, color, user_id, is_active FROM finance_categories ${where} ORDER BY id ASC`,
);
return stmt.all(params).map(mapCategory);
}