diff --git a/app/Console/Commands/CategorizeBackfillCommand.php b/app/Console/Commands/CategorizeBackfillCommand.php index 32a24244..5d953c17 100644 --- a/app/Console/Commands/CategorizeBackfillCommand.php +++ b/app/Console/Commands/CategorizeBackfillCommand.php @@ -27,7 +27,7 @@ class CategorizeBackfillCommand extends Command return self::FAILURE; } - if (! $gate->allowsBackfill($user)) { + if (! $gate->allows($user)) { $this->warn('User is not eligible (needs the feature enabled, a pro plan and active AI consent).'); return self::FAILURE; diff --git a/app/Features/AiCategorization.php b/app/Features/AiCategorization.php deleted file mode 100644 index 1e3c3eb2..00000000 --- a/app/Features/AiCategorization.php +++ /dev/null @@ -1,35 +0,0 @@ -created_at !== null - && $user->created_at->gt(Carbon::parse($rolloutAfter)); - } -} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index bf971af2..4d3b4c6c 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -92,6 +92,7 @@ class HandleInertiaRequests extends Middleware 'password' => config('app.demo.password'), ] : null, 'subscriptionsEnabled' => config('subscriptions.enabled', false), + 'aiCategorizationUpsellRate' => (int) config('ai_categorization.upsell_sample_rate'), 'pricing' => [ 'plans' => config('subscriptions.plans', []), 'defaultPlan' => config('subscriptions.default_plan', 'monthly'), diff --git a/app/Services/Ai/AiCategorizationGate.php b/app/Services/Ai/AiCategorizationGate.php index 4cf46a82..ea9b4751 100644 --- a/app/Services/Ai/AiCategorizationGate.php +++ b/app/Services/Ai/AiCategorizationGate.php @@ -2,39 +2,16 @@ namespace App\Services\Ai; -use App\Features\AiCategorization; use App\Models\User; -use Laravel\Pennant\Feature; /** * The eligibility gate for AI auto-categorization: a hard config kill switch, a - * pro subscription, an active (current-version) AI consent, and the per-user - * Pennant rollout flag. All four must hold before any transaction is sent. + * pro subscription and an active (current-version) AI consent. All three must + * hold before any transaction is sent. */ class AiCategorizationGate { public function allows(User $user): bool - { - if (! (bool) config('ai_categorization.enabled')) { - return false; - } - - if (! $user->hasProPlan()) { - return false; - } - - if (! $user->hasActiveAiConsent()) { - return false; - } - - return Feature::for($user)->active(AiCategorization::class); - } - - /** - * Backfill is an explicit, user-initiated action, so it requires the kill - * switch, a pro plan and active consent — but not the gradual rollout flag. - */ - public function allowsBackfill(User $user): bool { return (bool) config('ai_categorization.enabled') && $user->hasProPlan() diff --git a/config/ai_categorization.php b/config/ai_categorization.php index 68e5c784..78d9f7bd 100644 --- a/config/ai_categorization.php +++ b/config/ai_categorization.php @@ -27,20 +27,6 @@ return [ 'enabled' => (bool) env('AI_CATEGORIZATION_ENABLED', true), - /* - |-------------------------------------------------------------------------- - | Rollout cohort - |-------------------------------------------------------------------------- - | - | The AiCategorization Pennant feature resolves to active for users created - | strictly after this timestamp (parsed in the app timezone). This rolls the - | feature out to new signups only. Set to null to deactivate the cohort - | (the flag can still be activated per-user via Pennant directly). - | - */ - - 'rollout_after' => env('AI_CATEGORIZATION_ROLLOUT_AFTER', '2026-06-13 21:00:00'), - /* |-------------------------------------------------------------------------- | Confidence bars @@ -83,4 +69,18 @@ return [ 'queue' => env('AI_CATEGORIZATION_QUEUE', 'ai'), + /* + |-------------------------------------------------------------------------- + | Free-plan upsell nudge + |-------------------------------------------------------------------------- + | + | Percentage (0-100) of a free user's uncategorized transactions that show + | the "AI could categorize this" sparkle. Sampled deterministically by + | transaction id so the same rows always decide the same way. Exposed to the + | frontend as the `aiCategorizationUpsellRate` Inertia prop. + | + */ + + 'upsell_sample_rate' => (int) env('AI_CATEGORIZATION_UPSELL_SAMPLE_RATE', 40), + ]; diff --git a/lang/es.json b/lang/es.json index 9ab9cc25..8a441226 100644 --- a/lang/es.json +++ b/lang/es.json @@ -31,6 +31,7 @@ "You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.", "Remove one vote": "Quitar un voto", "Categorized by AI": "Categorizado por IA", + "Let AI categorize your transactions": "Deja que la IA categorice tus transacciones", "Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza", "Only show AI guesses": "Mostrar solo sugerencias de IA", "Created by AI": "Creado por IA", diff --git a/resources/js/components/transactions/ai-upsell-sample.test.ts b/resources/js/components/transactions/ai-upsell-sample.test.ts new file mode 100644 index 00000000..5a02f8dc --- /dev/null +++ b/resources/js/components/transactions/ai-upsell-sample.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { showsAiUpsell } from './ai-upsell-sample'; + +describe('showsAiUpsell', () => { + it('is deterministic for the same id and rate', () => { + const id = '019ec83c-9837-7245-8717-ae66b6dd9957'; + + expect(showsAiUpsell(id, 33)).toBe(showsAiUpsell(id, 33)); + }); + + it('shows nothing at 0 and everything at 100', () => { + const id = '019ec83c-9837-7245-8717-ae66b6dd9957'; + + expect(showsAiUpsell(id, 0)).toBe(false); + expect(showsAiUpsell(id, 100)).toBe(true); + }); + + it('thresholds on the id last byte', () => { + // 0x54 = 84, below the 33% cutoff (84.48) -> shown + expect(showsAiUpsell('00000000-0000-0000-0000-000000000054', 33)).toBe( + true, + ); + // 0x55 = 85, at/above the cutoff -> hidden + expect(showsAiUpsell('00000000-0000-0000-0000-000000000055', 33)).toBe( + false, + ); + }); + + it('samples about the configured rate across all 256 final bytes', () => { + const rateShown = (pct: number) => + Array.from({ length: 256 }, (_, n) => + showsAiUpsell( + `00000000-0000-0000-0000-0000000000${n.toString(16).padStart(2, '0')}`, + pct, + ), + ).filter(Boolean).length; + + expect(rateShown(33)).toBe(85); // bytes 0..84 + expect(rateShown(50)).toBe(128); // bytes 0..127 + expect(rateShown(75)).toBe(192); // bytes 0..191 + }); +}); diff --git a/resources/js/components/transactions/ai-upsell-sample.ts b/resources/js/components/transactions/ai-upsell-sample.ts new file mode 100644 index 00000000..982a1416 --- /dev/null +++ b/resources/js/components/transactions/ai-upsell-sample.ts @@ -0,0 +1,21 @@ +/** + * Deterministic sampler keyed on a transaction id, so the AI-categorization + * upsell sparkle shows on the same rows across reloads instead of flickering and + * does not appear on every uncategorized row. + * + * The share is configurable: `samplePercent` (0-100) comes from the + * `aiCategorizationUpsellRate` Inertia prop, backed by + * `ai_categorization.upsell_sample_rate` (env: AI_CATEGORIZATION_UPSELL_SAMPLE_RATE). + * + * ponytail: map the id's last byte (0-255) onto the 0-100 threshold. Uniform + * enough for a cosmetic nudge; swap for a real hash if the split must be exact. + */ +export function showsAiUpsell( + transactionId: string, + samplePercent: number, +): boolean { + const lastByte = parseInt(transactionId.replace(/-/g, '').slice(-2), 16); + const byte = Number.isNaN(lastByte) ? 256 : lastByte; // 256 → never shows + + return byte < (samplePercent / 100) * 256; +} diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index a285c6cf..68c759c9 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -1,3 +1,4 @@ +import { showsAiUpsell } from '@/components/transactions/ai-upsell-sample'; import { CategorySelect } from '@/components/transactions/category-select'; import { AiSparkleIcon } from '@/components/ui/ai-sparkle-icon'; import { @@ -8,11 +9,14 @@ import { } from '@/components/ui/tooltip'; import { useIsMobile } from '@/hooks/use-mobile'; import { cn } from '@/lib/utils'; +import { billing } from '@/routes/settings'; import { transactionSyncService } from '@/services/transaction-sync'; +import { type SharedData } from '@/types'; import { type Account, type Bank } from '@/types/account'; import { type Category } from '@/types/category'; import { type DecryptedTransaction } from '@/types/transaction'; import { __ } from '@/utils/i18n'; +import { router, usePage } from '@inertiajs/react'; import { useState } from 'react'; interface CategoryCellProps { @@ -42,6 +46,16 @@ export function CategoryCell({ }: CategoryCellProps) { const [isUpdating, setIsUpdating] = useState(false); const isMobile = useIsMobile(); + const { auth, subscriptionsEnabled, aiCategorizationUpsellRate } = + usePage().props; + + // Free-plan nudge: AI could categorize this row. Sampled to a configurable + // share of rows so it stays subtle instead of marking every uncategorized one. + const showAiUpsell = + !transaction.category_id && + subscriptionsEnabled && + !auth.hasProPlan && + showsAiUpsell(transaction.id, aiCategorizationUpsellRate); async function handleCategoryChange(value: string) { const categoryId = value === 'null' ? null : value; @@ -123,6 +137,29 @@ export function CategoryCell({ ); + const aiUpsell = ( + + + + + + + {__('Let AI categorize your transactions')} + + + + ); + return (
@@ -151,8 +188,8 @@ export function CategoryCell({ horizontally on every row. Desktop shows confidence on hover; mobile relies on the dropdown header. */} - {isAiCategorized && - (isMobile ? ( + {isAiCategorized ? ( + isMobile ? ( aiIcon ) : ( @@ -163,7 +200,10 @@ export function CategoryCell({ {aiNote} - ))} + ) + ) : showAiUpsell ? ( + aiUpsell + ) : null}
); diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 9212bf2a..856fb78a 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -78,6 +78,7 @@ export interface SharedData { includeLoansInNetWorthChart: boolean; includeRealEstateInNetWorthChart: boolean; subscriptionsEnabled: boolean; + aiCategorizationUpsellRate: number; subscriptionPaymentIssue: SubscriptionPaymentIssueNotification | null; pricing: PricingConfig; sidebarOpen: boolean; diff --git a/tests/Feature/Ai/AiCategorizationFeatureTest.php b/tests/Feature/Ai/AiCategorizationFeatureTest.php deleted file mode 100644 index e09eaaee..00000000 --- a/tests/Feature/Ai/AiCategorizationFeatureTest.php +++ /dev/null @@ -1,29 +0,0 @@ -set('ai_categorization.rollout_after', '2026-06-13 21:00:00'); -}); - -it('activates for users created after the rollout cutoff', function () { - $user = User::factory()->create(['created_at' => '2026-06-13 21:00:01']); - - expect(Feature::for($user)->active(AiCategorization::class))->toBeTrue(); -}); - -it('stays inactive for users created before the rollout cutoff', function () { - $user = User::factory()->create(['created_at' => '2026-06-13 20:59:59']); - - expect(Feature::for($user)->active(AiCategorization::class))->toBeFalse(); -}); - -it('is inactive when no rollout cutoff is configured', function () { - config()->set('ai_categorization.rollout_after', null); - - $user = User::factory()->create(['created_at' => '2026-06-14 00:00:00']); - - expect(Feature::for($user)->active(AiCategorization::class))->toBeFalse(); -}); diff --git a/tests/Feature/Ai/AiCategorizationGateTest.php b/tests/Feature/Ai/AiCategorizationGateTest.php index 89b61104..112f74b9 100644 --- a/tests/Feature/Ai/AiCategorizationGateTest.php +++ b/tests/Feature/Ai/AiCategorizationGateTest.php @@ -1,20 +1,17 @@ create(); $user->recordAiConsent(); - Feature::for($user)->activate(AiCategorization::class); return $user; } -it('allows an enabled, pro, consented, flagged user', function () { +it('allows an enabled, pro, consented user', function () { expect(app(AiCategorizationGate::class)->allows(eligibleUser()))->toBeTrue(); }); @@ -26,17 +23,6 @@ it('denies when the master kill switch is off', function () { it('denies a user without active AI consent', function () { $user = User::factory()->create(); - Feature::for($user)->activate(AiCategorization::class); - - expect(app(AiCategorizationGate::class)->allows($user))->toBeFalse(); -}); - -it('denies a user the rollout flag is not active for', function () { - config()->set('ai_categorization.rollout_after', '2026-06-13 21:00:00'); - - // Signed up before the rollout cutoff, so the feature does not resolve on. - $user = User::factory()->create(['created_at' => '2026-06-13 20:00:00']); - $user->recordAiConsent(); expect(app(AiCategorizationGate::class)->allows($user))->toBeFalse(); }); diff --git a/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php b/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php index 2304aaa9..96ef777c 100644 --- a/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php +++ b/tests/Feature/Ai/CategorizeOnboardingTransactionsJobTest.php @@ -4,19 +4,16 @@ use App\Ai\Agents\TransactionCategorizationAgent; use App\Enums\CategoryCashflowDirection; use App\Enums\CategorySource; use App\Enums\CategoryType; -use App\Features\AiCategorization; use App\Jobs\CategorizeOnboardingTransactionsJob; use App\Models\Category; use App\Models\Transaction; use App\Models\User; use App\Services\Ai\CategoryCatalog; -use Laravel\Pennant\Feature; function eligibleOnboardedUser(): User { $user = User::factory()->onboarded()->create(); $user->recordAiConsent(); - Feature::for($user)->activate(AiCategorization::class); return $user; } diff --git a/tests/Feature/Ai/CategorizeTransactionWithAiTest.php b/tests/Feature/Ai/CategorizeTransactionWithAiTest.php index f93c1c43..81c82b7c 100644 --- a/tests/Feature/Ai/CategorizeTransactionWithAiTest.php +++ b/tests/Feature/Ai/CategorizeTransactionWithAiTest.php @@ -4,18 +4,15 @@ use App\Ai\Agents\TransactionCategorizationAgent; use App\Enums\CategoryCashflowDirection; use App\Enums\CategorySource; use App\Enums\CategoryType; -use App\Features\AiCategorization; use App\Models\Category; use App\Models\Transaction; use App\Models\User; use App\Services\Ai\CategoryCatalog; -use Laravel\Pennant\Feature; function eligible(): User { $user = User::factory()->onboarded()->create(); $user->recordAiConsent(); - Feature::for($user)->activate(AiCategorization::class); return $user; }