feat(transactions): release transaction analysis to all users (#579)

## Summary

Removes the `TransactionAnalysis` Pennant feature flag and all its
gating. After this PR the transaction analysis feature (analysis drawer
+ saved filters) is available to every user.

## Changes

- Delete `app/Features/TransactionAnalysis.php`.
- `TransactionAnalysisController` — drop the `abort_unless(...403)` flag
gate and Pennant imports.
- `HandleInertiaRequests` — stop sharing the `transactionAnalysis` flag
in Inertia props.
- `Features` TS type — remove the `transactionAnalysis` field.
- `transaction-actions-menu.tsx` / `transaction-filters.tsx` — remove
the `features.transactionAnalysis &&` gates so the Analysis button, the
analysis drawer, and saved filters always render.
- Tests updated: dropped the endpoint-gating test and the "hidden when
flag off" UI test; adjusted shared-flag expectations.

## Verification

- `./vendor/bin/pest` — 23 passed (affected suites)
- Vitest — affected component/page tests pass
- Pint, Prettier, ESLint clean

## Notes

- The orphaned value left in Pennant's `features` DB table is harmless
and not addressed here.
This commit is contained in:
Víctor Falcón 2026-06-22 16:09:09 +02:00 committed by GitHub
parent d11aa2dfe5
commit ae6f869611
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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;
@ -179,19 +178,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()