feat: migrate backend storage to postgres
Some checks failed
Deploy to Production / Build and Test (push) Successful in 10m51s
Deploy to Production / Deploy to Server (push) Failing after 6m41s

This commit is contained in:
你的用户名
2025-11-06 22:01:50 +08:00
parent 3646405a47
commit b68511b2e2
28 changed files with 2641 additions and 1801 deletions

View File

@@ -1,14 +1,16 @@
import db from './sqlite';
import type { PoolClient } from 'pg';
import { query, withTransaction } from './db';
const BASE_CURRENCY = 'CNY';
interface TransactionRow {
id: number;
type: string;
amount: number;
amount: number | string;
currency: string;
exchange_rate_to_base: number;
amount_in_base: number;
exchange_rate_to_base: number | string;
amount_in_base: number | string;
category_id: null | number;
account_id: null | number;
transaction_date: string;
@@ -23,7 +25,7 @@ interface TransactionRow {
submitted_by: null | string;
approved_by: null | string;
approved_at: null | string;
is_deleted: number;
is_deleted: boolean;
deleted_at: null | string;
}
@@ -49,32 +51,24 @@ interface TransactionPayload {
}
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;
}
| '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: 'expense' as const,
amount: Math.abs(row.amount),
type: row.type as 'expense' | 'income' | 'transfer',
amount: Math.abs(amount),
currency: row.currency,
exchangeRateToBase: row.exchange_rate_to_base,
amountInBase: Math.abs(row.amount_in_base),
exchangeRateToBase,
amountInBase: Math.abs(amountInBase),
categoryId: row.category_id ?? undefined,
accountId: row.account_id ?? undefined,
transactionDate: row.transaction_date,
@@ -94,231 +88,350 @@ function mapTransaction(row: TransactionRow) {
};
}
export function fetchTransactions(
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;
type?: string;
statuses?: TransactionStatus[];
type?: string;
} = {},
) {
const clauses: string[] = [];
const params: Record<string, unknown> = {};
const params: any[] = [];
if (!options.includeDeleted) {
clauses.push('is_deleted = 0');
clauses.push('is_deleted = FALSE');
}
if (options.type) {
clauses.push('type = @type');
params.type = options.type;
params.push(options.type);
clauses.push(`type = $${params.length}`);
}
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 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 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`,
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 stmt.all(params).map(mapTransaction);
return rows.map((row) => mapTransaction(row));
}
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 = ?`,
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 = stmt.get(id);
const row = rows[0];
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;
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 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,
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]);
});
return getTransactionById(Number(info.lastInsertRowid));
}
export function updateTransaction(id: number, payload: TransactionPayload) {
const current = getTransactionById(id);
export async function updateTransaction(
id: number,
payload: TransactionPayload,
) {
const current = await 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;
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 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 exchangeRate = await getExchangeRateToBase(client, next.currency);
const amountInBase = +(next.amount * exchangeRate).toFixed(2);
const deletedAt = next.isDeleted ? new Date().toISOString() : null;
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 { 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,
],
);
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 mapTransaction(rows[0]);
});
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`,
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],
);
stmt.run({ id, deletedAt: new Date().toISOString() });
return getTransactionById(id);
const row = rows[0];
return row ? mapTransaction(row) : null;
}
export function restoreTransaction(id: number) {
const stmt = db.prepare(
`UPDATE finance_transactions SET is_deleted = 0, deleted_at = NULL WHERE id = @id`,
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],
);
stmt.run({ id });
return getTransactionById(id);
const row = rows[0];
return row ? mapTransaction(row) : null;
}
export function replaceAllTransactions(
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;
transactionDate: string;
type: string;
status?: TransactionStatus;
statusUpdatedAt?: string;
reimbursementBatch?: null | string;
reviewNotes?: null | string;
status?: TransactionStatus;
statusUpdatedAt?: string;
submittedBy?: null | string;
approvedBy?: null | string;
approvedAt?: null | string;
isDeleted?: boolean;
transactionDate: string;
type: string;
}>,
) {
db.prepare('DELETE FROM finance_transactions').run();
await withTransaction(async (client) => {
await client.query(
'TRUNCATE TABLE finance_transactions RESTART IDENTITY CASCADE',
);
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;
for (const item of rows) {
const rate = await getExchangeRateToBase(client, item.currency);
const amountInBase = +(item.amount * rate).toFixed(2);
const createdAt =
item.createdAt ??
@@ -326,38 +439,67 @@ export function replaceAllTransactions(
const status = item.status ?? 'approved';
const statusUpdatedAt =
item.statusUpdatedAt ??
new Date(
`${item.transactionDate}T00:00:00Z`,
).toISOString();
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:
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
? (item.approvedBy ?? null)
: null,
approvedAt,
isDeleted: item.isDeleted ? 1 : 0,
});
approvedAt,
item.isDeleted ?? false,
],
);
}
});
insertMany(rows);
}
// 分类相关函数
interface CategoryRow {
id: number;
name: string;
@@ -365,7 +507,7 @@ interface CategoryRow {
icon: null | string;
color: null | string;
user_id: null | number;
is_active: number;
is_active: boolean;
}
function mapCategory(row: CategoryRow) {
@@ -382,15 +524,53 @@ function mapCategory(row: CategoryRow) {
};
}
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`,
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 stmt.all(params).map(mapCategory);
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;
}