From 7e36bbafef8faa076b25dc70451b7165b000349a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 1 Jul 2026 09:26:36 +0200 Subject: [PATCH] feat(ai): dismissable AI consent banner that stops after the first decision (#617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What The transactions AI consent banner now lets users decline, tells them the outcome, and stops appearing once they've decided. - **Dismiss option**: an X button before "Enable AI" permanently hides the banner without granting consent. - **Show only until the first decision**: the choice is persisted on `users.ai_consent_prompt_dismissed_at` (set on both accept and dismiss). The banner shows only while the user hasn't responded and never reappears afterwards — even if they later revoke consent from settings. - **Clear feedback**: accepting or dismissing shows a toast stating whether AI was enabled and that the choice can be changed anytime in **Settings > Manage Plan**. - **Full-width layout on desktop**: the banner content spans the full available width (button pushed to the right, text on a single line). The narrow stacked layout is kept for mobile only. ## How - New `POST ai/consent/dismiss` endpoint (`AiConsentController::dismiss`). - `User::dismissAiConsentPrompt()` (idempotent) + `hasDismissedAiConsentPrompt()`; `store` now also marks the prompt dismissed on accept. - Migration adds the nullable `ai_consent_prompt_dismissed_at` timestamp. - `TransactionController` passes `aiConsentPromptDismissed` to the page. ## Tests - `AiConsentTest`: idempotent dismissal, dismissal without consent, and accept marking the prompt dismissed. - Full consent + backfill suite and the `create a transaction` browser test pass; pint / lint / format clean. --- .../Controllers/Ai/AiConsentController.php | 11 +++ .../Controllers/TransactionController.php | 1 + app/Models/User.php | 21 ++++++ ...ent_prompt_dismissed_at_to_users_table.php | 28 +++++++ lang/es.json | 3 + resources/js/pages/transactions/index.tsx | 73 ++++++++++++++----- routes/web.php | 1 + tests/Feature/AiConsentTest.php | 36 +++++++++ 8 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 database/migrations/2026_07_01_064324_add_ai_consent_prompt_dismissed_at_to_users_table.php diff --git a/app/Http/Controllers/Ai/AiConsentController.php b/app/Http/Controllers/Ai/AiConsentController.php index 792df759..69440222 100644 --- a/app/Http/Controllers/Ai/AiConsentController.php +++ b/app/Http/Controllers/Ai/AiConsentController.php @@ -17,6 +17,7 @@ class AiConsentController extends Controller { $user = $request->user(); $user->recordAiConsent(); + $user->dismissAiConsentPrompt(); return response()->json([ 'consented' => true, @@ -24,6 +25,16 @@ class AiConsentController extends Controller ]); } + /** + * Permanently dismiss the AI consent prompt without granting consent. + */ + public function dismiss(Request $request): JsonResponse + { + $request->user()->dismissAiConsentPrompt(); + + return response()->json(['dismissed' => true]); + } + /** * Revoke the user's AI consent. */ diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 3fea8cc1..648cb8c2 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -135,6 +135,7 @@ class TransactionController extends Controller 'labels' => $labels, 'automationRules' => $automationRules, 'hasAiConsent' => $user->hasActiveAiConsent(), + 'aiConsentPromptDismissed' => $user->hasDismissedAiConsentPrompt(), 'lastVisitAt' => $lastVisitAt?->toISOString(), ]); } diff --git a/app/Models/User.php b/app/Models/User.php index 507ab806..973e1c16 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -28,6 +28,7 @@ use Laravel\Pennant\Concerns\HasFeatures; * @property ?Carbon $last_logged_in_at * @property ?Carbon $last_active_at * @property ?Carbon $transactions_last_visited_at + * @property ?Carbon $ai_consent_prompt_dismissed_at */ class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail { @@ -79,6 +80,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'last_logged_in_at' => 'datetime', 'last_active_at' => 'datetime', 'transactions_last_visited_at' => 'datetime', + 'ai_consent_prompt_dismissed_at' => 'datetime', ]; } @@ -193,6 +195,25 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma $this->aiConsents()->active($scope)->update(['revoked_at' => now()]); } + /** + * Whether the user has already answered the AI consent prompt (accepted or + * dismissed it), so the transactions banner should no longer be shown. + */ + public function hasDismissedAiConsentPrompt(): bool + { + return $this->ai_consent_prompt_dismissed_at !== null; + } + + /** + * Permanently dismiss the AI consent prompt (idempotent). + */ + public function dismissAiConsentPrompt(): void + { + if ($this->ai_consent_prompt_dismissed_at === null) { + $this->forceFill(['ai_consent_prompt_dismissed_at' => now()])->save(); + } + } + /** @return HasMany */ public function labels(): HasMany { diff --git a/database/migrations/2026_07_01_064324_add_ai_consent_prompt_dismissed_at_to_users_table.php b/database/migrations/2026_07_01_064324_add_ai_consent_prompt_dismissed_at_to_users_table.php new file mode 100644 index 00000000..dfeb320a --- /dev/null +++ b/database/migrations/2026_07_01_064324_add_ai_consent_prompt_dismissed_at_to_users_table.php @@ -0,0 +1,28 @@ +timestamp('ai_consent_prompt_dismissed_at')->nullable()->after('paywall_seen_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('ai_consent_prompt_dismissed_at'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 84cbb937..d94d041a 100644 --- a/lang/es.json +++ b/lang/es.json @@ -22,6 +22,9 @@ "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", + "AI enabled. You can change this anytime in Settings > Manage Plan.": "IA activada. Puedes cambiar esta opción cuando quieras en Configuración > Gestionar Plan.", + "AI not enabled. You can turn it on anytime in Settings > Manage Plan.": "IA no activada. Puedes activarla cuando quieras en Configuración > Gestionar Plan.", + "Dismiss": "Descartar", "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", diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 950e8c63..c951b057 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -14,7 +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 { ChevronRight, X } from 'lucide-react'; import { createElement, useCallback, @@ -89,7 +89,10 @@ import { getBulkDeleteConfirmationText } from '@/lib/transaction-delete-confirma 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 { + dismiss as dismissConsent, + store as storeConsent, +} from '@/routes/ai/consent'; import { transactionSyncService } from '@/services/transaction-sync'; import { type BreadcrumbItem, type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; @@ -137,6 +140,7 @@ interface Props { labels: Label[]; automationRules: AutomationRule[]; hasAiConsent: boolean; + aiConsentPromptDismissed: boolean; lastVisitAt: string | null; } @@ -429,17 +433,19 @@ export default function Transactions({ labels: initialLabels, automationRules, hasAiConsent, + aiConsentPromptDismissed, lastVisitAt, }: Props) { const locale = useLocale(); const { auth, features } = usePage().props; - const [aiConsentGiven, setAiConsentGiven] = useState(false); + const [aiConsentResolved, setAiConsentResolved] = useState(false); const [aiConsentSaving, setAiConsentSaving] = useState(false); const showAiConsentBanner = auth.hasProPlan && features.aiConsentSettings && !hasAiConsent && - !aiConsentGiven; + !aiConsentPromptDismissed && + !aiConsentResolved; const [categorizingIds, setCategorizingIds] = useState>( () => new Set(), ); @@ -733,7 +739,12 @@ export default function Transactions({ const { data } = await axios.post( storeConsent.url(), ); - setAiConsentGiven(true); + setAiConsentResolved(true); + toast.success( + __( + 'AI enabled. You can change this anytime in Settings > Manage Plan.', + ), + ); if (data.categorization) { // Spin every currently-visible uncategorized row while the @@ -755,8 +766,6 @@ export default function Transactions({ data.categorization.job_id, data.categorization.total, ); - } else { - toast.success(__('AI categorization enabled')); } } catch { toast.error(__('Something went wrong.')); @@ -765,6 +774,18 @@ export default function Transactions({ } }, [allTransactions, pollCategorizationStatus]); + const handleDismissAiConsent = useCallback(() => { + setAiConsentResolved(true); + axios.post(dismissConsent.url()).catch(() => { + // Best-effort: the banner is already hidden for this session. + }); + toast( + __( + 'AI not enabled. You can turn it on anytime in Settings > Manage Plan.', + ), + ); + }, []); + // Load More with cursor pagination (fetch directly to avoid cursor in URL) const { component, version } = usePage(); const handleLoadMore = useCallback(async () => { @@ -1428,12 +1449,12 @@ export default function Transactions({ )} topRow={ showAiConsentBanner ? ( -
-
+
+
-
+

{__( 'Let AI categorize your transactions', @@ -1446,15 +1467,29 @@ export default function Transactions({

- +
+ + +
) : null } diff --git a/routes/web.php b/routes/web.php index 4ef1ef79..75d547e3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -118,6 +118,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::post('ai/consent/dismiss', [AiConsentController::class, 'dismiss'])->name('ai.consent.dismiss'); 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 () { diff --git a/tests/Feature/AiConsentTest.php b/tests/Feature/AiConsentTest.php index 2b805e39..49f067cf 100644 --- a/tests/Feature/AiConsentTest.php +++ b/tests/Feature/AiConsentTest.php @@ -3,6 +3,8 @@ use App\Models\AiConsent; use App\Models\User; +use function Pest\Laravel\actingAs; + it('records a consent and reports it as active', function () { $user = User::factory()->create(); @@ -41,3 +43,37 @@ it('treats consent from a previous version as inactive', function () { expect($user->hasActiveAiConsent())->toBeFalse(); }); + +it('dismisses the consent prompt idempotently', function () { + $user = User::factory()->create(); + + expect($user->hasDismissedAiConsentPrompt())->toBeFalse(); + + $user->dismissAiConsentPrompt(); + $dismissedAt = $user->ai_consent_prompt_dismissed_at; + + $user->dismissAiConsentPrompt(); + + expect($user->hasDismissedAiConsentPrompt())->toBeTrue() + ->and($user->ai_consent_prompt_dismissed_at->equalTo($dismissedAt))->toBeTrue(); +}); + +it('marks the prompt as dismissed when consent is granted', function () { + $user = User::factory()->create(); + + actingAs($user)->postJson(route('ai.consent.store'))->assertOk(); + + expect($user->refresh()->hasDismissedAiConsentPrompt())->toBeTrue() + ->and($user->hasActiveAiConsent())->toBeTrue(); +}); + +it('dismisses the prompt without granting consent', function () { + $user = User::factory()->create(); + + actingAs($user)->postJson(route('ai.consent.dismiss')) + ->assertOk() + ->assertJson(['dismissed' => true]); + + expect($user->refresh()->hasDismissedAiConsentPrompt())->toBeTrue() + ->and($user->hasActiveAiConsent())->toBeFalse(); +});