option('dry-run'); $userEmail = $this->option('user'); $userId = null; if ($isDryRun) { $this->warn('DRY RUN — no changes will be saved.'); } if ($userEmail) { $user = User::query()->where('email', $userEmail)->first(); if (! $user) { $this->error("User with email '{$userEmail}' not found."); return self::FAILURE; } $userId = $user->id; } $transactions = $this->reformatTransactions($formatter, $userId, $isDryRun); // User rules were written against the raw text ("description contains // /TXT/D|MERCADONA"), so rewriting descriptions without rewriting the // rules would silently stop them from ever matching again. $rules = $this->reformatRules($formatter, $userId, $isDryRun); $verb = $isDryRun ? 'would be reformatted' : 'reformatted'; $this->info("{$transactions} transaction(s) and {$rules} automation rule(s) {$verb}."); return self::SUCCESS; } private function reformatTransactions(TransactionDescriptionFormatter $formatter, ?string $userId, bool $isDryRun): int { $query = Transaction::query() ->with('account.bank') ->whereNull('description_iv') ->where('description', 'like', RemittanceTagFormatter::TAG.'%') ->when($userId, fn ($q) => $q->where('user_id', $userId)); $reformatted = 0; // chunkById: the update takes rows out of the result set, so a // page-offset chunk() would skip half of them. $query->chunkById(500, function ($transactions) use ($formatter, $isDryRun, &$reformatted): void { foreach ($transactions as $transaction) { $formatted = $formatter->format($transaction->description, $transaction->account?->bank?->name); if ($formatted['description'] === $transaction->description) { continue; } if ($this->output->isVerbose()) { $this->line(" {$transaction->description} → {$formatted['description']}"); } if (! $isDryRun) { // Quietly: the only listener on TransactionUpdated assigns // budgets from the category, labels, amount and date, none // of which this touches. $transaction->updateQuietly([ 'description' => $formatted['description'], 'original_description' => $transaction->original_description ?? $formatted['original_description'], ]); } $reformatted++; } }); return $reformatted; } private function reformatRules(TransactionDescriptionFormatter $formatter, ?string $userId, bool $isDryRun): int { $reformatted = 0; // No `like '%/TXT/%'` prefilter: whether the tag survives in the stored // JSON with its slashes escaped depends on how the rule was written, so // the formatter — not a fragile SQL pattern — decides what is tagged. AutomationRule::query() ->when($userId, fn ($q) => $q->where('user_id', $userId)) ->chunkById(500, function ($rules) use ($formatter, $isDryRun, &$reformatted): void { foreach ($rules as $rule) { // The column holds the rule either as an array or as a JSON // string; whichever it was has to survive the rewrite. $stored = $rule->rules_json; $decoded = is_string($stored) ? json_decode($stored, true) : $stored; if (! is_array($decoded)) { continue; } $rewritten = $this->rewriteTaggedValues($decoded, $formatter); if ($rewritten === $decoded) { continue; } if ($this->output->isVerbose()) { $this->line(" rule {$rule->id}: ".json_encode($rewritten)); } if (! $isDryRun) { $rule->rules_json = is_string($stored) ? json_encode($rewritten) : $rewritten; $rule->save(); } $reformatted++; } }); return $reformatted; } /** * Run every string literal in a rule tree through the formatter, so the * text a rule matches on is normalized the same way descriptions now are. * Untagged literals — operators, variable names, plain merchant text — * come back unchanged. * * @param array $node * @return array */ private function rewriteTaggedValues(array $node, TransactionDescriptionFormatter $formatter): array { foreach ($node as $key => $value) { if (is_array($value)) { $node[$key] = $this->rewriteTaggedValues($value, $formatter); continue; } if (is_string($value)) { $node[$key] = $formatter->format($value, null)['description']; } } return $node; } }