feat(ai): manage AI consent outside onboarding with live backfill (#591)

## Summary

Lets existing (non-onboarding) users discover and manage AI
categorization, gated behind a new Pennant feature flag. When a flagged
paid user grants consent, every uncategorized transaction is categorized
in the background with live, on-screen feedback.

Enable per user: `php artisan feature:enable AiConsentSettings
user@example.com`

## What's included

**Feature flag**
- New `AiConsentSettings` Pennant flag (off by default), exposed to the
frontend via the shared `features` prop.

**Billing settings (`/settings/billing`)**
- When the flag is active, a section to grant or revoke AI consent
(checkbox → `POST`/`DELETE /ai/consent`).

**Transactions page**
- A consent prompt rendered as the first row of the transactions table
(new `DataTable` `topRow` slot), styled with the shared gradient
`AiSparkleIcon`. Only shown to paid users with the flag who haven't
consented yet.
- An inline "Enable AI" button records consent without leaving the page.

**Backfill on consent**
- Granting consent dispatches a queued
`CategorizeUncategorizedTransactionsJob` that categorizes all of the
user's uncategorized transactions, **most recent first**, recording
progress in the cache.
- The transactions page polls a new status endpoint (`GET
/ai/categorization/{jobId}/status`): visible uncategorized rows show a
spinner + pulse while the backfill runs, categories stream in via
partial reloads, and a toast tracks `processed / total` until
completion.

## Notes / decisions

- **Polling, not websockets** — the project has no broadcasting
configured; this mirrors the existing `ReEvaluateTransactionRules` job +
status-endpoint pattern.
- The backfill job is kept separate from
`CategorizeOnboardingTransactionsJob` so the onboarding pass stays
progress-free (small, commented duplication).
- Reuses the existing `AiCategorizationGate` (config kill-switch + pro
plan + active consent) — free users and disabled-AI never dispatch the
job.
- Not included (YAGNI): cancelling an in-flight backfill on revoke;
persisting the spinner across full page reloads.

## Testing

- New Pest coverage: feature-flag exposure, billing/transactions consent
props, consent-triggered dispatch + gating, the status endpoint
(200/404), job progress recording, and recent-first ordering.
- `php artisan test --exclude-testsuite=Browser` passes (one unrelated,
pre-existing `DashboardTest` failure on `main`).
- Pint, Prettier and ESLint clean.
This commit is contained in:
Víctor Falcón 2026-06-25 10:50:35 +02:00 committed by GitHub
parent 578a9b44d8
commit 9a458b1031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 989 additions and 45 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace App\Actions\Ai;
use App\Jobs\CategorizeUncategorizedTransactionsJob;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class StartCategorizationBackfill
{
public function __construct(private readonly AiCategorizationGate $gate) {}
/**
* Dispatch a categorization backfill when the user is eligible and has
* something to categorize, seeding the progress cache the client polls.
*
* @return array{job_id: string, total: int}|null
*/
public function handle(User $user): ?array
{
if (! $this->gate->allows($user)) {
return null;
}
$total = Transaction::query()
->where('user_id', $user->id)
->pendingAiCategorization()
->count();
if ($total === 0) {
return null;
}
$jobId = (string) Str::uuid();
Cache::put(
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId),
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0],
now()->addHour(),
);
CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId);
return ['job_id' => $jobId, 'total' => $total];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* @api
*/
class AiConsentSettings
{
/**
* Resolve the feature's initial value.
*
* Off by default; enable per user with
* `php artisan feature:enable AiConsentSettings user@example.com`
* to surface AI consent management outside of onboarding (billing
* settings toggle + transactions banner).
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Ai;
use App\Actions\Ai\StartCategorizationBackfill;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -9,13 +10,18 @@ use Illuminate\Http\Request;
class AiConsentController extends Controller
{
/**
* Record the user's broad "use AI to help understand my finances" consent.
* Record the user's broad "use AI to help understand my finances" consent
* and kick off a backfill of their uncategorized transactions.
*/
public function store(Request $request): JsonResponse
public function store(Request $request, StartCategorizationBackfill $startBackfill): JsonResponse
{
$request->user()->recordAiConsent();
$user = $request->user();
$user->recordAiConsent();
return response()->json(['consented' => true]);
return response()->json([
'consented' => true,
'categorization' => $startBackfill->handle($user),
]);
}
/**

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers\Ai;
use App\Http\Controllers\Controller;
use App\Jobs\CategorizeUncategorizedTransactionsJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
class CategorizationController extends Controller
{
/**
* Return current progress for a consent-triggered categorization backfill.
*/
public function status(string $jobId): JsonResponse
{
$progress = Cache::get(CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId));
if ($progress === null) {
return response()->json(['message' => 'Job not found.'], 404);
}
return response()->json($progress);
}
}

View File

@ -176,7 +176,9 @@ class SubscriptionController extends Controller
return redirect()->route('dashboard');
}
return Inertia::render('settings/billing');
return Inertia::render('settings/billing', [
'hasAiConsent' => $request->user()->hasActiveAiConsent(),
]);
}
public function billingPortal(Request $request): RedirectResponse

View File

@ -127,6 +127,7 @@ class TransactionController extends Controller
'banks' => $banks,
'labels' => $labels,
'automationRules' => $automationRules,
'hasAiConsent' => $user->hasActiveAiConsent(),
]);
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Features\AiConsentSettings;
use App\Features\CalculateBalancesOnImport;
use App\Features\InteractiveBrokers;
use App\Models\BankingConnection;
@ -180,18 +181,21 @@ class HandleInertiaRequests extends Middleware
'cashflow' => true,
'calculateBalancesOnImport' => false,
'interactiveBrokers' => false,
'aiConsentSettings' => false,
];
}
$features = Feature::for($user)->values([
CalculateBalancesOnImport::class,
InteractiveBrokers::class,
AiConsentSettings::class,
]);
return [
'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'interactiveBrokers' => $features[InteractiveBrokers::class] !== false,
'aiConsentSettings' => $features[AiConsentSettings::class] !== false,
];
}

View File

@ -2,12 +2,10 @@
namespace App\Jobs;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
/**
@ -40,24 +38,6 @@ class CategorizeOnboardingTransactionsJob implements ShouldQueue
return;
}
$pendingIds = Transaction::query()
->where('user_id', $this->user->id)
->whereNull('category_id')
->whereNull('description_iv')
->pluck('id');
if ($pendingIds->isEmpty()) {
return;
}
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
// Chunk a fixed snapshot of ids so transactions left blank (below the
// confidence bar) are never re-processed on a later iteration.
foreach ($pendingIds->chunk($batchSize) as $chunkIds) {
$chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get();
$categorizer->run($this->user, new Collection($chunk->all()));
}
$categorizer->backfill($this->user);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Jobs;
use App\Models\User;
use App\Services\Ai\AiCategorizationGate;
use App\Services\Ai\AiCategorizer;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
use Throwable;
/**
* Categorize every uncategorized transaction for a user who has just granted AI
* consent outside of onboarding. Progress is written to the cache so the
* transactions page can poll it and surface live progress while the batch runs.
*
* ponytail: mirrors CategorizeOnboardingTransactionsJob's selection + chunking;
* kept separate so the onboarding pass stays progress-free. Fold the two
* together if a third caller ever needs the same loop.
*/
class CategorizeUncategorizedTransactionsJob implements ShouldQueue
{
use Queueable;
/**
* A backfill can span many model calls, so give the batch plenty of room.
*/
public int $timeout = 300;
/**
* Re-running a partially completed backfill resets progress and re-bills the
* model, so never retry surface the failure to the client instead.
*/
public int $tries = 1;
public function __construct(public User $user, public string $jobId) {}
public function viaQueue(): string
{
return (string) config('ai_categorization.queue');
}
public static function cacheKeyForJobId(string $jobId): string
{
return "categorize_transactions_job_{$jobId}";
}
public function handle(AiCategorizationGate $gate, AiCategorizer $categorizer): void
{
if (! $gate->allows($this->user)) {
$this->updateProgress('done', 0, 0, 0);
return;
}
$result = $categorizer->backfill(
$this->user,
fn (int $processed, int $total, int $applied) => $this->updateProgress('processing', $processed, $total, $applied),
);
$this->updateProgress('done', $result['processed'], $result['total'], $result['applied']);
}
/**
* Mark the run as failed so the polling client stops waiting instead of
* spinning until the cache entry expires.
*/
public function failed(?Throwable $exception): void
{
$progress = Cache::get(self::cacheKeyForJobId($this->jobId), [
'processed' => 0,
'total' => 0,
'applied' => 0,
]);
$this->updateProgress(
'failed',
$progress['processed'] ?? 0,
$progress['total'] ?? 0,
$progress['applied'] ?? 0,
);
}
/**
* @param 'processing'|'done'|'failed' $status
*/
private function updateProgress(string $status, int $processed, int $total, int $applied): void
{
Cache::put(self::cacheKeyForJobId($this->jobId), [
'status' => $status,
'processed' => $processed,
'total' => $total,
'applied' => $applied,
], now()->addHour());
}
}

View File

@ -162,6 +162,18 @@ class Transaction extends Model
return $this->hasMany(BudgetTransaction::class);
}
/**
* Transactions the AI backfill can act on: still uncategorized and stored
* in plaintext (encrypted descriptions are never sent to the AI provider).
*
* @param Builder<Transaction> $query
* @return Builder<Transaction>
*/
public function scopePendingAiCategorization(Builder $query): Builder
{
return $query->whereNull('category_id')->whereNull('description_iv');
}
/**
* @param Builder<Transaction> $query
* @param array<string, mixed> $filters

View File

@ -33,4 +33,48 @@ class AiCategorizer
return $outcomes;
}
/**
* Categorize every transaction still awaiting AI categorization for the
* user, most recent first. A fixed snapshot of ids is chunked so rows left
* blank (below the confidence bar) are never re-processed. The optional
* $onProgress callback is invoked once up-front and after each batch with
* (processed, total, applied) so callers can report live progress.
*
* @param (callable(int, int, int): void)|null $onProgress
* @return array{processed: int, total: int, applied: int}
*/
public function backfill(User $user, ?callable $onProgress = null): array
{
$pendingIds = Transaction::query()
->where('user_id', $user->id)
->pendingAiCategorization()
->orderByDesc('transaction_date')
->orderByDesc('id')
->pluck('id');
$total = $pendingIds->count();
$processed = 0;
$applied = 0;
if ($onProgress !== null) {
$onProgress($processed, $total, $applied);
}
$batchSize = max(1, (int) config('ai_categorization.group_batch_size'));
foreach ($pendingIds->chunk($batchSize) as $chunkIds) {
$chunk = Transaction::query()->whereIn('id', $chunkIds->all())->get();
$outcomes = $this->run($user, $chunk);
$applied += count(array_filter($outcomes, fn (CategorizationOutcome $outcome): bool => $outcome->applied));
$processed += $chunkIds->count();
if ($onProgress !== null) {
$onProgress($processed, $total, $applied);
}
}
return ['processed' => $processed, 'total' => $total, 'applied' => $applied];
}
}

View File

@ -64,12 +64,12 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"URL=$(npx --no-install portless get dev.whisper.money 2>/dev/null); if [ -n \"$URL\" ]; then if command -v pbcopy >/dev/null 2>&1; then printf %s \"$URL\" | pbcopy; elif command -v wl-copy >/dev/null 2>&1; then printf %s \"$URL\" | wl-copy; elif command -v xclip >/dev/null 2>&1; then printf %s \"$URL\" | xclip -selection clipboard; fi; echo \"\u2713 $URL copied to clipboard\"; fi; PORT=$(awk 'BEGIN{srand(); print 8000 + int(rand()*1000)}'); npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"npx portless run --name dev.whisper.money --app-port $PORT php artisan serve --port=$PORT\" \"php artisan queue:listen --tries=1 --queue=emails\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://dev.whisper.money.localhost/stripe/webhook\" --names=server,emails-queue,logs,vite,stripe"
"URL=$(npx --no-install portless get dev.whisper.money 2>/dev/null); if [ -n \"$URL\" ]; then if command -v pbcopy >/dev/null 2>&1; then printf %s \"$URL\" | pbcopy; elif command -v wl-copy >/dev/null 2>&1; then printf %s \"$URL\" | wl-copy; elif command -v xclip >/dev/null 2>&1; then printf %s \"$URL\" | xclip -selection clipboard; fi; echo \"\u2713 $URL copied to clipboard\"; fi; PORT=$(awk 'BEGIN{srand(); print 8000 + int(rand()*1000)}'); npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"npx portless run --name dev.whisper.money --app-port $PORT php artisan serve --port=$PORT\" \"php artisan queue:listen --tries=1 --queue=emails,ai\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://dev.whisper.money.localhost/stripe/webhook\" --names=server,queue,logs,vite,stripe"
],
"dev:ssr": [
"bun run build:ssr",
"Composer\\Config::disableProcessTimeout",
"PORT=$(awk 'BEGIN{srand(); print 8000 + int(rand()*1000)}'); npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"npx portless run --name dev.whisper.money --app-port $PORT php artisan serve --port=$PORT\" \"php artisan queue:listen --tries=1 --queue=emails\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr --runtime=bun\" --names=server,emails-queue,logs,ssr,stripe --kill-others"
"PORT=$(awk 'BEGIN{srand(); print 8000 + int(rand()*1000)}'); npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"npx portless run --name dev.whisper.money --app-port $PORT php artisan serve --port=$PORT\" \"php artisan queue:listen --tries=1 --queue=emails,ai\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr --runtime=bun\" --names=server,queue,logs,ssr,stripe --kill-others"
],
"test": [
"@php artisan config:clear --ansi",

View File

@ -1,4 +1,18 @@
{
"AI Categorization": "Categorización con IA",
"Let AI suggest categories for your transactions automatically.": "Deja que la IA sugiera categorías para tus transacciones automáticamente.",
"Allow AI categorization": "Permitir categorización con IA",
"You can give consent now, but AI categorization only runs while you have a paid plan.": "Puedes dar tu consentimiento ahora, pero la categorización con IA solo funciona mientras tengas un plan de pago.",
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Con tu permiso, enviamos los nombres de los comercios de tus transacciones a nuestro proveedor de IA para que pueda sugerir categorías. Puedes revocarlo en cualquier momento.",
"AI categorization enabled": "Categorización con IA activada",
"AI categorization disabled": "Categorización con IA desactivada",
"Let AI categorize your transactions": "Deja que la IA categorice tus transacciones",
"Give your consent and our AI will suggest categories for your transactions automatically.": "Da tu consentimiento y nuestra IA sugerirá categorías para tus transacciones automáticamente.",
"Enable AI": "Activar IA",
"Categorizing…": "Categorizando…",
"Categorizing :processed of :total transactions…": "Categorizando :processed de :total transacciones…",
"AI categorized :count transactions": "La IA categorizó :count transacciones",
"AI categorization failed. Please try again.": "La categorización con IA falló. Inténtalo de nuevo.",
"Drag to reorder": "Arrastra para reordenar",
"Manage Accounts": "Gestionar cuentas",
"Choose which accounts from this bank are synced and where their transactions go.": "Elige qué cuentas de este banco se sincronizan y a dónde van sus transacciones.",

View File

@ -1,4 +1,18 @@
{
"AI Categorization": "Catégorisation par IA",
"Let AI suggest categories for your transactions automatically.": "Laissez l'IA suggérer automatiquement des catégories pour vos transactions.",
"Allow AI categorization": "Autoriser la catégorisation par IA",
"You can give consent now, but AI categorization only runs while you have a paid plan.": "Vous pouvez donner votre consentement maintenant, mais la catégorisation par IA ne fonctionne que tant que vous avez un forfait payant.",
"With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.": "Avec votre permission, nous envoyons les noms des commerçants de vos transactions à notre fournisseur d'IA afin qu'il puisse suggérer des catégories. Vous pouvez révoquer cela à tout moment.",
"AI categorization enabled": "Catégorisation par IA activée",
"AI categorization disabled": "Catégorisation par IA désactivée",
"Let AI categorize your transactions": "Laissez l'IA catégoriser vos transactions",
"Give your consent and our AI will suggest categories for your transactions automatically.": "Donnez votre consentement et notre IA suggérera automatiquement des catégories pour vos transactions.",
"Enable AI": "Activer l'IA",
"Categorizing…": "Catégorisation…",
"Categorizing :processed of :total transactions…": "Catégorisation de :processed sur :total transactions…",
"AI categorized :count transactions": "L'IA a catégorisé :count transactions",
"AI categorization failed. Please try again.": "La catégorisation par IA a échoué. Veuillez réessayer.",
"Manage Accounts": "Gérer les comptes",
"Choose which accounts from this bank are synced and where their transactions go.": "Choisissez quels comptes de cette banque sont synchronisés et où vont leurs transactions.",
"No accounts are syncing yet.": "Aucun compte n'est encore synchronisé.",

View File

@ -1,6 +1,7 @@
import { showsAiUpsell } from '@/components/transactions/ai-upsell-sample';
import { CategorySelect } from '@/components/transactions/category-select';
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
import { Spinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
@ -32,6 +33,8 @@ interface CategoryCellProps {
) => void;
className?: string;
withoutChevronIcon?: boolean;
/** AI is currently categorizing this row in the background. */
isCategorizing?: boolean;
}
export function CategoryCell({
@ -43,6 +46,7 @@ export function CategoryCell({
onCategorized,
className,
withoutChevronIcon,
isCategorizing,
}: CategoryCellProps) {
const [isUpdating, setIsUpdating] = useState(false);
const isMobile = useIsMobile();
@ -160,6 +164,20 @@ export function CategoryCell({
</TooltipProvider>
);
if (isCategorizing) {
return (
<div
className={cn(
'flex w-full animate-pulse items-center gap-2 text-muted-foreground',
className,
)}
>
<Spinner className="size-4 shrink-0" />
<span className="truncate text-sm">{__('Categorizing…')}</span>
</div>
);
}
return (
<div className="flex w-full items-center gap-1">
<div className="min-w-0 flex-1">

View File

@ -43,6 +43,8 @@ interface CreateColumnsOptions {
) => void;
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
isDateHidden?: boolean;
/** Ids of transactions AI is categorizing in the background right now. */
categorizingIds?: Set<string>;
}
export function createTransactionColumns({
@ -57,6 +59,7 @@ export function createTransactionColumns({
onCategorized,
onReEvaluateRules,
isDateHidden = false,
categorizingIds,
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
return [
{
@ -154,6 +157,7 @@ export function createTransactionColumns({
onCategorized={onCategorized}
className="relative -top-0.5 max-w-[150px] md:max-w-[180px]"
withoutChevronIcon
isCategorizing={categorizingIds?.has(row.original.id)}
/>
);
},

View File

@ -41,6 +41,8 @@ interface DataTableProps<TData, TValue> {
) => React.ReactNode;
getRowDate?: (row: TData) => string;
maxHeight?: number;
/** Static content pinned as the first row of the table body, spanning all columns. */
topRow?: React.ReactNode;
}
export function DataTable<TData, TValue>({
@ -51,6 +53,7 @@ export function DataTable<TData, TValue>({
renderDateHeader,
getRowDate,
maxHeight,
topRow,
}: DataTableProps<TData, TValue>) {
const tableContainerRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
@ -121,6 +124,16 @@ export function DataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{topRow && (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={visibleColumnCount}
className="p-0 whitespace-normal"
>
{topRow}
</TableCell>
</TableRow>
)}
{rows.length ? (
<>
{paddingTop > 0 && (

View File

@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef } from 'react';
/**
* Self-rescheduling poll loop for background-job status endpoints. The hook owns
* the timer and tears it down on unmount (or when a new poll starts); the caller
* provides a `tick` that does the request and returns `'continue'` to keep
* polling or `'stop'` to finish. A throwing `tick` stops the loop.
*/
export function usePollJobStatus() {
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const activeRef = useRef(false);
const stop = useCallback(() => {
activeRef.current = false;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
}, []);
const start = useCallback(
(tick: () => Promise<'continue' | 'stop'>, intervalMs = 2000) => {
stop();
activeRef.current = true;
const run = async () => {
if (!activeRef.current) {
return;
}
let result: 'continue' | 'stop';
try {
result = await tick();
} catch {
result = 'stop';
}
if (!activeRef.current || result === 'stop') {
activeRef.current = false;
return;
}
timerRef.current = setTimeout(run, intervalMs);
};
run();
},
[stop],
);
useEffect(() => stop, [stop]);
return { start, stop };
}

View File

@ -1,9 +1,15 @@
import HeadingSmall from '@/components/heading-small';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { cn } from '@/lib/utils';
import {
destroy as revokeConsent,
store as storeConsent,
} from '@/routes/ai/consent';
import { billing } from '@/routes/settings';
import { portal } from '@/routes/settings/billing';
import { checkout } from '@/routes/subscribe';
@ -12,6 +18,7 @@ import { Plan } from '@/types/pricing';
import { formatCurrency } from '@/utils/currency';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import axios from 'axios';
import {
CheckIcon,
CreditCardIcon,
@ -23,6 +30,7 @@ import {
ZapIcon,
} from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
const breadcrumbs: BreadcrumbItem[] = [
{
@ -298,8 +306,96 @@ function SubscribedSection({
);
}
function AiConsentSection({
initialConsent,
hasProPlan,
}: {
initialConsent: boolean;
hasProPlan: boolean;
}) {
const [consented, setConsented] = useState(initialConsent);
const [saving, setSaving] = useState(false);
const handleToggle = async (checked: boolean) => {
setSaving(true);
try {
if (checked) {
await axios.post(storeConsent.url());
} else {
await axios.delete(revokeConsent.url());
}
setConsented(checked);
toast.success(
checked
? __('AI categorization enabled')
: __('AI categorization disabled'),
);
} catch {
toast.error(__('Something went wrong.'));
} finally {
setSaving(false);
}
};
return (
<div className="space-y-6">
<header>
<div className="mb-0.5 flex items-center gap-2">
<h3 className="text-base font-medium">
{__('AI Categorization')}
</h3>
<Badge
variant="secondary"
className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
PRO
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{__(
'Let AI suggest categories for your transactions automatically.',
)}
</p>
</header>
<div className="rounded-lg border bg-card p-5">
<label className="flex items-start gap-3">
<Checkbox
checked={consented}
disabled={saving}
onCheckedChange={(checked) =>
handleToggle(checked === true)
}
className="mt-0.5"
/>
<div>
<span className="font-medium">
{__('Allow AI categorization')}
</span>
<p className="mt-1 text-sm text-muted-foreground">
{__(
'With your permission, we send merchant names from your transactions to our AI provider so it can suggest categories. You can revoke this at any time.',
)}
</p>
</div>
</label>
{!hasProPlan && (
<p className="mt-3 border-t pt-3 text-sm text-amber-600 dark:text-amber-400">
{__(
'You can give consent now, but AI categorization only runs while you have a paid plan.',
)}
</p>
)}
</div>
</div>
);
}
export default function Billing() {
const { auth, pricing, locale } = usePage<SharedData>().props;
const { auth, pricing, locale, features, hasAiConsent } = usePage<
SharedData & { hasAiConsent: boolean }
>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const hasProPlan = auth?.hasProPlan ?? false;
const planEntries = Object.entries(pricing.plans);
@ -310,21 +406,30 @@ export default function Billing() {
<Head title={__('Manage Plan')} />
<SettingsLayout>
{hasProPlan ? (
<SubscribedSection
isDemoAccount={isDemoAccount}
defaultPlan={defaultPlan}
currency={pricing.currency}
locale={locale}
/>
) : (
<UpgradeSection
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
locale={locale}
/>
)}
<div className="space-y-8">
{hasProPlan ? (
<SubscribedSection
isDemoAccount={isDemoAccount}
defaultPlan={defaultPlan}
currency={pricing.currency}
locale={locale}
/>
) : (
<UpgradeSection
planEntries={planEntries}
defaultPlan={pricing.defaultPlan}
currency={pricing.currency}
locale={locale}
/>
)}
{features.aiConsentSettings && (
<AiConsentSection
initialConsent={hasAiConsent}
hasProPlan={hasProPlan}
/>
)}
</div>
</SettingsLayout>
</AppLayout>
);

View File

@ -1,4 +1,5 @@
import { useLocale } from '@/hooks/use-locale';
import { usePollJobStatus } from '@/hooks/use-poll-job-status';
import { __ } from '@/utils/i18n';
import { Head, router, usePage } from '@inertiajs/react';
import {
@ -13,6 +14,7 @@ import {
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import axios from 'axios';
import { format, getYear, parseISO } from 'date-fns';
import { ChevronRight } from 'lucide-react';
import {
createElement,
useCallback,
@ -40,6 +42,7 @@ import { EditTransactionDialog } from '@/components/transactions/edit-transactio
import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu';
import { createTransactionColumns } from '@/components/transactions/transaction-columns';
import { TransactionFilters as TransactionFiltersComponent } from '@/components/transactions/transaction-filters';
import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon';
import {
AlertDialog,
AlertDialogAction,
@ -84,10 +87,16 @@ import { captureEvent } from '@/lib/posthog';
import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirmation';
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
import { cn } from '@/lib/utils';
import { status as categorizationStatus } from '@/routes/ai/categorization';
import { store as storeConsent } from '@/routes/ai/consent';
import { transactionSyncService } from '@/services/transaction-sync';
import { type BreadcrumbItem } from '@/types';
import { type BreadcrumbItem, type SharedData } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import {
type AiConsentResponse,
type CategorizationProgress,
} from '@/types/categorization';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import {
@ -126,6 +135,7 @@ interface Props {
banks: Bank[];
labels: Label[];
automationRules: AutomationRule[];
hasAiConsent: boolean;
}
const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility';
@ -403,8 +413,25 @@ export default function Transactions({
banks,
labels: initialLabels,
automationRules,
hasAiConsent,
}: Props) {
const locale = useLocale();
const { auth, features } = usePage<SharedData>().props;
const [aiConsentGiven, setAiConsentGiven] = useState(false);
const [aiConsentSaving, setAiConsentSaving] = useState(false);
const showAiConsentBanner =
auth.hasProPlan &&
features.aiConsentSettings &&
!hasAiConsent &&
!aiConsentGiven;
const [categorizingIds, setCategorizingIds] = useState<Set<string>>(
() => new Set(),
);
const categorizationToastRef = useRef<string | number | undefined>(
undefined,
);
const { start: startCategorizationPoll } = usePollJobStatus();
const [labels, setLabels] = useState<Label[]>(() => initialLabels);
useEffect(() => {
@ -590,6 +617,133 @@ export default function Transactions({
});
}, []);
// Clear the spinner for any row AI has finished categorizing.
useEffect(() => {
setCategorizingIds((previous) => {
if (previous.size === 0) {
return previous;
}
let changed = false;
const next = new Set(previous);
for (const transaction of allTransactions) {
if (next.has(transaction.id) && transaction.category_id) {
next.delete(transaction.id);
changed = true;
}
}
return changed ? next : previous;
});
}, [allTransactions]);
const pollCategorizationStatus = useCallback(
(jobId: string, fallbackTotal: number) => {
let pendingTicks = 0;
const dismiss = () => {
setCategorizingIds(new Set());
toast.dismiss(categorizationToastRef.current);
categorizationToastRef.current = undefined;
};
startCategorizationPoll(async () => {
let data;
try {
({ data } = await axios.get<CategorizationProgress>(
categorizationStatus(jobId).url,
));
} catch {
dismiss();
return 'stop';
}
// The job never started (e.g. no queue worker running) — give up
// instead of polling forever.
if (data.status === 'pending' && ++pendingTicks > 15) {
dismiss();
return 'stop';
}
const total = data.total || fallbackTotal;
categorizationToastRef.current = toast.loading(
__('Categorizing :processed of :total transactions…', {
processed: data.processed,
total,
}),
{ id: categorizationToastRef.current },
);
// Pull whatever AI has applied so far into the visible rows.
if (data.status === 'processing' || data.status === 'done') {
refreshTransactions();
}
if (data.status === 'done' || data.status === 'failed') {
setCategorizingIds(new Set());
if (data.status === 'failed') {
toast.error(
__('AI categorization failed. Please try again.'),
{ id: categorizationToastRef.current },
);
} else if (data.applied > 0) {
toast.success(
__('AI categorized :count transactions', {
count: data.applied,
}),
{ id: categorizationToastRef.current },
);
} else {
toast.dismiss(categorizationToastRef.current);
}
categorizationToastRef.current = undefined;
return 'stop';
}
return 'continue';
}, 2000);
},
[startCategorizationPoll, refreshTransactions],
);
const handleEnableAi = useCallback(async () => {
setAiConsentSaving(true);
try {
const { data } = await axios.post<AiConsentResponse>(
storeConsent.url(),
);
setAiConsentGiven(true);
if (data.categorization) {
// Spin every currently-visible uncategorized row while the
// backfill runs; the prune effect clears each as it lands.
setCategorizingIds(
new Set(
allTransactions
.filter((transaction) => !transaction.category_id)
.map((transaction) => transaction.id),
),
);
categorizationToastRef.current = toast.loading(
__('Categorizing :processed of :total transactions…', {
processed: 0,
total: data.categorization.total,
}),
);
pollCategorizationStatus(
data.categorization.job_id,
data.categorization.total,
);
} else {
toast.success(__('AI categorization enabled'));
}
} catch {
toast.error(__('Something went wrong.'));
} finally {
setAiConsentSaving(false);
}
}, [allTransactions, pollCategorizationStatus]);
// Load More with cursor pagination (fetch directly to avoid cursor in URL)
const { component, version } = usePage();
const handleLoadMore = useCallback(async () => {
@ -848,6 +1002,7 @@ export default function Transactions({
onCategorized: showAutomatizeToast,
onReEvaluateRules: handleReEvaluateRules,
isDateHidden: columnVisibility.transaction_date === false,
categorizingIds,
}),
[
accounts,
@ -859,6 +1014,7 @@ export default function Transactions({
showAutomatizeToast,
handleReEvaluateRules,
columnVisibility,
categorizingIds,
],
);
@ -1248,6 +1404,38 @@ export default function Transactions({
renderDateHeader={(date, colSpan) => (
<DateHeader date={date} colSpan={colSpan} />
)}
topRow={
showAiConsentBanner ? (
<div className="flex flex-col gap-3 bg-gradient-to-r from-violet-50 via-sky-50 to-rose-50 px-4 py-4 sm:flex-row sm:items-center dark:from-violet-950/30 dark:via-sky-950/20 dark:to-rose-950/20">
<div className="flex gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-md bg-white/80 shadow-sm dark:bg-white/10">
<AiSparkleIcon className="h-5 w-5" />
</div>
<div className="max-w-80">
<p className="font-medium">
{__(
'Let AI categorize your transactions',
)}
</p>
<p className="text-sm text-muted-foreground">
{__(
'Give your consent and our AI will suggest categories for your transactions automatically.',
)}
</p>
</div>
</div>
<Button
onClick={handleEnableAi}
disabled={aiConsentSaving}
variant="secondary"
className="max-w-[calc(var(--spacing)_*_86)] bg-white px-6!"
>
{__('Enable AI')}
<ChevronRight />
</Button>
</div>
) : null
}
/>
<DataTablePagination

View File

@ -0,0 +1,18 @@
export type CategorizationStatus = 'pending' | 'processing' | 'done' | 'failed';
export interface CategorizationProgress {
status: CategorizationStatus;
processed: number;
total: number;
applied: number;
}
export interface CategorizationKickoff {
job_id: string;
total: number;
}
export interface AiConsentResponse {
consented: boolean;
categorization: CategorizationKickoff | null;
}

View File

@ -43,6 +43,7 @@ export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
interactiveBrokers: boolean;
aiConsentSettings: boolean;
}
export interface ExpiredBankingConnectionNotification {

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\AccountController;
use App\Http\Controllers\Ai\AiConsentController;
use App\Http\Controllers\Ai\CategorizationController;
use App\Http\Controllers\Ai\RuleSuggestionController;
use App\Http\Controllers\Auth\VerifyEmailController;
use App\Http\Controllers\BudgetController;
@ -118,6 +119,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
// AI rule suggestions — accessible during onboarding (auto-apply) and after.
Route::post('ai/consent', [AiConsentController::class, 'store'])->name('ai.consent.store');
Route::delete('ai/consent', [AiConsentController::class, 'destroy'])->name('ai.consent.destroy');
Route::get('ai/categorization/{jobId}/status', [CategorizationController::class, 'status'])->name('ai.categorization.status');
Route::prefix('ai/rule-suggestions')->name('ai.rule-suggestions.')->group(function () {
Route::get('/', [RuleSuggestionController::class, 'show'])->name('show');
Route::post('generate', [RuleSuggestionController::class, 'generate'])->name('generate');

View File

@ -0,0 +1,206 @@
<?php
use App\Ai\Agents\TransactionCategorizationAgent;
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Jobs\CategorizeUncategorizedTransactionsJob;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\Ai\CategorizeTransactions;
use App\Services\Ai\CategoryCatalog;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Cache;
use function Pest\Laravel\actingAs;
it('dispatches a backfill and returns job progress when consent is granted', function () {
Bus::fake();
$user = User::factory()->create();
Transaction::factory()->plaintext()->count(2)->create([
'user_id' => $user->id,
'category_id' => null,
]);
actingAs($user)->postJson(route('ai.consent.store'))
->assertOk()
->assertJson(['consented' => true])
->assertJsonPath('categorization.total', 2)
->assertJsonStructure(['categorization' => ['job_id', 'total']]);
expect($user->hasActiveAiConsent())->toBeTrue();
Bus::assertDispatched(CategorizeUncategorizedTransactionsJob::class);
});
it('does not dispatch a backfill when nothing is uncategorized', function () {
Bus::fake();
$user = User::factory()->create();
$category = Category::factory()->for($user)->create();
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => $category->id,
]);
actingAs($user)->postJson(route('ai.consent.store'))
->assertOk()
->assertJsonPath('categorization', null);
Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class);
});
it('does not dispatch a backfill for a user without a paid plan', function () {
config(['subscriptions.enabled' => true]);
Bus::fake();
$user = User::factory()->create();
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
]);
actingAs($user)->postJson(route('ai.consent.store'))
->assertOk()
->assertJson(['consented' => true])
->assertJsonPath('categorization', null);
expect($user->hasActiveAiConsent())->toBeTrue();
Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class);
});
it('does not dispatch a backfill when AI categorization is disabled', function () {
config(['ai_categorization.enabled' => false]);
Bus::fake();
$user = User::factory()->create();
Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
]);
actingAs($user)->postJson(route('ai.consent.store'))
->assertOk()
->assertJsonPath('categorization', null);
Bus::assertNotDispatched(CategorizeUncategorizedTransactionsJob::class);
});
it('returns categorization progress from the status endpoint', function () {
$user = User::factory()->create();
Cache::put(
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId('job-123'),
['status' => 'processing', 'processed' => 1, 'total' => 4, 'applied' => 1],
now()->addHour(),
);
actingAs($user)->getJson(route('ai.categorization.status', 'job-123'))
->assertOk()
->assertJson(['status' => 'processing', 'processed' => 1, 'total' => 4, 'applied' => 1]);
});
it('returns 404 from the status endpoint for an unknown job', function () {
$user = User::factory()->create();
actingAs($user)->getJson(route('ai.categorization.status', 'missing'))
->assertNotFound();
});
it('records progress while categorizing the uncategorized transactions', function () {
$user = User::factory()->create();
$user->recordAiConsent();
$category = Category::factory()->for($user)->create([
'type' => CategoryType::Expense,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
]);
$catalog = CategoryCatalog::forUser($user);
$index = 0;
while (
($id = $catalog->categoryIdForIndex($index)) !== null
&& $id !== $category->id
) {
$index++;
}
TransactionCategorizationAgent::fake(function (string $prompt) use ($index): array {
preg_match_all('/"ref":"([0-9a-f-]+)"/', $prompt, $matches);
return ['results' => array_map(fn (string $ref): array => [
'ref' => $ref,
'category_index' => $index,
'confidence' => 0.95,
'merchant_unambiguous' => false,
], $matches[1])];
});
Transaction::factory()->plaintext()->count(2)->create([
'user_id' => $user->id,
'category_id' => null,
'creditor_name' => 'mercadona',
]);
$jobId = 'job-run-1';
app()->call([new CategorizeUncategorizedTransactionsJob($user, $jobId), 'handle']);
$progress = Cache::get(CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId));
expect($progress['status'])->toBe('done')
->and($progress['total'])->toBe(2)
->and($progress['processed'])->toBe(2)
->and($progress['applied'])->toBe(2);
});
it('marks the cache as failed and preserves counts when the job fails', function () {
$user = User::factory()->create();
$jobId = 'failed-job';
Cache::put(
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId),
['status' => 'processing', 'processed' => 3, 'total' => 10, 'applied' => 2],
now()->addHour(),
);
(new CategorizeUncategorizedTransactionsJob($user, $jobId))
->failed(new RuntimeException('boom'));
$progress = Cache::get(CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($jobId));
expect($progress['status'])->toBe('failed')
->and($progress['processed'])->toBe(3)
->and($progress['total'])->toBe(10)
->and($progress['applied'])->toBe(2);
});
it('categorizes the most recent transactions first', function () {
config(['ai_categorization.group_batch_size' => 1]);
$user = User::factory()->create();
$user->recordAiConsent();
$oldest = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'transaction_date' => '2026-01-01',
]);
$newest = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'transaction_date' => '2026-06-01',
]);
$middle = Transaction::factory()->plaintext()->create([
'user_id' => $user->id,
'category_id' => null,
'transaction_date' => '2026-03-01',
]);
$order = [];
$this->mock(CategorizeTransactions::class, function ($mock) use (&$order) {
$mock->shouldReceive('forTransactions')->andReturnUsing(function ($user, $transactions) use (&$order) {
foreach ($transactions as $transaction) {
$order[] = $transaction->id;
}
return [];
});
});
app()->call([new CategorizeUncategorizedTransactionsJob($user, 'order-job'), 'handle']);
expect($order)->toBe([$newest->id, $middle->id, $oldest->id]);
});

View File

@ -0,0 +1,62 @@
<?php
use App\Features\AiConsentSettings;
use App\Models\User;
use Inertia\Testing\AssertableInertia as Assert;
use Laravel\Pennant\Feature;
use function Pest\Laravel\actingAs;
test('ai consent settings flag is off by default in shared props', function () {
$user = User::factory()->onboarded()->create();
actingAs($user)->withoutVite()->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->where('features.aiConsentSettings', false)
);
});
test('ai consent settings flag is exposed when activated for the user', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(AiConsentSettings::class);
actingAs($user)->withoutVite()->get(route('dashboard'))
->assertInertia(fn (Assert $page) => $page
->where('features.aiConsentSettings', true)
);
});
test('billing page reports current ai consent state', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create();
actingAs($user)->withoutVite()->get(route('settings.billing'))
->assertInertia(fn (Assert $page) => $page
->component('settings/billing')
->where('hasAiConsent', false)
);
$user->recordAiConsent();
actingAs($user)->withoutVite()->get(route('settings.billing'))
->assertInertia(fn (Assert $page) => $page
->where('hasAiConsent', true)
);
});
test('transactions page reports current ai consent state', function () {
$user = User::factory()->onboarded()->create();
actingAs($user)->withoutVite()->get(route('transactions.index'))
->assertInertia(fn (Assert $page) => $page
->component('transactions/index')
->where('hasAiConsent', false)
);
$user->recordAiConsent();
actingAs($user)->withoutVite()->get(route('transactions.index'))
->assertInertia(fn (Assert $page) => $page
->where('hasAiConsent', true)
);
});

View File

@ -44,6 +44,7 @@ test('shared feature flags do not include coinbase flag', function () {
'cashflow' => true,
'calculateBalancesOnImport' => false,
'interactiveBrokers' => false,
'aiConsentSettings' => false,
]);
});