Apply automation rules to existing transactions (#413)

## Summary
- add apply-to-existing-transactions flow for automation rules
- preview matching transactions with uncategorized filter and infinite
scroll
- apply rule actions sync or via queued job with progress polling
- fix duplicate preview rows and repeat apply prompt when labels change

## Tests
- vendor/bin/pint --dirty --format agent
- npm test -- --run
resources/js/components/automation-rules/post-save-apply-rule-prompt.test.ts
resources/js/components/automation-rules/apply-automation-rule-flow.test.ts
- php artisan test --compact
tests/Feature/AutomationRuleApplicationTest.php

## Video

https://github.com/user-attachments/assets/e74f5e58-e582-4fb6-b6d4-2702398804b7
This commit is contained in:
Víctor Falcón 2026-05-22 07:36:18 +01:00 committed by GitHub
parent 933dfdeb1b
commit 9d7a91dcd0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 3301 additions and 1844 deletions

View File

@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ApplyAutomationRuleRequest;
use App\Jobs\ApplySingleAutomationRuleJob;
use App\Models\AutomationRule;
use App\Models\Transaction;
use App\Services\AutomationRuleService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class AutomationRuleApplicationController extends Controller
{
use AuthorizesRequests;
private const SYNC_THRESHOLD = 100;
private const MATCHES_CACHE_TTL_MINUTES = 15;
private const PER_PAGE_DEFAULT = 50;
private const PER_PAGE_MAX = 100;
/**
* Return paginated transactions matching this rule.
*/
public function matches(
Request $request,
AutomationRule $automationRule,
AutomationRuleService $service,
): JsonResponse {
$this->authorize('update', $automationRule);
$onlyUncategorized = $request->boolean('only_uncategorized', true);
$offset = max(0, (int) $request->integer('offset', 0));
$perPage = min(self::PER_PAGE_MAX, max(1, (int) $request->integer('per_page', self::PER_PAGE_DEFAULT)));
$matchingIds = $this->resolveMatchingIds($automationRule, $service, $onlyUncategorized);
$total = count($matchingIds);
$pageIds = array_slice($matchingIds, $offset, $perPage);
$transactions = Transaction::query()
->whereIn('id', $pageIds)
->with(['account:id,name,bank_id', 'account.bank:id,name', 'category:id,name,icon,color', 'labels:id,name,color'])
->orderByDesc('transaction_date')
->orderByDesc('created_at')
->get();
$byId = $transactions->keyBy('id');
$ordered = collect($pageIds)
->map(fn (string $id) => $byId->get($id))
->filter()
->values();
$nextOffset = $offset + $transactions->count();
return response()->json([
'data' => $ordered,
'total' => $total,
'next_offset' => $nextOffset < $total ? $nextOffset : null,
]);
}
/**
* Apply the rule's actions to all matching transactions.
*
* Runs synchronously when the match count is below the threshold, otherwise
* dispatches a queued job and returns a job id for status polling.
*/
public function apply(
ApplyAutomationRuleRequest $request,
AutomationRule $automationRule,
AutomationRuleService $service,
): JsonResponse {
$this->authorize('update', $automationRule);
$automationRule->loadMissing('labels');
$onlyUncategorized = (bool) $request->boolean('only_uncategorized', true);
$matchingIds = $this->resolveMatchingIds($automationRule, $service, $onlyUncategorized);
$total = count($matchingIds);
if ($total === 0) {
return response()->json([
'status' => 'done',
'processed' => 0,
'total' => 0,
'applied' => 0,
'updated' => 0,
]);
}
if ($total <= self::SYNC_THRESHOLD) {
$changed = 0;
$transactions = Transaction::query()
->where('user_id', $automationRule->user_id)
->whereIn('id', $matchingIds)
->whereNull('description_iv')
->with(['account.bank', 'category', 'labels'])
->get();
foreach ($transactions as $transaction) {
if ($service->applyRuleActions($transaction, $automationRule)) {
$changed++;
}
}
$applied = $transactions->count();
$this->forgetMatchesCache($automationRule, $onlyUncategorized);
return response()->json([
'status' => 'done',
'processed' => $applied,
'total' => $total,
'applied' => $applied,
'updated' => $changed,
]);
}
$jobId = (string) Str::uuid();
Cache::put(
ApplySingleAutomationRuleJob::cacheKeyForJobId($jobId),
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0, 'updated' => 0],
now()->addHour(),
);
ApplySingleAutomationRuleJob::dispatch($automationRule, $jobId, $matchingIds);
$this->forgetMatchesCache($automationRule, $onlyUncategorized);
return response()->json([
'job_id' => $jobId,
'total' => $total,
], 202);
}
/**
* Return progress for a running apply job.
*/
public function status(Request $request, string $jobId): JsonResponse
{
$progress = Cache::get(ApplySingleAutomationRuleJob::cacheKeyForJobId($jobId));
if ($progress === null) {
return response()->json(['message' => 'Job not found.'], 404);
}
return response()->json($progress);
}
/**
* Resolve and cache the list of transaction IDs matching this rule.
*
* @return array<int, string>
*/
private function resolveMatchingIds(
AutomationRule $rule,
AutomationRuleService $service,
bool $onlyUncategorized,
): array {
$cacheKey = $this->matchesCacheKey($rule, $onlyUncategorized);
$cached = Cache::get($cacheKey);
if (is_array($cached)) {
return array_values(array_unique($cached));
}
$rule->loadMissing('labels');
$ids = [];
Transaction::query()
->where('user_id', $rule->user_id)
->whereNull('description_iv')
->with(['account.bank', 'category', 'labels'])
->orderByDesc('transaction_date')
->orderByDesc('created_at')
->chunkById(500, function ($transactions) use ($rule, $service, $onlyUncategorized, &$ids) {
foreach ($transactions as $transaction) {
if ($onlyUncategorized && $service->shouldSkipForOnlyUncategorized($rule, $transaction)) {
continue;
}
if ($service->ruleMatches($rule, $transaction)) {
$ids[] = $transaction->id;
}
}
});
$ids = array_values(array_unique($ids));
Cache::put($cacheKey, $ids, now()->addMinutes(self::MATCHES_CACHE_TTL_MINUTES));
return $ids;
}
private function matchesCacheKey(AutomationRule $rule, bool $onlyUncategorized): string
{
$flag = $onlyUncategorized ? '1' : '0';
$stamp = $rule->updated_at?->getTimestamp() ?? 0;
return "automation_rule_matches:{$rule->user_id}:{$rule->id}:{$flag}:{$stamp}";
}
private function forgetMatchesCache(AutomationRule $rule, bool $onlyUncategorized): void
{
Cache::forget($this->matchesCacheKey($rule, $onlyUncategorized));
}
}

View File

@ -8,6 +8,7 @@ use App\Http\Requests\Settings\UpdateAutomationRuleRequest;
use App\Models\AutomationRule;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
@ -20,15 +21,7 @@ class AutomationRuleController extends Controller
*/
public function index(): Response
{
$rules = auth()->user()
->automationRules()
->with(['category:id,name,icon,color', 'labels:id,name,color'])
->orderBy('priority')
->get(['id', 'title', 'priority', 'rules_json', 'action_category_id', 'action_note', 'action_note_iv']);
return Inertia::render('settings/automation-rules', [
'automationRules' => $rules,
]);
return Inertia::render('settings/automation-rules');
}
/**
@ -47,7 +40,10 @@ class AutomationRuleController extends Controller
$rule->touch();
}
return back();
return back()->with([
'saved_automation_rule_id' => $rule->id,
'saved_automation_rule_token' => (string) Str::uuid(),
]);
}
/**
@ -65,7 +61,10 @@ class AutomationRuleController extends Controller
$automationRule->labels()->sync($labelIds);
$automationRule->touch();
return back();
return back()->with([
'saved_automation_rule_id' => $automationRule->id,
'saved_automation_rule_token' => (string) Str::uuid(),
]);
}
/**

View File

@ -69,6 +69,8 @@ class HandleInertiaRequests extends Middleware
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
'saved_automation_rule_id' => $request->session()->get('saved_automation_rule_id'),
'saved_automation_rule_token' => $request->session()->get('saved_automation_rule_token'),
],
'name' => config('app.name'),
'appUrl' => config('app.url'),

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class ApplyAutomationRuleRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'only_uncategorized' => ['sometimes', 'boolean'],
];
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Jobs;
use App\Models\AutomationRule;
use App\Models\Transaction;
use App\Services\AutomationRuleService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
class ApplySingleAutomationRuleJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 600;
/**
* @param array<int, string> $transactionIds
*/
public function __construct(
public AutomationRule $rule,
public string $jobId,
public array $transactionIds,
) {}
public function handle(AutomationRuleService $service): void
{
$rule = $this->rule->loadMissing('labels');
$total = count($this->transactionIds);
$this->updateProgress(status: 'processing', processed: 0, total: $total, applied: 0, updated: 0);
$processed = 0;
$applied = 0;
$changed = 0;
Transaction::query()
->where('user_id', $rule->user_id)
->whereIn('id', $this->transactionIds)
->whereNull('description_iv')
->with(['account.bank', 'category', 'labels'])
->chunkById(100, function ($transactions) use ($service, $rule, $total, &$processed, &$applied, &$changed) {
foreach ($transactions as $transaction) {
if ($service->applyRuleActions($transaction, $rule)) {
$changed++;
}
$applied++;
$processed++;
$this->updateProgress(status: 'processing', processed: $processed, total: $total, applied: $applied, updated: $changed);
}
});
$this->updateProgress(status: 'done', processed: $processed, total: $total, applied: $applied, updated: $changed);
}
public function failed(\Throwable $exception): void
{
$cached = Cache::get($this->cacheKey());
$this->updateProgress(
status: 'failed',
processed: $cached['processed'] ?? 0,
total: $cached['total'] ?? 0,
applied: $cached['applied'] ?? 0,
updated: $cached['updated'] ?? 0,
);
}
public static function cacheKeyForJobId(string $jobId): string
{
return "apply_automation_rule_job_{$jobId}";
}
private function cacheKey(): string
{
return self::cacheKeyForJobId($this->jobId);
}
/**
* @param 'pending'|'processing'|'done'|'failed' $status
*/
private function updateProgress(string $status, int $processed, int $total, int $applied, int $updated): void
{
Cache::put($this->cacheKey(), [
'status' => $status,
'processed' => $processed,
'total' => $total,
'applied' => $applied,
'updated' => $updated,
], now()->addHour());
}
}

View File

@ -33,6 +33,64 @@ class AutomationRuleService
}
}
/**
* Determine whether a single rule's conditions match the transaction.
*
* Encrypted transactions are skipped because rule evaluation reads the
* plaintext description and notes which the server cannot access.
*/
public function ruleMatches(AutomationRule $rule, Transaction $transaction): bool
{
if ($transaction->description_iv !== null) {
return false;
}
$transactionData = $this->prepareTransactionData($transaction);
try {
$normalizedRulesJson = $this->normalizeRuleJson($rule->rules_json);
return JsonLogic::apply($normalizedRulesJson, $transactionData) === true;
} catch (\Throwable) {
return false;
}
}
/**
* Apply a single rule's actions to the transaction, ignoring rule
* priority and other rules. The transaction must already match the rule.
*
* Returns true if any attribute of the transaction was changed.
*/
public function applyRuleActions(Transaction $transaction, AutomationRule $rule): bool
{
return $this->applyActions($transaction, $rule);
}
/**
* Whether a transaction should be skipped when "only uncategorized" is on.
*
* For category-setting rules: skip if the transaction already has a category.
* For label-only rules: skip if the transaction already has every label
* the rule would add (otherwise a label-only rule could never apply).
*/
public function shouldSkipForOnlyUncategorized(AutomationRule $rule, Transaction $transaction): bool
{
if ($rule->action_category_id !== null) {
return $transaction->category_id !== null;
}
$ruleLabelIds = $rule->labels->pluck('id')->all();
if (empty($ruleLabelIds)) {
return false;
}
$transactionLabelIds = $transaction->labels->pluck('id')->all();
return empty(array_diff($ruleLabelIds, $transactionLabelIds));
}
/**
* @return array{description: string, amount: float, transaction_date: string, bank_name: string, account_name: string, category: string|null, notes: string|null}
*/
@ -83,13 +141,14 @@ class AutomationRuleService
return null;
}
private function applyActions(Transaction $transaction, AutomationRule $rule): void
private function applyActions(Transaction $transaction, AutomationRule $rule): bool
{
$dirty = false;
$changed = false;
if ($rule->action_category_id) {
if ($rule->action_category_id !== null
&& $transaction->category_id !== $rule->action_category_id) {
$transaction->category_id = $rule->action_category_id;
$dirty = true;
$changed = true;
}
// Only apply plain (unencrypted) notes — encrypted notes require the user's key
@ -101,18 +160,23 @@ class AutomationRuleService
$transaction->notes = $existingNotes
? $existingNotes."\n".$ruleNote
: $ruleNote;
$dirty = true;
$changed = true;
}
}
if ($dirty) {
if ($transaction->isDirty()) {
$transaction->saveQuietly();
}
$labelIds = $rule->labels->pluck('id')->all();
if (! empty($labelIds)) {
$transaction->labels()->syncWithoutDetaching($labelIds);
$result = $transaction->labels()->syncWithoutDetaching($labelIds);
if (! empty($result['attached'])) {
$changed = true;
}
}
return $changed;
}
private function noteAlreadyPresent(string $existingNotes, string $note): bool

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,50 @@
import { ApplyAutomationRuleFlow } from '@/components/automation-rules/apply-automation-rule-flow';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { AutomationRule } from '@/types/automation-rule';
import { __ } from '@/utils/i18n';
interface ApplyAutomationRuleDialogProps {
rule: AutomationRule;
open: boolean;
onOpenChange: (open: boolean) => void;
initialStep?: 'prompt' | 'preview';
}
export function ApplyAutomationRuleDialog({
rule,
open,
onOpenChange,
initialStep = 'preview',
}: ApplyAutomationRuleDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-x-hidden sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>
{__('Apply rule to existing transactions')}
</DialogTitle>
<DialogDescription>
{initialStep === 'prompt'
? __(
'The rule was saved. Optionally apply it to your existing transactions.',
)
: __(
'Preview the transactions this rule will affect and apply its actions.',
)}
</DialogDescription>
</DialogHeader>
<ApplyAutomationRuleFlow
rule={rule}
initialStep={initialStep}
onClose={() => onOpenChange(false)}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,43 @@
import { mergeUniqueTransactions } from '@/components/automation-rules/apply-automation-rule-flow';
import type { ServerTransaction } from '@/types/transaction';
import { describe, expect, it } from 'vitest';
const transaction = (id: string, description: string): ServerTransaction => ({
id,
user_id: 'user-id',
account_id: 'account-id',
category_id: null,
description,
description_iv: null,
transaction_date: '2026-05-22',
amount: -1000,
currency_code: 'USD',
notes: null,
notes_iv: null,
source: 'imported',
created_at: '2026-05-22T00:00:00.000Z',
updated_at: '2026-05-22T00:00:00.000Z',
});
describe('mergeUniqueTransactions', () => {
it('removes duplicates when replacing the preview list', () => {
const grocery = transaction('tx-1', 'Grocery');
expect(
mergeUniqueTransactions([], [grocery, grocery], true).map(
(item) => item.id,
),
).toEqual(['tx-1']);
});
it('keeps existing rows and ignores duplicate paginated rows', () => {
const grocery = transaction('tx-1', 'Grocery');
const coffee = transaction('tx-2', 'Coffee');
expect(
mergeUniqueTransactions([grocery], [grocery, coffee], false).map(
(item) => item.id,
),
).toEqual(['tx-1', 'tx-2']);
});
});

View File

@ -0,0 +1,477 @@
import {
apply as applyRoute,
matches as matchesRoute,
status as statusRoute,
} from '@/actions/App/Http/Controllers/Settings/AutomationRuleApplicationController';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import type { AutomationRule } from '@/types/automation-rule';
import type { ServerTransaction } from '@/types/transaction';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { format, parseISO } from 'date-fns';
import { ArrowLeft, ArrowRight, Loader2, Sparkles } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
type ApplyResult =
| {
status: 'done';
processed: number;
total: number;
applied: number;
updated: number;
}
| {
status: 'pending' | 'processing';
processed: number;
total: number;
applied: number;
updated: number;
}
| {
status: 'failed';
processed: number;
total: number;
applied: number;
updated: number;
};
interface MatchesResponse {
data: ServerTransaction[];
total: number;
next_offset: number | null;
}
interface ApplyResponse {
status?: 'done';
processed?: number;
total?: number;
applied?: number;
updated?: number;
job_id?: string;
}
type Step = 'prompt' | 'preview' | 'applying';
interface ApplyAutomationRuleFlowProps {
rule: AutomationRule;
/** Whether we should show the initial "do you want to apply" prompt or jump straight into preview. */
initialStep?: Extract<Step, 'prompt' | 'preview'>;
onBackToEdit?: () => void;
onClose: () => void;
onApplied?: () => void;
}
export function ApplyAutomationRuleFlow({
rule,
initialStep = 'prompt',
onBackToEdit,
onClose,
onApplied,
}: ApplyAutomationRuleFlowProps) {
const [step, setStep] = useState<Step>(initialStep);
const [onlyUncategorized, setOnlyUncategorized] = useState(true);
const [transactions, setTransactions] = useState<ServerTransaction[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [nextOffset, setNextOffset] = useState<number | null>(null);
const [loadingPage, setLoadingPage] = useState(false);
const [initialLoad, setInitialLoad] = useState(false);
const [applying, setApplying] = useState(false);
const [progress, setProgress] = useState<ApplyResult | null>(null);
const sentinelRef = useRef<HTMLLIElement | null>(null);
const activeOnlyUncategorizedRef = useRef(onlyUncategorized);
const loadingOffsetsRef = useRef<Set<number>>(new Set());
const loadedOffsetsRef = useRef<Set<number>>(new Set());
const fetchPage = useCallback(
async (offset: number, replace: boolean) => {
const requestedOnlyUncategorized = onlyUncategorized;
if (
!replace &&
(loadingOffsetsRef.current.has(offset) ||
loadedOffsetsRef.current.has(offset))
) {
return;
}
loadingOffsetsRef.current.add(offset);
setLoadingPage(true);
try {
const url = matchesRoute(rule.id, {
query: {
offset,
per_page: 50,
only_uncategorized: requestedOnlyUncategorized ? 1 : 0,
},
}).url;
const res = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error('Failed to load matches');
}
const json = (await res.json()) as MatchesResponse;
if (
activeOnlyUncategorizedRef.current !==
requestedOnlyUncategorized
) {
return;
}
loadedOffsetsRef.current.add(offset);
setTotal(json.total);
setNextOffset(json.next_offset);
setTransactions((prev) =>
mergeUniqueTransactions(prev, json.data, replace),
);
} catch (error) {
console.error(error);
toast.error(__('Failed to load matching transactions.'));
} finally {
loadingOffsetsRef.current.delete(offset);
setLoadingPage(false);
}
},
[onlyUncategorized, rule.id],
);
// Reset list when entering preview or toggling filter
useEffect(() => {
if (step !== 'preview') {
return;
}
activeOnlyUncategorizedRef.current = onlyUncategorized;
loadingOffsetsRef.current.clear();
loadedOffsetsRef.current.clear();
setTransactions([]);
setNextOffset(null);
setInitialLoad(true);
fetchPage(0, true).finally(() => setInitialLoad(false));
}, [step, onlyUncategorized, fetchPage]);
// Infinite scroll
useEffect(() => {
if (step !== 'preview' || !sentinelRef.current) {
return;
}
const el = sentinelRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (
entries[0]?.isIntersecting &&
nextOffset !== null &&
!loadingPage
) {
fetchPage(nextOffset, false);
}
},
{ rootMargin: '200px' },
);
observer.observe(el);
return () => observer.disconnect();
}, [step, nextOffset, loadingPage, fetchPage]);
const pollStatus = useCallback(
async (jobId: string) => {
const url = statusRoute(jobId).url;
const poll = async (): Promise<void> => {
const res = await fetch(url, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error('Status check failed');
}
const data = (await res.json()) as ApplyResult;
setProgress(data);
if (data.status === 'done') {
toast.success(
__('Rule applied to :count transaction(s).', {
count: String(data.applied),
}),
);
onApplied?.();
onClose();
return;
}
if (data.status === 'failed') {
toast.error(__('Failed to apply rule to transactions.'));
setApplying(false);
return;
}
setTimeout(() => {
void poll();
}, 1000);
};
await poll();
},
[onApplied, onClose],
);
const handleApply = useCallback(async () => {
setApplying(true);
setStep('applying');
try {
const url = applyRoute(rule.id).url;
const csrf =
(
document.querySelector(
'meta[name="csrf-token"]',
) as HTMLMetaElement | null
)?.content ?? '';
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
'X-CSRF-TOKEN': csrf,
},
body: JSON.stringify({
only_uncategorized: onlyUncategorized,
}),
});
if (!res.ok && res.status !== 202) {
throw new Error('Apply failed');
}
const data = (await res.json()) as ApplyResponse;
if (data.job_id) {
setProgress({
status: 'processing',
processed: 0,
total: data.total ?? total ?? 0,
applied: 0,
updated: 0,
});
await pollStatus(data.job_id);
return;
}
toast.success(
__('Rule applied to :count transaction(s).', {
count: String(data.applied ?? 0),
}),
);
onApplied?.();
onClose();
} catch (error) {
console.error(error);
toast.error(__('Failed to apply rule to transactions.'));
setApplying(false);
setStep('preview');
}
}, [onApplied, onClose, onlyUncategorized, pollStatus, rule.id, total]);
if (step === 'prompt') {
return (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md border bg-muted/30 p-4">
<Sparkles className="mt-0.5 h-5 w-5 text-primary" />
<div className="space-y-1 text-sm">
<p className="font-medium">
{__(
'Apply this rule to your existing transactions?',
)}
</p>
<p className="text-muted-foreground">
{__(
'Future transactions will be categorized automatically either way. You can preview the matches before confirming.',
)}
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{__('Skip for now')}
</Button>
<Button onClick={() => setStep('preview')}>
{__('Review matches')}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</div>
);
}
if (step === 'applying') {
return (
<div className="space-y-4 py-6 text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">
{progress
? __('Applying rule… :processed of :total processed', {
processed: String(progress.processed),
total: String(progress.total),
})
: __('Applying rule…')}
</p>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
{total === null
? __('Loading matches…')
: __(':count matching transaction(s)', {
count: String(total),
})}
</div>
<label className="flex items-center gap-2 text-xs">
<Checkbox
checked={onlyUncategorized}
onCheckedChange={(checked) =>
setOnlyUncategorized(checked === true)
}
disabled={applying}
/>
<span>{__('Only apply to uncategorized')}</span>
</label>
</div>
<div className="max-h-80 overflow-y-auto rounded-md border">
{initialLoad ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : transactions.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{__(
'No matching transactions. Future transactions will still be categorized automatically.',
)}
</div>
) : (
<ul className="divide-y">
{transactions.map((tx) => (
<PreviewRow
key={tx.id}
transaction={tx}
rule={rule}
/>
))}
<li ref={sentinelRef} className="h-1" />
{loadingPage && nextOffset !== null && (
<li className="flex items-center justify-center p-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</li>
)}
</ul>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
{onBackToEdit && (
<Button
variant="ghost"
onClick={onBackToEdit}
disabled={applying}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{__('Back to edit')}
</Button>
)}
<Button variant="outline" onClick={onClose} disabled={applying}>
{__('Cancel')}
</Button>
<Button
onClick={handleApply}
disabled={applying || total === 0 || total === null}
>
{total !== null && total > 0
? __('Apply to :count transaction(s)', {
count: String(total),
})
: __('Apply')}
</Button>
</div>
</div>
);
}
export function mergeUniqueTransactions(
previous: ServerTransaction[],
incoming: ServerTransaction[],
replace: boolean,
): ServerTransaction[] {
const seen = new Set<string>();
const transactions = replace ? incoming : [...previous, ...incoming];
return transactions.filter((transaction) => {
if (seen.has(transaction.id)) {
return false;
}
seen.add(transaction.id);
return true;
});
}
function PreviewRow({
transaction,
rule,
}: {
transaction: ServerTransaction;
rule: AutomationRule;
}) {
const date = transaction.transaction_date
? format(parseISO(transaction.transaction_date), 'MMM d, yyyy')
: '';
const currentCategory = transaction.category?.name ?? __('Uncategorized');
const newCategory = rule.category?.name ?? null;
const newLabels = rule.labels ?? [];
const existingLabelIds = new Set(
transaction.labels?.map((l) => l.id) ?? [],
);
const labelsToAdd = newLabels.filter((l) => !existingLabelIds.has(l.id));
return (
<li className="flex flex-col gap-1 px-3 py-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="line-clamp-1 font-medium">
{transaction.description}
</span>
<span className="shrink-0 tabular-nums">
{formatCurrency(
transaction.amount,
transaction.currency_code,
)}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{date}</span>
{transaction.account?.name && (
<span>· {transaction.account.name}</span>
)}
<span className="ml-auto inline-flex flex-wrap items-center gap-1">
<Badge variant="outline">{currentCategory}</Badge>
{newCategory && newCategory !== currentCategory && (
<>
<ArrowRight className="h-3 w-3" />
<Badge>{newCategory}</Badge>
</>
)}
{labelsToAdd.map((label) => (
<Badge
key={label.id}
variant="secondary"
style={{
backgroundColor: label.color
? `${label.color}33`
: undefined,
color: label.color ?? undefined,
}}
>
+{label.name}
</Badge>
))}
</span>
</div>
</li>
);
}

View File

@ -46,7 +46,7 @@ export function CreateAutomationRuleDialog({
</CreateButton>
)}
</DialogTrigger>
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
<DialogContent className="overflow-x-hidden sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{__('Create Automation Rule')}</DialogTitle>
<DialogDescription>

View File

@ -33,7 +33,7 @@ export function EditAutomationRuleDialog({
}: EditAutomationRuleDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
<DialogContent className="overflow-x-hidden sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>{__('Edit Automation Rule')}</DialogTitle>
<DialogDescription>

View File

@ -0,0 +1,28 @@
import { savedAutomationRuleFlashKey } from '@/components/automation-rules/post-save-apply-rule-prompt';
import { describe, expect, it } from 'vitest';
describe('savedAutomationRuleFlashKey', () => {
it('changes when the same rule is saved again with a new token', () => {
expect(
savedAutomationRuleFlashKey({
saved_automation_rule_id: 'rule-1',
saved_automation_rule_token: 'first-save',
}),
).toBe('rule-1:first-save');
expect(
savedAutomationRuleFlashKey({
saved_automation_rule_id: 'rule-1',
saved_automation_rule_token: 'second-save',
}),
).toBe('rule-1:second-save');
});
it('falls back to rule id for older flash payloads', () => {
expect(
savedAutomationRuleFlashKey({
saved_automation_rule_id: 'rule-1',
}),
).toBe('rule-1:rule-1');
});
});

View File

@ -0,0 +1,79 @@
import { ApplyAutomationRuleDialog } from '@/components/automation-rules/apply-automation-rule-dialog';
import type { AutomationRule } from '@/types/automation-rule';
import { usePage } from '@inertiajs/react';
import { useEffect, useMemo, useRef, useState } from 'react';
type SavedAutomationRuleFlash = {
saved_automation_rule_id?: string | null;
saved_automation_rule_token?: string | null;
};
export function savedAutomationRuleFlashKey(
flash?: SavedAutomationRuleFlash,
): string | null {
const ruleId = flash?.saved_automation_rule_id ?? null;
if (!ruleId) {
return null;
}
return `${ruleId}:${flash?.saved_automation_rule_token ?? ruleId}`;
}
/**
* Watches the shared Inertia `flash.saved_automation_rule_id` value and
* renders the apply-rule dialog when a rule was just saved.
*
* Mount this once at the page level (NOT inside per-row dialogs that may
* remount during Inertia visits).
*/
export function PostSaveApplyRulePrompt() {
const { automationRules, flash } = usePage<{
automationRules: AutomationRule[];
flash?: SavedAutomationRuleFlash;
}>().props;
const [activeRuleId, setActiveRuleId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const consumedFlashRef = useRef<string | null>(null);
const flashedRuleId = flash?.saved_automation_rule_id ?? null;
const flashKey = savedAutomationRuleFlashKey(flash);
useEffect(() => {
if (!flashedRuleId || !flashKey) {
return;
}
if (consumedFlashRef.current === flashKey) {
return;
}
consumedFlashRef.current = flashKey;
setActiveRuleId(flashedRuleId);
setOpen(true);
}, [flashKey, flashedRuleId]);
const activeRule = useMemo(
() =>
activeRuleId
? (automationRules.find((r) => r.id === activeRuleId) ?? null)
: null,
[automationRules, activeRuleId],
);
if (!activeRule) {
return null;
}
return (
<ApplyAutomationRuleDialog
rule={activeRule}
open={open}
initialStep="prompt"
onOpenChange={(next) => {
setOpen(next);
if (!next) {
setActiveRuleId(null);
}
}}
/>
);
}

View File

@ -30,6 +30,7 @@ import {
AutomateCategorizationDialog,
type AutomateCategorizationCandidate,
} from '@/components/automation-rules/automate-categorization-dialog';
import { PostSaveApplyRulePrompt } from '@/components/automation-rules/post-save-apply-rule-prompt';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu';
@ -1397,6 +1398,8 @@ export function TransactionList({
onOpenChange={setAutomateDialogOpen}
/>
<PostSaveApplyRulePrompt />
<AlertDialog
open={!!deleteTransaction}
onOpenChange={(open) => {

View File

@ -16,10 +16,12 @@ import { MoreHorizontal } from 'lucide-react';
import { useMemo, useState } from 'react';
import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController';
import { ApplyAutomationRuleDialog } from '@/components/automation-rules/apply-automation-rule-dialog';
import { AutomationRuleActionBadges } from '@/components/automation-rules/automation-rule-action-badges';
import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog';
import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog';
import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog';
import { PostSaveApplyRulePrompt } from '@/components/automation-rules/post-save-apply-rule-prompt';
import HeadingSmall from '@/components/heading-small';
import { Button } from '@/components/ui/button';
import {
@ -71,6 +73,7 @@ function AutomationRuleActions({
}) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
return (
<>
@ -90,6 +93,9 @@ function AutomationRuleActions({
<DropdownMenuItem onClick={() => setEditOpen(true)}>
{__('Edit')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setApplyOpen(true)}>
{__('Apply to existing transactions')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
@ -106,6 +112,11 @@ function AutomationRuleActions({
open={editOpen}
onOpenChange={setEditOpen}
/>
<ApplyAutomationRuleDialog
rule={rule}
open={applyOpen}
onOpenChange={setApplyOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
@ -127,6 +138,7 @@ function AutomationRuleRow({
const rule = row.original;
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
return (
@ -156,6 +168,9 @@ function AutomationRuleRow({
<ContextMenuItem onClick={() => setEditOpen(true)}>
{__('Edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => setApplyOpen(true)}>
{__('Apply to existing transactions')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
@ -172,6 +187,11 @@ function AutomationRuleRow({
open={editOpen}
onOpenChange={setEditOpen}
/>
<ApplyAutomationRuleDialog
rule={rule}
open={applyOpen}
onOpenChange={setApplyOpen}
/>
<DeleteAutomationRuleDialog
rule={rule}
open={deleteOpen}
@ -352,6 +372,7 @@ export default function AutomationRules() {
</div>
</div>
</SettingsLayout>
<PostSaveApplyRulePrompt />
</AppLayout>
);
}

View File

@ -1,6 +1,7 @@
import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController';
import { AutomateCategorizationDialog } from '@/components/automation-rules/automate-categorization-dialog';
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
import { PostSaveApplyRulePrompt } from '@/components/automation-rules/post-save-apply-rule-prompt';
import { CategorizerCard } from '@/components/transactions/categorizer-card';
import { CategorizerCommand } from '@/components/transactions/categorizer-command';
import { Button } from '@/components/ui/button';
@ -70,13 +71,16 @@ export default function CategorizeTransactions({
const backHref = categorizeRoute.url()?.replace('/categorize', '') ?? '';
const automateDialog = (
<AutomateCategorizationDialog
open={automateDialogOpen}
candidate={automateCandidate}
categories={categories}
onOpenChange={handleAutomateDialogOpenChange}
onSaved={handleAutomateSaved}
/>
<>
<AutomateCategorizationDialog
open={automateDialogOpen}
candidate={automateCandidate}
categories={categories}
onOpenChange={handleAutomateDialogOpenChange}
onSaved={handleAutomateSaved}
/>
<PostSaveApplyRulePrompt />
</>
);
useEffect(() => {

View File

@ -33,6 +33,7 @@ import {
AutomateCategorizationDialog,
type AutomateCategorizationCandidate,
} from '@/components/automation-rules/automate-categorization-dialog';
import { PostSaveApplyRulePrompt } from '@/components/automation-rules/post-save-apply-rule-prompt';
import HeadingSmall from '@/components/heading-small';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
@ -1252,6 +1253,8 @@ export default function Transactions({
onOpenChange={setAutomateDialogOpen}
/>
<PostSaveApplyRulePrompt />
<AlertDialog
open={!!deleteTransaction && !isBulkDeleteMode}
onOpenChange={(open) => {

View File

@ -55,6 +55,7 @@ export interface ExpiredBankingConnectionNotification {
export interface Flash {
success: string | null;
error: string | null;
saved_automation_rule_id?: string | null;
}
export type ChartColorScheme = 'neutral' | 'colorful' | 'blue' | 'pink';

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\OpenBanking\ConnectionController;
use App\Http\Controllers\Settings\AccountController;
use App\Http\Controllers\Settings\AutomationRuleApplicationController;
use App\Http\Controllers\Settings\AutomationRuleController;
use App\Http\Controllers\Settings\BankController;
use App\Http\Controllers\Settings\CategoryController;
@ -59,6 +60,13 @@ Route::middleware('auth')->group(function () {
Route::patch('settings/automation-rules/{automationRule}', [AutomationRuleController::class, 'update'])->name('automation-rules.update');
Route::delete('settings/automation-rules/{automationRule}', [AutomationRuleController::class, 'destroy'])->name('automation-rules.destroy');
Route::get('settings/automation-rules/{automationRule}/matches', [AutomationRuleApplicationController::class, 'matches'])
->name('automation-rules.matches');
Route::post('settings/automation-rules/{automationRule}/apply', [AutomationRuleApplicationController::class, 'apply'])
->name('automation-rules.apply');
Route::get('settings/automation-rules/apply/status/{jobId}', [AutomationRuleApplicationController::class, 'status'])
->name('automation-rules.apply.status');
Route::get('settings/appearance', function () {
return Inertia::render('settings/appearance');
})->name('appearance.edit');

View File

@ -0,0 +1,317 @@
<?php
use App\Events\TransactionCreated;
use App\Events\TransactionUpdated;
use App\Jobs\ApplySingleAutomationRuleJob;
use App\Models\Account;
use App\Models\AutomationRule;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
beforeEach(function () {
Event::fake([TransactionCreated::class, TransactionUpdated::class]);
$this->user = User::factory()->onboarded()->create();
$this->bank = Bank::factory()->create(['name' => 'Test Bank', 'user_id' => $this->user->id]);
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
'bank_id' => $this->bank->id,
'name' => 'Checking Account',
'encrypted' => false,
]);
$this->category = Category::factory()->create(['user_id' => $this->user->id]);
$this->rule = AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 1,
'rules_json' => ['in' => ['grocery', ['var' => 'description']]],
'action_category_id' => $this->category->id,
]);
});
test('matches endpoint returns transactions matching the rule', function () {
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Coffee Shop',
'amount' => -500,
]);
$response = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule));
$response->assertOk()
->assertJsonPath('total', 1)
->assertJsonCount(1, 'data');
});
test('matches endpoint skips already categorized when only_uncategorized is true', function () {
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store A',
'amount' => -1000,
]);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store B',
'amount' => -1500,
]);
$response = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule).'?only_uncategorized=1');
$response->assertOk()->assertJsonPath('total', 1);
$allResponse = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule).'?only_uncategorized=0');
$allResponse->assertOk()->assertJsonPath('total', 2);
});
test('matches endpoint deduplicates cached matching transaction ids', function () {
$transaction = Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store A',
'amount' => -1000,
]);
$stamp = $this->rule->updated_at?->getTimestamp() ?? 0;
Cache::put(
"automation_rule_matches:{$this->user->id}:{$this->rule->id}:0:{$stamp}",
[$transaction->id, $transaction->id],
now()->addMinutes(15),
);
$response = $this->actingAs($this->user)
->getJson(route('automation-rules.matches', $this->rule).'?only_uncategorized=0');
$response->assertOk()
->assertJsonPath('total', 1)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $transaction->id);
});
test('apply endpoint runs synchronously when matches are below threshold', function () {
Queue::fake();
Transaction::factory()->enableBanking()->count(3)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => true,
]);
$response->assertOk()
->assertJsonPath('status', 'done')
->assertJsonPath('applied', 3)
->assertJsonPath('updated', 3)
->assertJsonPath('total', 3);
Queue::assertNothingPushed();
expect(
Transaction::where('user_id', $this->user->id)
->where('category_id', $this->category->id)
->count()
)->toBe(3);
});
test('apply endpoint queues a job when matches exceed threshold', function () {
Queue::fake();
Transaction::factory()->enableBanking()->count(101)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => true,
]);
$response->assertStatus(202)
->assertJsonPath('total', 101)
->assertJsonStructure(['job_id']);
Queue::assertPushed(ApplySingleAutomationRuleJob::class);
});
test('apply endpoint returns done with zero matches when no transactions match', function () {
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'description' => 'Coffee Shop',
'amount' => -500,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule));
$response->assertOk()
->assertJsonPath('status', 'done')
->assertJsonPath('total', 0)
->assertJsonPath('updated', 0);
});
test('cannot apply rule belonging to another user', function () {
$otherUser = User::factory()->onboarded()->create();
$this->actingAs($otherUser)
->postJson(route('automation-rules.apply', $this->rule))
->assertForbidden();
$this->actingAs($otherUser)
->getJson(route('automation-rules.matches', $this->rule))
->assertForbidden();
});
test('label-only rule applies when only_uncategorized is true', function () {
$labelOnlyRule = AutomationRule::factory()->create([
'user_id' => $this->user->id,
'priority' => 2,
'rules_json' => ['in' => ['coffee', ['var' => 'description']]],
'action_category_id' => null,
]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
$labelOnlyRule->labels()->attach($label);
Transaction::factory()->enableBanking()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Coffee Shop',
'amount' => -500,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $labelOnlyRule), [
'only_uncategorized' => true,
]);
$response->assertOk()->assertJsonPath('applied', 1);
});
test('applying after changing the rule category reports all matches as applied', function () {
$newCategory = Category::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->enableBanking()->count(5)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$this->rule->update(['action_category_id' => $newCategory->id]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => false,
]);
$response->assertOk()
->assertJsonPath('applied', 5)
->assertJsonPath('updated', 5)
->assertJsonPath('total', 5);
expect(
Transaction::where('user_id', $this->user->id)
->where('category_id', $newCategory->id)
->count()
)->toBe(5);
});
test('re-applying same rule reports applied count even when nothing changes', function () {
Transaction::factory()->enableBanking()->count(4)->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $this->category->id,
'description' => 'Grocery Store',
'amount' => -1000,
]);
$response = $this->actingAs($this->user)
->postJson(route('automation-rules.apply', $this->rule), [
'only_uncategorized' => false,
]);
$response->assertOk()
->assertJsonPath('applied', 4)
->assertJsonPath('updated', 0)
->assertJsonPath('total', 4);
});
test('store flashes saved automation rule id and token', function () {
$payload = [
'title' => 'Test Rule',
'priority' => 0,
'rules_json' => json_encode(['in' => ['amazon', ['var' => 'description']]]),
'action_category_id' => $this->category->id,
'action_note' => null,
'action_note_iv' => null,
'action_label_ids' => [],
];
$response = $this->actingAs($this->user)
->post(route('automation-rules.store'), $payload);
$response->assertRedirect();
$response->assertSessionHas('saved_automation_rule_id');
$response->assertSessionHas('saved_automation_rule_token');
});
test('updating labels flashes a new saved automation rule token', function () {
$firstLabel = Label::factory()->create(['user_id' => $this->user->id]);
$secondLabel = Label::factory()->create(['user_id' => $this->user->id]);
$this->rule->labels()->attach($firstLabel);
$payload = [
'title' => $this->rule->title,
'priority' => $this->rule->priority,
'rules_json' => json_encode($this->rule->rules_json),
'action_category_id' => $this->rule->action_category_id,
'action_note' => null,
'action_note_iv' => null,
'action_label_ids' => [$secondLabel->id],
];
$response = $this->actingAs($this->user)
->patch(route('automation-rules.update', $this->rule), $payload);
$response->assertRedirect();
$response->assertSessionHas('saved_automation_rule_id', $this->rule->id);
$response->assertSessionHas('saved_automation_rule_token');
expect($this->rule->labels()->pluck('labels.id')->all())->toBe([
$secondLabel->id,
]);
});