224 lines
7.3 KiB
JavaScript
224 lines
7.3 KiB
JavaScript
import { DEFAULT_LEDGER_BY_ENTRY_TYPE } from '../config/ledger.js'
|
||
import notificationRulesSource from '../config/notificationRules.js'
|
||
|
||
const fallbackRules = notificationRulesSource.map((rule) => ({
|
||
...rule,
|
||
merchantPattern: rule.merchantPattern?.pattern
|
||
? new RegExp(rule.merchantPattern.pattern, rule.merchantPattern.flags || '')
|
||
: null,
|
||
}))
|
||
|
||
const amountPatterns = [
|
||
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
|
||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/,
|
||
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
|
||
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
|
||
]
|
||
|
||
let cachedRules = [...fallbackRules]
|
||
let remoteRuleFetcher = async () => []
|
||
|
||
const buildLedgerPayload = (rule, notification, merchant) => {
|
||
const entryType = rule?.entryType || 'expense'
|
||
const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense
|
||
const channelName = notification?.channel || ''
|
||
|
||
const resolveName = (field, fromChannelFlag) => {
|
||
if (rule?.[fromChannelFlag] && channelName) return channelName
|
||
return rule?.[field] || defaults[field] || ''
|
||
}
|
||
|
||
return {
|
||
entryType,
|
||
fundSourceType: rule?.fundSourceType || defaults.fundSourceType,
|
||
fundSourceName: resolveName('fundSourceName', 'fundSourceNameFromChannel'),
|
||
fundTargetType: rule?.fundTargetType || defaults.fundTargetType,
|
||
fundTargetName:
|
||
resolveName('fundTargetName', 'fundTargetNameFromChannel') ||
|
||
(entryType === 'expense' ? merchant : defaults.fundTargetName),
|
||
impactExpense: rule?.impactExpense ?? defaults.impactExpense,
|
||
impactIncome: rule?.impactIncome ?? defaults.impactIncome,
|
||
}
|
||
}
|
||
|
||
const buildEmptyTransaction = (notification, options = {}) => ({
|
||
id: options.id,
|
||
merchant: options.merchant || 'Unknown',
|
||
category: options.category || 'Uncategorized',
|
||
amount: 0,
|
||
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
|
||
note: notification?.text || '',
|
||
syncStatus: 'pending',
|
||
...DEFAULT_LEDGER_BY_ENTRY_TYPE.expense,
|
||
})
|
||
|
||
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] using cached fallback rules because remote sync failed:', err)
|
||
}
|
||
return cachedRules
|
||
}
|
||
|
||
export const getCachedNotificationRules = () => cachedRules
|
||
|
||
const extractAmount = (text = '') => {
|
||
const normalized = String(text).replace(/,/g, '').trim()
|
||
|
||
for (const pattern of amountPatterns) {
|
||
const match = normalized.match(pattern)
|
||
const value = match?.[1]
|
||
if (!value) continue
|
||
const parsed = Math.abs(Number.parseFloat(value))
|
||
if (Number.isFinite(parsed) && parsed > 0) {
|
||
return parsed
|
||
}
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
const looksLikeAmount = (raw = '') => {
|
||
const value = String(raw).trim()
|
||
if (!value) return false
|
||
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value)
|
||
}
|
||
|
||
const hasTransitRouteAmountFallback = (rule, text = '') => {
|
||
if (rule?.id !== 'transit-card-expense') return false
|
||
return /[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[::]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)/.test(
|
||
text,
|
||
)
|
||
}
|
||
|
||
const extractMerchant = (text, rule) => {
|
||
const content = text || ''
|
||
|
||
if (rule?.merchantPattern instanceof RegExp) {
|
||
const matched = content.match(rule.merchantPattern)
|
||
if (matched?.[1]) return matched[1].trim()
|
||
}
|
||
|
||
const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)
|
||
if (routeMatch?.[1]) {
|
||
return routeMatch[1].trim()
|
||
}
|
||
|
||
const genericPatterns = [
|
||
/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
|
||
/商户[::]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
|
||
]
|
||
|
||
for (const pattern of genericPatterns) {
|
||
const matched = content.match(pattern)
|
||
if (matched?.[1]) return matched[1].trim()
|
||
}
|
||
|
||
const parenMatch = content.match(/\(([^)]+)\)/)
|
||
if (parenMatch?.[1]) {
|
||
const inner = parenMatch[1]
|
||
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-\s]*([^\d元¥¥]+)$/)
|
||
if (payMatch?.[1]) {
|
||
const candidate = payMatch[1].trim()
|
||
if (candidate && !looksLikeAmount(candidate)) {
|
||
return candidate
|
||
}
|
||
}
|
||
}
|
||
|
||
const parts = content.split(/:|:/)
|
||
if (parts.length > 1) {
|
||
const candidate = parts[1].split(/[,,\s]/)[0]
|
||
if (candidate && !looksLikeAmount(candidate)) return candidate.trim()
|
||
}
|
||
|
||
return 'Unknown'
|
||
}
|
||
|
||
const matchRule = (notification, rule) => {
|
||
if (!rule || rule.enabled === false) return false
|
||
|
||
const channel = notification?.channel || ''
|
||
const text = notification?.text || ''
|
||
const channelHit =
|
||
!rule.channels?.length ||
|
||
rule.channels.some((item) => channel.includes(item)) ||
|
||
(!channel && rule.channels.some((item) => text.includes(item)))
|
||
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
|
||
const requiredTextHit =
|
||
!rule.requiredTextPatterns?.length ||
|
||
rule.requiredTextPatterns.some((word) => text.includes(word)) ||
|
||
hasTransitRouteAmountFallback(rule, text)
|
||
|
||
return channelHit && keywordHit && requiredTextHit
|
||
}
|
||
|
||
export const transformNotificationToTransaction = (notification, options = {}) => {
|
||
const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules
|
||
const rule = ruleSet.find((item) => matchRule(notification, item))
|
||
|
||
if (!rule) {
|
||
return {
|
||
transaction: buildEmptyTransaction(notification, options),
|
||
rule: undefined,
|
||
requiresConfirmation: false,
|
||
}
|
||
}
|
||
|
||
const amountFromText = extractAmount(notification?.text || '')
|
||
if (amountFromText <= 0) {
|
||
return {
|
||
transaction: buildEmptyTransaction(notification, options),
|
||
rule: undefined,
|
||
requiresConfirmation: false,
|
||
}
|
||
}
|
||
|
||
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 ledger = buildLedgerPayload(rule, notification, merchant)
|
||
|
||
const transaction = {
|
||
id: options.id,
|
||
merchant,
|
||
category: options.category || rule.defaultCategory || 'Uncategorized',
|
||
amount: normalizedAmount,
|
||
date: new Date(date).toISOString(),
|
||
note: notification?.text || '',
|
||
syncStatus: 'pending',
|
||
...ledger,
|
||
}
|
||
|
||
const requiresConfirmation =
|
||
options.requiresConfirmation ??
|
||
!(rule.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
|
||
|
||
return { transaction, rule, requiresConfirmation }
|
||
}
|