Files
echo/src/services/notificationRuleService.js

224 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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