, * proposed_category_id: ?string, * new_category_name: ?string, * new_category_direction: ?string, * confidence: float, * }> $groups * @return array{rules_created: int, transactions_categorized: int} */ public function apply(User $user, array $groups, bool $applyToExisting): array { $groups = array_values(array_filter($groups, fn (array $group): bool => $group['conditions'] !== [])); if ($groups === []) { return ['rules_created' => 0, 'transactions_categorized' => 0]; } // Narrower rules (fewer matches) first, then higher confidence, so the // more specific rule gets the lower priority and wins the overlap. $groups = array_map(function (array $group) use ($user): array { $group['match_count'] = $this->matcher->countMatchingAny($user, $group['conditions']); return $group; }, $groups); usort($groups, fn (array $a, array $b): int => [$a['match_count'], -$a['confidence']] <=> [$b['match_count'], -$b['confidence']]); $priority = (int) AutomationRule::query()->where('user_id', $user->id)->max('priority'); $rulesCreated = 0; $categorized = 0; foreach ($groups as $group) { $rule = DB::transaction(function () use ($user, $group, &$priority): AutomationRule { $categoryId = $this->resolveCategoryId($user, $group); return AutomationRule::create([ 'user_id' => $user->id, 'title' => $this->title($group, $categoryId), 'priority' => ++$priority, 'rules_json' => $this->rulesJson($group['conditions']), 'action_category_id' => $categoryId, ]); }); $rulesCreated++; if ($applyToExisting) { $matches = $this->matcher->matchingAny($user, $group['conditions']); if ($matches->isNotEmpty()) { $categorized += $this->automationRules->applyRuleActionsToTransactions($matches, $rule); } } } return ['rules_created' => $rulesCreated, 'transactions_categorized' => $categorized]; } /** * Resolve the rule's target category, creating a proposed new category when * the group calls for one. * * @param array $group */ private function resolveCategoryId(User $user, array $group): string { if (! empty($group['proposed_category_id'])) { return (string) $group['proposed_category_id']; } $direction = ($group['new_category_direction'] ?? null) === CategoryCashflowDirection::Inflow->value ? CategoryCashflowDirection::Inflow : CategoryCashflowDirection::Outflow; $category = Category::query()->firstOrCreate( [ 'user_id' => $user->id, 'parent_id' => null, 'name' => $group['new_category_name'], ], [ 'type' => $direction === CategoryCashflowDirection::Inflow ? CategoryType::Income : CategoryType::Expense, 'cashflow_direction' => $direction, ], ); return $category->id; } /** * Build the OR'd JSON-Logic rule for the group's conditions. A single * condition stays flat; multiple are wrapped in an `or` (matching the shape * the settings rule editor produces and parses back). * * @param list $conditions * @return array */ private function rulesJson(array $conditions): array { $clauses = array_map(function (array $condition): array { $variable = ['var' => $condition['field']]; return $condition['operator'] === 'equals' ? ['==' => [$variable, $condition['token']]] : ['in' => [$condition['token'], $variable]]; }, $conditions); return count($clauses) === 1 ? $clauses[0] : ['or' => $clauses]; } /** * @param array $group */ private function title(array $group, string $categoryId): string { $categoryName = Category::query()->whereKey($categoryId)->value('name') ?? ''; $tokens = array_map(fn (array $condition): string => Str::title($condition['token']), $group['conditions']); $label = implode(', ', array_slice($tokens, 0, 3)); if (count($tokens) > 3) { $label .= '…'; } return trim($label.' → '.$categoryName); } }