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:
parent
578a9b44d8
commit
9a458b1031
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ class TransactionController extends Controller
|
|||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
'automationRules' => $automationRules,
|
||||
'hasAiConsent' => $user->hasActiveAiConsent(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
14
lang/es.json
14
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.",
|
||||
|
|
|
|||
14
lang/fr.json
14
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é.",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ export interface Features {
|
|||
cashflow: boolean;
|
||||
calculateBalancesOnImport: boolean;
|
||||
interactiveBrokers: boolean;
|
||||
aiConsentSettings: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiredBankingConnectionNotification {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
@ -44,6 +44,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
'interactiveBrokers' => false,
|
||||
'aiConsentSettings' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue