238 lines
7.5 KiB
JavaScript
238 lines
7.5 KiB
JavaScript
const fallbackRules = [
|
||
// 支付宝消费:日常吃饭、网购等
|
||
{
|
||
id: 'alipay-expense',
|
||
label: '支付宝消费',
|
||
channels: ['支付宝', '支付', 'Alipay'],
|
||
keywords: ['支付', '扣款', '支出', '已消费', '成功支付'],
|
||
direction: 'expense',
|
||
defaultCategory: 'Food',
|
||
autoCapture: false,
|
||
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
|
||
},
|
||
// 支付宝收款 / 到账
|
||
{
|
||
id: 'alipay-income',
|
||
label: '支付宝收款',
|
||
channels: ['支付宝', '支付', 'Alipay'],
|
||
keywords: ['到账', '收入', '收款', '入账'],
|
||
direction: 'income',
|
||
defaultCategory: 'Income',
|
||
autoCapture: true,
|
||
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
|
||
},
|
||
// 微信消费
|
||
{
|
||
id: 'wechat-expense',
|
||
label: '微信消费',
|
||
channels: ['微信', '微信支付', 'WeChat'],
|
||
keywords: ['支付', '支出', '扣款', '消费成功'],
|
||
direction: 'expense',
|
||
defaultCategory: 'Groceries',
|
||
autoCapture: false,
|
||
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||
},
|
||
// 微信收款 / 转账入账
|
||
{
|
||
id: 'wechat-income',
|
||
label: '微信收款',
|
||
channels: ['微信', '微信支付', 'WeChat'],
|
||
keywords: ['收款', '到账', '入账', '转入'],
|
||
direction: 'income',
|
||
defaultCategory: 'Income',
|
||
autoCapture: true,
|
||
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||
},
|
||
// 银行工资 / 常规入账
|
||
{
|
||
id: 'bank-income',
|
||
label: '工资到账',
|
||
channels: [
|
||
'招商银行',
|
||
'中国银行',
|
||
'建设银行',
|
||
'工商银行',
|
||
'农业银行',
|
||
'交通银行',
|
||
'浦发银行',
|
||
'兴业银行',
|
||
'光大银行',
|
||
'中信银行',
|
||
'广发银行',
|
||
'平安银行',
|
||
],
|
||
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
|
||
direction: 'income',
|
||
defaultCategory: 'Income',
|
||
autoCapture: true,
|
||
merchantPattern: /(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||
},
|
||
// 银行消费 / 借记卡扣款
|
||
{
|
||
id: 'bank-expense',
|
||
label: '银行卡支出',
|
||
channels: [
|
||
'招商银行',
|
||
'中国银行',
|
||
'建设银行',
|
||
'工商银行',
|
||
'农业银行',
|
||
'交通银行',
|
||
'浦发银行',
|
||
'兴业银行',
|
||
'光大银行',
|
||
'中信银行',
|
||
'广发银行',
|
||
'平安银行',
|
||
'借记卡',
|
||
'信用卡',
|
||
],
|
||
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
|
||
direction: 'expense',
|
||
defaultCategory: 'Expense',
|
||
autoCapture: false,
|
||
merchantPattern: /(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
|
||
},
|
||
// 手机公交卡 / 乘车码扣款(地铁、公交等),按交通分类
|
||
{
|
||
id: 'transit-card-expense',
|
||
label: '公交出行',
|
||
channels: [
|
||
'公交',
|
||
'地铁',
|
||
'乘车码',
|
||
'交通联合',
|
||
'一卡通',
|
||
'北京一卡通',
|
||
'上海公共交通卡',
|
||
'深圳通',
|
||
'苏州公交卡',
|
||
'杭州通互联互通卡',
|
||
'Mi Pay',
|
||
'Huawei Pay',
|
||
'华为钱包',
|
||
'小米智能卡',
|
||
'小米钱包',
|
||
'OPPO 钱包',
|
||
],
|
||
keywords: ['扣款', '支出', '乘车', '刷卡', '过闸','行程中','地铁','公交','进站','出站'],
|
||
direction: 'expense',
|
||
defaultCategory: 'Transport',
|
||
autoCapture: true,
|
||
// 公交卡类通知里商户往往不重要,给一个统一名称
|
||
defaultMerchant: '公交/地铁出行',
|
||
},
|
||
]
|
||
|
||
let cachedRules = [...fallbackRules]
|
||
let remoteRuleFetcher = async () => []
|
||
|
||
/**
|
||
* 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数
|
||
* @param {(currentRules: Array) => Promise<Array>} fetcher
|
||
*/
|
||
export const setRemoteRuleFetcher = (fetcher) => {
|
||
remoteRuleFetcher = typeof fetcher === 'function' ? fetcher : remoteRuleFetcher
|
||
}
|
||
|
||
const dedupeRules = (rules) => {
|
||
const bucket = new Map()
|
||
fallbackRules.forEach((rule) => bucket.set(rule.id, { ...rule }))
|
||
rules.forEach((rule) => {
|
||
const prev = bucket.get(rule.id) || {}
|
||
bucket.set(rule.id, { ...prev, ...rule })
|
||
})
|
||
return Array.from(bucket.values())
|
||
}
|
||
|
||
export const loadNotificationRules = async () => {
|
||
try {
|
||
const remote = await remoteRuleFetcher(cachedRules)
|
||
if (Array.isArray(remote) && remote.length > 0) {
|
||
cachedRules = dedupeRules(remote)
|
||
}
|
||
} catch (err) {
|
||
cachedRules = [...cachedRules]
|
||
console.warn('[rules] 使用本地规则,远程规则获取失败:', err)
|
||
}
|
||
return cachedRules
|
||
}
|
||
|
||
export const getCachedNotificationRules = () => cachedRules
|
||
|
||
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
|
||
const extractAmount = (text = '') => {
|
||
const primary = text.match(
|
||
/(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元/,
|
||
)
|
||
const value = primary?.[1] || primary?.[2]
|
||
if (value) {
|
||
return Math.abs(parseFloat(value))
|
||
}
|
||
const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/)
|
||
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
|
||
}
|
||
|
||
const extractMerchant = (text, rule) => {
|
||
if (rule?.merchantPattern instanceof RegExp) {
|
||
const m = text.match(rule.merchantPattern)
|
||
if (m?.[1]) return m[1].trim()
|
||
}
|
||
const generic = text.match(/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/)
|
||
if (generic?.[1]) return generic[1].trim()
|
||
const parts = text.split(/:|:/)
|
||
if (parts.length > 1) {
|
||
const candidate = parts[1].split(/[,,\s]/)[0]
|
||
if (candidate) return candidate.trim()
|
||
}
|
||
return 'Unknown'
|
||
}
|
||
|
||
const matchRule = (notification, rule) => {
|
||
if (!rule) return false
|
||
const channel = notification?.channel || ''
|
||
const text = notification?.text || ''
|
||
const channelHit = !rule.channels?.length || rule.channels.some((item) => channel.includes(item))
|
||
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
|
||
return channelHit && keywordHit
|
||
}
|
||
|
||
/**
|
||
* 根据当前规则集生成交易草稿,并返回命中的规则信息
|
||
* @param {{ id: string, channel: string, text: string, createdAt: string }} notification
|
||
* @param {{ ruleSet?: Array, overrides?: object }} options
|
||
* @returns {{ transaction: import('../types/transaction').Transaction, rule?: object, requiresConfirmation: boolean }}
|
||
*/
|
||
export const transformNotificationToTransaction = (notification, options = {}) => {
|
||
const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules
|
||
const rule = ruleSet.find((item) => matchRule(notification, item))
|
||
const amountFromText = extractAmount(notification?.text)
|
||
const direction = options.direction || rule?.direction || 'expense'
|
||
const normalizedAmount =
|
||
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
|
||
|
||
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
|
||
if (merchant === 'Unknown' && rule?.defaultMerchant) {
|
||
merchant = rule.defaultMerchant
|
||
}
|
||
|
||
const date = options.date || notification?.createdAt || new Date().toISOString()
|
||
|
||
const transaction = {
|
||
id: options.id,
|
||
merchant,
|
||
category: options.category || rule?.defaultCategory || 'Uncategorized',
|
||
amount: Number.isFinite(normalizedAmount) ? normalizedAmount : 0,
|
||
date: new Date(date).toISOString(),
|
||
note: notification?.text || '',
|
||
syncStatus: 'pending',
|
||
}
|
||
|
||
const requiresConfirmation =
|
||
options.requiresConfirmation ??
|
||
!(rule?.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
|
||
|
||
return { transaction, rule, requiresConfirmation }
|
||
}
|
||
|