merchantUnambiguous) { return null; } if ($outcome->confidence < (float) config('ai_categorization.rule_confidence')) { return null; } $key = $this->merchantKey($outcome->transaction); if ($key === null) { return null; } [$field, $token] = $key; $rule = $this->existingAiRule($outcome->transaction->user_id, $outcome->categoryId) ?? $this->createAiRule($outcome->transaction->user_id, $outcome->categoryId); $this->appendCondition($rule, $field, $token); $outcome->transaction->categorized_by_rule_id = $rule->id; $outcome->transaction->saveQuietly(); return $rule; } /** * @return array{0: string, 1: string}|null [field, token] */ private function merchantKey(Transaction $transaction): ?array { foreach (['creditor_name', 'debtor_name'] as $field) { $value = $this->normalize((string) ($transaction->{$field} ?? '')); if ($value !== '') { return [$field, $value]; } } return null; } private function existingAiRule(string $userId, string $categoryId): ?AutomationRule { return AutomationRule::query() ->where('user_id', $userId) ->where('action_category_id', $categoryId) ->origin(RuleOrigin::Ai) ->first(); } private function createAiRule(string $userId, string $categoryId): AutomationRule { $priority = (int) AutomationRule::query()->where('user_id', $userId)->max('priority'); return AutomationRule::create([ 'user_id' => $userId, 'title' => $this->title($categoryId, []), 'priority' => $priority + 1, 'origin' => RuleOrigin::Ai, 'rules_json' => [], 'action_category_id' => $categoryId, ]); } /** * Self-heal after a user corrects a transaction this ai-owned rule labeled: * drop the merchant condition(s) matching the transaction so the rule stops * forcing the wrong category on future transactions from that merchant. The * rule is deleted when no condition remains. */ public function forget(AutomationRule $rule, Transaction $transaction): void { $tokens = []; foreach (['creditor_name', 'debtor_name'] as $field) { $value = $this->normalize((string) ($transaction->{$field} ?? '')); if ($value !== '') { $tokens[$value] = true; } } if ($tokens === []) { return; } $clauses = $this->clauses($rule->rules_json); $remaining = array_values(array_filter($clauses, function (array $clause) use ($tokens): bool { $token = $clause['=='][1] ?? null; return ! (is_string($token) && isset($tokens[$token])); })); if (count($remaining) === count($clauses)) { return; } if ($remaining === []) { $rule->delete(); return; } $rule->rules_json = count($remaining) === 1 ? $remaining[0] : ['or' => $remaining]; $rule->title = $this->title((string) $rule->action_category_id, $this->tokens($remaining)); $rule->save(); } private function appendCondition(AutomationRule $rule, string $field, string $token): void { $clause = ['==' => [['var' => $field], $token]]; $clauses = $this->clauses($rule->rules_json); foreach ($clauses as $existing) { if ($existing == $clause) { return; } } $clauses[] = $clause; $rule->rules_json = count($clauses) === 1 ? $clauses[0] : ['or' => $clauses]; $rule->title = $this->title((string) $rule->action_category_id, $this->tokens($clauses)); $rule->save(); } /** * The individual condition clauses of a rule, normalised to a flat list * regardless of whether it is a single clause or an OR of several. * * @param mixed $rulesJson * @return list> */ private function clauses($rulesJson): array { if (! is_array($rulesJson) || $rulesJson === []) { return []; } if (isset($rulesJson['or']) && is_array($rulesJson['or'])) { return array_values($rulesJson['or']); } return [$rulesJson]; } /** * @param list> $clauses * @return list */ private function tokens(array $clauses): array { $tokens = []; foreach ($clauses as $clause) { $token = $clause['=='][1] ?? null; if (is_string($token)) { $tokens[] = $token; } } return $tokens; } /** * @param list $tokens */ private function title(string $categoryId, array $tokens): string { $categoryName = Category::query()->whereKey($categoryId)->value('name') ?? ''; if ($tokens === []) { return trim($categoryName.' (AI)'); } $label = implode(', ', array_map(fn (string $token): string => Str::title($token), array_slice($tokens, 0, 3))); if (count($tokens) > 3) { $label .= '…'; } return trim($label.' → '.$categoryName); } private function normalize(string $value): string { return trim(preg_replace('/\s+/', ' ', mb_strtolower($value)) ?? ''); } }