Files
kt-financial-system/apps/backend/utils/finance-repository.ts
你的用户名 b68511b2e2
Some checks failed
Deploy to Production / Build and Test (push) Successful in 10m51s
Deploy to Production / Deploy to Server (push) Failing after 6m41s
feat: migrate backend storage to postgres
2025-11-06 22:01:50 +08:00

577 lines
16 KiB
TypeScript

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<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`,
params,
);
return rows.map((row) => mapTransaction(row));
}
export async function getTransactionById(id: number) {
const { rows } = await query<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 = $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<TransactionRow>(
`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<TransactionRow>(
`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<TransactionRow>(
`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<TransactionRow>(
`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<CategoryRow>(
`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<CategoryRow>(
`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;
}