whisper-money/resources/js/lib/rule-engine.ts

260 lines
7.9 KiB
TypeScript

import { consoleDebug } from '@/lib/debug';
import type { Account, Bank } from '@/types/account';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { DecryptedTransaction } from '@/types/transaction';
import type { UUID } from '@/types/uuid';
import jsonLogic from 'json-logic-js';
export interface RuleEvaluationResult {
rule: AutomationRule;
categoryId: UUID | null;
note: string | null;
noteIv: string | null;
}
export interface TransactionData {
description: string;
amount: number;
transaction_date: string;
bank_name: string;
account_name: string;
category: string | null;
notes: string | null;
}
function normalizeRuleJson(rulesJson: unknown): unknown {
if (typeof rulesJson === 'string') {
return rulesJson.toLowerCase();
}
if (Array.isArray(rulesJson)) {
return rulesJson.map((item, index) => {
if (index === 0 && typeof item === 'string') {
return item.toLowerCase();
}
if (
typeof item === 'object' &&
item !== null &&
'var' in item &&
(item.var === 'description' || item.var === 'notes')
) {
return item;
}
return normalizeRuleJson(item);
});
}
if (typeof rulesJson === 'object' && rulesJson !== null) {
const normalized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(rulesJson)) {
normalized[key] = normalizeRuleJson(value);
}
return normalized;
}
return rulesJson;
}
export function prepareTransactionData(
transaction: DecryptedTransaction,
accounts: Account[],
banks: Bank[],
categories: Category[],
): TransactionData {
const account = accounts.find((a) => a.id === transaction.account_id);
const bank = account?.bank?.id
? banks.find((b) => b.id === account.bank.id)
: undefined;
const category = transaction.category_id
? categories.find((c) => c.id === transaction.category_id)
: null;
const normalizeWhitespace = (str: string): string => {
return str.trim().replace(/\s+/g, ' ');
};
return {
description: normalizeWhitespace((transaction.decryptedDescription || '').toLowerCase()),
amount: transaction.amount / 100,
transaction_date: transaction.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',
category: category?.name || null,
notes: transaction.decryptedNotes ? normalizeWhitespace(transaction.decryptedNotes.toLowerCase()) : null,
};
}
export function evaluateRules(
transaction: DecryptedTransaction,
rules: AutomationRule[],
categories: Category[],
accounts: Account[],
banks: Bank[],
): RuleEvaluationResult | null {
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
const transactionData = prepareTransactionData(
transaction,
accounts,
banks,
categories,
);
consoleDebug('[Rule Engine] Transaction data prepared:', transactionData);
consoleDebug(`[Rule Engine] Evaluating ${sortedRules.length} rules`);
for (const rule of sortedRules) {
try {
consoleDebug(
`[Rule Engine] Evaluating rule #${rule.id}: "${rule.title}"`,
);
consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json);
const normalizedRulesJson = normalizeRuleJson(rule.rules_json);
consoleDebug(
'[Rule Engine] Normalized Rule JSON:',
normalizedRulesJson,
);
const result = jsonLogic.apply(
normalizedRulesJson,
transactionData,
);
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);
if (result === true) {
consoleDebug(`[Rule Engine] ✓ Rule #${rule.id} matched!`);
return {
rule,
categoryId: rule.action_category_id,
note: rule.action_note,
noteIv: rule.action_note_iv,
};
}
} catch (error) {
consoleDebug(
`[Rule Engine] ❌ Error evaluating rule ${rule.id}:`,
error,
);
console.error(`Error evaluating rule ${rule.id}:`, error);
}
}
consoleDebug('[Rule Engine] No rules matched');
return null;
}
export function evaluateRulesForTransactions(
transactions: DecryptedTransaction[],
rules: AutomationRule[],
categories: Category[],
accounts: Account[],
banks: Bank[],
): Map<string, RuleEvaluationResult> {
const results = new Map<string, RuleEvaluationResult>();
for (const transaction of transactions) {
const result = evaluateRules(
transaction,
rules,
categories,
accounts,
banks,
);
if (result) {
results.set(transaction.id, result);
}
}
return results;
}
export interface NewTransactionData {
description: string;
amount: number;
transaction_date: string;
account_id: UUID;
notes?: string;
}
export function evaluateRulesForNewTransaction(
transactionData: NewTransactionData,
rules: AutomationRule[],
categories: Category[],
accounts: Account[],
banks: Bank[],
): RuleEvaluationResult | null {
if (!rules || !categories || !accounts || !banks) {
consoleDebug('[Rule Engine] Missing required data for rule evaluation');
return null;
}
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
const account = accounts.find((a) => a.id === transactionData.account_id);
const bank = account?.bank?.id
? banks.find((b) => b.id === account.bank.id)
: undefined;
const normalizeWhitespace = (str: string): string => {
return str.trim().replace(/\s+/g, ' ');
};
const preparedData: TransactionData = {
description: normalizeWhitespace(transactionData.description.toLowerCase()),
amount: transactionData.amount,
transaction_date: transactionData.transaction_date,
bank_name: bank?.name || '',
account_name: account?.name || '',
category: null,
notes: transactionData.notes ? normalizeWhitespace(transactionData.notes.toLowerCase()) : null,
};
consoleDebug('[Rule Engine] Evaluating new transaction data:', preparedData);
consoleDebug(`[Rule Engine] Evaluating ${sortedRules.length} rules`);
for (const rule of sortedRules) {
try {
consoleDebug(
`[Rule Engine] Evaluating rule #${rule.id}: "${rule.title}"`,
);
consoleDebug('[Rule Engine] Rule JSON:', rule.rules_json);
const normalizedRulesJson = normalizeRuleJson(rule.rules_json);
consoleDebug(
'[Rule Engine] Normalized Rule JSON:',
normalizedRulesJson,
);
const result = jsonLogic.apply(
normalizedRulesJson,
preparedData,
);
consoleDebug(`[Rule Engine] Rule #${rule.id} result:`, result);
if (result === true) {
consoleDebug(`[Rule Engine] ✓ Rule #${rule.id} matched!`);
return {
rule,
categoryId: rule.action_category_id,
note: rule.action_note,
noteIv: rule.action_note_iv,
};
}
} catch (error) {
consoleDebug(
`[Rule Engine] ❌ Error evaluating rule ${rule.id}:`,
error,
);
console.error(`Error evaluating rule ${rule.id}:`, error);
}
}
consoleDebug('[Rule Engine] No rules matched');
return null;
}