chore: migrate to KT financial system

This commit is contained in:
woshiqp465
2025-11-04 16:06:44 +08:00
parent 2c0505b73d
commit f4cd0a5f22
289 changed files with 7362 additions and 41458 deletions

View File

@@ -1,3 +1,3 @@
PORT=5320
PORT=5666
ACCESS_TOKEN_SECRET=access_token_secret
REFRESH_TOKEN_SECRET=refresh_token_secret

View File

@@ -13,3 +13,19 @@ $ pnpm run start
# production mode
$ pnpm run build
```
## Telegram Webhook 集成
财务系统新增交易后可自动通知本地的 Telegram 机器人,默认会将交易数据通过以下 Webhook 发送:
- `http://192.168.9.28:8889/webhook/transaction`
- 认证密钥:`ktapp.cc`
如需自定义目标地址或密钥,可在运行前设置以下环境变量:
```bash
export TELEGRAM_WEBHOOK_URL="http://<bot-host>:8889/webhook/transaction"
export TELEGRAM_WEBHOOK_SECRET="自定义密钥"
```
也可以使用旧变量 `FINANCE_BOT_WEBHOOK_URL``FINANCE_BOT_WEBHOOK_SECRET` 进行兼容配置。

View File

@@ -0,0 +1,29 @@
import { getQuery } from 'h3';
import { fetchMediaMessages } from '~/utils/media-repository';
import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler((event) => {
const query = getQuery(event);
const limit =
typeof query.limit === 'string' && query.limit.length > 0
? Number.parseInt(query.limit, 10)
: undefined;
const rawTypes = (query.types ?? query.type ?? query.fileType) as
| string
| undefined;
const fileTypes = rawTypes
? rawTypes
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
: undefined;
const messages = fetchMediaMessages({
limit,
fileTypes,
});
return useResponseSuccess(messages);
});

View File

@@ -0,0 +1,22 @@
import { getRouterParam } from 'h3';
import { getMediaMessageById } from '~/utils/media-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
export default defineEventHandler((event) => {
const idParam = getRouterParam(event, 'id');
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
if (!Number.isInteger(id)) {
return useResponseError('媒体ID不合法', -1);
}
const media = getMediaMessageById(id);
if (!media) {
return useResponseError('未找到对应的媒体记录', -1);
}
return useResponseSuccess(media);
});

View File

@@ -0,0 +1,46 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import { basename } from 'pathe';
import {
getRouterParam,
sendStream,
setResponseHeader,
setResponseStatus,
} from 'h3';
import { getMediaMessageById } from '~/utils/media-repository';
import { useResponseError } from '~/utils/response';
export default defineEventHandler((event) => {
const idParam = getRouterParam(event, 'id');
const id = idParam ? Number.parseInt(idParam, 10) : NaN;
if (!Number.isInteger(id)) {
setResponseStatus(event, 400);
return useResponseError('媒体ID不合法', -1);
}
const media = getMediaMessageById(id);
if (!media) {
setResponseStatus(event, 404);
return useResponseError('未找到对应的媒体记录', -1);
}
if (!media.filePath || !existsSync(media.filePath)) {
setResponseStatus(event, 404);
return useResponseError('媒体文件不存在或已被移除', -1);
}
const fileStats = statSync(media.filePath);
setResponseHeader(event, 'Content-Type', media.mimeType ?? 'application/octet-stream');
setResponseHeader(
event,
'Content-Disposition',
`attachment; filename="${encodeURIComponent(media.fileName ?? basename(media.filePath))}"`,
);
setResponseHeader(event, 'Content-Length', `${fileStats.size}`);
return sendStream(event, createReadStream(media.filePath));
});

View File

@@ -0,0 +1,35 @@
import { getQuery } from 'h3';
import {
fetchTransactions,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseSuccess } from '~/utils/response';
const DEFAULT_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const includeDeleted = query.includeDeleted === 'true';
const type = query.type as string | undefined;
const rawStatuses = (query.statuses ?? query.status) as string | undefined;
const statuses = rawStatuses
? (rawStatuses
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0) as TransactionStatus[])
: DEFAULT_STATUSES;
const reimbursements = fetchTransactions({
includeDeleted,
type,
statuses,
});
return useResponseSuccess(reimbursements);
});

View File

@@ -0,0 +1,73 @@
import { readBody } from 'h3';
import {
createTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
const DEFAULT_CURRENCY = 'CNY';
const DEFAULT_STATUS: TransactionStatus = 'pending';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const body = await readBody(event);
if (!body?.amount || !body?.transactionDate) {
return useResponseError('缺少必填字段', -1);
}
const amount = Number(body.amount);
if (Number.isNaN(amount)) {
return useResponseError('金额格式不正确', -1);
}
const type =
(body.type as 'expense' | 'income' | 'transfer' | undefined) ?? 'expense';
const status =
(body.status as TransactionStatus | undefined) ?? DEFAULT_STATUS;
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
const reimbursement = createTransaction({
type,
amount,
currency: body.currency ?? DEFAULT_CURRENCY,
categoryId: body.categoryId ?? null,
accountId: body.accountId ?? null,
transactionDate: body.transactionDate,
description:
body.description ??
body.item ??
(body.notes ? `${body.notes}` : '') ??
'',
project: body.project ?? body.category ?? null,
memo: body.memo ?? body.notes ?? null,
status,
reimbursementBatch: body.reimbursementBatch ?? null,
reviewNotes: body.reviewNotes ?? null,
submittedBy: body.submittedBy ?? body.requester ?? null,
approvedBy: body.approvedBy ?? null,
approvedAt: body.approvedAt ?? null,
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
});
notifyTransactionWebhook(reimbursement, {
action: 'reimbursement.created',
}).catch((error) =>
console.error(
'[finance][reimbursements.post] webhook notify failed',
error,
),
);
return useResponseSuccess(reimbursement);
});

View File

@@ -0,0 +1,85 @@
import { getRouterParam, readBody } from 'h3';
import {
restoreTransaction,
updateTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'));
if (Number.isNaN(id)) {
return useResponseError('参数错误', -1);
}
const body = await readBody(event);
if (body?.isDeleted === false) {
const restored = restoreTransaction(id);
if (!restored) {
return useResponseError('报销单不存在', -1);
}
return useResponseSuccess(restored);
}
const payload: Record<string, unknown> = {};
if (body?.type) payload.type = body.type;
if (body?.amount !== undefined) {
const amount = Number(body.amount);
if (Number.isNaN(amount)) {
return useResponseError('金额格式不正确', -1);
}
payload.amount = amount;
}
if (body?.currency) payload.currency = body.currency;
if (body?.categoryId !== undefined)
payload.categoryId = body.categoryId ?? null;
if (body?.accountId !== undefined) payload.accountId = body.accountId ?? null;
if (body?.transactionDate) payload.transactionDate = body.transactionDate;
if (body?.description !== undefined)
payload.description = body.description ?? '';
if (body?.project !== undefined) payload.project = body.project ?? null;
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
if (body?.status !== undefined) {
const status = body.status as TransactionStatus;
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
payload.status = status;
}
if (body?.statusUpdatedAt !== undefined) {
payload.statusUpdatedAt = body.statusUpdatedAt;
}
if (body?.reimbursementBatch !== undefined) {
payload.reimbursementBatch = body.reimbursementBatch ?? null;
}
if (body?.reviewNotes !== undefined) {
payload.reviewNotes = body.reviewNotes ?? null;
}
if (body?.submittedBy !== undefined) {
payload.submittedBy = body.submittedBy ?? null;
}
if (body?.approvedBy !== undefined) {
payload.approvedBy = body.approvedBy ?? null;
}
if (body?.approvedAt !== undefined) {
payload.approvedAt = body.approvedAt ?? null;
}
const updated = updateTransaction(id, payload);
if (!updated) {
return useResponseError('报销单不存在', -1);
}
return useResponseSuccess(updated);
});

View File

@@ -1,11 +1,28 @@
import { getQuery } from 'h3';
import { fetchTransactions } from '~/utils/finance-repository';
import {
fetchTransactions,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseSuccess } from '~/utils/response';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const type = query.type as string | undefined;
const transactions = fetchTransactions({ type });
const includeDeleted = query.includeDeleted === 'true';
const rawStatuses = (query.statuses ?? query.status) as
| string
| undefined;
const statuses = rawStatuses
? (rawStatuses
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0) as TransactionStatus[])
: (['approved', 'paid'] satisfies TransactionStatus[]);
const transactions = fetchTransactions({
type,
includeDeleted,
statuses,
});
return useResponseSuccess(transactions);
});

View File

@@ -1,8 +1,19 @@
import { readBody } from 'h3';
import { createTransaction } from '~/utils/finance-repository';
import {
createTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
import { notifyTransactionWebhook } from '~/utils/telegram-webhook';
const DEFAULT_CURRENCY = 'CNY';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const body = await readBody(event);
@@ -16,6 +27,12 @@ export default defineEventHandler(async (event) => {
return useResponseError('金额格式不正确', -1);
}
const status =
(body.status as TransactionStatus | undefined) ?? 'approved';
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
const transaction = createTransaction({
type: body.type,
amount,
@@ -26,7 +43,18 @@ export default defineEventHandler(async (event) => {
description: body.description ?? '',
project: body.project ?? null,
memo: body.memo ?? null,
status,
reimbursementBatch: body.reimbursementBatch ?? null,
reviewNotes: body.reviewNotes ?? null,
submittedBy: body.submittedBy ?? null,
approvedBy: body.approvedBy ?? null,
statusUpdatedAt: body.statusUpdatedAt ?? undefined,
approvedAt: body.approvedAt ?? undefined,
});
notifyTransactionWebhook(transaction, { action: 'created' }).catch((error) =>
console.error('[finance][transactions.post] webhook notify failed', error),
);
return useResponseSuccess(transaction);
});

View File

@@ -2,9 +2,18 @@ import { getRouterParam, readBody } from 'h3';
import {
restoreTransaction,
updateTransaction,
type TransactionStatus,
} from '~/utils/finance-repository';
import { useResponseError, useResponseSuccess } from '~/utils/response';
const ALLOWED_STATUSES: TransactionStatus[] = [
'draft',
'pending',
'approved',
'rejected',
'paid',
];
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'));
if (Number.isNaN(id)) {
@@ -41,6 +50,31 @@ export default defineEventHandler(async (event) => {
if (body?.project !== undefined) payload.project = body.project ?? null;
if (body?.memo !== undefined) payload.memo = body.memo ?? null;
if (body?.isDeleted !== undefined) payload.isDeleted = body.isDeleted;
if (body?.status !== undefined) {
const status = body.status as TransactionStatus;
if (!ALLOWED_STATUSES.includes(status)) {
return useResponseError('状态值不合法', -1);
}
payload.status = status;
}
if (body?.statusUpdatedAt !== undefined) {
payload.statusUpdatedAt = body.statusUpdatedAt;
}
if (body?.reimbursementBatch !== undefined) {
payload.reimbursementBatch = body.reimbursementBatch ?? null;
}
if (body?.reviewNotes !== undefined) {
payload.reviewNotes = body.reviewNotes ?? null;
}
if (body?.submittedBy !== undefined) {
payload.submittedBy = body.submittedBy ?? null;
}
if (body?.approvedBy !== undefined) {
payload.approvedBy = body.approvedBy ?? null;
}
if (body?.approvedAt !== undefined) {
payload.approvedAt = body.approvedAt ?? null;
}
const updated = updateTransaction(id, payload);
if (!updated) {

BIN
apps/backend/backend.tar.gz Normal file

Binary file not shown.

View File

@@ -42,6 +42,24 @@ fs.mkdirSync(storeDir, { recursive: true });
const dbFile = path.join(storeDir, 'finance.db');
const db = new Database(dbFile);
function assertIdentifier(name) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Invalid identifier: ${name}`);
}
return name;
}
function ensureColumn(table, column, definition) {
const safeTable = assertIdentifier(table);
const safeColumn = assertIdentifier(column);
const columns = db
.prepare(`PRAGMA table_info(${safeTable})`)
.all()
.map((item) => item.name);
if (!columns.includes(safeColumn)) {
db.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
}
}
db.pragma('journal_mode = WAL');
db.exec(`
@@ -106,11 +124,38 @@ db.exec(`
project TEXT,
memo TEXT,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'approved',
status_updated_at TEXT,
reimbursement_batch TEXT,
review_notes TEXT,
submitted_by TEXT,
approved_by TEXT,
approved_at TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT
);
`);
ensureColumn(
'finance_transactions',
'status',
"status TEXT NOT NULL DEFAULT 'approved'",
);
ensureColumn(
'finance_transactions',
'status_updated_at',
'status_updated_at TEXT',
);
ensureColumn(
'finance_transactions',
'reimbursement_batch',
'reimbursement_batch TEXT',
);
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
if (lines.length <= 1) {

View File

@@ -5,13 +5,39 @@ import {
MOCK_CURRENCIES,
MOCK_EXCHANGE_RATES,
} from './mock-data';
import db from './sqlite';
export function listAccounts() {
return MOCK_ACCOUNTS;
}
export function listCategories() {
return MOCK_CATEGORIES;
// 从数据库读取分类
try {
const stmt = db.prepare(`
SELECT id, name, type, icon, color, user_id as userId, is_active as isActive
FROM finance_categories
WHERE is_active = 1
ORDER BY type, id
`);
const categories = stmt.all() as any[];
// 转换为前端需要的格式
return categories.map(cat => ({
id: cat.id,
userId: cat.userId,
name: cat.name,
type: cat.type,
icon: cat.icon,
color: cat.color,
sortOrder: cat.id,
isSystem: true,
isActive: Boolean(cat.isActive),
}));
} catch (error) {
console.error('从数据库读取分类失败使用MOCK数据:', error);
return MOCK_CATEGORIES;
}
}
export function listBudgets() {
@@ -27,29 +53,78 @@ export function listExchangeRates() {
}
export function createCategoryRecord(category: any) {
const newCategory = {
...category,
id: MOCK_CATEGORIES.length + 1,
createdAt: new Date().toISOString(),
};
MOCK_CATEGORIES.push(newCategory);
return newCategory;
try {
const stmt = db.prepare(`
INSERT INTO finance_categories (name, type, icon, color, user_id, is_active)
VALUES (?, ?, ?, ?, ?, 1)
`);
const result = stmt.run(
category.name,
category.type,
category.icon || '📝',
category.color || '#dfe4ea',
category.userId || 1
);
return {
id: result.lastInsertRowid,
...category,
createdAt: new Date().toISOString(),
};
} catch (error) {
console.error('创建分类失败:', error);
return null;
}
}
export function updateCategoryRecord(id: number, category: any) {
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
if (index !== -1) {
MOCK_CATEGORIES[index] = { ...MOCK_CATEGORIES[index], ...category };
return MOCK_CATEGORIES[index];
try {
const updates: string[] = [];
const params: any[] = [];
if (category.name) {
updates.push('name = ?');
params.push(category.name);
}
if (category.icon) {
updates.push('icon = ?');
params.push(category.icon);
}
if (category.color) {
updates.push('color = ?');
params.push(category.color);
}
if (updates.length === 0) return null;
params.push(id);
const stmt = db.prepare(`
UPDATE finance_categories
SET ${updates.join(', ')}
WHERE id = ?
`);
stmt.run(...params);
// 返回更新后的分类
const selectStmt = db.prepare('SELECT * FROM finance_categories WHERE id = ?');
return selectStmt.get(id);
} catch (error) {
console.error('更新分类失败:', error);
return null;
}
return null;
}
export function deleteCategoryRecord(id: number) {
const index = MOCK_CATEGORIES.findIndex((c) => c.id === id);
if (index !== -1) {
MOCK_CATEGORIES.splice(index, 1);
try {
// 软删除
const stmt = db.prepare(`
UPDATE finance_categories
SET is_active = 0
WHERE id = ?
`);
stmt.run(id);
return true;
} catch (error) {
console.error('删除分类失败:', error);
return false;
}
return false;
}

View File

@@ -16,6 +16,13 @@ interface TransactionRow {
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;
}
@@ -32,8 +39,22 @@ interface TransactionPayload {
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;
@@ -49,11 +70,11 @@ function mapTransaction(row: TransactionRow) {
return {
id: row.id,
userId: 1,
type: row.type as 'expense' | 'income' | 'transfer',
amount: row.amount,
type: 'expense' as const,
amount: Math.abs(row.amount),
currency: row.currency,
exchangeRateToBase: row.exchange_rate_to_base,
amountInBase: row.amount_in_base,
amountInBase: Math.abs(row.amount_in_base),
categoryId: row.category_id ?? undefined,
accountId: row.account_id ?? undefined,
transactionDate: row.transaction_date,
@@ -61,13 +82,24 @@ function mapTransaction(row: TransactionRow) {
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 } = {},
options: {
includeDeleted?: boolean;
type?: string;
statuses?: TransactionStatus[];
} = {},
) {
const clauses: string[] = [];
const params: Record<string, unknown> = {};
@@ -79,11 +111,19 @@ export function fetchTransactions(
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, is_deleted, deleted_at FROM finance_transactions ${where} ORDER BY transaction_date DESC, id DESC`,
`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);
@@ -91,7 +131,7 @@ export function fetchTransactions(
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, is_deleted, deleted_at FROM finance_transactions WHERE id = ?`,
`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;
@@ -104,9 +144,20 @@ export function createTransaction(payload: TransactionPayload) {
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, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
`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({
@@ -122,6 +173,13 @@ export function createTransaction(payload: TransactionPayload) {
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));
@@ -133,6 +191,25 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
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,
@@ -144,13 +221,21 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
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, is_deleted = @isDeleted, deleted_at = @deletedAt WHERE id = @id`,
`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;
@@ -168,6 +253,13 @@ export function updateTransaction(id: number, payload: TransactionPayload) {
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,
});
@@ -203,12 +295,20 @@ export function replaceAllTransactions(
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, is_deleted) VALUES (@type, @amount, @currency, @exchangeRateToBase, @amountInBase, @categoryId, @accountId, @transactionDate, @description, @project, @memo, @createdAt, 0)`,
`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(
@@ -220,15 +320,36 @@ export function replaceAllTransactions(
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:
item.createdAt ??
new Date(`${item.transactionDate}T00:00:00Z`).toISOString(),
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,
});
}
});

View File

@@ -0,0 +1,117 @@
import { existsSync } from 'node:fs';
import db from './sqlite';
interface MediaRow {
id: number;
chat_id: number;
message_id: number;
user_id: number;
username: null | string;
display_name: null | string;
file_type: string;
file_id: string;
file_unique_id: null | string;
caption: null | string;
file_name: null | string;
file_path: string;
file_size: null | number;
mime_type: null | string;
duration: null | number;
width: null | number;
height: null | number;
forwarded_to: null | number;
created_at: string;
updated_at: string;
}
export interface MediaMessage {
id: number;
chatId: number;
messageId: number;
userId: number;
username?: string;
displayName?: string;
fileType: string;
fileId: string;
fileUniqueId?: string;
caption?: string;
fileName?: string;
filePath: string;
fileSize?: number;
mimeType?: string;
duration?: number;
width?: number;
height?: number;
forwardedTo?: number;
createdAt: string;
updatedAt: string;
available: boolean;
downloadUrl: string | null;
}
function mapMediaRow(row: MediaRow): MediaMessage {
const fileExists = existsSync(row.file_path);
return {
id: row.id,
chatId: row.chat_id,
messageId: row.message_id,
userId: row.user_id,
username: row.username ?? undefined,
displayName: row.display_name ?? undefined,
fileType: row.file_type,
fileId: row.file_id,
fileUniqueId: row.file_unique_id ?? undefined,
caption: row.caption ?? undefined,
fileName: row.file_name ?? undefined,
filePath: row.file_path,
fileSize: row.file_size ?? undefined,
mimeType: row.mime_type ?? undefined,
duration: row.duration ?? undefined,
width: row.width ?? undefined,
height: row.height ?? undefined,
forwardedTo: row.forwarded_to ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
available: fileExists,
downloadUrl: fileExists ? `/finance/media/${row.id}/download` : null,
};
}
export function fetchMediaMessages(params: {
limit?: number;
fileTypes?: string[];
} = {}) {
const clauses: string[] = [];
const bindParams: Record<string, unknown> = {};
if (params.fileTypes && params.fileTypes.length > 0) {
clauses.push(
`file_type IN (${params.fileTypes.map((_, index) => `@type${index}`).join(', ')})`,
);
params.fileTypes.forEach((type, index) => {
bindParams[`type${index}`] = type;
});
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const limitClause =
params.limit && params.limit > 0 ? `LIMIT ${Number(params.limit)}` : '';
const stmt = db.prepare<MediaRow>(
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages ${where} ORDER BY datetime(created_at) DESC, id DESC ${limitClause}`,
);
return stmt.all(bindParams).map(mapMediaRow);
}
export function getMediaMessageById(id: number) {
const stmt = db.prepare<MediaRow>(
`SELECT id, chat_id, message_id, user_id, username, display_name, file_type, file_id, file_unique_id, caption, file_name, file_path, file_size, mime_type, duration, width, height, forwarded_to, created_at, updated_at FROM finance_media_messages WHERE id = ?`,
);
const row = stmt.get(id);
return row ? mapMediaRow(row) : null;
}

View File

@@ -0,0 +1,15 @@
import { mkdirSync } from 'node:fs';
import { join } from 'pathe';
const MEDIA_ROOT = join(process.cwd(), 'storage', 'telegram-media');
mkdirSync(MEDIA_ROOT, { recursive: true });
export function getMediaRoot() {
return MEDIA_ROOT;
}
export function resolveMediaAbsolutePath(relativePath: string) {
return join(MEDIA_ROOT, relativePath);
}

View File

@@ -693,11 +693,11 @@ export interface Category {
}
export const MOCK_CATEGORIES: Category[] = [
// 支出分类
// 支出分类 (ID 1-17)
{
id: 1,
userId: null,
name: '餐饮',
name: '餐饮美食',
type: 'expense',
icon: '🍜',
color: '#ff6b6b',
@@ -708,9 +708,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 2,
userId: null,
name: '交通',
name: '佣金/返佣',
type: 'expense',
icon: '🚗',
icon: '💸',
color: '#4ecdc4',
sortOrder: 2,
isSystem: true,
@@ -719,9 +719,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 3,
userId: null,
name: '购物',
name: '分红',
type: 'expense',
icon: '🛍️',
icon: '💰',
color: '#95e1d3',
sortOrder: 3,
isSystem: true,
@@ -730,9 +730,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 4,
userId: null,
name: '娱乐',
name: '技术/软件',
type: 'expense',
icon: '🎮',
icon: '💻',
color: '#f38181',
sortOrder: 4,
isSystem: true,
@@ -741,9 +741,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 5,
userId: null,
name: '软件订阅',
name: '固定资产',
type: 'expense',
icon: '💻',
icon: '🏠',
color: '#aa96da',
sortOrder: 5,
isSystem: true,
@@ -752,9 +752,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 6,
userId: null,
name: '投资支出',
name: '退款',
type: 'expense',
icon: '📊',
icon: '↩️',
color: '#fcbad3',
sortOrder: 6,
isSystem: true,
@@ -763,9 +763,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 7,
userId: null,
name: '医疗健康',
name: '服务器/技术',
type: 'expense',
icon: '🏥',
icon: '🖥️',
color: '#a8d8ea',
sortOrder: 7,
isSystem: true,
@@ -774,9 +774,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 8,
userId: null,
name: '房租房贷',
name: '工资',
type: 'expense',
icon: '🏠',
icon: '💼',
color: '#ffcccc',
sortOrder: 8,
isSystem: true,
@@ -785,9 +785,9 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 9,
userId: null,
name: '教育',
name: '借款/转账',
type: 'expense',
icon: '📚',
icon: '🔄',
color: '#ffd3b6',
sortOrder: 9,
isSystem: true,
@@ -796,29 +796,106 @@ export const MOCK_CATEGORIES: Category[] = [
{
id: 10,
userId: null,
name: '广告推广',
type: 'expense',
icon: '📢',
color: '#dfe4ea',
sortOrder: 10,
isSystem: true,
isActive: true,
},
{
id: 11,
userId: null,
name: '交通出行',
type: 'expense',
icon: '🚗',
color: '#74b9ff',
sortOrder: 11,
isSystem: true,
isActive: true,
},
{
id: 12,
userId: null,
name: '购物消费',
type: 'expense',
icon: '🛍️',
color: '#fd79a8',
sortOrder: 12,
isSystem: true,
isActive: true,
},
{
id: 13,
userId: null,
name: '娱乐休闲',
type: 'expense',
icon: '🎮',
color: '#fdcb6e',
sortOrder: 13,
isSystem: true,
isActive: true,
},
{
id: 14,
userId: null,
name: '医疗健康',
type: 'expense',
icon: '🏥',
color: '#55efc4',
sortOrder: 14,
isSystem: true,
isActive: true,
},
{
id: 15,
userId: null,
name: '教育学习',
type: 'expense',
icon: '📚',
color: '#a29bfe',
sortOrder: 15,
isSystem: true,
isActive: true,
},
{
id: 16,
userId: null,
name: '房租房贷',
type: 'expense',
icon: '🏘️',
color: '#ff7675',
sortOrder: 16,
isSystem: true,
isActive: true,
},
{
id: 17,
userId: null,
name: '其他支出',
type: 'expense',
icon: '📝',
color: '#dfe4ea',
color: '#b2bec3',
sortOrder: 99,
isSystem: true,
isActive: true,
},
// 收入分类
// 收入分类 (ID 18-23)
{
id: 11,
id: 18,
userId: null,
name: '工资',
name: '工资收入',
type: 'income',
icon: '💼',
icon: '💵',
color: '#38ada9',
sortOrder: 1,
isSystem: true,
isActive: true,
},
{
id: 12,
id: 19,
userId: null,
name: '奖金',
type: 'income',
@@ -829,7 +906,7 @@ export const MOCK_CATEGORIES: Category[] = [
isActive: true,
},
{
id: 13,
id: 20,
userId: null,
name: '投资收益',
type: 'income',
@@ -840,7 +917,7 @@ export const MOCK_CATEGORIES: Category[] = [
isActive: true,
},
{
id: 14,
id: 21,
userId: null,
name: '副业收入',
type: 'income',
@@ -851,12 +928,23 @@ export const MOCK_CATEGORIES: Category[] = [
isActive: true,
},
{
id: 15,
id: 22,
userId: null,
name: '退款收入',
type: 'income',
icon: '↩️',
color: '#82ccdd',
sortOrder: 5,
isSystem: true,
isActive: true,
},
{
id: 23,
userId: null,
name: '其他收入',
type: 'income',
icon: '💰',
color: '#82ccdd',
color: '#10ac84',
sortOrder: 99,
isSystem: true,
isActive: true,

View File

@@ -9,6 +9,24 @@ mkdirSync(dirname(dbFile), { recursive: true });
const database = new Database(dbFile);
function assertIdentifier(name: string) {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
throw new Error(`Invalid identifier: ${name}`);
}
return name;
}
function ensureColumn(table: string, column: string, definition: string) {
const safeTable = assertIdentifier(table);
const safeColumn = assertIdentifier(column);
const columns = database
.prepare<{ name: string }>(`PRAGMA table_info(${safeTable})`)
.all();
if (!columns.some((item) => item.name === safeColumn)) {
database.exec(`ALTER TABLE ${safeTable} ADD COLUMN ${definition}`);
}
}
database.pragma('journal_mode = WAL');
database.exec(`
@@ -72,6 +90,13 @@ database.exec(`
project TEXT,
memo TEXT,
created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'approved',
status_updated_at TEXT,
reimbursement_batch TEXT,
review_notes TEXT,
submitted_by TEXT,
approved_by TEXT,
approved_at TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0,
deleted_at TEXT,
FOREIGN KEY (currency) REFERENCES finance_currencies(code),
@@ -80,4 +105,56 @@ database.exec(`
);
`);
ensureColumn(
'finance_transactions',
'status',
"status TEXT NOT NULL DEFAULT 'approved'",
);
ensureColumn('finance_transactions', 'status_updated_at', 'status_updated_at TEXT');
ensureColumn(
'finance_transactions',
'reimbursement_batch',
'reimbursement_batch TEXT',
);
ensureColumn('finance_transactions', 'review_notes', 'review_notes TEXT');
ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT');
ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT');
ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT');
database.exec(`
CREATE TABLE IF NOT EXISTS finance_media_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
username TEXT,
display_name TEXT,
file_type TEXT NOT NULL,
file_id TEXT NOT NULL,
file_unique_id TEXT,
caption TEXT,
file_name TEXT,
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
duration INTEGER,
width INTEGER,
height INTEGER,
forwarded_to INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(chat_id, message_id)
);
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_created_at
ON finance_media_messages (created_at DESC);
`);
database.exec(`
CREATE INDEX IF NOT EXISTS idx_finance_media_messages_user_id
ON finance_media_messages (user_id);
`);
export default database;

View File

@@ -0,0 +1,73 @@
const DEFAULT_WEBHOOK_URL =
process.env.TELEGRAM_WEBHOOK_URL ??
process.env.FINANCE_BOT_WEBHOOK_URL ??
'http://192.168.9.28:8889/webhook/transaction';
const DEFAULT_WEBHOOK_SECRET =
process.env.TELEGRAM_WEBHOOK_SECRET ??
process.env.FINANCE_BOT_WEBHOOK_SECRET ??
'ktapp.cc';
interface TransactionPayload {
[key: string]: unknown;
}
async function postToWebhook(
payload: TransactionPayload,
webhookURL: string,
webhookSecret: string,
) {
try {
const response = await fetch(webhookURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${webhookSecret}`,
},
body: JSON.stringify(payload),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
console.error(
'[telegram-webhook] Failed to notify webhook',
response.status,
text,
);
}
} catch (error) {
console.error('[telegram-webhook] Webhook request error', error);
}
}
export async function notifyTransactionWebhook(
transaction: TransactionPayload,
options: {
webhookURL?: string;
webhookSecret?: string;
action?: string;
source?: string;
} = {},
) {
const url = (options.webhookURL ?? DEFAULT_WEBHOOK_URL).trim();
const secret = (options.webhookSecret ?? DEFAULT_WEBHOOK_SECRET).trim();
if (!url || !secret) {
return;
}
const payload: TransactionPayload = {
...transaction,
};
if (options.action) {
payload.action = options.action;
}
if (options.source) {
payload.source = options.source;
} else {
payload.source = payload.source ?? 'finwise-backend';
}
await postToWebhook(payload, url, secret);
}