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 } }