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:
parent
933dfdeb1b
commit
9d7a91dcd0
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
3654
lang/es.json
3654
lang/es.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
Loading…
Reference in New Issue