feat:新增账单导出、支付宝账单导入

This commit is contained in:
2026-03-12 15:09:25 +08:00
parent e70b87a212
commit 6c209d8781
9 changed files with 886 additions and 69 deletions

View File

@@ -0,0 +1,495 @@
import { v4 as uuidv4 } from 'uuid'
import { fetchTransactions, insertTransaction, mergeImportedTransaction } from './transactionService.js'
import { getDb, saveDbToStore } from '../lib/sqlite.js'
const IMPORT_SOURCE_ALIPAY = 'alipay'
const IMPORT_SUCCESS_STATUS = '交易成功'
const ALIPAY_REQUIRED_HEADERS = ['交易时间', '交易分类', '交易对方', '商品说明', '收/支', '金额', '交易状态']
const ALIPAY_HEADERS = {
occurredAt: '交易时间',
tradeCategory: '交易分类',
merchant: '交易对方',
counterpartyAccount: '对方账号',
productName: '商品说明',
direction: '收/支',
amount: '金额',
paymentMethod: '收/付款方式',
status: '交易状态',
orderId: '交易订单号',
merchantOrderId: '商家订单号',
note: '备注',
}
const downloadText = (filename, text, mimeType) => {
const blob = new Blob([text], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
const escapeCsvCell = (value) => `"${String(value ?? '').replace(/"/g, '""')}"`
const cleanCell = (value) =>
String(value ?? '')
.replace(/^\uFEFF/, '')
.replace(/\r/g, '')
.replace(/^"|"$/g, '')
.trim()
const normalizeMerchantKey = (value) =>
String(value || '')
.toLowerCase()
.replace(/[\s"'`~!@#$%^&*()_+\-=[\]{};:,.<>/?\\|,。!?;:()【】《》·、]/g, '')
.slice(0, 80)
const normalizeAmount = (value) => {
const numeric = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
return Number.isFinite(numeric) ? numeric : 0
}
const normalizeDate = (value) => {
if (!value) return ''
const normalized = String(value).trim().replace(/\//g, '-')
const parsed = new Date(normalized)
return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString()
}
const parseDelimitedLine = (line, delimiter) => {
const cells = []
let current = ''
let inQuotes = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
const next = line[index + 1]
if (char === '"') {
if (inQuotes && next === '"') {
current += '"'
index += 1
} else {
inQuotes = !inQuotes
}
continue
}
if (!inQuotes && char === delimiter) {
cells.push(cleanCell(current))
current = ''
continue
}
current += char
}
cells.push(cleanCell(current))
return cells
}
const detectHeaderIndex = (rows) =>
rows.findIndex((row) => ALIPAY_REQUIRED_HEADERS.every((header) => row.includes(header)))
const extractRowsFromHtml = (content) => {
const parser = new DOMParser()
const doc = parser.parseFromString(content, 'text/html')
return Array.from(doc.querySelectorAll('tr')).map((row) =>
Array.from(row.querySelectorAll('th,td')).map((cell) => cleanCell(cell.textContent)),
)
}
const extractRowsFromText = (content) => {
const lines = content.split(/\r?\n/).filter((line) => line.trim())
const headerLine = lines.find((line) => ALIPAY_REQUIRED_HEADERS.every((header) => line.includes(header))) || ''
const delimiter = headerLine.split('\t').length >= headerLine.split(',').length ? '\t' : ','
return lines.map((line) => parseDelimitedLine(line, delimiter))
}
const parseAlipayRows = (content) => {
const rows = /<table/i.test(content) ? extractRowsFromHtml(content) : extractRowsFromText(content)
const headerIndex = detectHeaderIndex(rows)
if (headerIndex < 0) {
throw new Error('未找到支付宝账单表头,请确认导出文件内容完整')
}
const header = rows[headerIndex]
const getCell = (row, key) => row[header.indexOf(ALIPAY_HEADERS[key])] || ''
return rows
.slice(headerIndex + 1)
.filter((row) => cleanCell(getCell(row, 'occurredAt')))
.map((row) => ({
occurredAt: cleanCell(getCell(row, 'occurredAt')),
tradeCategory: cleanCell(getCell(row, 'tradeCategory')),
merchant: cleanCell(getCell(row, 'merchant')),
counterpartyAccount: cleanCell(getCell(row, 'counterpartyAccount')),
productName: cleanCell(getCell(row, 'productName')),
direction: cleanCell(getCell(row, 'direction')),
amount: cleanCell(getCell(row, 'amount')),
paymentMethod: cleanCell(getCell(row, 'paymentMethod')),
status: cleanCell(getCell(row, 'status')),
orderId: cleanCell(getCell(row, 'orderId')),
merchantOrderId: cleanCell(getCell(row, 'merchantOrderId')),
note: cleanCell(getCell(row, 'note')),
}))
}
const resolveCategory = (row, direction) => {
const text = `${row.tradeCategory} ${row.productName} ${row.merchant}`.toLowerCase()
if (direction === 'neutral') return 'Transfer'
if (direction === 'income') return 'Income'
if (/餐饮|美食|咖啡|奶茶|外卖|饭|面|粉/.test(text)) return 'Food'
if (/交通|出行|打车|公交|地铁|单车|骑行|哈啰|滴滴/.test(text)) return 'Transport'
if (/超市|便利|百货|买菜|生鲜|7-11|罗森|全家/.test(text)) return 'Groceries'
if (/健康|药|医院|诊所|体检/.test(text)) return 'Health'
if (/娱乐|电影|游戏|门票|演出|网吧/.test(text)) return 'Entertainment'
return direction === 'expense' ? 'Expense' : 'Uncategorized'
}
const resolveFundingSource = (paymentMethod) => {
const text = String(paymentMethod || '')
if (!text) return { fundSourceType: 'cash', fundSourceName: '现金账户' }
if (/信用卡|花呗|白条/.test(text)) {
return { fundSourceType: 'credit', fundSourceName: text }
}
if (/储蓄卡|银行卡|银行/.test(text)) {
return { fundSourceType: 'bank', fundSourceName: text }
}
if (/余额|零钱|账户余额/.test(text)) {
return { fundSourceType: 'cash', fundSourceName: text }
}
if (/卡/.test(text)) {
return { fundSourceType: 'stored_value', fundSourceName: text }
}
return { fundSourceType: 'cash', fundSourceName: text }
}
const buildSourceRecordHash = (payload) =>
[
payload.sourceType,
payload.date,
payload.amount.toFixed(2),
normalizeMerchantKey(payload.merchant),
normalizeMerchantKey(payload.note),
].join('|')
const normalizeImportedRow = (row, importBatchId) => {
const normalizedDate = normalizeDate(row.occurredAt)
if (!normalizedDate) return null
const directionText = row.direction
const rawAmount = Math.abs(normalizeAmount(row.amount))
const direction =
directionText.includes('收入') ? 'income' : directionText.includes('支出') ? 'expense' : 'neutral'
const { fundSourceType, fundSourceName } = resolveFundingSource(row.paymentMethod)
const merchant = row.merchant || row.productName || '支付宝账单导入'
const note = [row.productName, row.note].filter(Boolean).join(' | ')
const amount =
direction === 'income' ? rawAmount : direction === 'expense' ? (rawAmount === 0 ? 0 : rawAmount * -1) : rawAmount
const payload = {
merchant,
category: resolveCategory(row, direction),
note,
date: normalizedDate,
amount,
syncStatus: 'pending',
entryType: direction === 'neutral' ? 'transfer' : direction,
fundSourceType,
fundSourceName,
fundTargetType: direction === 'income' ? 'cash' : direction === 'neutral' ? 'stored_value' : 'merchant',
fundTargetName:
direction === 'income' ? '现金账户' : direction === 'neutral' ? merchant || '储值账户' : merchant,
impactExpense: direction === 'expense',
impactIncome: direction === 'income',
sourceType: IMPORT_SOURCE_ALIPAY,
sourceOrderId: row.orderId,
sourceMerchantOrderId: row.merchantOrderId,
sourceCounterparty: row.merchant,
sourceAccount: row.counterpartyAccount,
sourcePaymentMethod: row.paymentMethod,
importBatchId,
}
payload.sourceRecordHash = buildSourceRecordHash(payload)
return payload
}
const isSameAmount = (left, right) => Math.abs(Number(left) - Number(right)) < 0.005
const isMerchantLikelySame = (left, right) => {
const leftKey = normalizeMerchantKey(left)
const rightKey = normalizeMerchantKey(right)
if (!leftKey || !rightKey) return false
return leftKey === rightKey || leftKey.includes(rightKey) || rightKey.includes(leftKey)
}
const findExactDuplicate = (transactions, payload) =>
transactions.find((transaction) => {
if (payload.sourceOrderId && transaction.sourceType === payload.sourceType) {
return transaction.sourceOrderId === payload.sourceOrderId
}
return payload.sourceRecordHash && transaction.sourceRecordHash === payload.sourceRecordHash
})
const findMergeCandidate = (transactions, payload) => {
const payloadTime = new Date(payload.date).getTime()
return transactions.find((transaction) => {
const diff = Math.abs(new Date(transaction.date).getTime() - payloadTime)
return (
diff <= 10 * 60 * 1000 &&
isSameAmount(transaction.amount, payload.amount) &&
isMerchantLikelySame(transaction.merchant, payload.merchant)
)
})
}
const createImportBatch = async ({ source, fileName }) => {
const db = await getDb()
const id = uuidv4()
await db.run(
`
INSERT INTO import_batches (id, source, file_name, imported_at, status)
VALUES (?, ?, ?, ?, ?)
`,
[id, source, fileName || null, new Date().toISOString(), 'running'],
)
return id
}
const recordImportLine = async (batchId, payload) => {
const db = await getDb()
await db.run(
`
INSERT INTO import_lines (
id,
batch_id,
source_order_id,
merchant,
amount,
occurred_at,
status,
transaction_id,
reason,
raw_data,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
uuidv4(),
batchId,
payload.sourceOrderId || null,
payload.merchant || null,
Number(payload.amount || 0),
payload.date || null,
payload.status,
payload.transactionId || null,
payload.reason || null,
JSON.stringify(payload.rawData || {}),
new Date().toISOString(),
],
)
}
const finalizeImportBatch = async (batchId, summary) => {
const db = await getDb()
await db.run(
`
UPDATE import_batches
SET total_count = ?, inserted_count = ?, merged_count = ?, skipped_count = ?, status = ?, summary_json = ?
WHERE id = ?
`,
[
summary.totalCount,
summary.insertedCount,
summary.mergedCount,
summary.skippedCount,
'completed',
JSON.stringify(summary),
batchId,
],
)
}
export const importAlipayStatement = async (file) => {
const content = await file.text()
const parsedRows = parseAlipayRows(content)
const batchId = await createImportBatch({
source: IMPORT_SOURCE_ALIPAY,
fileName: file?.name || 'alipay-statement',
})
const summary = {
batchId,
source: IMPORT_SOURCE_ALIPAY,
fileName: file?.name || '',
totalCount: 0,
insertedCount: 0,
mergedCount: 0,
skippedCount: 0,
skippedReasons: [],
}
const existingTransactions = await fetchTransactions()
const inBatchKeys = new Set()
for (const row of parsedRows) {
summary.totalCount += 1
if (row.status && row.status !== IMPORT_SUCCESS_STATUS) {
summary.skippedCount += 1
summary.skippedReasons.push(`${row.occurredAt} ${row.merchant || row.productName}:状态不是交易成功`)
await recordImportLine(batchId, {
sourceOrderId: row.orderId,
merchant: row.merchant || row.productName,
amount: normalizeAmount(row.amount),
date: normalizeDate(row.occurredAt),
status: 'skipped',
reason: '交易状态不是交易成功',
rawData: row,
})
continue
}
const payload = normalizeImportedRow(row, batchId)
if (!payload) {
summary.skippedCount += 1
summary.skippedReasons.push(`${row.occurredAt}:无法解析交易时间`)
await recordImportLine(batchId, {
sourceOrderId: row.orderId,
merchant: row.merchant || row.productName,
amount: normalizeAmount(row.amount),
date: '',
status: 'skipped',
reason: '无法解析交易时间',
rawData: row,
})
continue
}
const inBatchKey = payload.sourceOrderId || payload.sourceRecordHash
if (inBatchKey && inBatchKeys.has(inBatchKey)) {
summary.skippedCount += 1
summary.skippedReasons.push(`${payload.merchant}:导入文件内重复记录已跳过`)
await recordImportLine(batchId, {
...payload,
status: 'skipped',
reason: '导入文件内重复记录',
rawData: row,
})
continue
}
if (inBatchKey) {
inBatchKeys.add(inBatchKey)
}
const exactDuplicate = findExactDuplicate(existingTransactions, payload)
if (exactDuplicate) {
summary.skippedCount += 1
await recordImportLine(batchId, {
...payload,
transactionId: exactDuplicate.id,
status: 'skipped',
reason: '外部订单号或记录指纹已存在',
rawData: row,
})
continue
}
const mergeCandidate = findMergeCandidate(existingTransactions, payload)
if (mergeCandidate) {
const merged = await mergeImportedTransaction(mergeCandidate.id, payload)
if (merged) {
summary.mergedCount += 1
const index = existingTransactions.findIndex((item) => item.id === merged.id)
if (index >= 0) existingTransactions[index] = merged
await recordImportLine(batchId, {
...payload,
transactionId: merged.id,
status: 'merged',
reason: '与现有记录合并',
rawData: row,
})
continue
}
}
const inserted = await insertTransaction(payload)
existingTransactions.unshift(inserted)
summary.insertedCount += 1
await recordImportLine(batchId, {
...payload,
transactionId: inserted.id,
status: 'inserted',
reason: '新记录已导入',
rawData: row,
})
}
await finalizeImportBatch(batchId, summary)
await saveDbToStore()
return summary
}
export const exportTransactionsAsCsv = async () => {
const transactions = await fetchTransactions()
const rows = [
[
'交易时间',
'金额',
'方向',
'商户',
'分类',
'备注',
'记录类型',
'资金来源',
'资金去向',
'AI 状态',
'来源',
'外部订单号',
],
...transactions.map((transaction) => [
transaction.date,
transaction.amount,
transaction.amount > 0 ? '收入' : transaction.amount < 0 ? '支出' : '零额',
transaction.merchant,
transaction.category,
transaction.note,
transaction.entryType,
transaction.fundSourceName || transaction.fundSourceType,
transaction.fundTargetName || transaction.fundTargetType,
transaction.aiStatus,
transaction.sourceType,
transaction.sourceOrderId,
]),
]
const content = `\uFEFF${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n')}`
downloadText(`echo-ledger-${new Date().toISOString().slice(0, 10)}.csv`, content, 'text/csv;charset=utf-8;')
return transactions.length
}
export const exportTransactionsAsJson = async () => {
const transactions = await fetchTransactions()
const content = JSON.stringify(
{
exportedAt: new Date().toISOString(),
count: transactions.length,
transactions,
},
null,
2,
)
downloadText(
`echo-ledger-${new Date().toISOString().slice(0, 10)}.json`,
content,
'application/json;charset=utf-8;',
)
return transactions.length
}

View File

@@ -32,7 +32,15 @@ const TRANSACTION_SELECT_FIELDS = `
ai_reason,
ai_status,
ai_model,
ai_normalized_merchant
ai_normalized_merchant,
source_type,
source_order_id,
source_merchant_order_id,
source_counterparty,
source_account,
source_payment_method,
source_record_hash,
import_batch_id
`
const normalizeStatus = (value) => {
@@ -66,6 +74,17 @@ const normalizeFlag = (value, fallback = false) => {
const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense')
const sanitizeSourceMetadata = (payload = {}, existing = null) => ({
sourceType: payload.sourceType ?? existing?.sourceType ?? '',
sourceOrderId: payload.sourceOrderId ?? existing?.sourceOrderId ?? '',
sourceMerchantOrderId: payload.sourceMerchantOrderId ?? existing?.sourceMerchantOrderId ?? '',
sourceCounterparty: payload.sourceCounterparty ?? existing?.sourceCounterparty ?? '',
sourceAccount: payload.sourceAccount ?? existing?.sourceAccount ?? '',
sourcePaymentMethod: payload.sourcePaymentMethod ?? existing?.sourcePaymentMethod ?? '',
sourceRecordHash: payload.sourceRecordHash ?? existing?.sourceRecordHash ?? '',
importBatchId: payload.importBatchId ?? existing?.importBatchId ?? '',
})
const buildLedgerDefaults = (payload = {}, existing = null) => {
const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0)
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
@@ -118,6 +137,14 @@ const mapRow = (row) => {
aiStatus: normalizeAiStatus(row.ai_status),
aiModel: row.ai_model || '',
aiNormalizedMerchant: row.ai_normalized_merchant || '',
sourceType: row.source_type || '',
sourceOrderId: row.source_order_id || '',
sourceMerchantOrderId: row.source_merchant_order_id || '',
sourceCounterparty: row.source_counterparty || '',
sourceAccount: row.source_account || '',
sourcePaymentMethod: row.source_payment_method || '',
sourceRecordHash: row.source_record_hash || '',
importBatchId: row.import_batch_id || '',
}
}
@@ -249,6 +276,7 @@ export const insertTransaction = async (payload) => {
const id = payload.id || uuidv4()
const amount = Number(payload.amount)
const ledger = buildLedgerDefaults(payload)
const sourceMeta = sanitizeSourceMetadata(payload)
const sanitized = {
merchant: payload.merchant?.trim() || 'Unknown',
category: payload.category?.trim() || 'Uncategorized',
@@ -257,6 +285,7 @@ export const insertTransaction = async (payload) => {
amount,
syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending,
...ledger,
...sourceMeta,
}
await db.run(
@@ -275,9 +304,17 @@ export const insertTransaction = async (payload) => {
fund_target_type,
fund_target_name,
impact_expense,
impact_income
impact_income,
source_type,
source_order_id,
source_merchant_order_id,
source_counterparty,
source_account,
source_payment_method,
source_record_hash,
import_batch_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[
id,
@@ -294,6 +331,14 @@ export const insertTransaction = async (payload) => {
sanitized.fundTargetName || null,
sanitized.impactExpense ? 1 : 0,
sanitized.impactIncome ? 1 : 0,
sanitized.sourceType || null,
sanitized.sourceOrderId || null,
sanitized.sourceMerchantOrderId || null,
sanitized.sourceCounterparty || null,
sanitized.sourceAccount || null,
sanitized.sourcePaymentMethod || null,
sanitized.sourceRecordHash || null,
sanitized.importBatchId || null,
],
)
@@ -310,6 +355,14 @@ export const insertTransaction = async (payload) => {
aiStatus: 'idle',
aiModel: '',
aiNormalizedMerchant: '',
sourceType: sanitized.sourceType,
sourceOrderId: sanitized.sourceOrderId,
sourceMerchantOrderId: sanitized.sourceMerchantOrderId,
sourceCounterparty: sanitized.sourceCounterparty,
sourceAccount: sanitized.sourceAccount,
sourcePaymentMethod: sanitized.sourcePaymentMethod,
sourceRecordHash: sanitized.sourceRecordHash,
importBatchId: sanitized.importBatchId,
}
}
@@ -319,10 +372,11 @@ export const updateTransaction = async (payload) => {
const db = await getDb()
const ledger = buildLedgerDefaults(payload, existing)
const sourceMeta = sanitizeSourceMetadata(payload, existing)
await db.run(
`
UPDATE transactions
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?, entry_type = ?, fund_source_type = ?, fund_source_name = ?, fund_target_type = ?, fund_target_name = ?, impact_expense = ?, impact_income = ?
SET amount = ?, merchant = ?, category = ?, date = ?, note = ?, sync_status = ?, entry_type = ?, fund_source_type = ?, fund_source_name = ?, fund_target_type = ?, fund_target_name = ?, impact_expense = ?, impact_income = ?, source_type = ?, source_order_id = ?, source_merchant_order_id = ?, source_counterparty = ?, source_account = ?, source_payment_method = ?, source_record_hash = ?, import_batch_id = ?
WHERE id = ?
`,
[
@@ -339,6 +393,14 @@ export const updateTransaction = async (payload) => {
ledger.fundTargetName || null,
ledger.impactExpense ? 1 : 0,
ledger.impactIncome ? 1 : 0,
sourceMeta.sourceType || null,
sourceMeta.sourceOrderId || null,
sourceMeta.sourceMerchantOrderId || null,
sourceMeta.sourceCounterparty || null,
sourceMeta.sourceAccount || null,
sourceMeta.sourcePaymentMethod || null,
sourceMeta.sourceRecordHash || null,
sourceMeta.importBatchId || null,
payload.id,
],
)
@@ -435,3 +497,51 @@ export const deleteTransaction = async (id) => {
await db.run(`DELETE FROM transactions WHERE id = ?`, [id])
await saveDbToStore()
}
export const mergeImportedTransaction = async (transactionId, payload) => {
const existing = await getTransactionById(transactionId)
if (!existing) return null
const mergedNote = [existing.note, payload.note]
.filter(Boolean)
.filter((value, index, list) => list.indexOf(value) === index)
.join(' | ')
.slice(0, 500)
const nextCategory =
existing.category === 'Uncategorized' || existing.category === 'Expense'
? payload.category || existing.category
: existing.category
return updateTransaction({
...existing,
id: transactionId,
category: nextCategory,
note: mergedNote,
fundSourceType:
existing.fundSourceType === 'cash' && payload.fundSourceType
? payload.fundSourceType
: existing.fundSourceType,
fundSourceName:
existing.fundSourceType === 'cash' && payload.fundSourceName
? payload.fundSourceName
: existing.fundSourceName,
fundTargetType:
existing.fundTargetType === 'merchant' && payload.fundTargetType
? payload.fundTargetType
: existing.fundTargetType,
fundTargetName:
(!existing.fundTargetName || existing.fundTargetName === existing.merchant) &&
payload.fundTargetName
? payload.fundTargetName
: existing.fundTargetName,
sourceType: payload.sourceType || existing.sourceType,
sourceOrderId: payload.sourceOrderId || existing.sourceOrderId,
sourceMerchantOrderId: payload.sourceMerchantOrderId || existing.sourceMerchantOrderId,
sourceCounterparty: payload.sourceCounterparty || existing.sourceCounterparty,
sourceAccount: payload.sourceAccount || existing.sourceAccount,
sourcePaymentMethod: payload.sourcePaymentMethod || existing.sourcePaymentMethod,
sourceRecordHash: payload.sourceRecordHash || existing.sourceRecordHash,
importBatchId: payload.importBatchId || existing.importBatchId,
})
}