feat(ai): dismissable AI consent banner that stops after the first decision (#617)

## 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.
This commit is contained in:
Víctor Falcón 2026-07-01 09:26:36 +02:00 committed by GitHub
parent af64f56399
commit 7e36bbafef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 155 additions and 19 deletions

View File

@ -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.
*/

View File

@ -135,6 +135,7 @@ class TransactionController extends Controller
'labels' => $labels,
'automationRules' => $automationRules,
'hasAiConsent' => $user->hasActiveAiConsent(),
'aiConsentPromptDismissed' => $user->hasDismissedAiConsentPrompt(),
'lastVisitAt' => $lastVisitAt?->toISOString(),
]);
}

View File

@ -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<Label, $this> */
public function labels(): HasMany
{

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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",

View File

@ -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<SharedData>().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<Set<string>>(
() => new Set(),
);
@ -733,7 +739,12 @@ export default function Transactions({
const { data } = await axios.post<AiConsentResponse>(
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 ? (
<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 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 sm:justify-between dark:from-violet-950/30 dark:via-sky-950/20 dark:to-rose-950/20">
<div className="flex gap-3 sm:flex-1">
<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">
<div className="max-w-80 sm:max-w-none">
<p className="font-medium">
{__(
'Let AI categorize your transactions',
@ -1446,15 +1467,29 @@ export default function Transactions({
</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 className="flex items-center gap-2">
<Button
onClick={
handleDismissAiConsent
}
disabled={aiConsentSaving}
className="opacity-20 hover:opacity-100 transition-all duration-300"
variant="ghost"
size="icon"
aria-label={__('Dismiss')}
>
<X />
</Button>
<Button
onClick={handleEnableAi}
disabled={aiConsentSaving}
variant="secondary"
className="max-w-[calc(var(--spacing)_*_86)] bg-white px-6!"
>
{__('Enable AI')}
<ChevronRight />
</Button>
</div>
</div>
) : null
}

View File

@ -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 () {

View File

@ -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();
});