feat: migrate backend storage to postgres
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user