diff --git a/app/Actions/Ai/StartCategorizationBackfill.php b/app/Actions/Ai/StartCategorizationBackfill.php new file mode 100644 index 00000000..b8506b66 --- /dev/null +++ b/app/Actions/Ai/StartCategorizationBackfill.php @@ -0,0 +1,49 @@ +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]; + } +} diff --git a/app/Features/AiConsentSettings.php b/app/Features/AiConsentSettings.php new file mode 100644 index 00000000..92049d47 --- /dev/null +++ b/app/Features/AiConsentSettings.php @@ -0,0 +1,24 @@ +user()->recordAiConsent(); + $user = $request->user(); + $user->recordAiConsent(); - return response()->json(['consented' => true]); + return response()->json([ + 'consented' => true, + 'categorization' => $startBackfill->handle($user), + ]); } /** diff --git a/app/Http/Controllers/Ai/CategorizationController.php b/app/Http/Controllers/Ai/CategorizationController.php new file mode 100644 index 00000000..7c705d3b --- /dev/null +++ b/app/Http/Controllers/Ai/CategorizationController.php @@ -0,0 +1,25 @@ +json(['message' => 'Job not found.'], 404); + } + + return response()->json($progress); + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 88d588e1..fec16c43 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -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 diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index e4087fb3..074bcddf 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -127,6 +127,7 @@ class TransactionController extends Controller 'banks' => $banks, 'labels' => $labels, 'automationRules' => $automationRules, + 'hasAiConsent' => $user->hasActiveAiConsent(), ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 6750e4d0..a2477c80 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -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, ]; } diff --git a/app/Jobs/CategorizeOnboardingTransactionsJob.php b/app/Jobs/CategorizeOnboardingTransactionsJob.php index 1035a261..76813122 100644 --- a/app/Jobs/CategorizeOnboardingTransactionsJob.php +++ b/app/Jobs/CategorizeOnboardingTransactionsJob.php @@ -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); } } diff --git a/app/Jobs/CategorizeUncategorizedTransactionsJob.php b/app/Jobs/CategorizeUncategorizedTransactionsJob.php new file mode 100644 index 00000000..07ce36c2 --- /dev/null +++ b/app/Jobs/CategorizeUncategorizedTransactionsJob.php @@ -0,0 +1,97 @@ +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()); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index b3def01d..1273f4cb 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -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 $query + * @return Builder + */ + public function scopePendingAiCategorization(Builder $query): Builder + { + return $query->whereNull('category_id')->whereNull('description_iv'); + } + /** * @param Builder $query * @param array $filters diff --git a/app/Services/Ai/AiCategorizer.php b/app/Services/Ai/AiCategorizer.php index a087c83f..2256461e 100644 --- a/app/Services/Ai/AiCategorizer.php +++ b/app/Services/Ai/AiCategorizer.php @@ -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]; + } } diff --git a/composer.json b/composer.json index 8c684e80..e37588e4 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/lang/es.json b/lang/es.json index 70129d58..01352d15 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/lang/fr.json b/lang/fr.json index 0bedf950..2e5b60c9 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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é.", diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index fbaac1a5..044a779a 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -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({ ); + if (isCategorizing) { + return ( +
+ + {__('Categorizing…')} +
+ ); + } + return (
diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx index 700e8358..5c9a2499 100644 --- a/resources/js/components/transactions/transaction-columns.tsx +++ b/resources/js/components/transactions/transaction-columns.tsx @@ -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; } export function createTransactionColumns({ @@ -57,6 +59,7 @@ export function createTransactionColumns({ onCategorized, onReEvaluateRules, isDateHidden = false, + categorizingIds, }: CreateColumnsOptions): ColumnDef[] { 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)} /> ); }, diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx index 6d7b24db..e84a4bcd 100644 --- a/resources/js/components/ui/data-table.tsx +++ b/resources/js/components/ui/data-table.tsx @@ -41,6 +41,8 @@ interface DataTableProps { ) => 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({ @@ -51,6 +53,7 @@ export function DataTable({ renderDateHeader, getRowDate, maxHeight, + topRow, }: DataTableProps) { const tableContainerRef = useRef(null); const rows = table.getRowModel().rows; @@ -121,6 +124,16 @@ export function DataTable({ ))} + {topRow && ( + + + {topRow} + + + )} {rows.length ? ( <> {paddingTop > 0 && ( diff --git a/resources/js/hooks/use-poll-job-status.ts b/resources/js/hooks/use-poll-job-status.ts new file mode 100644 index 00000000..7533d865 --- /dev/null +++ b/resources/js/hooks/use-poll-job-status.ts @@ -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>(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 }; +} diff --git a/resources/js/pages/settings/billing.tsx b/resources/js/pages/settings/billing.tsx index 8ecc4d83..7f51c236 100644 --- a/resources/js/pages/settings/billing.tsx +++ b/resources/js/pages/settings/billing.tsx @@ -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 ( +
+
+
+

+ {__('AI Categorization')} +

+ + PRO + +
+

+ {__( + 'Let AI suggest categories for your transactions automatically.', + )} +

+
+ +
+ + + {!hasProPlan && ( +

+ {__( + 'You can give consent now, but AI categorization only runs while you have a paid plan.', + )} +

+ )} +
+
+ ); +} + export default function Billing() { - const { auth, pricing, locale } = usePage().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() { - {hasProPlan ? ( - - ) : ( - - )} +
+ {hasProPlan ? ( + + ) : ( + + )} + + {features.aiConsentSettings && ( + + )} +
); diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 05c620c1..778573d5 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -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().props; + const [aiConsentGiven, setAiConsentGiven] = useState(false); + const [aiConsentSaving, setAiConsentSaving] = useState(false); + const showAiConsentBanner = + auth.hasProPlan && + features.aiConsentSettings && + !hasAiConsent && + !aiConsentGiven; + const [categorizingIds, setCategorizingIds] = useState>( + () => new Set(), + ); + const categorizationToastRef = useRef( + undefined, + ); + const { start: startCategorizationPoll } = usePollJobStatus(); + const [labels, setLabels] = useState(() => 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( + 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( + 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) => ( )} + topRow={ + showAiConsentBanner ? ( +
+
+
+ +
+
+

+ {__( + 'Let AI categorize your transactions', + )} +

+

+ {__( + 'Give your consent and our AI will suggest categories for your transactions automatically.', + )} +

+
+
+ +
+ ) : null + } /> 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'); diff --git a/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php b/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php new file mode 100644 index 00000000..cb6dbbe3 --- /dev/null +++ b/tests/Feature/Ai/CategorizeUncategorizedTransactionsTest.php @@ -0,0 +1,206 @@ +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]); +}); diff --git a/tests/Feature/AiConsentSettingsTest.php b/tests/Feature/AiConsentSettingsTest.php new file mode 100644 index 00000000..e7af5450 --- /dev/null +++ b/tests/Feature/AiConsentSettingsTest.php @@ -0,0 +1,62 @@ +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) + ); +}); diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index 114f038b..743ce623 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -44,6 +44,7 @@ test('shared feature flags do not include coinbase flag', function () { 'cashflow' => true, 'calculateBalancesOnImport' => false, 'interactiveBrokers' => false, + 'aiConsentSettings' => false, ]); });