# Expense Tracking — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a Pro-tier expense tracking system to ECStores with two Filament resources (ExpenseCategories, Expenses) and a dedicated Expense Reports page with date range filtering and CSV export.

**Architecture:** Two new tenant-scoped tables (`expense_categories`, `expenses`). `ExpenseCategory` and `Expense` models follow the existing pattern. Two self-contained Filament resources and one Filament page follow the exact patterns of `CouponResource` and `FinancialReports`. Plan gate uses `PlanService::hasExpenseTracker()` — already wired in `PlanService`, just needs to be called.

**Tech Stack:** Laravel 13, Filament 5.6, stancl/tenancy 3.x, PHPUnit

**Spec:** `docs/superpowers/specs/2026-05-22-expense-tracking-design.md`

---

## File Map

| Status | Path | Purpose |
|---|---|---|
| CREATE | `database/migrations/tenant/2026_05_22_000001_create_expense_tables.php` | `expense_categories` and `expenses` tenant tables |
| CREATE | `app/Models/ExpenseCategory.php` | ExpenseCategory model |
| CREATE | `app/Models/Expense.php` | Expense model with `total` accessor |
| CREATE | `database/seeders/TenantExpenseCategorySeeder.php` | Seeds 8 default categories per tenant |
| CREATE | `tests/Unit/Models/ExpenseTest.php` | Unit tests for `Expense::total` accessor |
| CREATE | `app/Filament/Resources/ExpenseCategories/ExpenseCategoryResource.php` | Filament resource |
| CREATE | `app/Filament/Resources/ExpenseCategories/Pages/ListExpenseCategories.php` | List page with plan gate |
| CREATE | `app/Filament/Resources/ExpenseCategories/Pages/CreateExpenseCategory.php` | Create page |
| CREATE | `app/Filament/Resources/ExpenseCategories/Pages/EditExpenseCategory.php` | Edit page |
| CREATE | `app/Filament/Resources/Expenses/ExpenseResource.php` | Filament resource |
| CREATE | `app/Filament/Resources/Expenses/Pages/ListExpenses.php` | List page with plan gate |
| CREATE | `app/Filament/Resources/Expenses/Pages/CreateExpense.php` | Create page |
| CREATE | `app/Filament/Resources/Expenses/Pages/EditExpense.php` | Edit page |
| CREATE | `app/Filament/Pages/ExpenseReports.php` | Reports page |
| CREATE | `resources/views/filament/pages/expense-reports.blade.php` | Reports blade view |

---

## Task 1: Tenant Migration

**Files:**
- Create: `database/migrations/tenant/2026_05_22_000001_create_expense_tables.php`

- [ ] **Step 1: Create the migration file**

```php
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('expense_categories', function (Blueprint $table) {
            $table->id();
            $table->string('name', 100);
            $table->boolean('is_default')->default(false);
            $table->unsignedInteger('sort_order')->default(0);
            $table->timestamps();
        });

        Schema::create('expenses', function (Blueprint $table) {
            $table->id();
            $table->foreignId('expense_category_id')
                ->nullable()
                ->constrained('expense_categories')
                ->nullOnDelete();
            $table->string('description', 255);
            $table->decimal('amount', 10, 2);
            $table->decimal('tax_amount', 10, 2)->default(0);
            $table->date('expense_date');
            $table->string('receipt_reference', 100)->nullable();
            $table->text('notes')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('expenses');
        Schema::dropIfExists('expense_categories');
    }
};
```

- [ ] **Step 2: Run migration on all tenant databases**

```bash
php artisan tenants:artisan "migrate --force"
```

Expected: each tenant DB gets `expense_categories` and `expenses` tables.

- [ ] **Step 3: Commit**

```bash
git add database/migrations/tenant/2026_05_22_000001_create_expense_tables.php
git commit -m "feat: add expense_categories and expenses tenant migrations"
```

---

## Task 2: Write Failing Unit Tests + Create Models

**Files:**
- Create: `tests/Unit/Models/ExpenseTest.php`
- Create: `app/Models/ExpenseCategory.php`
- Create: `app/Models/Expense.php`

- [ ] **Step 1: Create the failing test file**

```php
<?php

declare(strict_types=1);

namespace Tests\Unit\Models;

use App\Models\Expense;
use Tests\TestCase;

class ExpenseTest extends TestCase
{
    public function test_total_sums_amount_and_tax(): void
    {
        $expense = new Expense(['amount' => '10.00', 'tax_amount' => '1.30']);
        $this->assertEquals(11.30, $expense->total);
    }

    public function test_total_with_zero_tax(): void
    {
        $expense = new Expense(['amount' => '50.00', 'tax_amount' => '0.00']);
        $this->assertEquals(50.00, $expense->total);
    }

    public function test_total_returns_float(): void
    {
        $expense = new Expense(['amount' => '25.00', 'tax_amount' => '3.25']);
        $this->assertIsFloat($expense->total);
    }
}
```

- [ ] **Step 2: Run test to confirm it fails**

```bash
php artisan test tests/Unit/Models/ExpenseTest.php
```

Expected: `ERROR` — class `App\Models\Expense` not found.

- [ ] **Step 3: Create ExpenseCategory model**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ExpenseCategory extends Model
{
    protected $fillable = ['name', 'sort_order', 'is_default'];

    protected function casts(): array
    {
        return [
            'is_default' => 'boolean',
            'sort_order' => 'integer',
        ];
    }

    public function expenses(): HasMany
    {
        return $this->hasMany(Expense::class);
    }
}
```

- [ ] **Step 4: Create Expense model**

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Expense extends Model
{
    protected $fillable = [
        'expense_category_id',
        'description',
        'amount',
        'tax_amount',
        'expense_date',
        'receipt_reference',
        'notes',
    ];

    protected function casts(): array
    {
        return [
            'amount'       => 'decimal:2',
            'tax_amount'   => 'decimal:2',
            'expense_date' => 'date',
        ];
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(ExpenseCategory::class, 'expense_category_id');
    }

    protected function total(): Attribute
    {
        return Attribute::make(
            get: fn () => (float) $this->amount + (float) $this->tax_amount,
        );
    }
}
```

- [ ] **Step 5: Run tests to confirm they pass**

```bash
php artisan test tests/Unit/Models/ExpenseTest.php
```

Expected: `PASS` — 3 tests, 3 assertions.

- [ ] **Step 6: Commit**

```bash
git add tests/Unit/Models/ExpenseTest.php app/Models/ExpenseCategory.php app/Models/Expense.php
git commit -m "feat: add ExpenseCategory and Expense models with unit tests"
```

---

## Task 3: TenantExpenseCategorySeeder

**Files:**
- Create: `database/seeders/TenantExpenseCategorySeeder.php`

- [ ] **Step 1: Create the seeder**

```php
<?php

declare(strict_types=1);

namespace Database\Seeders;

use App\Models\ExpenseCategory;
use Illuminate\Database\Seeder;

class TenantExpenseCategorySeeder extends Seeder
{
    public function run(): void
    {
        $categories = [
            ['name' => 'Cost of Goods Sold',            'sort_order' => 1],
            ['name' => 'Shipping Supplies & Packaging',  'sort_order' => 2],
            ['name' => 'Advertising & Marketing',        'sort_order' => 3],
            ['name' => 'Platform & Software Fees',       'sort_order' => 4],
            ['name' => 'Shipping & Postage',             'sort_order' => 5],
            ['name' => 'Office & Supplies',              'sort_order' => 6],
            ['name' => 'Professional Services',          'sort_order' => 7],
            ['name' => 'Other',                          'sort_order' => 8],
        ];

        foreach ($categories as $data) {
            ExpenseCategory::firstOrCreate(
                ['name' => $data['name']],
                ['sort_order' => $data['sort_order'], 'is_default' => true]
            );
        }

        $this->command->info('Default expense categories seeded.');
    }
}
```

- [ ] **Step 2: Run seeder on all tenant databases**

```bash
php artisan tenants:artisan "db:seed --class=TenantExpenseCategorySeeder"
```

Expected: each tenant sees "Default expense categories seeded."

- [ ] **Step 3: Verify** — connect to any tenant DB and run:

```sql
SELECT id, name, is_default, sort_order FROM expense_categories ORDER BY sort_order;
```

Expected: 8 rows with `is_default = 1`.

- [ ] **Step 4: Commit**

```bash
git add database/seeders/TenantExpenseCategorySeeder.php
git commit -m "feat: add TenantExpenseCategorySeeder with 8 default categories"
```

---

## Task 4: ExpenseCategoryResource

**Files:**
- Create: `app/Filament/Resources/ExpenseCategories/ExpenseCategoryResource.php`
- Create: `app/Filament/Resources/ExpenseCategories/Pages/ListExpenseCategories.php`
- Create: `app/Filament/Resources/ExpenseCategories/Pages/CreateExpenseCategory.php`
- Create: `app/Filament/Resources/ExpenseCategories/Pages/EditExpenseCategory.php`

- [ ] **Step 1: Create ExpenseCategoryResource**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ExpenseCategories;

use App\Filament\Resources\ExpenseCategories\Pages\CreateExpenseCategory;
use App\Filament\Resources\ExpenseCategories\Pages\EditExpenseCategory;
use App\Filament\Resources\ExpenseCategories\Pages\ListExpenseCategories;
use App\Models\ExpenseCategory;
use App\Services\PlanService;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;

class ExpenseCategoryResource extends Resource
{
    protected static ?string $model = ExpenseCategory::class;

    protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFolderOpen;

    protected static ?string $recordTitleAttribute = 'name';

    public static function getNavigationGroup(): ?string
    {
        return 'Expenses';
    }

    public static function getNavigationSort(): ?int
    {
        return 1;
    }

    public static function getNavigationBadge(): ?string
    {
        return app(PlanService::class)->hasExpenseTracker() ? null : 'Pro';
    }

    public static function getNavigationBadgeColor(): string|array|null
    {
        return app(PlanService::class)->hasExpenseTracker() ? null : 'warning';
    }

    public static function canCreate(): bool
    {
        return app(PlanService::class)->hasExpenseTracker();
    }

    public static function form(Schema $schema): Schema
    {
        return $schema->components([
            TextInput::make('name')
                ->required()
                ->maxLength(100),

            TextInput::make('sort_order')
                ->label('Sort Order')
                ->integer()
                ->default(0)
                ->minValue(0),
        ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('name')
                    ->searchable()
                    ->sortable(),

                TextColumn::make('sort_order')
                    ->label('Sort')
                    ->sortable(),

                IconColumn::make('is_default')
                    ->label('Default')
                    ->boolean(),

                TextColumn::make('expenses_count')
                    ->label('Expenses')
                    ->counts('expenses')
                    ->sortable(),
            ])
            ->recordActions([
                EditAction::make(),
                DeleteAction::make()
                    ->before(function (DeleteAction $action, ExpenseCategory $record) {
                        if ($record->expenses()->exists()) {
                            Notification::make()
                                ->title('Cannot delete category')
                                ->body('Reassign or delete all expenses in this category first.')
                                ->danger()
                                ->send();
                            $action->halt();
                        }
                    }),
            ])
            ->defaultSort('sort_order');
    }

    public static function getPages(): array
    {
        return [
            'index'  => ListExpenseCategories::route('/'),
            'create' => CreateExpenseCategory::route('/create'),
            'edit'   => EditExpenseCategory::route('/{record}/edit'),
        ];
    }
}
```

- [ ] **Step 2: Create ListExpenseCategories**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ExpenseCategories\Pages;

use App\Filament\Resources\ExpenseCategories\ExpenseCategoryResource;
use App\Services\PlanService;
use Filament\Actions\CreateAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;

class ListExpenseCategories extends ListRecords
{
    protected static string $resource = ExpenseCategoryResource::class;

    public function mount(): void
    {
        parent::mount();

        if (! app(PlanService::class)->hasExpenseTracker()) {
            Notification::make()
                ->title('Expense tracking requires the Pro plan')
                ->body('Contact us to upgrade.')
                ->warning()
                ->persistent()
                ->send();
        }
    }

    protected function getHeaderActions(): array
    {
        return [
            CreateAction::make(),
        ];
    }
}
```

- [ ] **Step 3: Create CreateExpenseCategory**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ExpenseCategories\Pages;

use App\Filament\Resources\ExpenseCategories\ExpenseCategoryResource;
use Filament\Resources\Pages\CreateRecord;

class CreateExpenseCategory extends CreateRecord
{
    protected static string $resource = ExpenseCategoryResource::class;
}
```

- [ ] **Step 4: Create EditExpenseCategory**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\ExpenseCategories\Pages;

use App\Filament\Resources\ExpenseCategories\ExpenseCategoryResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;

class EditExpenseCategory extends EditRecord
{
    protected static string $resource = ExpenseCategoryResource::class;

    protected function getHeaderActions(): array
    {
        return [
            DeleteAction::make(),
        ];
    }
}
```

- [ ] **Step 5: Verify in browser** — log into any tenant admin. Confirm "Expenses" nav group appears with "Expense Categories" (sort 1). Open it — 8 default categories listed. Create a new category — it appears. Attempt to delete a default category that has no expenses — it deletes. Try to delete one that has expenses — red notification appears and it is not deleted.

- [ ] **Step 6: Commit**

```bash
git add app/Filament/Resources/ExpenseCategories/
git commit -m "feat: add ExpenseCategoryResource with plan gate"
```

---

## Task 5: ExpenseResource

**Files:**
- Create: `app/Filament/Resources/Expenses/ExpenseResource.php`
- Create: `app/Filament/Resources/Expenses/Pages/ListExpenses.php`
- Create: `app/Filament/Resources/Expenses/Pages/CreateExpense.php`
- Create: `app/Filament/Resources/Expenses/Pages/EditExpense.php`

- [ ] **Step 1: Create ExpenseResource**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\Expenses;

use App\Filament\Resources\Expenses\Pages\CreateExpense;
use App\Filament\Resources\Expenses\Pages\EditExpense;
use App\Filament\Resources\Expenses\Pages\ListExpenses;
use App\Models\Expense;
use App\Models\ExpenseCategory;
use App\Services\PlanService;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class ExpenseResource extends Resource
{
    protected static ?string $model = Expense::class;

    protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBanknotes;

    protected static ?string $recordTitleAttribute = 'description';

    public static function getNavigationGroup(): ?string
    {
        return 'Expenses';
    }

    public static function getNavigationSort(): ?int
    {
        return 2;
    }

    public static function getNavigationBadge(): ?string
    {
        return app(PlanService::class)->hasExpenseTracker() ? null : 'Pro';
    }

    public static function getNavigationBadgeColor(): string|array|null
    {
        return app(PlanService::class)->hasExpenseTracker() ? null : 'warning';
    }

    public static function canCreate(): bool
    {
        return app(PlanService::class)->hasExpenseTracker();
    }

    public static function form(Schema $schema): Schema
    {
        return $schema->components([
            DatePicker::make('expense_date')
                ->label('Date')
                ->required()
                ->default(now()),

            Select::make('expense_category_id')
                ->label('Category')
                ->options(fn () => ExpenseCategory::orderBy('sort_order')->pluck('name', 'id'))
                ->searchable()
                ->createOptionForm([
                    TextInput::make('name')
                        ->required()
                        ->maxLength(100),
                    TextInput::make('sort_order')
                        ->integer()
                        ->default(0),
                ])
                ->createOptionUsing(function (array $data): int {
                    return ExpenseCategory::create([
                        'name'       => $data['name'],
                        'sort_order' => $data['sort_order'] ?? 0,
                        'is_default' => false,
                    ])->id;
                })
                ->required(),

            TextInput::make('description')
                ->required()
                ->maxLength(255),

            TextInput::make('amount')
                ->label('Amount (pre-tax)')
                ->numeric()
                ->prefix('$')
                ->minValue(0)
                ->required(),

            TextInput::make('tax_amount')
                ->label('Tax (HST/GST)')
                ->numeric()
                ->prefix('$')
                ->minValue(0)
                ->default(0)
                ->helperText('HST/GST paid on this expense'),

            TextInput::make('receipt_reference')
                ->label('Receipt / Invoice #')
                ->maxLength(100),

            Textarea::make('notes')
                ->rows(3),
        ]);
    }

    public static function table(Table $table): Table
    {
        return $table
            ->columns([
                TextColumn::make('expense_date')
                    ->label('Date')
                    ->date('M j, Y')
                    ->sortable(),

                TextColumn::make('category.name')
                    ->label('Category')
                    ->sortable(),

                TextColumn::make('description')
                    ->searchable()
                    ->limit(40),

                TextColumn::make('amount')
                    ->label('Amount')
                    ->prefix('$')
                    ->numeric(decimalPlaces: 2)
                    ->sortable(),

                TextColumn::make('tax_amount')
                    ->label('Tax')
                    ->prefix('$')
                    ->numeric(decimalPlaces: 2),

                TextColumn::make('total')
                    ->label('Total')
                    ->prefix('$')
                    ->numeric(decimalPlaces: 2)
                    ->getStateUsing(fn (Expense $record): float => (float) $record->amount + (float) $record->tax_amount)
                    ->sortable(false),
            ])
            ->filters([
                SelectFilter::make('expense_category_id')
                    ->label('Category')
                    ->options(fn () => ExpenseCategory::orderBy('sort_order')->pluck('name', 'id')),

                Filter::make('expense_date')
                    ->form([
                        DatePicker::make('from')->label('From'),
                        DatePicker::make('until')->label('Until'),
                    ])
                    ->query(function (Builder $query, array $data): Builder {
                        return $query
                            ->when($data['from'],  fn ($q) => $q->whereDate('expense_date', '>=', $data['from']))
                            ->when($data['until'], fn ($q) => $q->whereDate('expense_date', '<=', $data['until']));
                    }),
            ])
            ->recordActions([
                EditAction::make(),
                DeleteAction::make(),
            ])
            ->defaultSort('expense_date', 'desc');
    }

    public static function getPages(): array
    {
        return [
            'index'  => ListExpenses::route('/'),
            'create' => CreateExpense::route('/create'),
            'edit'   => EditExpense::route('/{record}/edit'),
        ];
    }
}
```

- [ ] **Step 2: Create ListExpenses**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\Expenses\Pages;

use App\Filament\Resources\Expenses\ExpenseResource;
use App\Services\PlanService;
use Filament\Actions\CreateAction;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;

class ListExpenses extends ListRecords
{
    protected static string $resource = ExpenseResource::class;

    public function mount(): void
    {
        parent::mount();

        if (! app(PlanService::class)->hasExpenseTracker()) {
            Notification::make()
                ->title('Expense tracking requires the Pro plan')
                ->body('Contact us to upgrade.')
                ->warning()
                ->persistent()
                ->send();
        }
    }

    protected function getHeaderActions(): array
    {
        return [
            CreateAction::make(),
        ];
    }
}
```

- [ ] **Step 3: Create CreateExpense**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\Expenses\Pages;

use App\Filament\Resources\Expenses\ExpenseResource;
use Filament\Resources\Pages\CreateRecord;

class CreateExpense extends CreateRecord
{
    protected static string $resource = ExpenseResource::class;
}
```

- [ ] **Step 4: Create EditExpense**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Resources\Expenses\Pages;

use App\Filament\Resources\Expenses\ExpenseResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;

class EditExpense extends EditRecord
{
    protected static string $resource = ExpenseResource::class;

    protected function getHeaderActions(): array
    {
        return [
            DeleteAction::make(),
        ];
    }
}
```

- [ ] **Step 5: Verify in browser** — in a Pro tenant admin: "Expenses" nav group shows "Expense Categories" (1) and "Expenses" (2). Create an expense — select a category, enter amount $25.00, tax $3.25. Save. Confirm it appears in the list with Total = $28.25. Filter by category — only that category's expenses appear. Filter by date range — only expenses in that range appear. Search by description — correct expense appears.

  In a Starter tenant: both nav items show "Pro" warning badge. Clicking Expenses shows the list with a persistent warning notification and no "New expense" button.

- [ ] **Step 6: Commit**

```bash
git add app/Filament/Resources/Expenses/
git commit -m "feat: add ExpenseResource with plan gate, filters, and inline category creation"
```

---

## Task 6: ExpenseReports Page

**Files:**
- Create: `app/Filament/Pages/ExpenseReports.php`
- Create: `resources/views/filament/pages/expense-reports.blade.php`

- [ ] **Step 1: Create ExpenseReports page class**

```php
<?php

declare(strict_types=1);

namespace App\Filament\Pages;

use App\Models\SiteSettings;
use App\Services\PlanService;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Url;
use Symfony\Component\HttpFoundation\StreamedResponse;

class ExpenseReports extends Page
{
    protected string $view = 'filament.pages.expense-reports';

    protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedReceiptPercent;

    public static function getNavigationGroup(): ?string
    {
        return 'Reports';
    }

    public static function getNavigationSort(): ?int
    {
        return 2;
    }

    public static function getNavigationLabel(): string
    {
        return 'Expense Reports';
    }

    public function getTitle(): string
    {
        return 'Expense Reports';
    }

    #[Url]
    public string $dateFrom = '';

    #[Url]
    public string $dateTo = '';

    public function mount(): void
    {
        if (! app(PlanService::class)->hasExpenseTracker()) {
            Notification::make()
                ->title('Expense reports require the Pro plan')
                ->body('Contact us to upgrade.')
                ->warning()
                ->send();

            $this->redirect(url('/admin'));
            return;
        }

        if ($this->dateFrom === '') {
            $this->dateFrom = now()->startOfYear()->format('Y-m-d');
        }
        if ($this->dateTo === '') {
            $this->dateTo = now()->format('Y-m-d');
        }
    }

    public function applyDates(): void {}

    public function setPreset(string $preset): void
    {
        [$this->dateFrom, $this->dateTo] = match ($preset) {
            'this_month' => [now()->startOfMonth()->format('Y-m-d'),             now()->format('Y-m-d')],
            'last_month' => [now()->subMonth()->startOfMonth()->format('Y-m-d'), now()->subMonth()->endOfMonth()->format('Y-m-d')],
            'q1'         => [now()->year . '-01-01', now()->year . '-03-31'],
            'q2'         => [now()->year . '-04-01', now()->year . '-06-30'],
            'q3'         => [now()->year . '-07-01', now()->year . '-09-30'],
            'q4'         => [now()->year . '-10-01', now()->year . '-12-31'],
            'this_year'  => [now()->startOfYear()->format('Y-m-d'),              now()->format('Y-m-d')],
            'last_year'  => [now()->subYear()->startOfYear()->format('Y-m-d'),   now()->subYear()->endOfYear()->format('Y-m-d')],
            default      => [$this->dateFrom, $this->dateTo],
        };
    }

    private function from(): string
    {
        return $this->dateFrom ?: now()->startOfYear()->format('Y-m-d');
    }

    private function to(): string
    {
        return $this->dateTo ?: now()->format('Y-m-d');
    }

    private function computeStats(): object
    {
        return DB::table('expenses')
            ->whereBetween('expense_date', [$this->from(), $this->to()])
            ->selectRaw('
                COUNT(*)                                   AS entry_count,
                COALESCE(SUM(amount), 0)                   AS total_amount,
                COALESCE(SUM(tax_amount), 0)               AS total_tax,
                COALESCE(SUM(amount + tax_amount), 0)      AS total_spent
            ')
            ->first();
    }

    private function computeMonthly(): Collection
    {
        return DB::table('expenses')
            ->whereBetween('expense_date', [$this->from(), $this->to()])
            ->selectRaw("
                DATE_FORMAT(expense_date, '%Y-%m')   AS period,
                DATE_FORMAT(expense_date, '%M %Y')   AS period_label,
                COUNT(*)                             AS entry_count,
                COALESCE(SUM(amount), 0)             AS total_amount,
                COALESCE(SUM(tax_amount), 0)         AS total_tax,
                COALESCE(SUM(amount + tax_amount), 0) AS total_spent
            ")
            ->groupBy('period', 'period_label')
            ->orderBy('period')
            ->get();
    }

    private function computeByCategory(): Collection
    {
        return DB::table('expenses')
            ->leftJoin('expense_categories', 'expenses.expense_category_id', '=', 'expense_categories.id')
            ->whereBetween('expenses.expense_date', [$this->from(), $this->to()])
            ->selectRaw("
                COALESCE(expense_categories.name, 'Uncategorized') AS category_name,
                COUNT(*)                                           AS entry_count,
                COALESCE(SUM(expenses.amount), 0)                  AS total_amount,
                COALESCE(SUM(expenses.tax_amount), 0)              AS total_tax,
                COALESCE(SUM(expenses.amount + expenses.tax_amount), 0) AS total_spent
            ")
            ->groupBy('expense_categories.name')
            ->orderByDesc('total_spent')
            ->get();
    }

    public function exportCsv(): StreamedResponse
    {
        $rows = DB::table('expenses')
            ->leftJoin('expense_categories', 'expenses.expense_category_id', '=', 'expense_categories.id')
            ->whereBetween('expenses.expense_date', [$this->from(), $this->to()])
            ->selectRaw("
                expenses.expense_date,
                COALESCE(expense_categories.name, 'Uncategorized') AS category_name,
                expenses.description,
                expenses.amount,
                expenses.tax_amount,
                (expenses.amount + expenses.tax_amount) AS total,
                expenses.receipt_reference,
                expenses.notes
            ")
            ->orderBy('expenses.expense_date')
            ->get();

        $filename = 'expenses-' . $this->from() . '-to-' . $this->to() . '.csv';

        return response()->streamDownload(function () use ($rows) {
            $f = fopen('php://output', 'w');
            fputcsv($f, ['Date', 'Category', 'Description', 'Amount (pre-tax)', 'Tax', 'Total', 'Receipt Reference', 'Notes']);
            foreach ($rows as $r) {
                fputcsv($f, [
                    $r->expense_date,
                    $r->category_name,
                    $r->description,
                    number_format((float) $r->amount,     2, '.', ''),
                    number_format((float) $r->tax_amount, 2, '.', ''),
                    number_format((float) $r->total,      2, '.', ''),
                    $r->receipt_reference ?? '',
                    $r->notes ?? '',
                ]);
            }
            fclose($f);
        }, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
    }

    public function getViewData(): array
    {
        return [
            'stats'      => $this->computeStats(),
            'monthly'    => $this->computeMonthly(),
            'byCategory' => $this->computeByCategory(),
            'settings'   => SiteSettings::current(),
        ];
    }
}
```

- [ ] **Step 2: Create the blade view**

```blade
<x-filament-panels::page>

<style>
.ecw-kpi-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 0.75rem;
}
@media (max-width: 640px) {
    .ecw-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.ecw-card {
    border-radius: 0.5rem;
    padding: 1rem;
    background: rgba(0, 0, 0, 0.04);
    border: 1px solid rgba(0, 0, 0, 0.1);
}
.dark .ecw-card {
    background: rgba(255, 255, 255, 0.06);
    border-color: rgba(255, 255, 255, 0.1);
}
.ecw-label {
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: #6b7280;
    margin-bottom: 0.35rem;
}
.ecw-value {
    font-size: 1.5rem;
    font-weight: 700;
    line-height: 1.2;
    color: #111827;
}
.dark .ecw-value { color: #f9fafb; }
.ecw-sub {
    font-size: 0.72rem;
    color: #9ca3af;
    margin-top: 0.2rem;
}
</style>

<div class="space-y-6">

    {{-- Date Range Controls --}}
    <x-filament::section>
        <div style="display:flex; flex-wrap:wrap; align-items:flex-end; gap:0.75rem;">
            <div>
                <p class="ecw-label" style="margin-bottom:0.3rem;">From</p>
                <input type="date" wire:model="dateFrom"
                       style="border:1px solid rgba(107,114,128,0.4); border-radius:0.4rem; padding:0.35rem 0.65rem; font-size:0.875rem; background:transparent; color:inherit; outline:none;">
            </div>
            <div>
                <p class="ecw-label" style="margin-bottom:0.3rem;">To</p>
                <input type="date" wire:model="dateTo"
                       style="border:1px solid rgba(107,114,128,0.4); border-radius:0.4rem; padding:0.35rem 0.65rem; font-size:0.875rem; background:transparent; color:inherit; outline:none;">
            </div>
            <x-filament::button wire:click="applyDates" wire:loading.attr="disabled">
                Apply
            </x-filament::button>

            <div style="display:flex; flex-wrap:wrap; gap:0.35rem; padding-left:0.85rem; border-left:1px solid rgba(107,114,128,0.25); margin-left:0.25rem;">
                @foreach([
                    'this_month' => 'This Month',
                    'last_month' => 'Last Month',
                    'q1' => 'Q1', 'q2' => 'Q2', 'q3' => 'Q3', 'q4' => 'Q4',
                    'this_year'  => 'This Year',
                    'last_year'  => 'Last Year',
                ] as $preset => $label)
                    <x-filament::button color="gray" size="sm"
                        wire:click="setPreset('{{ $preset }}')"
                        wire:loading.attr="disabled">
                        {{ $label }}
                    </x-filament::button>
                @endforeach
            </div>
        </div>

        <p style="font-size:0.75rem; color:#6b7280; margin-top:0.75rem;">
            {{ $dateFrom }} — {{ $dateTo }}
            &nbsp;·&nbsp; All expenses in this date range.
        </p>
    </x-filament::section>

    {{-- Stat Cards --}}
    <x-filament::section>
        @php $sym = $settings->currency_symbol ?? '$'; @endphp
        <div class="ecw-kpi-grid">
            <div class="ecw-card">
                <p class="ecw-label">Total Expenses</p>
                <p class="ecw-value">{{ $sym . number_format((float) $stats->total_amount, 2) }}</p>
                <p class="ecw-sub">pre-tax</p>
            </div>
            <div class="ecw-card">
                <p class="ecw-label">Total Tax Paid</p>
                <p class="ecw-value">{{ $sym . number_format((float) $stats->total_tax, 2) }}</p>
                <p class="ecw-sub">input credits (HST/GST)</p>
            </div>
            <div class="ecw-card">
                <p class="ecw-label">Total Spent</p>
                <p class="ecw-value">{{ $sym . number_format((float) $stats->total_spent, 2) }}</p>
                <p class="ecw-sub">amount + tax</p>
            </div>
        </div>
    </x-filament::section>

    {{-- Monthly Breakdown --}}
    <x-filament::section>
        <x-slot name="heading">Monthly Breakdown</x-slot>

        <table style="width:100%; font-size:0.875rem; border-collapse:collapse;">
            <thead>
                <tr style="border-bottom:1px solid rgba(107,114,128,0.2);">
                    <th style="text-align:left; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Month</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Entries</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Pre-tax</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Tax Paid</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Total</th>
                </tr>
            </thead>
            <tbody>
                @forelse($monthly as $row)
                    <tr style="border-bottom:1px solid rgba(107,114,128,0.08);">
                        <td style="padding:0.5rem 0;">{{ $row->period_label }}</td>
                        <td style="text-align:right; padding:0.5rem 0; color:#374151;">{{ $row->entry_count }}</td>
                        <td style="text-align:right; padding:0.5rem 0; color:#374151;">{{ $sym . number_format((float) $row->total_amount, 2) }}</td>
                        <td style="text-align:right; padding:0.5rem 0; color:#374151;">{{ $sym . number_format((float) $row->total_tax, 2) }}</td>
                        <td style="text-align:right; padding:0.5rem 0; font-weight:600; color:#111827;">{{ $sym . number_format((float) $row->total_spent, 2) }}</td>
                    </tr>
                @empty
                    <tr><td colspan="5" style="padding:1rem 0; color:#9ca3af; text-align:center;">No expenses in this period.</td></tr>
                @endforelse
            </tbody>
        </table>
    </x-filament::section>

    {{-- Category Breakdown --}}
    <x-filament::section>
        <x-slot name="heading">By Category</x-slot>

        <table style="width:100%; font-size:0.875rem; border-collapse:collapse;">
            <thead>
                <tr style="border-bottom:1px solid rgba(107,114,128,0.2);">
                    <th style="text-align:left; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Category</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Entries</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Pre-tax</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Tax Paid</th>
                    <th style="text-align:right; padding:0.5rem 0; color:#6b7280; font-weight:600; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.06em;">Total</th>
                </tr>
            </thead>
            <tbody>
                @forelse($byCategory as $row)
                    <tr style="border-bottom:1px solid rgba(107,114,128,0.08);">
                        <td style="padding:0.5rem 0;">{{ $row->category_name }}</td>
                        <td style="text-align:right; padding:0.5rem 0; color:#374151;">{{ $row->entry_count }}</td>
                        <td style="text-align:right; padding:0.5rem 0; color:#374151;">{{ $sym . number_format((float) $row->total_amount, 2) }}</td>
                        <td style="text-align:right; padding:0.5rem 0; color:#374151;">{{ $sym . number_format((float) $row->total_tax, 2) }}</td>
                        <td style="text-align:right; padding:0.5rem 0; font-weight:600; color:#111827;">{{ $sym . number_format((float) $row->total_spent, 2) }}</td>
                    </tr>
                @empty
                    <tr><td colspan="5" style="padding:1rem 0; color:#9ca3af; text-align:center;">No expenses in this period.</td></tr>
                @endforelse
            </tbody>
        </table>
    </x-filament::section>

    {{-- Export --}}
    <x-filament::section>
        <x-slot name="heading">Export</x-slot>
        <x-slot name="description">Download all expenses in the selected date range as a CSV file. Includes date, category, description, amount, tax, total, receipt reference, and notes.</x-slot>

        <x-filament::button wire:click="exportCsv" wire:loading.attr="disabled" color="gray">
            Export CSV
        </x-filament::button>
    </x-filament::section>

</div>
</x-filament-panels::page>
```

- [ ] **Step 3: Verify in browser** — in a Pro tenant admin, navigate to Reports → Expense Reports. Confirm:
  - Date range defaults to Jan 1 – today
  - Stat cards show zeros (no data yet)
  - Add a few expenses via the Expenses resource, then return to reports and confirm the numbers appear
  - Click "This Month" preset — dates update
  - Click "Export CSV" — file downloads with correct columns

  In a Starter tenant — navigating to `/admin/expense-reports` redirects to `/admin` with a warning notification.

- [ ] **Step 4: Commit**

```bash
git add app/Filament/Pages/ExpenseReports.php \
        resources/views/filament/pages/expense-reports.blade.php
git commit -m "feat: add ExpenseReports page with date range, monthly breakdown, category breakdown, and CSV export"
```

---

## Task 7: Full Test Suite + Final Verification

- [ ] **Step 1: Run full test suite**

```bash
php artisan test
```

Expected: all existing tests pass plus the 3 new `ExpenseTest` tests. No failures.

- [ ] **Step 2: Verify plan gate end-to-end**

In super-admin, assign a Starter plan to a test tenant. In that tenant's admin:
- Both "Expense Categories" and "Expenses" nav items show the "Pro" warning badge
- Both list pages show the persistent upgrade notification
- "New" buttons are absent on both
- "Expense Reports" page redirects to `/admin` with warning notification

Assign a Pro plan. Confirm:
- Badges gone, "New" buttons appear
- Expense Reports page loads normally

- [ ] **Step 3: Final commit**

```bash
git add -A
git commit -m "feat: complete expense tracking implementation"
```

---

## Self-Review Checklist (for agentic workers)

| Spec requirement | Task |
|---|---|
| `expense_categories` table | Task 1 |
| `expenses` table with nullable FK | Task 1 |
| 8 default categories seeded | Task 3 |
| `ExpenseCategory` model with `hasMany` | Task 2 |
| `Expense` model with `total` accessor | Task 2 |
| Unit tests for `total` accessor | Task 2 |
| ExpenseCategoryResource with plan gate | Task 4 |
| Category delete blocked when expenses exist + notification | Task 4 |
| ExpenseResource with plan gate | Task 5 |
| Description searchable | Task 5 |
| Date range filter on expense list | Task 5 |
| Category filter on expense list | Task 5 |
| Inline category creation | Task 5 |
| Total column (amount + tax) | Task 5 |
| ExpenseReports page (Pro gate) | Task 6 |
| Date range + presets | Task 6 |
| 3 stat cards | Task 6 |
| Monthly breakdown table | Task 6 |
| Category breakdown table | Task 6 |
| CSV export | Task 6 |
