feat(transactions): release transaction analysis to all users

Remove the TransactionAnalysis Pennant feature flag and its gating across the API, Inertia shared data, and the transactions UI so the analysis drawer and saved filters are available to everyone.
This commit is contained in:
Víctor Falcón 2026-06-22 12:39:08 +02:00
parent 5db6cdc6d2
commit 8b790013a9
10 changed files with 62 additions and 127 deletions

View File

@ -1,19 +0,0 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* @api
*/
class TransactionAnalysis
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers\Api;
use App\Features\TransactionAnalysis;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexTransactionRequest;
use App\Models\Label;
@ -12,7 +11,6 @@ use App\Services\ExchangeRateService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Laravel\Pennant\Feature;
class TransactionAnalysisController extends Controller
{
@ -37,8 +35,6 @@ class TransactionAnalysisController extends Controller
{
$user = $request->user();
abort_unless(Feature::for($user)->active(TransactionAnalysis::class), 403);
$validated = $request->validated();
$currency = $user->currency_code;

View File

@ -5,7 +5,6 @@ namespace App\Http\Middleware;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Features\CalculateBalancesOnImport;
use App\Features\TransactionAnalysis;
use App\Models\BankingConnection;
use App\Services\CurrencyOptions;
use Illuminate\Foundation\Inspiring;
@ -178,19 +177,16 @@ class HandleInertiaRequests extends Middleware
return [
'cashflow' => true,
'calculateBalancesOnImport' => false,
'transactionAnalysis' => false,
];
}
$features = Feature::for($user)->values([
CalculateBalancesOnImport::class,
TransactionAnalysis::class,
]);
return [
'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'transactionAnalysis' => $features[TransactionAnalysis::class] !== false,
];
}

View File

@ -1,11 +1,9 @@
import { type TransactionFilters } from '@/types/transaction';
import { fireEvent, render, screen } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { TransactionActionsMenu } from './transaction-actions-menu';
const features = { transactionAnalysis: true };
vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
categorize: { url: () => '/transactions/categorize' },
}));
@ -22,7 +20,6 @@ vi.mock('@inertiajs/react', () => ({
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
usePage: () => ({ props: { features } }),
}));
vi.mock('./import-transactions-drawer', () => ({
@ -61,17 +58,6 @@ function renderMenu(filters: TransactionFilters) {
}
describe('TransactionActionsMenu analysis button', () => {
beforeEach(() => {
features.transactionAnalysis = true;
});
it('is hidden when the TransactionAnalysis feature flag is off', () => {
features.transactionAnalysis = false;
renderMenu(emptyFilters);
expect(screen.queryByText('Analysis')).not.toBeInTheDocument();
});
it('is disabled when no filter is applied', () => {
renderMenu(emptyFilters);

View File

@ -17,7 +17,6 @@ import { useIsMobile } from '@/hooks/use-mobile';
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
import { hasActiveFilters } from '@/lib/transaction-filter-serialization';
import { type SharedData } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
@ -26,7 +25,7 @@ import {
type TransactionFilters,
} from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { Link, usePage } from '@inertiajs/react';
import { Link } from '@inertiajs/react';
import {
BarChart3,
ChevronDown,
@ -61,7 +60,6 @@ export function TransactionActionsMenu({
onImportComplete,
filters,
}: TransactionActionsMenuProps) {
const { features } = usePage<SharedData>().props;
const isMobile = useIsMobile();
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
const [analysisDrawerOpen, setAnalysisDrawerOpen] = useState(false);
@ -106,65 +104,60 @@ export function TransactionActionsMenu({
return (
<>
<ButtonGroup>
{features.transactionAnalysis &&
(isMobile ? (
<TooltipProvider>
<Tooltip
open={!canAnalyze && analysisHintOpen}
onOpenChange={setAnalysisHintOpen}
>
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!canAnalyze
? 'cursor-not-allowed opacity-50'
: ''
}
aria-disabled={!canAnalyze}
onClick={handleAnalysisClick}
>
<BarChart3 className="h-5 w-5" />
{__('Analysis')}
</Button>
</TooltipTrigger>
{!canAnalyze && (
<TooltipContent>
{__(
'Apply a filter to enable this button',
)}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!canAnalyze
? 'cursor-not-allowed opacity-50'
: ''
}
aria-disabled={!canAnalyze}
onClick={handleAnalysisClick}
>
<BarChart3 className="h-5 w-5" />
{__('Analysis')}
</Button>
</TooltipTrigger>
{!canAnalyze && (
<TooltipContent>
{__(
'Apply a filter to enable this button',
)}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
))}
{isMobile ? (
<TooltipProvider>
<Tooltip
open={!canAnalyze && analysisHintOpen}
onOpenChange={setAnalysisHintOpen}
>
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!canAnalyze
? 'cursor-not-allowed opacity-50'
: ''
}
aria-disabled={!canAnalyze}
onClick={handleAnalysisClick}
>
<BarChart3 className="h-5 w-5" />
{__('Analysis')}
</Button>
</TooltipTrigger>
{!canAnalyze && (
<TooltipContent>
{__('Apply a filter to enable this button')}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!canAnalyze
? 'cursor-not-allowed opacity-50'
: ''
}
aria-disabled={!canAnalyze}
onClick={handleAnalysisClick}
>
<BarChart3 className="h-5 w-5" />
{__('Analysis')}
</Button>
</TooltipTrigger>
{!canAnalyze && (
<TooltipContent>
{__('Apply a filter to enable this button')}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
@ -273,13 +266,11 @@ export function TransactionActionsMenu({
onImportComplete={onImportComplete}
/>
{features.transactionAnalysis && (
<TransactionAnalysisDrawer
open={analysisDrawerOpen}
onOpenChange={setAnalysisDrawerOpen}
filters={filters}
/>
)}
<TransactionAnalysisDrawer
open={analysisDrawerOpen}
onOpenChange={setAnalysisDrawerOpen}
filters={filters}
/>
</>
);
}

View File

@ -1,5 +1,4 @@
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { ChevronsUpDown, Tag, X } from 'lucide-react';
@ -32,7 +31,6 @@ import {
toggleCategorySelection,
} from '@/lib/category-tree';
import { cn } from '@/lib/utils';
import { type SharedData } from '@/types';
import { type Account } from '@/types/account';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { getLabelColorClasses, type Label } from '@/types/label';
@ -60,7 +58,6 @@ export function TransactionFilters({
hideAccountFilter = false,
enableSavedFilters = false,
}: TransactionFiltersProps) {
const { features } = usePage<SharedData>().props;
const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [categorySearch, setCategorySearch] = useState('');
@ -733,7 +730,7 @@ export function TransactionFilters({
/>
<div className="ml-auto flex items-center gap-2">
{enableSavedFilters && features.transactionAnalysis && (
{enableSavedFilters && (
<SavedFilters
filters={filters}
onLoad={onFiltersChange}

View File

@ -28,7 +28,6 @@ vi.mock('@inertiajs/react', () => ({
features: {
cashflow: true,
calculateBalancesOnImport: false,
transactionAnalysis: false,
},
},
}),

View File

@ -42,7 +42,6 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
transactionAnalysis: boolean;
}
export interface ExpiredBankingConnectionNotification {

View File

@ -43,7 +43,6 @@ test('shared feature flags do not include coinbase flag', function () {
expect($props['features'])->toBe([
'cashflow' => true,
'calculateBalancesOnImport' => false,
'transactionAnalysis' => false,
]);
});

View File

@ -1,7 +1,6 @@
<?php
use App\Enums\CategoryType;
use App\Features\TransactionAnalysis;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
@ -9,14 +8,12 @@ use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
beforeEach(function () {
Http::fake();
$this->user = User::factory()->create(['currency_code' => 'USD']);
$this->actingAs($this->user);
Feature::for($this->user)->activate(TransactionAnalysis::class);
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
@ -34,12 +31,6 @@ function makeTransaction(array $attributes = []): Transaction
]);
}
test('analysis endpoint is gated behind the TransactionAnalysis feature flag', function () {
Feature::for($this->user)->deactivate(TransactionAnalysis::class);
$this->getJson('/api/transactions/analysis')->assertForbidden();
});
test('analysis response is not cached between users', function () {
$this->getJson('/api/transactions/analysis')
->assertOk()