From bdda634abdc6308b333631016276396471007355 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 13 Jun 2026 01:11:16 +0200 Subject: [PATCH 01/18] fix(forms): filter numbering selector by document type (#152) QuoteForm now only shows numbering schemes with type=Quote; InvoiceForm only shows type=Invoice. Previously both forms showed all numbering schemes regardless of type, allowing an invoice to be assigned a Quote numbering scheme and vice versa. Co-Authored-By: Claude Sonnet 4.6 --- .../Company/Resources/Invoices/Schemas/InvoiceForm.php | 3 ++- .../Filament/Company/Resources/Quotes/Schemas/QuoteForm.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php index a04281be5..d13a035e6 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php @@ -14,6 +14,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; +use Modules\Core\Enums\NumberingType; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Support\InvoiceCalculator; use Modules\Products\Models\Product; @@ -91,7 +92,7 @@ public static function configure(Schema $schema): Schema Select::make('numbering_id') ->label(trans('ip.numbering')) - ->relationship('numbering', 'name') + ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::INVOICE->value)) ->required() ->searchable() ->preload() diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php index 432f8f2cd..997ab9495 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteForm.php @@ -15,6 +15,7 @@ use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; +use Modules\Core\Enums\NumberingType; use Modules\Products\Models\Product; use Modules\Quotes\Enums\QuoteStatus; use Modules\Quotes\Support\QuoteCalculator; @@ -98,7 +99,7 @@ public static function configure(Schema $schema): Schema Select::make('numbering_id') ->label(trans('ip.numbering')) - ->relationship('numbering', 'name') + ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::QUOTE->value)) ->required() ->searchable() ->preload() From 273c2f2ba6d8e5a5fca328b10481ae874b10e463 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 05:17:39 +0000 Subject: [PATCH 02/18] Add CLAUDE.md with full project knowledge map Captures module layout, Filament panel config, auth/tenancy flow, testing base classes and patterns, key model quirks (non-standard PKs, BelongsToCompany trait), service layer, seeder defaults, and a debugging quick-reference table. Lets Claude sessions start with full context instead of re-scanning the codebase on every request. https://claude.ai/code/session_01KVV91ECY14zwgavRm99zPs --- CLAUDE.md | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5c2f0624c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,253 @@ +# InvoicePlane v2 — Claude Context + +## What this is + +Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via `nwidart/laravel-modules`. PHP 8.1+ enums, Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`. + +--- + +## Directory map + +``` +app/ # Thin root (Http/Controllers/ empty, Providers/AppServiceProvider.php) +Modules/ # All business logic lives here (8 modules) +bootstrap/ # app.php +config/ # modules.php, ip.php, filament.php, database.php, app.php +database/migrations/ # Only the exports table — all other migrations are per-module +database/seeders/ # DatabaseSeeder.php — seeds ivplv2 company at id=22 +resources/css/filament/ # Per-panel Vite themes (company/nord.css, admin/*, user/*) +routes/ # Empty — routing is handled entirely by Filament panel providers +Modules/Core/Providers/ # All three Filament panel providers live here +``` + +### Module inventory + +| Module | Key models | +|--------|-----------| +| Core | User, Company, CompanyUser, TaxRate, Numbering, EmailTemplate, CustomField, Upload, Note, AuditLog, Setting, MailQueue | +| Clients | Relation (table: `relations`), Contact, Address, Communication, ClientCustom (`PK: client_custom_id`) | +| Invoices | Invoice, InvoiceItem, RecurringInvoice | +| Quotes | Quote, QuoteItem | +| Payments | Payment | +| Products | Product, ProductUnit, ProductCategory | +| Projects | Project, Task | +| Expenses | Expense, ExpenseCategory, ExpenseItem | + +Every module follows: +``` +Modules// + Database/{Factories,Migrations,Seeders}/ + Models/ + Filament/{Admin,Company}/Resources//{Pages,Tables,Schemas}/ + Services/ + Enums/ + Events/ Listeners/ Observers/ + Traits/ Helpers/ + Http/{Controllers,Requests,Middleware}/ + Providers/ + Tests/{Unit,Feature}/ +``` + +--- + +## Filament panels + +Three providers at `Modules/Core/Providers/`: + +| Provider | Panel ID | URL path | Tenanted | Access roles | +|----------|----------|----------|----------|--------------| +| AdminPanelProvider | `admin` | `/admin` | No | SUPER_ADMIN, ADMIN, ASSIST | +| CompanyPanelProvider | `company` | `` (root, default) | Yes — `Company` by `search_code` | CUSTOMER_ADMIN, CUSTOMER | +| UserPanelProvider | `user` | `/user` | No | (minimal, for future use) | + +### Company panel tenant config (critical) + +```php +->tenant(Company::class, slugAttribute: 'search_code') +->tenantMiddleware([ + SetTenantFromQueryString::class, // reads ?tenant=, sets session + Filament tenant + ConfigureTenant::class, // resolves tenant from route/query/session/user fallback + EnsureUserCanAccessCompany::class,// enforces access; 403 if regular user not in company_user +], isPersistent: true) +``` + +URLs look like `/ivplv2/dashboard` (search_code as route segment). + +--- + +## Auth & tenancy flow + +1. Login page: `Modules/Core/Filament/Pages/Auth/Login.php` + - Rate-limited (5 attempts), checks `$user->is_active`, regenerates session + - Returns `app(LoginResponse::class)` +2. `Modules/Core/Filament/Responses/LoginResponse.php` + - Elevated users (super_admin/admin/assist): find company globally → `filament()->setTenant()` + - Regular users: find company from `$user->companies()` → set session + Filament tenant + - Redirects to `route('filament.company.pages.dashboard', ['tenant' => Str::lower($company->search_code)])` +3. Company panel middleware chain then enforces access on every subsequent request. +4. Logout: `Modules/Core/Filament/Responses/LogoutResponse.php` → redirects to `filament.company.auth.login` + +### Key relationships + +```php +// User ↔ Company — BelongsToMany via company_user pivot (id, company_id, user_id — no timestamps) +$user->companies() // returns Collection +$company->users() + +// All business models are scoped to a company via BelongsToCompany trait +// Trait auto-injects company_id on create (from Filament tenant / session / user's first company) +// Trait adds global scope — queries are always filtered by current company +``` + +--- + +## Roles & permissions (Spatie) + +```php +// Modules/Core/Enums/UserRole.php +UserRole::SUPER_ADMIN = 'super_admin' // full system access +UserRole::ADMIN = 'admin' +UserRole::ASSIST = 'assist' +UserRole::CUSTOMER_ADMIN = 'client_admin' // company-scoped admin +UserRole::CUSTOMER = 'client' + +UserRole::elevated() // ['super_admin', 'admin', 'assist'] — can access any panel +UserRole::nonAdmin() // ['client_admin', 'client'] — company panel only + +// Usage +$user->hasRole(UserRole::SUPER_ADMIN->value) +$user->assignRole(UserRole::ADMIN->value) +$user->isSuperAdmin() // shorthand +``` + +--- + +## Testing + +### Base classes + +| Class | Extends | Sets up | Use for | +|-------|---------|---------|---------| +| `AbstractAdminPanelTestCase` | TestCase + RefreshDatabase | Company factory + superAdmin user; panel='admin'; Carbon frozen 2026-01-01 | Admin panel Livewire tests | +| `AbstractCompanyPanelTestCase` | TestCase + RefreshDatabase | User with company (search_code='IVPLV2'); Filament tenant set quietly; session current_company_id | Company panel Livewire tests | +| `AbstractTestCase` | TestCase (no RefreshDatabase) | Nothing | Pure unit tests | + +All live at `Modules/Core/Tests/`. + +### Patterns + +```php +// Company panel test +class FooTest extends AbstractCompanyPanelTestCase { + public function it_does_something(): void { + $response = Livewire::actingAs($this->user) + ->test(ListFoo::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertSuccessful(); + } + // Or use the helper: + $this->testLivewire(ListFoo::class)->assertSuccessful(); +} + +// Admin panel test +class BarTest extends AbstractAdminPanelTestCase { + public function it_does_something(): void { + Livewire::actingAs($this->superAdmin())->test(ListBar::class)->assertSuccessful(); + } +} + +// Create a role in tests (Spatie needs DB record) +Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); +$user->assignRole(UserRole::SUPER_ADMIN->value); +``` + +### Factory patterns + +```php +// User with a specific company attached +User::factory()->withCompany(['search_code' => 'IVPLV2', 'name' => '...'])->create() + +// Company-scoped model — pass company via ->for() or via session/tenant +Invoice::factory()->for($company)->create() + +// Active user +User::factory()->create(['is_active' => true, 'email_verified_at' => now()]) +``` + +### PHPUnit test discovery + +```xml + + Modules/*/Tests/Unit +Modules/*/Tests/Feature +``` + +### DB for tests + +Tests need a DB. Production CI uses MariaDB 11. For local dev without MySQL, set in `.env.testing`: +``` +DB_CONNECTION=sqlite +DB_DATABASE=:memory: +``` + +--- + +## Key model notes + +- `Company::$primaryKey` = `id` (standard); URL slug is `search_code` (10 chars, unique, e.g. `ivplv2`) +- `User::$timestamps = false` — no created_at/updated_at on users table +- `ClientCustom::$primaryKey = 'client_custom_id'` — non-standard PK +- `Import::$primaryKey = 'import_id'` — non-standard PK +- `Relation` model → table `relations` (not `customers`, not `clients`) +- Soft deletes on: Invoice, Quote (and their items) +- `BelongsToCompany` trait → adds `company()` BelongsTo, `scopeForCompany()`, and global scope + +--- + +## Services + +All services extend `Modules\Core\Services\BaseService`: +```php +// BaseService gives you: +$this->create(array $data): Model +$this->find($id): Model +$this->update(array $input, $model): Model +$this->delete($id): bool +$this->paginate($perPage): LengthAwarePaginator +$this->getCompanyId(): ?int // resolves from Filament/session/user +``` + +No DTO layer — services accept arrays and return Eloquent models. + +--- + +## Seeder + +`database/seeders/DatabaseSeeder.php` seeds: +- `ivplv2` company hard-coded at `id=22`, `search_code='ivplv2'`, `name='InvoicePlane Corporation'` +- 5 additional random companies +- Spatie roles for all UserRole values +- One user per role, all attached to ivplv2 + +--- + +## Common debugging shortcuts + +| Symptom | Check | +|---------|-------| +| Company-scoped query returns nothing | `session('current_company_id')` set? Filament tenant set? | +| 403 on company panel route | User in `company_user` pivot for that company? | +| Redirect after login goes to wrong company | `LoginResponse::DEFAULT_COMPANY_CODE` constant and `whereRaw('LOWER(search_code) = ?')` query | +| Factory creates model with null company_id | Pass `->for($company)` or set Filament tenant before creation | +| Livewire test fails with "No tenant" | Call `Filament::setTenant($company)` in test setUp | +| Test role check fails | Create Role DB record first with `Role::query()->firstOrCreate(...)` | + +--- + +## Don't re-derive these + +- The default company is **always** `ivplv2` (search_code, lowercase in URLs) +- Panel routes are named `filament..pages.` and `filament..resources..` +- `$user->companies()` returns the BelongsToMany — call `.first()`, `.attach()`, `.detach()` on it +- `Str::lower($company->search_code)` is always the URL tenant parameter +- The three tenant middleware classes live at `Modules/Core/Http/Middleware/` +- Panel providers live at `Modules/Core/Providers/`, not `app/Providers/` From 73348d8be05827260e53d98346fb8c71ee6c482d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 05:28:56 +0000 Subject: [PATCH 03/18] Add AI agent context files across all major tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md — OpenAI Codex / Agents .github/copilot-instructions.md — corrected and enriched with verified facts (Laravel 11 not 12+, PHP 8.1+, real panel paths, correct role names, no DTO/Repository layer) .junie/guidelines.md — corrected and enriched (same fixes) .junie/testing.md — testing base classes, factory patterns, common pitfalls, assertion cheat sheet .junie/architecture.md — auth flow, middleware chain, seeder layout, key relationships All files are consistent with CLAUDE.md and with each other. Each one lets the respective agent start with full project context so it does not need to scan the codebase from scratch on every request. https://claude.ai/code/session_01KVV91ECY14zwgavRm99zPs --- .github/copilot-instructions.md | 575 +++++++------------ .junie/architecture.md | 139 +++++ .junie/guidelines.md | 988 ++++++-------------------------- .junie/testing.md | 149 +++++ AGENTS.md | 217 +++++++ 5 files changed, 885 insertions(+), 1183 deletions(-) create mode 100644 .junie/architecture.md create mode 100644 .junie/testing.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5a51e92ff..5bd7e708c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,444 +1,287 @@ -# GitHub Copilot Context - -This is the default Copilot prompt for this project. +# GitHub Copilot Instructions — InvoicePlane v2 ## How to Use These Instructions -**IMPORTANT:** These instructions contain comprehensive information about the InvoicePlane v2 codebase, architecture, and development workflow. **Trust these instructions** and use them as your primary reference. Only perform additional searches if: -- The information you need is not covered here -- You encounter an error that contradicts these instructions -- You need to locate specific files or code not referenced here - -Following these guidelines will significantly reduce exploration time and prevent common mistakes. - -## Project Description - -This project is **InvoicePlane v2**, a **multi-tenant Laravel application** with a **modular architecture**. +These instructions contain verified facts about the InvoicePlane v2 codebase. **Trust them** — only search the repo when something is not covered here or contradicts what you find. -- The application uses **Laravel Filament** for Admin Panel, Company Panel, and InvoicePanel interfaces. -- Code is structured into **Modules**, each module encapsulating its own logic (models, services, repositories, DTOs, - transformers, tests, etc.). -- Tests for each module are located in: - `/Modules/(ModuleName)/Tests` +--- -## Tech Stack +## Verified Tech Stack -- **Backend:** Laravel 12+ (PHP 8.2+) -- **UI Framework:** Filament 4.0 -- **Frontend:** Livewire, Tailwind CSS -- **Testing:** PHPUnit 11+ -- **Code Quality:** Laravel Pint (PSR-12), PHPStan, Rector -- **Module System:** nwidart/laravel-modules -- **Permissions:** spatie/laravel-permission +- **Framework:** Laravel **11** (PHP **8.1**+) — not 12/8.2 +- **UI:** Filament **4.0** + Livewire **3** +- **Modules:** `nwidart/laravel-modules` +- **Permissions:** `spatie/laravel-permission` - **Multi-tenancy:** Filament Companies with `BelongsToCompany` trait -- **Queue System:** Required for export functionality (Redis, database, or sync for local development) +- **DB (production):** MariaDB 11 +- **DB (tests):** SQLite `:memory:` or MariaDB 11 (CI) +- **Code quality:** Laravel Pint (PSR-12), PHPStan, Rector -## Development Commands - -### Testing -```bash -# Run all tests (typically 30-60 seconds) -php artisan test +--- -# Run tests with coverage (typically 60-120 seconds) -php artisan test --coverage +## Project Layout -# Run specific test suite (faster - 10-30 seconds each) -php artisan test --testsuite=Unit -php artisan test --testsuite=Feature +``` +app/ # Thin root — Providers/AppServiceProvider.php only +Modules/ # ALL business logic lives here +Modules/Core/Providers/ # All three Filament panel providers +config/ # modules.php, ip.php, filament.php, database.php +database/migrations/ # Only the exports table; all others are per-module +database/seeders/ # DatabaseSeeder — seeds ivplv2 company + roles + users +resources/css/filament/ # Per-panel Vite themes +routes/ # Empty — all routing is done by Filament panel providers ``` -**Important:** Always run tests before finalizing changes. All tests must pass. +### Modules -### Code Quality -```bash -# Format code with Laravel Pint (auto-fixes PSR-12 violations) -vendor/bin/pint +| Module | Key models | +|--------|-----------| +| Core | User, Company, CompanyUser, TaxRate, Numbering, EmailTemplate, CustomField, Upload, Note, AuditLog, Setting, MailQueue | +| Clients | Relation (table: `relations`), Contact, Address, Communication, ClientCustom (PK: `client_custom_id`) | +| Invoices | Invoice, InvoiceItem, RecurringInvoice | +| Quotes | Quote, QuoteItem | +| Payments | Payment | +| Products | Product, ProductUnit, ProductCategory | +| Projects | Project, Task | +| Expenses | Expense, ExpenseCategory, ExpenseItem | -# Run static analysis (typically 20-40 seconds) -vendor/bin/phpstan analyse +### Module internal layout -# Run Rector for automated refactoring -vendor/bin/rector process --dry-run ``` - -**Validation Pipeline:** Before submitting code, you MUST run: -1. `vendor/bin/pint` - Format code -2. `vendor/bin/phpstan analyse` - Check for type errors -3. `php artisan test` - Run all tests - -### Setup & Installation -```bash -# See .github/INSTALLATION.md for detailed setup -composer install -cp .env.example .env -php artisan key:generate -php artisan migrate --seed - -# Start queue worker for export functionality -php artisan queue:work +Modules// + Models/ + Services/ # extend Modules\Core\Services\BaseService + Enums/ # PHP 8.1+ backed string enums + Filament/ + Admin/Resources/ # Admin panel resources + Company/Resources/ # Company panel resources — Pages/, Tables/, Schemas/ + Database/ + Factories/ # extend AbstractFactory + Migrations/ # auto-discovered + Seeders/ + Events/ Listeners/ Observers/ + Traits/ Helpers/ Http/ Providers/ + Tests/Feature/ Tests/Unit/ ``` -**Queue Configuration:** -- Export functionality requires a queue worker to be running -- For local development, you can use `QUEUE_CONNECTION=sync` in `.env` -- For production, use Redis or database queue driver with Supervisor +--- -**GitHub Actions CI/CD:** -The following automated checks run on every pull request and MUST pass: -- **PHPUnit** - All tests must pass (`php artisan test`) -- **PHPStan** - Static analysis must pass with no errors (`vendor/bin/phpstan analyse`) -- **Pint** - Code must follow PSR-12 standards (`vendor/bin/pint`) -- **Docker Build** - Docker images must build successfully +## Filament Panels (verified) -See `.github/workflows/` for workflow configurations. Reference `.github/workflows/README.md` for setup details. +| Provider | Panel ID | URL path | Tenant | Access | +|----------|----------|----------|--------|--------| +| AdminPanelProvider | `admin` | `/admin` | No | super_admin, admin, assist | +| CompanyPanelProvider | `company` | `` **(root, default)** | Yes — Company by `search_code` | client_admin, client | +| UserPanelProvider | `user` | `/user` | No | (future) | -## Related Documentation +> **The company panel path is an empty string** — URLs look like `/ivplv2/dashboard`, not `/company/...`. -- **Installation:** `.github/INSTALLATION.md` -- **Contributing:** `.github/CONTRIBUTING.md` -- **Seeding:** `.github/SEEDING.md` -- **Testing:** See test examples in `Modules/*/Tests/` -- **Commit Conventions:** `.github/git-commit-instructions.md` +Panel route naming: `filament..pages.` and `filament..resources..` -## Project Layout +Tenant middleware stack (company panel, persistent): +1. `SetTenantFromQueryString` — reads `?tenant=`, sets session + Filament tenant +2. `ConfigureTenant` — resolves from route/query/session/user fallback +3. `EnsureUserCanAccessCompany` — 403 if regular user not in `company_user` pivot -### Repository Structure +--- -``` -├── .github/ # Workflows, docs, issue/PR templates, Copilot instructions -├── Modules/ # All application logic lives here -│ ├── Clients/ # Client/customer management -│ ├── Core/ # Shared infrastructure, panels, base test cases -│ ├── Expenses/ # Expense tracking -│ ├── Invoices/ # Invoice management + Peppol e-invoicing -│ ├── Payments/ # Payment processing -│ ├── Products/ # Product/item catalog -│ ├── Projects/ # Project management -│ └── Quotes/ # Quote/estimate management -├── app/ # Laravel bootstrap only — business logic is in Modules/ -├── config/ # Laravel configuration files -├── database/seeders/ # Database seeders -└── routes/ # web.php, console.php -``` - -### Module Directory Conventions +## Roles (verified Spatie values) -Every module follows the same internal structure: - -``` -Modules/{Name}/ -├── Database/ # Migrations and model factories -├── Enums/ # PHP 8.1+ enums (used in $casts) -├── Events/ & Listeners/ # Domain events and their listeners -├── Filament/ # Filament UI resources, pages, and components -│ ├── Admin/ # Admin panel resources (Core module only) -│ └── Company/ # Company panel resources -├── Models/ # Eloquent models (no $fillable, no timestamps unless specified) -├── Observers/ # Eloquent observers -├── Providers/ # Module service providers -├── Services/ # Business logic (use Transformers, not raw DTOs) -├── Support/ # DTOs, Transformers, value objects -├── Tests/ -│ ├── Unit/ # Unit tests (extend AbstractTestCase) -│ └── Feature/ # Feature tests (extend AbstractAdminPanelTestCase or AbstractCompanyPanelTestCase) -└── Traits/ # Shared traits +```php +// Modules/Core/Enums/UserRole.php +UserRole::SUPER_ADMIN = 'super_admin' // full access, any panel +UserRole::ADMIN = 'admin' +UserRole::ASSIST = 'assist' +UserRole::CUSTOMER_ADMIN = 'client_admin' // company panel only +UserRole::CUSTOMER = 'client' // company panel only + +UserRole::elevated() // ['super_admin', 'admin', 'assist'] +UserRole::nonAdmin() // ['client_admin', 'client'] ``` -### Filament Panel Providers - -All panel providers are in `Modules/Core/Providers/`: - -| Provider | File | Path | Roles | -|---|---|---|---| -| Admin | `AdminPanelProvider.php` | `/admin` | `admin`, `superadmin` | -| Company | `CompanyPanelProvider.php` | `/company` | authenticated company users | -| User | `UserPanelProvider.php` | `/user` | end users | - -### Configuration Files - -| File | Purpose | -|---|---| -| `phpunit.xml` | PHPUnit config — Unit suite: `Modules/*/Tests/Unit`, Feature suite: `Modules/*/Tests/Feature` | -| `phpstan.neon` | PHPStan config (imports `phpstan-baseline.neon` for known suppressions) | -| `pint.json` | Laravel Pint (PSR-12) code style rules | -| `rector.php` | Rector automated refactoring rules | -| `modules_statuses.json` | Which nwidart modules are enabled | - -### Abstract Test Cases (always extend one of these) - -Located in `Modules/Core/Tests/`: - -- `AbstractTestCase` — application bootstrap only -- `AbstractAdminPanelTestCase` — admin panel + `RefreshDatabase` -- `AbstractCompanyPanelTestCase` — company panel + multi-tenancy + `RefreshDatabase` - -## Guidelines - -- **SOLID Principles** must be followed at all times. -- **Early returns** are preferred for readability. -- **Dynamic programming practices** must be applied where relevant. -- **Code must be modular and refactored**; avoid inline data setups. -- **No JSON columns** in Laravel migrations. -- **No ENUM columns** in Laravel migrations. -- **Abstractions must reduce dependencies** while ensuring single responsibility. -- **Centralize shared functionality in Traits** (avoid duplication). -- **Catch `Error`, `ErrorException`, and `Throwable` separately.** -- **Class names must always be provided in Markdown code blocks** for approval. - -### DTO & Transformer Rules - -- **All DTOs must avoid constructors.** -- DTOs use static named constructors when necessary. -- DTOs rely on getters and setters for data access. -- **All DTOs get transformed using Transformers.** -- **Services must not build DTOs manually**; instead, they must use Transformers directly. -- **EntityExtractionService must use Transformers** for the entire transformation process. -- Transformers use `toDto()` and `toModel()` methods. - -### API & Service Integration - -- **All API requests must go through the Advanced API Client.** -- No direct API calls in controllers, services, or jobs. -- Use Laravel’s HTTP client instead of curl or Guzzle. -- **All transformations must go through Transformers.** -- **API responses and errors must be logged separately** for debugging. -- **Upserts must use repository methods** instead of `updateOrCreate`. - -### Filament Rules - -- **Filament resources must respect proper panel separation and namespaces.** -- **Resource Generation (via commands):** - - Must use Filament internal traits (`CanReadModelSchemas`, etc.). - - No reflection for relationship detection. - - Separate form and table generators by field type. - - Keep a configurable `$excludedFields` array. - - Enums detected via `$casts` and `enum_exists()`. - - Add docblocks above `form()`, `table()`, `getRelations()` with relationships/fields. - - Use `copyStubToApp()` instead of inline string replacements. - - **Preserve the exact method signatures** for Filament resource methods. - - **Use the correct `Action::make()` syntax** with fluent methods. - - **Do not display raw `created_at` or `updated_at`** in tables/infolists; use dedicated timestamp columns. - -### Testing Rules - -- **Unit Tests must follow these rules:** - - Test functions must be prefixed with `it_` and make grammatical sense (e.g., `it_creates_payment`, `it_validates_invoice_has_customer`). - - Use `#[Test]` attribute instead of `@test` annotations. - - Prefer Fakes and Fixtures over Mocks. - - Place happy paths last in test cases. - - Reusable logic (e.g., fixtures, setup) must live in abstract test cases, not inline. - - Tests have inline comment blocks above sections (/* Arrange */, /* Act */, /* Assert */). - - **CRITICAL:** All tests MUST have an "act" section where variables are defined BEFORE assertions. - - Tests must be meaningful - avoid simple "ok" checks; validate actual behavior and data. - - Use data providers for testing multiple scenarios with the same logic. - - **NEVER extend `Tests\TestCase`** - all tests must extend one of the abstract test cases from `Modules/Core/Tests/`: - - `AbstractTestCase` - Basic test case with application bootstrap - - `AbstractAdminPanelTestCase` - For admin panel tests with RefreshDatabase - - `AbstractCompanyPanelTestCase` - For company panel tests with multi-tenancy - -**Test Structure Example:** +Seeder seeds exactly these five roles. Create DB records before assigning in tests: ```php -#[Test] -public function it_creates_invoice(): void -{ - /* Arrange */ - $data = ['number' => 'INV-001', 'total' => 100.00]; - - /* Act */ - $invoice = $this->service->createInvoice($data); // ❗ Define variable here - - /* Assert */ - $this->assertInstanceOf(Invoice::class, $invoice); // Use it here -} +Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); +$user->assignRole(UserRole::SUPER_ADMIN->value); ``` -### Export System Rules +--- -- **Exports use Filament's asynchronous export system** which requires queue workers. -- **Export tests must use fakes:** `Queue::fake()`, `Storage::fake()`, and verify job dispatching with `Bus::assertChained()`. -- **The `exports` table is temporary** and managed by Filament for job coordination only. -- **No export history feature** - export records are ephemeral and auto-prunable. -- **Queue configuration is required** for export functionality to work in production. -- See `Modules/Core/Filament/Exporters/README.md` for export architecture details. +## Key Model Facts -### Database & Models +- `User::$timestamps = false` — no created_at/updated_at on users +- `ClientCustom::$primaryKey = 'client_custom_id'` — non-standard PK +- `Import::$primaryKey = 'import_id'` — non-standard PK +- `Relation` model → table `relations` (not `customers`, not `clients`) +- `Company::search_code` — 10-char unique slug used in URLs (e.g. `ivplv2`) +- Soft deletes on Invoice, Quote (and their items) +- `company_user` pivot: columns `id, company_id, user_id` (no timestamps) +- Default company: `search_code='ivplv2'`, `id=22` (hard-coded in seeder) -- **No `$fillable` array in Models.** -- **No `timestamps()` or `softDeletes()` in Migrations** unless explicitly specified. -- **No `timestamps` or `softDeletes` properties/traits in Models** unless explicitly specified. -- **Use native PHP type hints** and utilize `$casts` for Enum fields. +`BelongsToCompany` trait (used on every business model): +- Adds `company()` BelongsTo relationship +- Adds global scope filtering by `company_id` +- Auto-injects `company_id` on create from Filament tenant / session / user -### Peppol Integration Rules +--- -- **Peppol service follows Strategy Pattern** for format handlers with 11 supported formats. -- **PeppolService coordinates** invoice transformation and transmission. -- **PeppolManagementService handles** integration lifecycle (create, test, validate, send). -- **Format handlers** must implement validation, transformation, and format-specific logic. -- **Provider Factory** creates provider-specific clients (e.g., EInvoiceBe). -- **All API calls** must go through the ApiClient with exception handling. -- **Logging** is done via LogsApiRequests and LogsPeppolActivity traits. -- **Events** are dispatched for all major Peppol operations (transmission, validation, etc.). +## Service Layer -**All 11 Supported Peppol Format Handlers:** -1. **CII** - Cross Industry Invoice (UN/CEFACT standard for Germany/France/Austria) -2. **EHF 3.0** - Norwegian e-invoice format (Elektronisk Handelsformat) -3. **Factur-X** - French/German hybrid (PDF with embedded XML) -4. **Facturae 3.2** - Spanish format (mandatory for public administration) -5. **FatturaPA 1.2** - Italian format (mandatory for all invoices) -6. **OIOUBL** - Danish e-invoice format -7. **PEPPOL BIS 3.0** - Default Peppol format (pan-European) -8. **UBL 2.1** - Universal Business Language (most common) -9. **UBL 2.4** - Updated UBL with enhanced features -10. **ZUGFeRD 1.0** - German format (PDF with embedded XML) -11. **ZUGFeRD 2.0** - Updated German format (compatible with Factur-X) +All services extend `Modules\Core\Services\BaseService`. **No DTO or Repository layer** — services accept plain arrays and return Eloquent models. -Each handler is registered in `FormatHandlerFactory` with comprehensive PHPUnit test coverage. -The factory automatically selects handlers with fallback logic and proper logging. +```php +$service->create(array $data): Model +$service->find($id): Model +$service->update(array $input, Model $model): Model +$service->delete($id): bool +$service->paginate(int $perPage): LengthAwarePaginator +$service->getCompanyId(): ?int // resolves Filament tenant → session → user's company +``` -### Seeding Rules +--- -- Seed 5 default roles (`superadmin`, `admin`, `assistance`, `useradmin`, `user`). -- Ensure users can belong to accounts when relevant. -- Admin Panel access restricted to `admin` and `superadmin`. +## Testing -### Code Refactoring Principles +### Base classes (Modules/Core/Tests/) -- **Extract duplicate code** into private/protected methods following Single Responsibility Principle. -- **Use early returns** to reduce nesting and improve readability. -- **Validate inputs** at the start of methods and abort/throw exceptions early. -- **Extract complex conditions** into well-named methods. -- **Use meaningful method names** that describe what they do. +``` +AbstractTestCase — no RefreshDatabase; pure unit tests +AbstractAdminPanelTestCase — RefreshDatabase; panel='admin'; $this->company; $this->superAdmin() +AbstractCompanyPanelTestCase — RefreshDatabase; panel='company'; $this->user; $this->company +``` -### Internationalization & Translations +**NEVER extend `Tests\TestCase`** — always extend one of the three above. -**CRITICAL:** InvoicePlane v2 uses `trans()` for all translations, NOT `__()`. +### Test patterns ```php -// ❌ WRONG - Do not use __() -$label = __('ip.invoice_total'); +// Company panel (most tests): +class InvoicesTest extends AbstractCompanyPanelTestCase { + #[Test] + #[Group('smoke')] + public function it_lists_invoices(): void { + /* Arrange */ + $invoice = Invoice::factory()->for($this->company)->create(); + + /* Act */ + $component = $this->testLivewire(ListInvoices::class); + + /* Assert */ + $component->assertSuccessful()->assertSee($invoice->number); + } +} -// ✅ CORRECT - Always use trans() -$label = trans('ip.invoice_total'); +// Admin panel: +class UsersTest extends AbstractAdminPanelTestCase { + #[Test] + public function it_lists_users(): void { + Livewire::actingAs($this->superAdmin())->test(ListUsers::class)->assertSuccessful(); + } +} ``` -**Translation Key Conventions:** -- Main translation file: `resources/lang/en/ip.php` -- Prefix keys with `ip.` (e.g., `ip.invoice_total`, `ip.payment_method`) -- Use snake_case for key names -- In Blade: Use `{{ trans('ip.key') }}` or `@lang('ip.key')` - -**UI Text Translation Requirements:** -ALL user-facing text must use trans(): -- Form field labels: `->label(trans('ip.field_label'))` -- Placeholders: `->placeholder(trans('ip.placeholder'))` -- Helper text: `->helperText(trans('ip.help_text'))` -- Section titles: `Section::make(trans('ip.section_title'))` -- Button labels: `trans('ip.button_text')` -- Table headers: `trans('ip.column_name')` -- Tooltips & hints: `trans('ip.tooltip')` -- Success/error messages: `trans('ip.message')` - -**Example:** -```php -TextInput::make('name') - ->label(trans('ip.report_block_name')) - ->placeholder(trans('ip.report_block_name_placeholder')) - ->helperText(trans('ip.report_block_name_help')); +### Factory patterns -Section::make(trans('ip.section_general')) - ->schema([...]); +```php +User::factory()->withCompany(['search_code' => 'IVPLV2'])->create() +User::factory()->create(['is_active' => true, 'email_verified_at' => now()]) +Invoice::factory()->for($this->company)->create() // always ->for($company) on scoped models +Company::factory()->create(['search_code' => 'acme']) ``` -## PHPStan Type Safety Guidelines +### Test conventions -### Float Array Keys (CRITICAL) -**Never use floats directly as array keys** - they cause PHPStan errors and precision issues: +- Method names: `it__` (snake_case) +- Use `#[Test]` attribute (not `@test`) +- Use `#[Group('smoke|crud|security|authentication|...')]` +- Structure with `/* Arrange */`, `/* Act */`, `/* Assert */` blocks +- Define all variables in the "act" section before asserting on them +- Prefer fakes over mocks (`Queue::fake()`, `Storage::fake()`) -```php -// ❌ WRONG -$rate = 21.0; -$taxGroups[$rate] = ['base' => 0]; +### PHPUnit discovery -// ✅ CORRECT -$rate = 21.0; -$rateKey = (string) $rate; -$taxGroups[$rateKey] = ['base' => 0]; - -// When iterating, cast back to float -foreach ($taxGroups as $rateKey => $group) { - $rate = (float) $rateKey; - // Use $rate for calculations -} +```xml + Modules/*/Tests/Unit +Modules/*/Tests/Feature ``` -### DTO Constructor Usage -**Always use static factory methods**, never call DTO constructors with parameters: +--- -```php -// ❌ WRONG -$dto = new GridPositionDTO(0, 0, 6, 4); +## Development Commands -// ✅ CORRECT -$dto = GridPositionDTO::create(0, 0, 6, 4); +```bash +# Tests +php artisan test # all tests (~30-60s) +php artisan test --testsuite=Unit # unit only +php artisan test --testsuite=Feature # feature only +php artisan test --group smoke # by group + +# Code quality (run in this order before committing) +vendor/bin/pint # format PSR-12 +vendor/bin/phpstan analyse # static analysis +php artisan test # all tests must pass ``` -### Property Type Consistency -**Match parent class property types exactly**: +--- -```php -// ❌ WRONG - Parent expects string -protected static ?string $navigationGroup = 'Reports'; +## Database & Model Conventions -// ✅ CORRECT -protected static string $navigationGroup = 'Reports'; +- **No `$fillable` in models** — use `$guarded = []` +- **No JSON or ENUM columns** in migrations +- **No `timestamps()` or `softDeletes()`** in migrations unless explicitly needed +- **Use `$casts`** for enum fields: `'status' => InvoiceStatus::class` +- Use native PHP type hints throughout + +--- + +## Filament Resource Conventions + +Each resource is split across focused files: ``` +InvoiceResource.php — getModel(), navigationIcon(), getPages() +Pages/ListInvoices.php — extends ListRecords +Pages/CreateInvoice.php — extends CreateRecord +Pages/EditInvoice.php — extends EditRecord +Tables/InvoicesTable.php — static table(Table $table): Table +Schemas/InvoiceForm.php — static form(Schema $schema): Schema +``` + +Rules: +- Respect panel namespace separation (Admin/ vs Company/) +- Use `Action::make()` with fluent methods +- Do not display raw `created_at`/`updated_at` in tables -### Import Statements -**Always use full namespace imports**: +--- + +## Internationalization + +**Always use `trans()`, never `__()`:** ```php // ❌ WRONG -use Log; +$label = __('ip.invoice_total'); // ✅ CORRECT -use Illuminate\Support\Facades\Log; +$label = trans('ip.invoice_total'); ``` -### Test Mocks -**Use PHPStan suppressions for test mocks with type mismatches**: - -```php -$customer = new stdClass(); -/** @phpstan-ignore-next-line */ -$invoice->customer = $customer; -``` +Translation keys: `resources/lang/en/ip.php`, prefixed `ip.`, snake_case. -### Factory Return Types -**Add type hints when factories return Collection but method expects Model**: +--- -```php -protected function createCompany(): Company -{ - /** @var Company $company */ - $company = Company::factory()->create(); - return $company; -} -``` +## Coding Rules Summary -## Development Workflow +- Business logic lives in `Modules/` — never in `app/` +- No routes in `routes/` — panels handle all routing +- No DTO or Repository layer — services use plain arrays +- `$user->companies()` BelongsToMany — use `.first()`, `.attach()`, `.detach()` +- `Str::lower($company->search_code)` is always the URL tenant parameter +- `$user->isSuperAdmin()` shorthand for role check -When making code changes, follow this workflow: +--- -1. **Understand the Task** - Read the requirements carefully -2. **Locate Files** - Use the module structure (`Modules/{ModuleName}/{Type}/`) -3. **Make Changes** - Follow all guidelines above (SOLID, DTOs, Transformers, etc.) -4. **Write/Update Tests** - Use `#[Test]`, `it_` prefix, Arrange/Act/Assert structure -5. **Validate Locally**: - - Run `vendor/bin/pint` to format code - - Run `vendor/bin/phpstan analyse` to check types - - Run `php artisan test` to verify tests pass -6. **Review Changes** - Ensure minimal, surgical changes only -7. **Commit** - Follow `.github/git-commit-instructions.md` +## CI/CD (must pass before merge) -**Remember:** All CI checks (PHPUnit, PHPStan, Pint) must pass before code can be merged. +- `php artisan test` — all tests green +- `vendor/bin/phpstan analyse` — no type errors +- `vendor/bin/pint` — PSR-12 compliant diff --git a/.junie/architecture.md b/.junie/architecture.md new file mode 100644 index 000000000..b3ce5da6d --- /dev/null +++ b/.junie/architecture.md @@ -0,0 +1,139 @@ +# Architecture — InvoicePlane v2 + +## Auth & login flow + +``` +POST /login (Filament Livewire Login component) + Modules/Core/Filament/Pages/Auth/Login.php + → rate-limit (5/min), check is_active, regenerate session + → return app(LoginResponse::class) + + Modules/Core/Filament/Responses/LoginResponse.php + → elevated user? (super_admin / admin / assist) + Company::whereRaw('LOWER(search_code) = ?', ['ivplv2'])->first() + ?? Company::query()->first() + filament()->setTenant($company) + → regular user? (client_admin / client) + $user->companies()->whereRaw('LOWER(search_code) = ?', ['ivplv2'])->first() + ?? $user->companies()->first() + session(['current_company_id' => $company->id]) + filament()->setTenant($company) + → redirect to filament.company.pages.dashboard [tenant=] + +Logout: Modules/Core/Filament/Responses/LogoutResponse.php + → redirect to filament.company.auth.login +``` + +--- + +## Tenant resolution middleware (company panel, every request) + +``` +1. SetTenantFromQueryString (Modules/Core/Http/Middleware/) + reads request('tenant') — the search_code from the URL segment + queries Company where LOWER(search_code) = lower(tenant) + sets session('current_company_id') + filament()->setTenant() + +2. ConfigureTenant + resolves tenant: route param → query string → session → user's first company + shares $currentCompany to all views + +3. EnsureUserCanAccessCompany + elevated users: pass unconditionally + regular users: must exist in company_user pivot — otherwise abort(403) + updates session('current_company_id') +``` + +--- + +## Company scoping (BelongsToCompany trait) + +Every business model applies this trait. It: + +1. Registers a **global scope** that appends `WHERE company_id = ` to all queries +2. On model `creating`, auto-injects `company_id` from: + - `Filament::getTenant()->id` + - `session('current_company_id')` + - `auth()->user()->companies()->first()->id` +3. Adds `company()` BelongsTo relationship +4. Adds `scopeForCompany(int $companyId)` named scope + +**Consequence:** Never do raw `Invoice::all()` inside tenant context — the global scope filters automatically. In tests, set the tenant before creating company-scoped factories. + +--- + +## Filament resource anatomy + +Each resource is split into focused files: + +``` +InvoiceResource.php — getModel(), navigationIcon(), navigationGroup(), getPages() +Pages/ + ListInvoices.php — extends ListRecords; table configuration via InvoicesTable + CreateInvoice.php — extends CreateRecord; form via InvoiceForm + EditInvoice.php — extends EditRecord; form via InvoiceForm +Tables/ + InvoicesTable.php — static table(Table $table): Table — columns, filters, actions +Schemas/ + InvoiceForm.php — static form(Schema $schema): Schema — fields, sections, repeaters +``` + +--- + +## Seeder (database/seeders/DatabaseSeeder.php) + +``` +Companies: + id=22, search_code='ivplv2', name='InvoicePlane Corporation' ← hard-coded default + 5 additional random companies + +Roles (Spatie): + super_admin, admin, assist, client_admin, client + +Users (one per role, all attached to ivplv2 company): + super_admin@example.com / password + admin@example.com / password + assist@example.com / password + client_admin@example.com / password + client@example.com / password +``` + +--- + +## Key relationships + +```php +// User ↔ Company (pivot: company_user — id, company_id, user_id, no timestamps) +$user->companies() // BelongsToMany +$company->users() // BelongsToMany +$user->companies()->attach($company->id) +$user->companies()->detach($company->id) + +// Business models → Company (via BelongsToCompany trait) +$invoice->company() // BelongsTo +$company->invoices() // HasMany + +// Relations (customers) → Contacts, Addresses +$relation->contacts() // HasMany +$relation->primaryContact() // BelongsTo +$relation->addresses() // MorphMany
+``` + +--- + +## Config files + +| File | Purpose | +|------|---------| +| `config/modules.php` | nwidart module settings; migration auto-discovery | +| `config/ip.php` | InvoicePlane custom settings | +| `config/filament.php` | Filament framework config | +| `config/database.php` | DB connections (mysql as `mariadb` driver in prod) | + +## Environment variables worth knowing + +``` +APP_EXTREME_LOGGING=true # verbose company-scope debug output +DB_CONNECTION=sqlite # use for local dev / tests without MySQL +DB_DATABASE=:memory: # paired with sqlite for in-memory test DB +``` diff --git a/.junie/guidelines.md b/.junie/guidelines.md index d41e0049b..d7eba23db 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -1,912 +1,266 @@ -# Junie AI Agent Guidelines for InvoicePlane v2 - -This document provides comprehensive guidelines for AI agents (like Junie) working on the InvoicePlane v2 codebase to ensure maximum information accuracy and performance. - ---- +# Junie Guidelines — InvoicePlane v2 ## How to Use These Guidelines -**IMPORTANT:** These guidelines contain comprehensive information about the InvoicePlane v2 codebase, architecture, and development workflow. **Trust these guidelines** and use them as your primary reference. Only perform additional searches if: -- The information you need is not covered here -- You encounter an error that contradicts these guidelines -- You need to locate specific files or code not referenced here - -Following these guidelines will significantly reduce exploration time and prevent common mistakes. +These guidelines contain verified facts about the InvoicePlane v2 codebase. **Trust them** and use them as the primary reference. Only search the repository when something is not covered here or contradicts what you find. --- -## Project Overview - -**InvoicePlane v2** is a multi-tenant invoicing and billing application built with modern PHP/Laravel technologies. +## Verified Tech Stack -### Core Architecture -- **Framework:** Laravel 12+ (PHP 8.2+) -- **UI:** Filament 4.0 (Admin/Company/Invoice panels) -- **Frontend:** Livewire + Tailwind CSS -- **Module System:** nwidart/laravel-modules (modular monolith) +- **Framework:** Laravel **11** (PHP **8.1**+) +- **UI:** Filament **4.0** + Livewire **3** +- **Modules:** `nwidart/laravel-modules` +- **Permissions:** `spatie/laravel-permission` - **Multi-tenancy:** Filament Companies with `BelongsToCompany` trait -- **Permissions:** spatie/laravel-permission -- **Queue System:** Required for export functionality - -### Module Structure -``` -Modules/ - ModuleName/ - Models/ # Eloquent models - Services/ # Business logic layer - Repositories/ # Data access layer - DTOs/ # Data Transfer Objects - Transformers/ # DTO ↔ Model transformations - Filament/ # Filament resources (Admin/Company panels) - Tests/ # PHPUnit tests - Database/ # Migrations, seeders, factories -``` +- **DB (prod):** MariaDB 11 | **DB (tests):** SQLite `:memory:` or MariaDB 11 (CI) +- **Code quality:** Laravel Pint (PSR-12), PHPStan, Rector --- -## Critical Principles (MUST FOLLOW) +## Project Architecture -### 1. SOLID Principles -- **Single Responsibility:** Each class has one clear purpose -- **Open/Closed:** Extend behavior without modifying existing code -- **Liskov Substitution:** Subtypes must be substitutable for base types -- **Interface Segregation:** No fat interfaces; clients shouldn't depend on unused methods -- **Dependency Inversion:** Depend on abstractions, not concretions +All business logic lives in `Modules/`. The `app/` directory is intentionally thin. -### 2. Code Quality Standards -- **Early Returns:** Prefer early returns over nested conditions -- **No Inline Logic:** Business logic must be in services, not controllers/resources -- **Dynamic Programming:** Apply where relevant (memoization, tabulation) -- **Centralize Shared Logic:** Use traits to avoid duplication -- **Type Safety:** Use native PHP type hints throughout +### Module inventory -### 3. Error Handling -```php -// Catch specific exceptions separately -try { - // code -} catch (Error $e) { - // Handle Error -} catch (ErrorException $e) { - // Handle ErrorException -} catch (Throwable $e) { - // Handle other throwables -} -``` +| Module | Key models | +|--------|-----------| +| Core | User, Company, CompanyUser, TaxRate, Numbering, EmailTemplate, CustomField, Upload, Note, AuditLog, Setting, MailQueue | +| Clients | Relation (table: `relations`), Contact, Address, Communication, ClientCustom (PK: `client_custom_id`) | +| Invoices | Invoice, InvoiceItem, RecurringInvoice | +| Quotes | Quote, QuoteItem | +| Payments | Payment | +| Products | Product, ProductUnit, ProductCategory | +| Projects | Project, Task | +| Expenses | Expense, ExpenseCategory, ExpenseItem | ---- - -## Architecture Patterns - -### DTO & Transformer Rules - -**DTOs (Data Transfer Objects):** -- NO constructors in DTOs -- Use static named constructors when necessary -- Rely on getters and setters for data access -- DTOs are transformed using Transformers - -**Transformers:** -- Must implement `toDto()` and `toModel()` methods -- Services must use Transformers directly (not build DTOs manually) -- EntityExtractionService must use Transformers for entire transformation process +### Module directory layout -**Example:** -```php -// DTO -class InvoiceDTO -{ - private string $number; - private float $total; - - // No constructor! - - public static function fromArray(array $data): self - { - $dto = new self(); - $dto->setNumber($data['number']); - $dto->setTotal($data['total']); - return $dto; - } - - public function getNumber(): string { return $this->number; } - public function setNumber(string $number): void { $this->number = $number; } -} - -// Transformer -class InvoiceTransformer -{ - public function toDto(Invoice $model): InvoiceDTO - { - return InvoiceDTO::fromArray([ - 'number' => $model->number, - 'total' => $model->total, - ]); - } - - public function toModel(InvoiceDTO $dto): Invoice - { - $model = new Invoice(); - $model->number = $dto->getNumber(); - $model->total = $dto->getTotal(); - return $model; - } -} ``` - -### Service Layer -- All business logic must be in services -- Services coordinate between repositories, transformers, and external systems -- Services must not build DTOs manually—use Transformers -- Services return DTOs or collections of DTOs - -### Repository Layer -- Repositories handle data access only -- Use repository methods for upserts (not `updateOrCreate`) -- Repositories return models or collections of models - -### API Integration -- All API requests must go through the Advanced API Client -- No direct API calls in controllers, services, or jobs -- Use Laravel's HTTP client (not curl or Guzzle) -- All transformations must go through Transformers -- API responses and errors must be logged separately - ---- - -## Testing Standards - -### Test Structure -```php -use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\Attributes\Group; - -class InvoiceServiceTest extends AbstractCompanyPanelTestCase -{ - use RefreshDatabase; - - #[Test] - #[Group('invoices')] - public function it_creates_invoice_with_valid_data(): void - { - /* Arrange */ - $data = ['number' => 'INV-001', 'total' => 100.00]; - - /* Act */ - $result = $this->service->createInvoice($data); - - /* Assert */ - $this->assertInstanceOf(InvoiceDTO::class, $result); - $this->assertEquals('INV-001', $result->getNumber()); - } -} -``` - -### Testing Rules (MANDATORY) -1. **Test Naming:** Functions prefixed with `it_` (e.g., `it_creates_invoice`) -2. **No `@test` Annotations:** Use `#[Test]` attribute instead -3. **Prefer Fakes over Mocks:** - ```php - Queue::fake(); - Storage::fake('local'); - Notification::fake(); - ``` -4. **Happy Paths Last:** Place success scenarios at the end -5. **Reusable Setup:** Abstract test cases for fixtures, not inline -6. **Comment Blocks:** Use `/* Arrange */`, `/* Act */`, `/* Assert */` -7. **NEVER extend `Tests\TestCase`:** All tests must extend one of the abstract test cases from `Modules/Core/Tests/`: - - `AbstractTestCase` - Basic test case with application bootstrap - - `AbstractAdminPanelTestCase` - For admin panel tests with RefreshDatabase - - `AbstractCompanyPanelTestCase` - For company panel tests with multi-tenancy - -### Export Testing -```php -#[Test] -#[Group('export')] -public function it_dispatches_csv_export_job(): void -{ - /* Arrange */ - Queue::fake(); - Storage::fake('local'); - $records = Model::factory()->count(3)->create(); - - /* Act */ - Livewire::actingAs($this->user) - ->test(ListPage::class) - ->callAction('exportCsv', data: [ - 'columnMap' => [ - 'field' => ['isEnabled' => true, 'label' => 'Label'], - ], - ]); - - /* Assert */ - Bus::assertChained([ - fn($batch) => $batch instanceof \Illuminate\Bus\PendingBatch - ]); -} +Modules// + Models/ + Services/ # extend BaseService; no DTO/Repository layer + Enums/ # PHP 8.1+ backed string enums + Filament/ + Admin/Resources/ # Admin panel CRUD + Company/Resources/ + / + Pages/ CreateX, EditX, ListX + Tables/ XTable (columns, filters, actions) + Schemas/ XForm (form schema) + Database/ + Factories/ # extend AbstractFactory + Migrations/ # auto-discovered + Seeders/ + Events/ Listeners/ Observers/ + Traits/ Helpers/ Http/ Providers/ + Tests/Feature/ Tests/Unit/ ``` --- -## Database & Models - -### Migration Rules -- NO JSON columns in migrations -- NO ENUM columns in migrations -- NO `timestamps()` unless explicitly specified -- NO `softDeletes()` unless explicitly specified - -### Model Rules -- NO `$fillable` array in models -- NO `timestamps` or `softDeletes` properties unless needed -- Use native PHP type hints -- Use `$casts` for Enum fields - -```php -class Invoice extends Model -{ - // No $fillable! - - protected $casts = [ - 'status' => InvoiceStatus::class, // Enum - 'total' => 'decimal:2', - 'issued_at' => 'datetime', - ]; -} -``` - ---- +## Filament Panels (verified) -## Filament Resources +| ID | Path | Tenant | Roles | +|----|------|--------|-------| +| `admin` | `/admin` | No | super_admin, admin, assist | +| `company` | `` **(root, empty)** | Yes — Company by `search_code` | client_admin, client | +| `user` | `/user` | No | (future) | -### Resource Generation -- Must use Filament internal traits (`CanReadModelSchemas`, etc.) -- No reflection for relationship detection -- Separate form and table generators by field type -- Keep configurable `$excludedFields` array -- Detect Enums via `$casts` and `enum_exists()` -- Add docblocks above `form()`, `table()`, `getRelations()` -- Use `copyStubToApp()` instead of inline string replacements +> Company panel path is **empty string** — URLs are `/{search_code}/{page}`, e.g. `/ivplv2/dashboard`. -### Panel Separation -- Respect proper panel namespaces (Admin/Company/Invoice) -- Resources in correct panel directories -- Preserve exact method signatures +Panel providers all live at `Modules/Core/Providers/`. -### Best Practices -- Use correct `Action::make()` syntax with fluent methods -- Don't display raw `created_at` or `updated_at` in tables/infolists -- Use dedicated timestamp columns instead +Route names: `filament..pages.` and `filament..resources..` --- -## Export System +## Multi-Tenancy -### Architecture -- Exports use Filament's asynchronous export system -- **Requires queue workers** to be running -- The `exports` table is temporary (job coordination only) -- NO export history feature -- Auto-prunable via Laravel's model pruning - -### Queue Configuration +```php +// URL tenant param — always lowercase search_code +Str::lower($company->search_code) // e.g. 'ivplv2' -**Local Development:** -```bash -# Option 1: Sync driver (blocks request) -QUEUE_CONNECTION=sync +// Pivot: company_user (id, company_id, user_id — no timestamps) +$user->companies() // BelongsToMany +$company->users() // BelongsToMany -# Option 2: Queue worker -php artisan queue:work +// Current tenant resolution (in order): +// 1. Filament::getTenant() +// 2. session('current_company_id') +// 3. auth()->user()->companies()->first() ``` -**Production:** -```bash -# Redis (recommended) -QUEUE_CONNECTION=redis - -# With Supervisor -[program:invoiceplane-worker] -command=php /path/to/artisan queue:work --sleep=3 --tries=3 -``` +Tenant middleware chain (company panel, persistent): +1. `SetTenantFromQueryString` → reads `?tenant=`, sets session + Filament tenant +2. `ConfigureTenant` → resolves from route/query/session/user +3. `EnsureUserCanAccessCompany` → 403 if user not in `company_user` for that company -### Export Test Requirements -- Must use `Queue::fake()` and `Storage::fake()` -- Verify job dispatching with `Bus::assertChained()` -- Don't test file content (test job dispatch only) -- See: `Modules/Core/Filament/Exporters/README.md` +`BelongsToCompany` trait (on every business model): +- Adds `company()` BelongsTo relationship +- Adds global scope filtering by `company_id` — queries are always tenant-scoped +- Auto-injects `company_id` on model creation --- -## Peppol E-Invoicing Integration - -### Architecture Overview -InvoicePlane v2 includes a comprehensive Peppol integration for sending electronic invoices across the European Peppol network. - -**Key Components:** -- **PeppolService:** Main facade for invoice transmission and status checking -- **PeppolManagementService:** Integration lifecycle management (create, test, validate, send) -- **Format Handlers:** Strategy Pattern for different e-invoice formats (UBL, FatturaPA, ZUGFeRD, etc.) -- **Provider Factory:** Creates provider-specific clients (e.g., EInvoiceBe) -- **API Client:** Centralized HTTP client with exception handling -- **Event System:** Dispatches events for all major operations - -### Format Handlers (Strategy Pattern) -Each format handler implements: -- `validate(Invoice $invoice): array` - Validates invoice for format requirements -- `transform(Invoice $invoice, array $options): array` - Converts to format-specific structure -- `getFormat(): PeppolDocumentFormat` - Returns format enum - -**Supported Formats (11 total):** -- **CII** (Cross Industry Invoice) - UN/CEFACT standard, common in Germany/France/Austria -- **EHF 3.0** - Norwegian e-invoice format (Elektronisk Handelsformat) -- **Factur-X** - French/German hybrid format (PDF with embedded XML) -- **Facturae 3.2** - Spanish e-invoice format (mandatory for public administration) -- **FatturaPA 1.2** - Italian e-invoice format (mandatory for all invoices in Italy) -- **OIOUBL** - Danish e-invoice format -- **PEPPOL BIS 3.0** - Default Peppol format for most European countries -- **UBL 2.1** - Universal Business Language (most common for Peppol) -- **UBL 2.4** - Updated UBL version with enhanced features -- **ZUGFeRD 1.0** - German e-invoice format (PDF with embedded XML) -- **ZUGFeRD 2.0** - Updated German format, compatible with Factur-X - -All format handlers are registered in `FormatHandlerFactory` and have comprehensive PHPUnit test coverage. -The factory automatically selects the appropriate handler based on: -1. Customer's preferred format (if set) -2. Mandatory format for customer's country -3. Recommended format for customer's country -4. Fallback to PEPPOL BIS 3.0 - -**Format Selection Logging:** -- Info level: Customer's preferred or recommended format unavailable -- Warning level: Mandatory format for country unavailable (serious configuration issue) - -### Service Layer Pattern +## Roles (verified Spatie values) + ```php -// PeppolService - Transmission & Status -$peppolService->sendInvoiceToPeppol($invoice, $options); -$peppolService->getDocumentStatus($documentId); -$peppolService->cancelDocument($documentId); - -// PeppolManagementService - Lifecycle -$service->createIntegration($companyId, $provider, $config, $token); -$service->testConnection($integration); -$service->validatePeppolId($customer, $integration); -$service->sendInvoice($invoice, $integration); +UserRole::SUPER_ADMIN = 'super_admin' // full access, any panel +UserRole::ADMIN = 'admin' +UserRole::ASSIST = 'assist' +UserRole::CUSTOMER_ADMIN = 'client_admin' // company panel only +UserRole::CUSTOMER = 'client' + +UserRole::elevated() // ['super_admin', 'admin', 'assist'] +UserRole::nonAdmin() // ['client_admin', 'client'] ``` -### Logging & Monitoring -- **LogsApiRequests trait:** Logs all API requests/responses -- **LogsPeppolActivity trait:** Logs Peppol-specific events -- **Events:** PeppolTransmissionCreated, PeppolTransmissionSent, etc. -- **Status Tracking:** Comprehensive enum-based status system - -### Database Structure -- `peppol_integrations` - Company provider configurations -- `peppol_integration_config` - Key-value config storage -- `peppol_transmissions` - Transmission tracking -- `peppol_transmission_responses` - Provider responses -- `customer_peppol_validation_history` - Validation records - -### Testing Peppol Components +Spatie roles require a DB record in tests: ```php -#[Test] -public function it_sends_invoice_to_peppol_successfully(): void -{ - /* Arrange */ - Http::fake(['https://api.e-invoice.be/*' => Http::response([ - 'document_id' => 'DOC-123456', - 'status' => 'submitted', - ], 200)]); - - $invoice = $this->createMockInvoice(); - - /* Act */ - $result = $this->service->sendInvoiceToPeppol($invoice); - - /* Assert */ - $this->assertTrue($result['success']); - $this->assertEquals('DOC-123456', $result['document_id']); -} +Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); +$user->assignRole(UserRole::SUPER_ADMIN->value); ``` --- -## Security & Permissions - -### Seeding Rules -- Seed 5 default roles: `superadmin`, `admin`, `assistance`, `useradmin`, `user` -- Users can belong to accounts (multi-tenancy) -- Admin Panel access restricted to `admin` and `superadmin` +## Key Model Facts -### Multi-tenancy -- Use `BelongsToCompany` trait on models -- Company context required for all user operations -- Filament panels enforce tenant isolation +- `User::$timestamps = false` — no created_at/updated_at +- `ClientCustom::$primaryKey = 'client_custom_id'` +- `Import::$primaryKey = 'import_id'` +- `Relation` → table `relations` (not `customers`, not `clients`) +- `Company::search_code` — 10-char unique string; URL slug +- Soft deletes on Invoice, Quote (and their items) +- Default company: `search_code='ivplv2'`, `id=22`, name `'InvoicePlane Corporation'` (seeder) --- -## Internationalization & Translations - -### Translation Function Usage -**CRITICAL:** InvoicePlane v2 uses `trans()` for all translations, NOT `__()`. - -```php -// ❌ WRONG - Do not use __() -$label = __('ip.invoice_total'); -$message = __('ip.payment_successful'); - -// ✅ CORRECT - Always use trans() -$label = trans('ip.invoice_total'); -$message = trans('ip.payment_successful'); -``` - -**Blade Templates:** -```blade -{{-- ✅ CORRECT --}} -{{ trans('ip.total') }} -@lang('ip.total') {{-- @lang() is acceptable in Blade --}} -``` - -### Translation Key Conventions -- Main translation file: `resources/lang/en/ip.php` -- Prefix all keys with `ip.` for InvoicePlane-specific translations -- Use snake_case for key names -- Group related translations logically -- Example keys: `ip.invoice_total`, `ip.payment_method`, `ip.report_field_company_name` +## Service Layer -### UI Text Translation Requirements -**ALL user-facing text must be translatable:** +**No DTO, Transformer, or Repository layer.** Services accept plain arrays, return Eloquent models. -**Form Fields:** ```php -// Labels -TextInput::make('name') - ->label(trans('ip.field_label')) - -// Placeholders -TextInput::make('email') - ->placeholder(trans('ip.email_placeholder')) - -// Helper Text -TextInput::make('vat_id') - ->helperText(trans('ip.vat_id_help')) - -// Section Titles -Section::make(trans('ip.section_general')) -``` - -**Required Translation Coverage:** -- ✅ Form field labels -- ✅ Form placeholders -- ✅ Helper text and hints -- ✅ Tips and tooltips -- ✅ Button labels -- ✅ Section titles -- ✅ Table column headers -- ✅ Success/error messages -- ✅ Validation messages -- ✅ Menu items -- ✅ Page titles - -### Service Translation Pattern -When services load translatable content from config files: -```php -// Load from config and translate -$label = trans(config('some-config.label')); - -// In service methods -public function getTranslatedLabel(): string -{ - return trans($this->configKey); +class InvoiceService extends BaseService { + public function model(): string { return Invoice::class; } + // Inherited from BaseService: + // create(array $data): Model + // find($id): Model + // update(array $input, Model $model): Model + // delete($id): bool + // paginate(int $perPage): LengthAwarePaginator + // getCompanyId(): ?int } ``` --- -## Development Workflow - -### Commands - -**Testing:** -```bash -php artisan test # All tests (typically 30-60 seconds) -php artisan test --coverage # With coverage (typically 60-120 seconds) -php artisan test --testsuite=Unit # Unit tests only (faster - 10-30 seconds) -php artisan test --group=export # Export tests only -``` - -**Important:** Always run tests before finalizing changes. All tests must pass. - -**Code Quality:** -```bash -vendor/bin/pint # Format code (PSR-12) - auto-fixes violations -vendor/bin/phpstan analyse # Static analysis (typically 20-40 seconds) -vendor/bin/rector process --dry-run # Refactoring suggestions -``` - -**Validation Pipeline:** Before submitting code, you MUST run: -1. `vendor/bin/pint` - Format code -2. `vendor/bin/phpstan analyse` - Check for type errors -3. `php artisan test` - Run all tests - -**Setup:** -```bash -composer install -cp .env.example .env -php artisan key:generate -php artisan migrate --seed -php artisan queue:work # For exports -``` - -### Git Commit Conventions -- Follow conventions in `.github/git-commit-instructions.md` -- Use semantic commit messages -- Reference issues when applicable - ---- - -## Documentation References - -### Key Documentation Files -- **Installation:** `.github/INSTALLATION.md` -- **Contributing:** `.github/CONTRIBUTING.md` -- **Testing:** Module tests in `Modules/*/Tests/` -- **Seeding:** `.github/SEEDING.md` -- **Commits:** `.github/git-commit-instructions.md` -- **Export Architecture:** `Modules/Core/Filament/Exporters/README.md` -- **Module Checklist:** `CHECKLIST.md` +## SOLID & Code Quality Principles -### Related Documentation -- Laravel 12: https://laravel.com/docs/12.x -- Filament 4: https://filamentphp.com/docs/4.x -- Livewire 3: https://livewire.laravel.com/docs -- PHPUnit 11: https://docs.phpunit.de/en/11.0/ +- **Single Responsibility:** one clear purpose per class +- **Early returns:** reduce nesting; validate inputs at the top of methods +- **No inline business logic:** logic in services, not resources/controllers +- **Centralize shared logic:** traits over copy-paste +- **Type hints everywhere:** native PHP types throughout --- -## GitHub Actions & Automation - -### CI/CD Pipeline - -The following automated checks run on every pull request and MUST pass: -- **PHPUnit** - All tests must pass (`php artisan test`) -- **PHPStan** - Static analysis must pass with no errors (`vendor/bin/phpstan analyse`) -- **Pint** - Code must follow PSR-12 standards (`vendor/bin/pint`) -- **Docker Build** - Docker images must build successfully - -See `.github/workflows/` for workflow configurations. Reference `.github/workflows/README.md` for setup details. - -### Automated Workflows - -InvoicePlane v2 uses GitHub Actions for automated dependency management and CI/CD: - -- **Composer Update** - Automated PHP dependency updates -- **Yarn Update** - Automated JavaScript dependency updates -- **Crowdin Sync** - Automated translation synchronization -- **Release** - Automated production releases - -### Required Secrets - -Automation workflows require repository secrets to function: - -**PAT_TOKEN** (Personal Access Token): -- Required for: Composer Update, Yarn Update workflows -- Reason: Default `GITHUB_TOKEN` cannot create PRs that trigger other workflows -- Scopes needed: `repo` and `workflow` -- Setup: Settings → Secrets and variables → Actions → New repository secret - -**CROWDIN_PROJECT_ID** and **CROWDIN_PERSONAL_TOKEN**: -- Required for: Crowdin Sync, Release workflows -- Setup: Settings → Secrets and variables → Actions - -For detailed setup instructions, see `.github/workflows/README.md` and `.github/MAINTENANCE.md`. - ---- - -## Performance Optimization - -### Query Optimization -- Use eager loading to prevent N+1 queries -- Index foreign keys and frequently queried columns -- Use `select()` to limit columns when possible -- Chunk large datasets for processing - -### Caching Strategy -- Cache expensive computations -- Use Redis for session and cache storage -- Implement query result caching where appropriate +## Database & Model Rules -### Queue Workers -- Use multiple workers for high-volume operations -- Configure max execution time appropriately -- Monitor failed jobs and retry logic +- **No `$fillable`** — use `$guarded = []` +- **No JSON or ENUM columns** in migrations +- **No `timestamps()` or `softDeletes()`** unless explicitly needed +- **Use `$casts`** for enum fields: `'status' => InvoiceStatus::class` +- Native PHP type hints on all properties and return types --- -## PHPStan Type Safety Rules - -### Critical: Avoiding Float Array Keys -**Problem:** Floats used directly as array keys cause PHPStan errors due to precision issues. - -**Solution:** Cast floats to strings when using as array keys: -```php -// ❌ WRONG - Float as array key -$rate = 21.0; -$taxGroups[$rate] = ['base' => 0, 'amount' => 0]; - -// ✅ CORRECT - Cast to string for array key -$rate = 21.0; -$rateKey = (string) $rate; -$taxGroups[$rateKey] = ['base' => 0, 'amount' => 0]; - -// When iterating, cast back to float for calculations -foreach ($taxGroups as $rateKey => $group) { - $rate = (float) $rateKey; - // Use $rate for calculations and comparisons -} -``` - -### DTO Constructor Invocation -**Problem:** DTOs with no-arg constructors being called with parameters. - -**Solution:** Use static factory methods instead: -```php -// ❌ WRONG - Calling constructor with parameters -$dto = new GridPositionDTO(0, 0, 6, 4); - -// ✅ CORRECT - Use static factory method -$dto = GridPositionDTO::create(0, 0, 6, 4); -``` - -### Test Variable Definition -**Problem:** Tests asserting on undefined variables (missing "act" section). - -**Solution:** Always include all three test sections: -```php -#[Test] -public function it_creates_user(): void -{ - /* Arrange */ - $data = ['name' => 'John', 'email' => 'john@example.com']; - - /* Act */ - $user = $this->service->createUser($data); // ❗ Must define variable before assert - - /* Assert */ - $this->assertInstanceOf(User::class, $user); -} -``` - -### Property Type Consistency -**Problem:** Child class properties have different types than parent. - -**Solution:** Match parent class property types exactly: -```php -// ❌ WRONG - Nullable when parent is not -class ChildResource extends ParentResource -{ - protected static ?string $navigationGroup = 'Reports'; // Parent expects string -} - -// ✅ CORRECT - Match parent type -class ChildResource extends ParentResource -{ - protected static string $navigationGroup = 'Reports'; -} -``` +## Internationalization -### Mock Object Type Hints -**Problem:** Using stdClass for mocks when proper type is expected. +**Always `trans()`, never `__()`:** -**Solution:** Use PHPStan suppression for test mocks: ```php -// When mocking with stdClass in tests -$customer = new stdClass(); -$customer->name = 'Test'; - -/** @phpstan-ignore-next-line */ -$invoice->customer = $customer; // Property expects Customer model -``` - -### Import Statements -**Problem:** Using class aliases without proper imports. - -**Solution:** Always import from the correct namespace: -```php -// ❌ WRONG - Bare class name -use Log; - -// ✅ CORRECT - Full namespace -use Illuminate\Support\Facades\Log; -``` - -### Method Return Types -**Problem:** Method return types don't match what the method actually returns. - -**Solution:** Use type annotations or suppressions when needed: -```php -// When factory returns Collection but method expects Model -protected function createCompany(): Company -{ - /** @var Company $company */ - $company = Company::factory()->create(); - return $company; -} +// ❌ WRONG +$label = __('ip.invoice_total'); -// Or use PHPStan suppression for complex cases -/** @phpstan-ignore-next-line */ -return $this->query->get(); +// ✅ CORRECT +$label = trans('ip.invoice_total'); ``` -### PHPDoc Annotations -**Problem:** Invalid PHPDoc syntax causing PHPStan errors. +Translation keys: `resources/lang/en/ip.php`, prefix `ip.`, snake_case. +Apply to: form labels, placeholders, helper text, section titles, button labels, table headers, messages. -**Solution:** Use correct PHPDoc/PHPStan syntax: -```php -// ❌ WRONG - Invalid PHPDoc syntax -/** @SuppressWarnings(PHPMD.UnusedFormalParameter) */ +--- -// ✅ CORRECT - Valid syntax -/** @SuppressWarnings PHPMD.UnusedFormalParameter */ +## Filament Conventions -// Or use PHPStan-specific suppression -/** @phpstan-ignore-next-line */ +Resource anatomy: ``` - -### Static vs Non-Static Properties -**Problem:** Child class makes parent property static or vice versa. - -**Solution:** Keep property modifiers consistent with parent: -```php -// If parent class has non-static $view, child must also be non-static -// Use PHPStan suppression if framework requires static -/** @phpstan-ignore-next-line */ -protected static string $view = 'view.name'; +InvoiceResource.php — getModel(), navigationIcon(), getPages() +Pages/ListInvoices.php — extends ListRecords +Pages/CreateInvoice.php — extends CreateRecord +Pages/EditInvoice.php — extends EditRecord +Tables/InvoicesTable.php — static table(Table $table): Table +Schemas/InvoiceForm.php — static form(Schema $schema): Schema ``` -## Common Pitfalls to Avoid - -1. Don't use `$fillable` in models -2. Don't create DTOs with constructors -3. Don't build DTOs manually in services—use Transformers -4. Don't use JSON or ENUM columns in migrations -5. Don't add timestamps/softDeletes unless specified -6. Don't test export file content—test job dispatching -7. Don't make direct API calls—use Advanced API Client -8. Don't use `updateOrCreate`—use repository upsert methods -9. Don't nest conditions deeply—use early returns -10. Don't duplicate logic—centralize in traits -11. **Don't use floats as array keys**—cast to string first -12. **Don't call DTO constructors with parameters**—use static factory methods -13. **Don't write tests without "act" sections**—always define variables before asserting -14. **Don't mismatch parent/child property types**—keep types consistent -15. **Don't forget proper imports**—always use full namespaces +- Respect panel namespace: `Filament/Admin/` vs `Filament/Company/` +- Use `Action::make()` with fluent method chains +- Do not display raw `created_at`/`updated_at` — use formatted timestamp columns --- -## Code Review Checklist - -Before submitting code, verify: - -- [ ] Follows SOLID principles -- [ ] No inline business logic (in services) -- [ ] DTOs use static constructors, not `__construct()` -- [ ] Transformers used for DTO ↔ Model conversions -- [ ] Tests use `it_` prefix and `#[Test]` attribute -- [ ] Tests have Arrange/Act/Assert comments -- [ ] **All test variables are defined in "act" section before assertions** -- [ ] No `$fillable` in models -- [ ] No JSON/ENUM in migrations -- [ ] Type hints used throughout -- [ ] **Floats cast to strings when used as array keys** -- [ ] **DTO static factory methods used instead of constructor calls** -- [ ] **Property types match parent class types exactly** -- [ ] **All imports use full namespace paths (no bare class names)** -- [ ] Early returns instead of nested conditions -- [ ] Fakes used instead of mocks in tests -- [ ] **Test mocks use PHPStan suppressions when type mismatches** -- [ ] Export tests use Queue/Storage fakes -- [ ] Code formatted with `vendor/bin/pint` -- [ ] Static analysis passes (`vendor/bin/phpstan`) -- [ ] All tests pass (`php artisan test`) -- [ ] Documentation updated if needed +## Common Pitfalls ---- +1. Never add business logic to `app/` — use `Modules/` +2. Never define routes in `routes/` — panels handle routing +3. Never use `$fillable` — use `$guarded = []` +4. Never add JSON or ENUM columns to migrations +5. Never call `Invoice::all()` inside tenant context without company scope — BelongsToCompany adds it automatically +6. Never skip `->for($company)` on factory calls for company-scoped models (results in `null` company_id) +7. Never use `__()` for translations — always `trans()` +8. Never use floats as array keys — cast to string first -## Step-by-Step Development Workflow - -When making code changes, follow this workflow for best results: - -### 1. Understand the Task -- Read the requirements carefully -- Identify the scope of changes needed -- Understand which modules are affected - -### 2. Locate Files -- Use the module structure (`Modules/{ModuleName}/{Type}/`) -- Check existing code patterns in similar features -- Review relevant tests for context - -### 3. Make Changes -- Follow all guidelines above (SOLID, DTOs, Transformers, etc.) -- Keep changes minimal and focused -- Extract duplicate code into reusable methods -- Use early returns for better readability +--- -### 4. Write/Update Tests -- Use `#[Test]` attribute -- Name tests with `it_` prefix (e.g., `it_creates_invoice`) -- Structure with Arrange/Act/Assert comments -- Define all variables in "act" section before assertions -- Extend appropriate abstract test case from `Modules/Core/Tests/` +## Development Commands -### 5. Validate Locally -Run the validation pipeline in order: ```bash -# 1. Format code -vendor/bin/pint - -# 2. Check for type errors -vendor/bin/phpstan analyse +# Setup +composer install && cp .env.example .env && php artisan key:generate +php artisan migrate --seed +php artisan queue:work # required for export functionality -# 3. Run all tests -php artisan test +# Validation pipeline (run in order before committing) +vendor/bin/pint # 1. format code +vendor/bin/phpstan analyse # 2. static analysis +php artisan test # 3. all tests must pass ``` -### 6. Review Changes -- Ensure changes are minimal and surgical -- Verify no unrelated code was modified -- Check that all new code follows project guidelines -- Confirm test coverage is adequate - -### 7. Commit -- Follow `.github/git-commit-instructions.md` -- Write clear, semantic commit messages -- Reference issues when applicable - -**Remember:** All CI checks (PHPUnit, PHPStan, Pint, Docker Build) must pass before code can be merged. - ---- - -## Learning Resources - -### InvoicePlane-Specific -- Review existing modules for patterns -- Check test files for examples -- Read module-specific README files -- Follow CHECKLIST.md for feature status - -### Laravel/PHP -- [Laravel Best Practices](https://github.com/alexeymezenin/laravel-best-practices) -- [PHP: The Right Way](https://phptherightway.com/) -- [SOLID Principles in PHP](https://solidprinciples.dev/) - -### Filament -- [Filament Tricks](https://filamentphp.com/tricks) -- [Filament Community](https://github.com/filamentphp) - ---- - -## Continuous Improvement - -This document should be updated as: -- New patterns emerge -- Architecture decisions change -- Best practices evolve -- Performance optimizations discovered - -**Last Updated:** 2026-02-21 +See `.github/git-commit-instructions.md` for commit message conventions. --- -## Support +## CI/CD (all must pass before merge) -- **Discord:** https://discord.gg/PPzD2hTrXt -- **Forums:** https://community.invoiceplane.com -- **Issues:** https://github.com/InvoicePlane/InvoicePlane/issues -- **Wiki:** https://wiki.invoiceplane.com +- `php artisan test` — PHPUnit green +- `vendor/bin/phpstan analyse` — no type errors +- `vendor/bin/pint` — PSR-12 compliant +- Docker build — must succeed --- -**Remember:** These guidelines ensure consistency, maintainability, and performance across the InvoicePlane v2 codebase. When in doubt, refer to existing code that follows these patterns, and always prioritize code quality over speed of delivery. +## Reference Files + +| File | Content | +|------|---------| +| `.github/copilot-instructions.md` | Copilot-specific context (same facts, Copilot format) | +| `.junie/testing.md` | Testing patterns, base classes, factory cheat sheet | +| `.junie/architecture.md` | Auth flow, middleware, seeder details | +| `CLAUDE.md` | Claude Code context (same facts, Claude format) | +| `AGENTS.md` | OpenAI Codex / Agents context | +| `.github/INSTALLATION.md` | Installation guide | +| `.github/CONTRIBUTING.md` | Contributing guide | +| `.github/git-commit-instructions.md` | Commit conventions | +| `Modules/Core/Filament/Exporters/README.md` | Export architecture | diff --git a/.junie/testing.md b/.junie/testing.md new file mode 100644 index 000000000..4051d08fc --- /dev/null +++ b/.junie/testing.md @@ -0,0 +1,149 @@ +# Testing — InvoicePlane v2 + +## Base classes + +All live at `Modules/Core/Tests/`. + +### AbstractCompanyPanelTestCase + +Use for any test that exercises the company panel (most tests). + +```php +class MyTest extends AbstractCompanyPanelTestCase { + // Available in every test method: + // $this->user — active User, attached to $this->company + // $this->company — Company with search_code='IVPLV2' + // + // Filament tenant is pre-set, session current_company_id is pre-set +} +``` + +### AbstractAdminPanelTestCase + +Use for admin panel tests. + +```php +class MyTest extends AbstractAdminPanelTestCase { + // Available: + // $this->company — random Company + // $this->superAdmin() — User (no role assigned, panel set to 'admin') +} +``` + +### AbstractTestCase + +Pure unit tests — no database refresh, no app setup beyond boot. + +--- + +## Writing Livewire tests + +```php +// Preferred shorthand (company panel): +$this->testLivewire(ListInvoices::class)->assertSuccessful(); + +// Explicit form: +Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertSuccessful(); + +// Admin panel: +Livewire::actingAs($this->superAdmin()) + ->test(ListUsers::class) + ->assertSuccessful(); + +// Filling a Filament form: +Livewire::actingAs($this->user) + ->test(CreateInvoice::class, ['tenant' => Str::lower($this->company->search_code)]) + ->fillForm(['number' => 'INV-001', 'status' => InvoiceStatus::DRAFT->value]) + ->call('create') + ->assertHasNoErrors(); +``` + +--- + +## Factories + +```php +// User with a specific company attached (company is created + pivoted automatically) +User::factory()->withCompany(['search_code' => 'IVPLV2', 'name' => 'InvoicePlane Corp'])->create() + +// Active, verified user +User::factory()->create(['is_active' => true, 'email_verified_at' => now()]) + +// Always pass ->for($company) on company-scoped models — omitting it leaves company_id null +Invoice::factory()->for($this->company)->create() +Expense::factory()->for($this->company)->create(['amount' => 50.00]) +``` + +--- + +## Roles in tests + +Spatie reads roles from the database. Create the DB record before assigning: + +```php +Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); +$user->assignRole(UserRole::SUPER_ADMIN->value); +``` + +--- + +## Test method conventions + +```php +#[Test] +#[Group('smoke')] // smoke | crud | security | authentication | redirect | edge-cases +public function it_lists_invoices(): void +{ + /* Arrange */ + + /* Act */ + + /* Assert */ +} +``` + +- Method names: `it__` (snake_case) +- Always use `#[Test]` attribute (not `/** @test */`) +- Always add `#[Group(...)]` for filtering +- Use `/* Arrange / Act / Assert */` block comments + +--- + +## PHPUnit discovery + +```xml + + Modules/*/Tests/Unit +Modules/*/Tests/Feature +``` + +Run all: `php artisan test` +Run one module: `php artisan test Modules/Invoices/Tests/` +Run one file: `php artisan test Modules/Invoices/Tests/Feature/InvoicesTest.php` +Run a group: `php artisan test --group smoke` + +--- + +## Database for tests + +CI uses MariaDB 11. Locally, if no MySQL is running, add to `.env.testing`: +``` +DB_CONNECTION=sqlite +DB_DATABASE=:memory: +``` + +Carbon is frozen to `2026-01-01 00:00:00` in both panel test base classes — use `Carbon::parse('2026-01-01')` in expected date assertions. + +--- + +## Common pitfalls + +| Problem | Fix | +|---------|-----| +| Model created with `null` company_id | Add `->for($this->company)` to factory call | +| Livewire test throws "No tenant" | `Filament::setTenant($company)` in setUp | +| Role check silently fails | Create Spatie `Role` DB record before `assignRole()` | +| Test hits MySQL instead of SQLite | Check `.env.testing` has `DB_CONNECTION=sqlite` | +| `assertRedirect` fails on login test | Set `filament()->setCurrentPanel(filament()->getPanel('company'))` in setUp | diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ef004d0de --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,217 @@ +# InvoicePlane v2 — Agent Context + +## What this is + +Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via `nwidart/laravel-modules`. PHP 8.1+ enums, Spatie roles/permissions, multi-tenancy scoped to `Company`. + +## Setup + +```bash +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate && php artisan db:seed +# Tests (no MySQL locally? use SQLite) +cp .env.testing.example .env.testing +# set DB_CONNECTION=sqlite, DB_DATABASE=:memory: in .env.testing +php artisan test +``` + +--- + +## Directory map + +``` +app/ # Thin root (Providers/AppServiceProvider.php only) +Modules/ # All business logic (8 modules) +Modules/Core/Providers/ # All three Filament panel providers +config/ # modules.php, ip.php, filament.php, database.php +database/migrations/ # Only the exports table — all other migrations are per-module +database/seeders/ # Seeds ivplv2 company (id=22) + 5 random + all roles +resources/css/filament/ # Per-panel Vite themes +routes/ # Empty — routing done entirely by Filament panel providers +``` + +### Modules + +| Module | Key models | +|--------|-----------| +| Core | User, Company, CompanyUser, TaxRate, Numbering, EmailTemplate, CustomField, Upload, Note, AuditLog, Setting, MailQueue | +| Clients | Relation (table: `relations`), Contact, Address, Communication, ClientCustom (PK: `client_custom_id`) | +| Invoices | Invoice, InvoiceItem, RecurringInvoice | +| Quotes | Quote, QuoteItem | +| Payments | Payment | +| Products | Product, ProductUnit, ProductCategory | +| Projects | Project, Task | +| Expenses | Expense, ExpenseCategory, ExpenseItem | + +Module structure: +``` +Modules// + Database/{Factories,Migrations,Seeders}/ + Models/ + Filament/{Admin,Company}/Resources//{Pages,Tables,Schemas}/ + Services/ # extend Modules\Core\Services\BaseService + Enums/ # PHP 8.1+ backed string enums + Events/ Listeners/ Observers/ + Traits/ Helpers/ + Http/{Controllers,Requests,Middleware}/ + Providers/ + Tests/{Unit,Feature}/ +``` + +--- + +## Filament panels + +| Provider | Panel ID | URL path | Tenanted | Roles | +|----------|----------|----------|----------|-------| +| AdminPanelProvider | `admin` | `/admin` | No | SUPER_ADMIN, ADMIN, ASSIST | +| CompanyPanelProvider | `company` | `` (root, default) | Yes — Company by `search_code` | CUSTOMER_ADMIN, CUSTOMER | +| UserPanelProvider | `user` | `/user` | No | (future) | + +Company panel tenant middleware (persistent): +1. `SetTenantFromQueryString` — reads `?tenant=`, sets session + Filament tenant +2. `ConfigureTenant` — resolves tenant from route/query/session/user fallback +3. `EnsureUserCanAccessCompany` — 403 if regular user not in `company_user` pivot + +URLs: `/ivplv2/dashboard` — `search_code` is the route segment, always lowercase. + +Panel routes: `filament..pages.` and `filament..resources..` + +--- + +## Auth flow + +``` +Login page (Modules/Core/Filament/Pages/Auth/Login.php) + → checks is_active, rate-limits, regenerates session + → returns app(LoginResponse::class) + +LoginResponse (Modules/Core/Filament/Responses/LoginResponse.php) + → elevated (super_admin/admin/assist): Company::whereRaw('LOWER(search_code) = ?', ['ivplv2'])->first() + filament()->setTenant($tenant) + → regular (client_admin/client): $user->companies()->whereRaw(...)->first() + session(['current_company_id' => $tenant->id]) + filament()->setTenant($tenant) + → redirect()->route('filament.company.pages.dashboard', ['tenant' => Str::lower($tenant->search_code)]) +``` + +--- + +## Roles + +```php +// Modules/Core/Enums/UserRole.php +SUPER_ADMIN = 'super_admin' // full access, any panel +ADMIN = 'admin' // elevated +ASSIST = 'assist' // elevated +CUSTOMER_ADMIN = 'client_admin' // company panel only +CUSTOMER = 'client' // company panel only + +UserRole::elevated() // ['super_admin', 'admin', 'assist'] +UserRole::nonAdmin() // ['client_admin', 'client'] + +$user->hasRole(UserRole::SUPER_ADMIN->value) +$user->assignRole(UserRole::ADMIN->value) +$user->isSuperAdmin() +``` + +Spatie roles need a DB record in tests: +```php +Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); +``` + +--- + +## Key model facts + +- `Company::$primaryKey = 'id'`; URL slug is `search_code` (10 chars, unique, e.g. `ivplv2`) +- `User::$timestamps = false` +- `ClientCustom::$primaryKey = 'client_custom_id'` +- `Import::$primaryKey = 'import_id'` +- `Relation` model → table `relations` (not `customers`) +- Soft deletes on Invoice, Quote (and their items) +- `BelongsToCompany` trait — all business models use it; auto-injects `company_id` on create, adds global scope +- `company_user` pivot: columns `id, company_id, user_id` (no timestamps) +- `$user->companies()` — BelongsToMany; `$company->users()` — BelongsToMany + +Default company: `ivplv2` — search_code `'ivplv2'`, name `'InvoicePlane Corporation'`, `id=22` (hard-coded in seeder). + +--- + +## Services + +All extend `Modules\Core\Services\BaseService`: +```php +$this->create(array $data): Model +$this->find($id): Model +$this->update(array $input, $model): Model +$this->delete($id): bool +$this->paginate($perPage): LengthAwarePaginator +$this->getCompanyId(): ?int // resolves from Filament/session/user +``` + +No DTO layer — services accept plain arrays. + +--- + +## Testing + +### Base classes (`Modules/Core/Tests/`) + +```php +AbstractAdminPanelTestCase // RefreshDatabase; panel='admin'; $this->company; $this->superAdmin() +AbstractCompanyPanelTestCase // RefreshDatabase; panel='company'; $this->user; $this->company (search_code='IVPLV2') +AbstractTestCase // no RefreshDatabase; pure unit tests +``` + +### Writing tests + +```php +// Company panel (most common) +class FooTest extends AbstractCompanyPanelTestCase { + public function it_lists_foo(): void { + $this->testLivewire(ListFoo::class)->assertSuccessful(); + // or explicitly: + Livewire::actingAs($this->user) + ->test(ListFoo::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertSuccessful(); + } +} + +// Admin panel +class BarTest extends AbstractAdminPanelTestCase { + public function it_lists_bar(): void { + Livewire::actingAs($this->superAdmin())->test(ListBar::class)->assertSuccessful(); + } +} +``` + +### Factories + +```php +User::factory()->withCompany(['search_code' => 'IVPLV2'])->create() +User::factory()->create(['is_active' => true, 'email_verified_at' => now()]) +Company::factory()->create(['search_code' => 'acme']) +Invoice::factory()->for($company)->create() // always pass ->for() on scoped models +``` + +### PHPUnit discovery + +```xml + Modules/*/Tests/Unit +Modules/*/Tests/Feature +``` + +--- + +## Debugging quick-reference + +| Symptom | Where to look | +|---------|--------------| +| Company-scoped query returns nothing | `session('current_company_id')` set? Filament tenant set? | +| 403 on company panel | User in `company_user` for that company? | +| Login redirects to wrong company | `LoginResponse` — checks `LOWER(search_code) = 'ivplv2'` first | +| Model created with null company_id | Missing `->for($company)` on factory | +| Livewire test "No tenant" error | `Filament::setTenant($company)` in setUp | +| Role check fails in tests | Create Spatie Role DB record first | From e78f2ae22abc1c1b176968fee93744382b9ef880 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 07:33:23 +0000 Subject: [PATCH 04/18] Remove Pest references and add pest-control skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove pestphp/pest-plugin from composer.json allow-plugins (Pest is not installed) - Fix .gitignore comment: # PHPUnit / Pest → # PHPUnit - Fix Makefile comment: remove "Pest-style dots" wording - Add PHPUnit-only notice to CLAUDE.md testing section - Add .claude/skills/pest-control/SKILL.md — enforces PHPUnit-only testing, provides Pest→PHPUnit conversion reference, and elimination checklist https://claude.ai/code/session_01YDhpDcaYqiVJWVZ67iakv6 --- .claude/skills/pest-control/SKILL.md | 193 +++++++++++++++++++++++++++ .gitignore | 2 +- CLAUDE.md | 4 + Makefile | 2 +- composer.json | 1 - 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 .claude/skills/pest-control/SKILL.md diff --git a/.claude/skills/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md new file mode 100644 index 000000000..b1eca531e --- /dev/null +++ b/.claude/skills/pest-control/SKILL.md @@ -0,0 +1,193 @@ +--- +name: pest-control +description: > + Enforces PHPUnit-only testing in this project. Activates when writing tests, reviewing test + files, or when any Pest syntax appears (it(), test(), describe(), uses(), expect() chains, + beforeEach/afterEach hooks). Scans for and eliminates all Pest references from code, + config, and documentation. +license: MIT +metadata: + author: project +--- + +# Pest Control + +## Rule 0 — Hard Stop + +**Pest is NOT installed in this project and must never be used.** + +This project uses **PHPUnit 12+** exclusively. + +Never write, suggest, or accept: +- `it('description', fn () => ...)` +- `test('description', fn () => ...)` +- `describe('group', fn () => ...)` +- `uses(SomeClass::class)` +- `expect($value)->toBe(...)` +- `beforeEach(fn () => ...)` +- `afterEach(fn () => ...)` +- `pest()` configuration + +--- + +## Rule 1 — Correct Test Class Pattern + +Every test MUST be a class extending one of the three base classes: + +```php +// Company panel tests +class FooTest extends AbstractCompanyPanelTestCase +{ + #[Test] + public function it_does_something(): void + { + // ... + } +} + +// Admin panel tests +class BarTest extends AbstractAdminPanelTestCase +{ + #[Test] + public function it_does_something(): void + { + // ... + } +} + +// Pure unit tests (no DB, no framework boot) +class BazTest extends AbstractTestCase +{ + #[Test] + public function it_does_something(): void + { + // ... + } +} +``` + +Base class locations: `Modules/Core/Tests/` + +--- + +## Rule 2 — Attribute Syntax + +Use PHP 8.1+ attributes for test metadata: + +```php +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\CoversClass; + +#[Test] +public function it_creates_an_invoice(): void {} + +#[Test] +#[DataProvider('invoiceDataProvider')] +public function it_validates_invoice_fields(array $data, string $error): void {} +``` + +Never use `/** @test */` docblock annotations — use `#[Test]` attributes. + +--- + +## Rule 3 — Assertion Style + +Use PHPUnit assertions, not Pest chains: + +```php +// Correct +$this->assertSame('expected', $actual); +$this->assertDatabaseHas('invoices', ['status' => 'paid']); +$this->assertCount(3, $results); + +// Wrong — Pest chain +expect($actual)->toBe('expected'); +expect($results)->toHaveCount(3); +``` + +--- + +## Rule 4 — Livewire Testing + +Filament/Livewire tests use the Livewire facade directly: + +```php +use Livewire\Livewire; + +Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => 'ivplv2']) + ->assertSuccessful(); +``` + +Or the base class helper: +```php +$this->testLivewire(ListInvoices::class)->assertSuccessful(); +``` + +--- + +## Rule 5 — File Placement + +``` +Modules//Tests/Unit/ ← AbstractTestCase, no DB +Modules//Tests/Feature/ ← AbstractCompanyPanelTestCase or AbstractAdminPanelTestCase +``` + +PHPUnit discovers tests via `phpunit.xml`: +```xml + Modules/*/Tests/Unit +Modules/*/Tests/Feature +``` + +--- + +## Rule 6 — Pest Elimination Checklist + +When asked to eliminate Pest from a codebase, check and fix all of the following: + +### composer.json +- [ ] Remove `"pestphp/pest-plugin": true` from `config.allow-plugins` +- [ ] Remove any `pestphp/pest*` entries from `require-dev` + +### Test files +- [ ] Convert `it('...', fn () => ...)` → class method with `#[Test]` attribute +- [ ] Convert `test('...', fn () => ...)` → class method with `#[Test]` attribute +- [ ] Remove all `uses(...)` declarations +- [ ] Replace `expect(...)->toBe(...)` chains with `$this->assertSame(...)` +- [ ] Replace `beforeEach` → `setUp()`, `afterEach` → `tearDown()` +- [ ] Remove `describe()` wrappers; flatten into separate methods or classes + +### Config / tooling +- [ ] Delete `pest.php` or `tests/Pest.php` if present +- [ ] Remove any `--pest` flag from CI workflow commands +- [ ] Update `.gitignore` comments: `# PHPUnit / Pest` → `# PHPUnit` +- [ ] Update Makefile comments that mention Pest + +### Documentation +- [ ] Update `CLAUDE.md` testing section to state PHPUnit-only +- [ ] Update any README or CONTRIBUTING docs that mention Pest + +--- + +## Rule 7 — Conversion Reference + +| Pest | PHPUnit equivalent | +|------|--------------------| +| `it('desc', fn() => ...)` | `#[Test] public function it_desc(): void` | +| `test('desc', fn() => ...)` | `#[Test] public function test_desc(): void` | +| `expect($x)->toBe($y)` | `$this->assertSame($y, $x)` | +| `expect($x)->toEqual($y)` | `$this->assertEquals($y, $x)` | +| `expect($x)->toBeTrue()` | `$this->assertTrue($x)` | +| `expect($x)->toBeFalse()` | `$this->assertFalse($x)` | +| `expect($x)->toBeNull()` | `$this->assertNull($x)` | +| `expect($x)->toBeEmpty()` | `$this->assertEmpty($x)` | +| `expect($x)->toHaveCount(n)` | `$this->assertCount(n, $x)` | +| `expect($x)->toContain($y)` | `$this->assertContains($y, $x)` | +| `expect($x)->toMatchArray([...])` | `$this->assertEquals([...], $x)` | +| `expect($x)->toBeInstanceOf(Cls::class)` | `$this->assertInstanceOf(Cls::class, $x)` | +| `beforeEach(fn() => ...)` | `protected function setUp(): void` | +| `afterEach(fn() => ...)` | `protected function tearDown(): void` | +| `uses(RefreshDatabase::class)` | `use RefreshDatabase;` inside the class | +| `dataset(...)` | `public static function provider(): array` + `#[DataProvider('provider')]` | diff --git a/.gitignore b/.gitignore index 61910ac57..b8045b692 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,7 @@ package-lock.json # Pint /pint.txt -# PHPUnit / Pest +# PHPUnit /.phpunit.cache /.phpunit.result.cache /depr.txt diff --git a/CLAUDE.md b/CLAUDE.md index 5c2f0624c..9dfe654aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -124,6 +124,10 @@ $user->isSuperAdmin() // shorthand ## Testing +> **This project uses PHPUnit exclusively. Pest is NOT installed and must NOT be used.** +> Never write `it()`, `test()`, `describe()`, `uses()`, or Pest `expect()` chains. +> All tests are class-based, extend one of the base classes below, and use `#[Test]` attributes. + ### Base classes | Class | Extends | Sets up | Use for | diff --git a/Makefile b/Makefile index eba7b6d60..e19765c79 100644 --- a/Makefile +++ b/Makefile @@ -290,7 +290,7 @@ artisan-filter: artisan-parallel: $(_artisan) --parallel -## Run artisan test with pretty output (Pest-style dots) +## Run artisan test with compact output artisan-pretty: $(_artisan) --compact diff --git a/composer.json b/composer.json index ca53ba126..b3603f84c 100644 --- a/composer.json +++ b/composer.json @@ -10,7 +10,6 @@ "preferred-install": "dist", "sort-packages": true, "allow-plugins": { - "pestphp/pest-plugin": true, "php-http/discovery": true, "wikimedia/composer-merge-plugin": true } From 4f4903868a10e00bb7907a92abf39880a3f553ef Mon Sep 17 00:00:00 2001 From: KhawarMehfooz Date: Thu, 18 Jun 2026 15:44:53 +0500 Subject: [PATCH 05/18] fix: allow empty MariaDB root password in docker-compoer. Closes #547 --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 891233258..698cf43b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: ports: - "3306:3306" environment: - MARIADB_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}" + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" MARIADB_DATABASE: "${DB_DATABASE}" MARIADB_USER: "${DB_USERNAME}" MARIADB_PASSWORD: "${DB_PASSWORD}" From 9fed92b4c57cf5f57aeec5f994f0ceed3103b1b0 Mon Sep 17 00:00:00 2001 From: KhawarMehfooz Date: Thu, 18 Jun 2026 16:13:17 +0500 Subject: [PATCH 06/18] fix: remove MARIADB_USER and MARIADB_PASSWORD from docker-compose --- docker-compose.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 698cf43b6..36dee7d56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,6 @@ services: environment: MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" MARIADB_DATABASE: "${DB_DATABASE}" - MARIADB_USER: "${DB_USERNAME}" - MARIADB_PASSWORD: "${DB_PASSWORD}" TZ: "Europe/London" mailcatcher: From 18632701fcac1e519de8855fdac32452b47b8992 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:11:07 +0000 Subject: [PATCH 07/18] Fix login: wire custom Login page, fix company redirect for elevated users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs combined to produce the silent redirect-to-/admin/login? symptom: 1. Both panel providers called ->login() with no arguments, so Filament used its own default login page. The custom Login class (with is_active check, rate-limit feedback, and translated validation messages) was never invoked — explaining zero feedback on bad credentials. 2. LoginResponse used Company::query()->first() for elevated users, picking whichever company has the lowest DB id (random from seeder). If that company's search_code is 'admin', the redirect URL becomes /admin/dashboard which hits the admin panel instead of the company panel, producing the /admin/login? redirect. Fixed with DEFAULT_COMPANY_CODE='ivplv2' and a whereRaw('LOWER(search_code) = ?') query with a safe fallback. 3. Session current_company_id was only set for regular users, not elevated ones. Elevated users now also get the session key set for consistency with what the tenant middleware expects. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01GWq4PTCCwznTv2K7rzYXVx --- .../Core/Filament/Responses/LoginResponse.php | 32 +++++++------------ Modules/Core/Providers/AdminPanelProvider.php | 3 +- .../Core/Providers/CompanyPanelProvider.php | 3 +- 3 files changed, 15 insertions(+), 23 deletions(-) diff --git a/Modules/Core/Filament/Responses/LoginResponse.php b/Modules/Core/Filament/Responses/LoginResponse.php index 97e506a4e..a937f5a16 100644 --- a/Modules/Core/Filament/Responses/LoginResponse.php +++ b/Modules/Core/Filament/Responses/LoginResponse.php @@ -8,21 +8,19 @@ class LoginResponse implements BaseLoginResponse { + public const string DEFAULT_COMPANY_CODE = 'ivplv2'; + public function toResponse($request): mixed { - $user = auth()->user(); - $elevatedRoles = UserRole::elevated(); - $isElevated = false; - - foreach ($elevatedRoles as $role) { - if ($user->hasRole($role)) { - $isElevated = true; - break; - } - } + $user = auth()->user(); + $isElevated = $user->hasAnyRole(UserRole::elevated()); if ($isElevated) { - $tenant = \Modules\Core\Models\Company::query()->first(); // <<whereRaw('LOWER(search_code) = ?', [self::DEFAULT_COMPANY_CODE]) + ->first() + ?? \Modules\Core\Models\Company::query()->first(); + if ( ! $tenant) { abort(500, 'Fallback company not found.'); } @@ -33,19 +31,11 @@ public function toResponse($request): mixed } } - // For super_admins or Filament panel routes (with {tenant}), do not set session, only set Filament tenant - if ($isElevated) { - filament()->setTenant($tenant); - } else { - // For regular users, set both session and Filament tenant - session(['current_company_id' => $tenant->id]); - filament()->setTenant($tenant); - } + session(['current_company_id' => $tenant->id]); + filament()->setTenant($tenant); return redirect()->route('filament.company.pages.dashboard', [ 'tenant' => Str::lower($tenant->search_code), ]); - - //return redirect()->intended(Filament::getUrl()); } } diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php index 9c78b1c27..a4993a27d 100644 --- a/Modules/Core/Providers/AdminPanelProvider.php +++ b/Modules/Core/Providers/AdminPanelProvider.php @@ -28,6 +28,7 @@ use Modules\Core\Filament\Admin\Resources\TaxRates\TaxRateResource; use Modules\Core\Filament\Admin\Resources\Users\UserResource; use Modules\Core\Filament\Pages\Auth\EditProfile; +use Modules\Core\Filament\Pages\Auth\Login; class AdminPanelProvider extends PanelProvider { @@ -37,7 +38,7 @@ public function panel(Panel $panel): Panel ->id('admin') ->path('admin') ->viteTheme('resources/css/filament/company/nord.css') - ->login() + ->login(Login::class) ->profile(EditProfile::class, isSimple: false) ->passwordReset() ->emailVerification() diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 13d935f02..de6870cf1 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -24,6 +24,7 @@ use Modules\Clients\Filament\Company\Resources\Relations\RelationResource; use Modules\Core\Filament\Company\Pages\Dashboard; use Modules\Core\Filament\Pages\Auth\EditProfile; +use Modules\Core\Filament\Pages\Auth\Login; use Modules\Core\Http\Middleware\ConfigureTenant; use Modules\Core\Http\Middleware\EnsureUserCanAccessCompany; use Modules\Core\Http\Middleware\SetTenantFromQueryString; @@ -53,7 +54,7 @@ public function panel(Panel $panel): Panel ->id('company') ->path('') ->viteTheme('resources/css/filament/company/nord.css') - ->login() + ->login(Login::class) ->profile(EditProfile::class, isSimple: false) ->passwordReset() ->emailVerification() From 166e24fc52537fa0e6906049ad347c83ba5bada2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:21:36 +0000 Subject: [PATCH 08/18] Add LoginResponseTest covering company selection and session edge cases Tests cover the three bugs fixed in the previous commit: - Elevated users land on ivplv2 (DEFAULT_COMPANY_CODE), not whichever company happens to have the lowest DB id - Graceful fallback to first() when ivplv2 does not exist - Regular users land on their own company, not the global default - session current_company_id is set for both elevated and regular users - abort(500) when elevated user finds no company at all - abort(500) when regular user has no company attached Also drops the PHP 8.3-only typed-constant syntax (const string) since composer.json constrains ^8.2. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01GWq4PTCCwznTv2K7rzYXVx --- .../Core/Filament/Responses/LoginResponse.php | 2 +- .../Core/Tests/Feature/LoginResponseTest.php | 190 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 Modules/Core/Tests/Feature/LoginResponseTest.php diff --git a/Modules/Core/Filament/Responses/LoginResponse.php b/Modules/Core/Filament/Responses/LoginResponse.php index a937f5a16..f2e00bfbe 100644 --- a/Modules/Core/Filament/Responses/LoginResponse.php +++ b/Modules/Core/Filament/Responses/LoginResponse.php @@ -8,7 +8,7 @@ class LoginResponse implements BaseLoginResponse { - public const string DEFAULT_COMPANY_CODE = 'ivplv2'; + public const DEFAULT_COMPANY_CODE = 'ivplv2'; public function toResponse($request): mixed { diff --git a/Modules/Core/Tests/Feature/LoginResponseTest.php b/Modules/Core/Tests/Feature/LoginResponseTest.php new file mode 100644 index 000000000..54e46612f --- /dev/null +++ b/Modules/Core/Tests/Feature/LoginResponseTest.php @@ -0,0 +1,190 @@ +firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); + $user = User::factory()->create(); + $user->assignRole(UserRole::SUPER_ADMIN->value); + + return $user; + } + + private function makeRegularUser(Company $company): User + { + Role::query()->firstOrCreate(['name' => UserRole::CUSTOMER->value, 'guard_name' => 'web']); + $user = User::factory()->create(); + $user->assignRole(UserRole::CUSTOMER->value); + $user->companies()->attach($company); + + return $user; + } + + private function dispatchResponse(): RedirectResponse + { + return (new LoginResponse())->toResponse(request()); + } + + // endregion + + // region company selection + + /** + * Regression test for the original bug: Company::query()->first() returns whatever + * company has the lowest DB id, which is not necessarily ivplv2. If the first company + * happened to have search_code='admin', the redirect URL became /admin/dashboard which + * hit the admin panel instead of the company panel, producing /admin/login? silently. + */ + #[Test] + #[Group('login')] + public function it_redirects_elevated_users_to_the_default_company_not_the_first_by_id(): void + { + /* Arrange — setUp already created $this->company (lower ID, random code); + create ivplv2 after so it gets a higher ID and first() would skip it */ + $ivplv2 = Company::factory()->create(['search_code' => 'ivplv2']); + $user = $this->makeElevatedUser(); + $this->actingAs($user); + + /* Act */ + $response = $this->dispatchResponse(); + + /* Assert — must land on ivplv2, never on the first-by-id company */ + $this->assertEquals( + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']), + $response->headers->get('Location'), + ); + } + + #[Test] + #[Group('login')] + public function it_falls_back_to_first_company_when_default_code_does_not_exist(): void + { + /* Arrange — only $this->company exists, no ivplv2 in the database */ + $user = $this->makeElevatedUser(); + $this->actingAs($user); + + /* Act */ + $response = $this->dispatchResponse(); + + /* Assert — falls back gracefully to the only company available */ + $this->assertEquals( + route('filament.company.pages.dashboard', ['tenant' => strtolower($this->company->search_code)]), + $response->headers->get('Location'), + ); + } + + #[Test] + #[Group('login')] + public function it_redirects_regular_users_to_their_attached_company_not_ivplv2(): void + { + /* Arrange — regular user has their own company, ivplv2 also exists */ + Company::factory()->create(['search_code' => 'ivplv2']); + $ownCompany = Company::factory()->create(['search_code' => 'myco1']); + $user = $this->makeRegularUser($ownCompany); + $this->actingAs($user); + + /* Act */ + $response = $this->dispatchResponse(); + + /* Assert — regular users land on their own company, not the global default */ + $this->assertEquals( + route('filament.company.pages.dashboard', ['tenant' => 'myco1']), + $response->headers->get('Location'), + ); + } + + // endregion + + // region session + + #[Test] + #[Group('login')] + public function it_sets_session_current_company_id_for_elevated_users(): void + { + /* Arrange */ + $ivplv2 = Company::factory()->create(['search_code' => 'ivplv2']); + $user = $this->makeElevatedUser(); + $this->actingAs($user); + + /* Act */ + $this->dispatchResponse(); + + /* Assert */ + $this->assertEquals($ivplv2->id, session('current_company_id')); + } + + #[Test] + #[Group('login')] + public function it_sets_session_current_company_id_for_regular_users(): void + { + /* Arrange */ + $company = Company::factory()->create(['search_code' => 'myco2']); + $user = $this->makeRegularUser($company); + $this->actingAs($user); + + /* Act */ + $this->dispatchResponse(); + + /* Assert */ + $this->assertEquals($company->id, session('current_company_id')); + } + + // endregion + + // region abort conditions + + #[Test] + #[Group('login')] + public function it_aborts_with_500_when_no_company_exists_at_all(): void + { + /* Arrange — remove all companies (setUp creates one; no FK records exist yet) */ + Company::query()->delete(); + $user = $this->makeElevatedUser(); + $this->actingAs($user); + + /* Assert */ + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Fallback company not found.'); + + /* Act */ + $this->dispatchResponse(); + } + + #[Test] + #[Group('login')] + public function it_aborts_with_500_when_regular_user_has_no_company_attached(): void + { + /* Arrange — user exists but is attached to no company */ + Role::query()->firstOrCreate(['name' => UserRole::CUSTOMER->value, 'guard_name' => 'web']); + $user = User::factory()->create(); + $user->assignRole(UserRole::CUSTOMER->value); + $this->actingAs($user); + + /* Assert */ + $this->expectException(HttpException::class); + $this->expectExceptionMessage('No company found for this user.'); + + /* Act */ + $this->dispatchResponse(); + } + + // endregion +} From 6950c5664611a7498b3c841d70cabf522ecd5b96 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:33:45 +0000 Subject: [PATCH 09/18] Simplify login redirect to use company attachments instead of roles All users now follow a single code path: prefer ivplv2 if attached, fall back to first attached company, abort 403 if no company attached. Fixes admin user by attaching them to ivplv2 in the seeder. Update LoginResponseTest to cover the new attachment-based behaviour and add a regression test against the old DB-presence bypass. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01GWq4PTCCwznTv2K7rzYXVx --- .../Core/Filament/Responses/LoginResponse.php | 27 ++-- .../Core/Tests/Feature/LoginResponseTest.php | 119 ++++++------------ database/seeders/DatabaseSeeder.php | 6 + 3 files changed, 53 insertions(+), 99 deletions(-) diff --git a/Modules/Core/Filament/Responses/LoginResponse.php b/Modules/Core/Filament/Responses/LoginResponse.php index f2e00bfbe..ed6dbb76c 100644 --- a/Modules/Core/Filament/Responses/LoginResponse.php +++ b/Modules/Core/Filament/Responses/LoginResponse.php @@ -4,7 +4,6 @@ use Filament\Auth\Http\Responses\Contracts\LoginResponse as BaseLoginResponse; use Illuminate\Support\Str; -use Modules\Core\Enums\UserRole; class LoginResponse implements BaseLoginResponse { @@ -12,23 +11,15 @@ class LoginResponse implements BaseLoginResponse public function toResponse($request): mixed { - $user = auth()->user(); - $isElevated = $user->hasAnyRole(UserRole::elevated()); - - if ($isElevated) { - $tenant = \Modules\Core\Models\Company::query() - ->whereRaw('LOWER(search_code) = ?', [self::DEFAULT_COMPANY_CODE]) - ->first() - ?? \Modules\Core\Models\Company::query()->first(); - - if ( ! $tenant) { - abort(500, 'Fallback company not found.'); - } - } else { - $tenant = $user->companies()->first(); - if ( ! $tenant) { - abort(500, 'No company found for this user.'); - } + $user = auth()->user(); + + $tenant = $user->companies() + ->whereRaw('LOWER(search_code) = ?', [self::DEFAULT_COMPANY_CODE]) + ->first() + ?? $user->companies()->first(); + + if ( ! $tenant) { + abort(403, 'No company found for your account. Please contact an administrator.'); } session(['current_company_id' => $tenant->id]); diff --git a/Modules/Core/Tests/Feature/LoginResponseTest.php b/Modules/Core/Tests/Feature/LoginResponseTest.php index 54e46612f..c09369b20 100644 --- a/Modules/Core/Tests/Feature/LoginResponseTest.php +++ b/Modules/Core/Tests/Feature/LoginResponseTest.php @@ -3,7 +3,6 @@ namespace Modules\Core\Tests\Feature; use Illuminate\Http\RedirectResponse; -use Modules\Core\Enums\UserRole; use Modules\Core\Filament\Responses\LoginResponse; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -11,7 +10,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; -use Spatie\Permission\Models\Role; use Symfony\Component\HttpKernel\Exception\HttpException; #[CoversClass(LoginResponse::class)] @@ -19,21 +17,12 @@ class LoginResponseTest extends AbstractAdminPanelTestCase { // region helpers - private function makeElevatedUser(): User + private function makeUser(Company ...$companies): User { - Role::query()->firstOrCreate(['name' => UserRole::SUPER_ADMIN->value, 'guard_name' => 'web']); $user = User::factory()->create(); - $user->assignRole(UserRole::SUPER_ADMIN->value); - - return $user; - } - - private function makeRegularUser(Company $company): User - { - Role::query()->firstOrCreate(['name' => UserRole::CUSTOMER->value, 'guard_name' => 'web']); - $user = User::factory()->create(); - $user->assignRole(UserRole::CUSTOMER->value); - $user->companies()->attach($company); + foreach ($companies as $company) { + $user->companies()->attach($company); + } return $user; } @@ -45,28 +34,18 @@ private function dispatchResponse(): RedirectResponse // endregion - // region company selection + // region redirect destination - /** - * Regression test for the original bug: Company::query()->first() returns whatever - * company has the lowest DB id, which is not necessarily ivplv2. If the first company - * happened to have search_code='admin', the redirect URL became /admin/dashboard which - * hit the admin panel instead of the company panel, producing /admin/login? silently. - */ #[Test] #[Group('login')] - public function it_redirects_elevated_users_to_the_default_company_not_the_first_by_id(): void + public function it_redirects_to_ivplv2_when_user_is_attached_to_it(): void { - /* Arrange — setUp already created $this->company (lower ID, random code); - create ivplv2 after so it gets a higher ID and first() would skip it */ $ivplv2 = Company::factory()->create(['search_code' => 'ivplv2']); - $user = $this->makeElevatedUser(); + $user = $this->makeUser($ivplv2); $this->actingAs($user); - /* Act */ $response = $this->dispatchResponse(); - /* Assert — must land on ivplv2, never on the first-by-id company */ $this->assertEquals( route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']), $response->headers->get('Location'), @@ -75,75 +54,75 @@ public function it_redirects_elevated_users_to_the_default_company_not_the_first #[Test] #[Group('login')] - public function it_falls_back_to_first_company_when_default_code_does_not_exist(): void + public function it_redirects_to_ivplv2_even_when_user_has_multiple_companies(): void { - /* Arrange — only $this->company exists, no ivplv2 in the database */ - $user = $this->makeElevatedUser(); + $other = Company::factory()->create(['search_code' => 'other1']); + $ivplv2 = Company::factory()->create(['search_code' => 'ivplv2']); + $user = $this->makeUser($other, $ivplv2); $this->actingAs($user); - /* Act */ $response = $this->dispatchResponse(); - /* Assert — falls back gracefully to the only company available */ $this->assertEquals( - route('filament.company.pages.dashboard', ['tenant' => strtolower($this->company->search_code)]), + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']), $response->headers->get('Location'), ); } #[Test] #[Group('login')] - public function it_redirects_regular_users_to_their_attached_company_not_ivplv2(): void + public function it_falls_back_to_first_attached_company_when_not_attached_to_ivplv2(): void { - /* Arrange — regular user has their own company, ivplv2 also exists */ + /* ivplv2 exists in DB but user is NOT attached to it */ Company::factory()->create(['search_code' => 'ivplv2']); $ownCompany = Company::factory()->create(['search_code' => 'myco1']); - $user = $this->makeRegularUser($ownCompany); + $user = $this->makeUser($ownCompany); $this->actingAs($user); - /* Act */ $response = $this->dispatchResponse(); - /* Assert — regular users land on their own company, not the global default */ $this->assertEquals( route('filament.company.pages.dashboard', ['tenant' => 'myco1']), $response->headers->get('Location'), ); } - // endregion - - // region session - + /** + * Regression: the old elevated-user code path used Company::query()->first() which + * redirected to ivplv2 based solely on DB presence, bypassing company_user membership. + * The new logic respects the user's actual attachments only. + */ #[Test] #[Group('login')] - public function it_sets_session_current_company_id_for_elevated_users(): void + public function it_does_not_redirect_to_ivplv2_based_solely_on_db_presence(): void { - /* Arrange */ - $ivplv2 = Company::factory()->create(['search_code' => 'ivplv2']); - $user = $this->makeElevatedUser(); + Company::factory()->create(['search_code' => 'ivplv2']); // in DB, user NOT attached + $ownCompany = Company::factory()->create(['search_code' => 'myco2']); + $user = $this->makeUser($ownCompany); $this->actingAs($user); - /* Act */ - $this->dispatchResponse(); + $response = $this->dispatchResponse(); - /* Assert */ - $this->assertEquals($ivplv2->id, session('current_company_id')); + $this->assertNotEquals( + route('filament.company.pages.dashboard', ['tenant' => 'ivplv2']), + $response->headers->get('Location'), + ); } + // endregion + + // region session + #[Test] #[Group('login')] - public function it_sets_session_current_company_id_for_regular_users(): void + public function it_sets_session_current_company_id(): void { - /* Arrange */ - $company = Company::factory()->create(['search_code' => 'myco2']); - $user = $this->makeRegularUser($company); + $company = Company::factory()->create(['search_code' => 'myco3']); + $user = $this->makeUser($company); $this->actingAs($user); - /* Act */ $this->dispatchResponse(); - /* Assert */ $this->assertEquals($company->id, session('current_company_id')); } @@ -153,36 +132,14 @@ public function it_sets_session_current_company_id_for_regular_users(): void #[Test] #[Group('login')] - public function it_aborts_with_500_when_no_company_exists_at_all(): void - { - /* Arrange — remove all companies (setUp creates one; no FK records exist yet) */ - Company::query()->delete(); - $user = $this->makeElevatedUser(); - $this->actingAs($user); - - /* Assert */ - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Fallback company not found.'); - - /* Act */ - $this->dispatchResponse(); - } - - #[Test] - #[Group('login')] - public function it_aborts_with_500_when_regular_user_has_no_company_attached(): void + public function it_aborts_with_403_when_user_has_no_company_attached(): void { - /* Arrange — user exists but is attached to no company */ - Role::query()->firstOrCreate(['name' => UserRole::CUSTOMER->value, 'guard_name' => 'web']); $user = User::factory()->create(); - $user->assignRole(UserRole::CUSTOMER->value); $this->actingAs($user); - /* Assert */ $this->expectException(HttpException::class); - $this->expectExceptionMessage('No company found for this user.'); + $this->expectExceptionMessage('No company found for your account. Please contact an administrator.'); - /* Act */ $this->dispatchResponse(); } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 13b15fd12..aa66bf43c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -61,6 +61,12 @@ public function run(): void $bar->finish(); $this->command->newLine(2); + $admin = User::query()->where('email', 'admin@invoiceplane.com')->first(); + $ivplv2 = Company::query()->whereRaw('LOWER(search_code) = ?', ['ivplv2'])->first(); + if ($admin && $ivplv2) { + $admin->companies()->syncWithoutDetaching([$ivplv2->id]); + } + $totalCompanies = Company::query()->count(); $companyBar = $this->command->getOutput()->createProgressBar($totalCompanies); $companyBar->setMessage('Seeding company data'); From 3640dbf3e10930dafc77d0dd710cdaacd45a1bae Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 16:25:33 +0000 Subject: [PATCH 10/18] Add APP_KEY to phpunit.xml and .env.testing so Livewire tests can run Without an encryption key, Livewire snapshot signing fails during tests. Adds the key to both phpunit.xml (authoritative for CI) and .env.testing (for local dev runs via php artisan test). Also adds !.env.testing to .gitignore so the file can be committed alongside the example. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01GWq4PTCCwznTv2K7rzYXVx --- .env.testing | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + phpunit.xml | 1 + 3 files changed, 65 insertions(+) create mode 100644 .env.testing diff --git a/.env.testing b/.env.testing new file mode 100644 index 000000000..3998969f9 --- /dev/null +++ b/.env.testing @@ -0,0 +1,63 @@ +APP_NAME="InvoicePlane v2" +APP_ENV=testing +APP_KEY=base64:JdgrYNc+daEj95jsjJIsYH2/wudsvwvi9LhR1QzFy08= +APP_DEBUG=false +APP_EXTREME_LOGGING=false +DEBUGBAR_ENABLED=false +APP_URL=http://localhost + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file + +PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=4 + +LOG_CHANNEL=stack +LOG_DAILY_DAYS=7 +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=:memory: +DB_USERNAME=root +DB_PASSWORD=root + +SESSION_DRIVER=array +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=sync + +CACHE_STORE=array + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=array +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitignore b/.gitignore index b8045b692..bbb362633 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .env* !.env.example +!.env.testing !.env.testing.example /storage/*.key diff --git a/phpunit.xml b/phpunit.xml index eb5d75b5a..7ca1e56dd 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -23,6 +23,7 @@ + From 4ece165b0d09cfa6c0b2138db506797f284ebcd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:39:08 +0000 Subject: [PATCH 11/18] chore(skills): audit and repair all 25 skill files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filament-resource-pages: replace wrong service-layer content with actual Filament v4 resource/pages/schemas/tables patterns from the codebase - service-layer: merge nuanced DTO guidance from the evicted duplicate; trim to single authoritative file - laravel-modules: remove false "no nwidart" claim (composer.json has it); fix directory structure to match reality (no src/ layer, uppercase Database/Tests/) - non-standard-pks: remove fabricated model PK table; keep only the two confirmed non-standard PKs (ClientCustom, Import) - senior-laravel-developer-phpunit-interpreter: remove hardcoded project path /home/runner/work/spotivel/ so skill is reusable across projects - filament-panel-setup: add missing User panel (three panels, not two) - autonomous-coding-workflow: add missing YAML frontmatter - safe-refactoring-rules: add missing YAML frontmatter - sync-stale-branches: add missing YAML frontmatter - ci-schema-invariant-gate: fix name mismatch (ci-schema-gate → ci-schema-invariant-gate) - dto-contract: fix name mismatch (dto-contracts → dto-contract) - filament-multi-tenancy: remove duplicated middleware chain docs; point to tenant-middleware skill - senior-laravel-developer-code-reviewer: add grumpy Senior Laravel developer persona with direct, no-nonsense tone and structured Must/Should/Could fix output format Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RCxcRGA8PyC5eocitHtjKy --- .../autonomous-coding-workflow/SKILL.md | 5 + .../skills/ci-schema-invariant-gate/SKILL.md | 2 +- .claude/skills/dto-contract/SKILL.md | 2 +- .../skills/filament-multi-tenancy/SKILL.md | 18 +- .claude/skills/filament-panel-setup/SKILL.md | 13 +- .../skills/filament-resource-pages/SKILL.md | 230 ++++++++++++------ .claude/skills/laravel-modules/SKILL.md | 89 ++++--- .claude/skills/non-standard-pks/SKILL.md | 128 +++------- .../skills/safe-refactoring-rules/SKILL.md | 5 + .../SKILL.md | 158 ++++++------ .../SKILL.md | 4 +- .claude/skills/service-layer/SKILL.md | 68 ++---- .claude/skills/sync-stale-branches/SKILL.md | 5 + 13 files changed, 366 insertions(+), 361 deletions(-) diff --git a/.claude/skills/autonomous-coding-workflow/SKILL.md b/.claude/skills/autonomous-coding-workflow/SKILL.md index 5c7e6dbc6..665630b84 100644 --- a/.claude/skills/autonomous-coding-workflow/SKILL.md +++ b/.claude/skills/autonomous-coding-workflow/SKILL.md @@ -1,3 +1,8 @@ +--- +name: autonomous-coding-workflow +description: Governs safe, incremental, repository-wide development workflow with continuous validation gates +--- + # Autonomous Coding Workflow ## Goal diff --git a/.claude/skills/ci-schema-invariant-gate/SKILL.md b/.claude/skills/ci-schema-invariant-gate/SKILL.md index 1907bfc50..51dcc7164 100644 --- a/.claude/skills/ci-schema-invariant-gate/SKILL.md +++ b/.claude/skills/ci-schema-invariant-gate/SKILL.md @@ -1,5 +1,5 @@ --- -name: ci-schema-gate +name: ci-schema-invariant-gate description: Ensures correct execution order of migrations, seeders, and tests in CI --- diff --git a/.claude/skills/dto-contract/SKILL.md b/.claude/skills/dto-contract/SKILL.md index 2d6303818..3ce1b87e8 100644 --- a/.claude/skills/dto-contract/SKILL.md +++ b/.claude/skills/dto-contract/SKILL.md @@ -1,5 +1,5 @@ --- -name: dto-contracts +name: dto-contract description: Defines DTO structure, lifecycle, and transformation rules across the application license: MIT metadata: diff --git a/.claude/skills/filament-multi-tenancy/SKILL.md b/.claude/skills/filament-multi-tenancy/SKILL.md index 843cabb9c..290bb29df 100644 --- a/.claude/skills/filament-multi-tenancy/SKILL.md +++ b/.claude/skills/filament-multi-tenancy/SKILL.md @@ -105,21 +105,9 @@ Filament::setTenant($companyA, isQuiet: true); ## Tenant Middleware Stack -The company panel uses three tenant middlewares (in order): - -1. `SetTenantFromQueryString` — reads `?tenant=search_code`, validates access -2. `ConfigureTenant` — resolves tenant from route/query/session/user default, writes to session -3. `EnsureUserCanAccessCompany` — enforces authorization (elevated roles bypass; regular users must own the company) - -These are registered as persistent tenant middleware in `CompanyPanelProvider`: - -```php -->tenantMiddleware([ - SetTenantFromQueryString::class, - ConfigureTenant::class, - EnsureUserCanAccessCompany::class, -], isPersistent: true) -``` +See the `tenant-middleware` skill for the full middleware chain. In short: three +persistent middlewares run on every company panel request in this order: +`SetTenantFromQueryString` → `ConfigureTenant` → `EnsureUserCanAccessCompany`. ## Tenant in Tests Setup diff --git a/.claude/skills/filament-panel-setup/SKILL.md b/.claude/skills/filament-panel-setup/SKILL.md index 88270b51b..7b35cf62c 100644 --- a/.claude/skills/filament-panel-setup/SKILL.md +++ b/.claude/skills/filament-panel-setup/SKILL.md @@ -8,12 +8,15 @@ metadata: # Filament Panel Setup -This app has two panels: +This app has three panels: -| Panel | Provider | Id | Default? | Tenant | -|-------|----------|----|----------|--------| -| Company | `CompanyPanelProvider` | `company` | No | `Company::class` | -| Admin | `AdminPanelProvider` | `admin` | Yes | None | +| Panel | Provider | Id | Default? | Tenant | Access | +|-------|----------|----|----------|--------|--------| +| Company | `CompanyPanelProvider` | `company` | Yes (root) | `Company::class` | `client_admin`, `client` | +| Admin | `AdminPanelProvider` | `admin` | No | None | `super_admin`, `admin`, `assist` | +| User | `UserPanelProvider` | `user` | No | None | minimal, future use | + +All three providers live at `Modules/Core/Providers/`. ## Registering Module Resources diff --git a/.claude/skills/filament-resource-pages/SKILL.md b/.claude/skills/filament-resource-pages/SKILL.md index 4824a6d00..5495baf60 100644 --- a/.claude/skills/filament-resource-pages/SKILL.md +++ b/.claude/skills/filament-resource-pages/SKILL.md @@ -1,126 +1,196 @@ --- -name: service-layer -description: Defines application service structure and business orchestration boundaries +name: filament-resource-pages +description: "Defines the Filament v4 resource page structure used in this project: Resource + Pages + Schemas + Tables split, action patterns, and BaseResource conventions." license: MIT metadata: author: project --- -# Service Layer +# Filament Resource Pages -Services define business orchestration and are the primary boundary for business logic execution. +## Resource Directory Layout -They are framework-agnostic and represent application behavior independent of UI or transport layers. +Every resource lives under `Modules/{Name}/Filament/{Panel}/Resources/{Model}/`: ---- - -# 1. Responsibility - -Services MUST: - -- contain business logic -- coordinate models and repositories -- enforce domain rules -- return models or internally used DTOs -- encapsulate persistence logic - -Services MUST NOT: +``` +{Model}Resource.php ← extends BaseResource; declares model, nav, pages +Pages/ + List{Model}.php ← extends ListRecords + Create{Model}.php ← extends CreateRecord + Edit{Model}.php ← extends EditRecord +Schemas/ + {Model}Form.php ← static configure(Schema $schema): Schema +Tables/ + {Model}sTable.php ← static configure(Table $table): Table +RelationManagers/ ← optional +``` -- use Filament -- depend on HTTP layer (requests/responses) -- contain UI logic -- use service locators (`app()`, `resolve()`) -- depend on transport concerns +Schemas and Tables are **separate classes**, never defined inline inside the Resource. --- -# 2. Dependency Rule - -Services MUST use constructor injection: +## Resource Class ```php -public function __construct( - private InvoiceRepository $repository -) {} +class InvoiceResource extends BaseResource +{ + protected static ?string $model = Invoice::class; + protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBanknotes; + protected static ?int $navigationSort = 10; + protected static bool $isScopedToTenant = true; + + public static function form(Schema $schema): Schema + { + return InvoiceForm::configure($schema); + } + + public static function table(Table $table): Table + { + return InvoicesTable::configure($table); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListInvoices::route('/'), + 'create' => Pages\CreateInvoice::route('/create'), + 'edit' => Pages\EditInvoice::route('/{record}/edit'), + ]; + } +} ``` -No service locator usage is allowed inside services. +`BaseResource` handles tenant-scoped queries automatically — do NOT add manual `company_id` filters. --- -# 3. DTO Rule (Refined) - -DTOs are NOT required for UI → Service communication. - -DTO usage depends on coupling, not layer type: - -## DTOs are required when: -- crossing system boundaries (API, external integrations, queues) -- multiple consumers share a contract -- transformation logic must be standardized -- stability across versions is required +## List Page -## DTOs are NOT required when: -- input originates from trusted UI layer (e.g. Filament Forms) -- single consumer exists -- payload is short-lived and not reused elsewhere - -### Rule of thumb: -> DTOs exist to stabilize unstable or shared contracts, not to formalize trusted UI input. - -Services MAY still use DTOs internally if they improve clarity or structure. +```php +class ListInvoices extends ListRecords +{ + protected static string $resource = InvoiceResource::class; + + protected function getHeaderActions(): array + { + return [ + CreateAction::make() + ->modalWidth('full') + ->action(function (array $data) { + app(InvoiceService::class)->createInvoice($data); + }), + ]; + } +} +``` --- -# 4. Filament Boundary Rule - -Filament is a UI orchestration layer. +## Edit Page -## Allowed usage: +Override `save()` when you need to route the update through the service layer: -### Pages / Resources -- constructor injection preferred -- `app(Service::class)` allowed as fallback when needed +```php +class EditInvoice extends EditRecord +{ + protected static string $resource = InvoiceResource::class; + + public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void + { + $this->authorizeAccess(); + $this->callHook('beforeValidate'); + $data = $this->form->getState(); + $this->callHook('afterValidate'); + $data = $this->mutateFormDataBeforeSave($data); + $this->callHook('beforeSave'); + + app(InvoiceService::class)->updateInvoice($data, $this->getRecord()); + + $this->callHook('afterSave'); + + if ($shouldRedirect) { + $this->redirect($this->getRedirectUrl()); + } + } + + protected function getHeaderActions(): array + { + return [DeleteAction::make()]; + } +} +``` -### Table Actions / Closures -- `app(Service::class)` is allowed -- constructor injection is not guaranteed in closure scope +--- -Example: +## Schema Class ```php -Action::make('create') - ->action(function (array $data) { - app(InvoiceService::class)->createInvoice($data); - }); +class InvoiceForm +{ + public static function configure(Schema $schema): Schema + { + return $schema->components([ + Grid::make(2)->schema([ + Section::make('Details')->schema([ + Select::make('customer_id')->relationship('customer', 'company_name')->required(), + DatePicker::make('invoice_date')->required(), + ]), + ]), + ]); + } +} ``` -This is acceptable boundary-layer behavior. - --- -# 5. Standard Service Shape +## Table Class -``` -Modules/{Name}/src/Services/{Model}Service.php +```php +class InvoicesTable +{ + public static function configure(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('invoice_number')->searchable()->sortable(), + TextColumn::make('invoice_status')->badge(), + ]) + ->actions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([DeleteBulkAction::make()]), + ]); + } +} ``` --- -# 6. Standard Methods +## Action Closure Rule + +Filament action closures do NOT support constructor injection. Always use `app()`: -- createX -- updateX -- deleteX -- findOrFail -- listForCompany +```php +->action(function (array $data) { + app(InvoiceService::class)->createInvoice($data); +}) +``` + +This is the only place `app()` is acceptable. Services themselves must never use it. --- -# 7. Core Principle +## Panel Registration -Services are pure business units. +Resources are discovered per module in `CompanyPanelProvider`: -They MUST remain independent of framework execution context. +```php +->discoverResources( + in: base_path('Modules/Invoices/Filament/Company/Resources'), + for: 'Modules\\Invoices\\Filament\\Company\\Resources' +) +``` -UI layers may use service locator as a pragmatic escape hatch where DI is not available. +Both `in` and `for` must exactly match the module's filesystem path and PHP namespace. diff --git a/.claude/skills/laravel-modules/SKILL.md b/.claude/skills/laravel-modules/SKILL.md index 9ee79834b..b3fdf1c61 100644 --- a/.claude/skills/laravel-modules/SKILL.md +++ b/.claude/skills/laravel-modules/SKILL.md @@ -8,49 +8,55 @@ metadata: # Laravel Modules -This project uses a hand-rolled modular structure under `Modules/`. There is no -`nwidart/laravel-modules` package — modules are plain directories registered -manually. +This project uses `nwidart/laravel-modules` (≥ v12). Modules live under `Modules/`. ## Directory Layout -Every module follows this exact layout: +Every module follows this exact layout (note uppercase `Database/` and `Tests/`): ``` Modules/{Name}/ - database/ - factories/ ← Eloquent factories - migrations/ ← Module-specific migrations - seeders/ ← Module seeders (extend AbstractSeeder) - resources/ - views/ ← Blade views namespaced as '{name}::' - src/ - Contracts/ ← Interfaces / contracts (optional) - Enums/ ← Module enums - Filament/ + Database/ + Factories/ ← Eloquent factories + Migrations/ ← Module-specific migrations + Seeders/ ← Module seeders (extend AbstractSeeder) + Filament/ + Admin/Resources/ ← Admin panel resources (if any) + Company/ Resources/ {Model}/ {Model}Resource.php ← extends BaseResource Pages/ - List{Model}.php ← extends ListRecords - Create{Model}.php ← extends CreateRecord - Edit{Model}.php ← extends EditRecord - Models/ ← Eloquent models - Observers/ ← Model observers (optional) - Providers/ ← {Name}ServiceProvider - Services/ ← {Name}Service (createX, updateX, deleteX, listForCompany) - Traits/ ← Shared traits (optional) - tests/ + List{Model}.php + Create{Model}.php + Edit{Model}.php + Schemas/ + {Model}Form.php + Tables/ + {Model}sTable.php + Models/ ← Eloquent models + Enums/ + Events/ + Listeners/ + Observers/ + Providers/ ← {Name}ServiceProvider + Services/ ← {Name}Service + Traits/ + Tests/ Feature/ ← PHPUnit feature tests Unit/ ← PHPUnit unit tests + resources/ + views/ ← Blade views namespaced as '{name}::' + composer.json + module.json ``` ## Namespace Convention ``` -Modules\{Name}\ root namespace -Modules\{Name}\Models\ models -Modules\{Name}\Filament\Resources\{Model}\{Model}Resource +Modules\{Name}\ +Modules\{Name}\Models\ +Modules\{Name}\Filament\Company\Resources\{Model}\{Model}Resource Modules\{Name}\Database\Factories\{Model}Factory Modules\{Name}\Database\Seeders\{Model}Seeder Modules\{Name}\Services\{Model}Service @@ -59,18 +65,15 @@ Modules\{Name}\Tests\Feature\{Model}Test ## Service Provider -Every module has a `{Name}ServiceProvider` that loads migrations and views. -Observers are registered here too: +Every module has a `{Name}ServiceProvider` registered in `config/app.php`: ```php class ClientsServiceProvider extends ServiceProvider { - public function register(): void {} - public function boot(): void { $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'clients'); - $this->loadMigrationsFrom(__DIR__ . '/../../database/migrations'); + $this->loadMigrationsFrom(__DIR__ . '/../../Database/Migrations'); Client::observe(ClientObserver::class); // only if observer exists } } @@ -79,29 +82,25 @@ class ClientsServiceProvider extends ServiceProvider ## Registering a New Module 1. Create the directory tree above. -2. Add the service provider to `config/app.php` providers array. +2. Add `{Name}ServiceProvider` to `config/app.php` providers array. 3. Register the factory namespace in `app/Providers/AppServiceProvider.php` - (look at how existing modules do it — there's a `Factory::guessFactoryNamesUsing` callback). -4. Add `->discoverResources(...)` to `CompanyPanelProvider` for the new module. - -## Registering Resources in the Panel - -`app/Providers/Filament/CompanyPanelProvider.php` discovers resources per module: + following the pattern used by existing modules (`Factory::guessFactoryNamesUsing`). +4. Add `->discoverResources(...)` to `CompanyPanelProvider`: ```php ->discoverResources( - in: base_path('Modules/mymodule/src/Filament/Resources'), - for: 'Modules\\Mymodule\\Filament\\Resources' + in: base_path('Modules/Mymodule/Filament/Company/Resources'), + for: 'Modules\\Mymodule\\Filament\\Company\\Resources' ) ``` -## Tests Are Auto-Discovered +## Test Auto-Discovery -`phpunit.xml` includes module test directories: +`phpunit.xml` discovers tests automatically — no registration needed: ```xml -Modules/*/tests/Unit -Modules/*/tests/Feature +Modules/*/Tests/Unit +Modules/*/Tests/Feature ``` -No extra registration needed — just put tests in the right place. +Just put tests under `Modules/{Name}/Tests/Feature/` or `Tests/Unit/`. diff --git a/.claude/skills/non-standard-pks/SKILL.md b/.claude/skills/non-standard-pks/SKILL.md index c062299f3..900e92a09 100644 --- a/.claude/skills/non-standard-pks/SKILL.md +++ b/.claude/skills/non-standard-pks/SKILL.md @@ -1,6 +1,6 @@ --- name: non-standard-pks -description: "Works with models that have non-standard primary key names. Activates when writing factories, tests, relationships, or seeders for models that use user_id, client_id, invoice_id, etc. instead of id." +description: "Works with models that have non-standard primary key names. Activates when writing factories, tests, relationships, or seeders for models that use a custom primary key instead of id." license: MIT metadata: author: project @@ -8,124 +8,74 @@ metadata: # Non-Standard Primary Keys -Most models in this app use descriptive primary key names, not `id`. +Most models in this app use `id` as their primary key (Laravel default). Only a +handful declare a custom `$primaryKey`. **Never assume a model has a non-standard +PK without checking the model file.** -## PK Reference Table +## Confirmed Non-Standard PKs | Model | Table | Primary Key | |-------|-------|-------------| -| User | users | `user_id` | -| Client | clients | `client_id` | -| Invoice | invoices | `invoice_id` | -| InvoiceGroup | invoice_groups | `invoice_group_id` | -| Quote | quotes | `quote_id` | -| Payment | payments | `payment_id` | -| Product | products | `product_id` | -| Project | projects | `project_id` | -| Task | tasks | `task_id` | -| TaxRate | tax_rates | `tax_rate_id` | -| Family | families | `family_id` | -| Unit | units | `unit_id` | -| EmailTemplate | email_templates | `email_template_id` | -| Company | companies | `id` ← standard | -| Expense | expenses | `id` ← standard | -| ExpenseCategory | expense_categories | `id` ← standard | - -## Accessing the PK - -Use `$model->getKey()` when you need the PK value generically. Use the named -attribute only when you know the model: +| `ClientCustom` | `client_custom` | `client_custom_id` | +| `Import` | `imports` | `import_id` | + +All other models should be assumed to use `id` unless their model file explicitly +declares `protected $primaryKey = '...'`. + +## Accessing the PK Safely + +Use `$model->getKey()` for generic access. Use the named attribute only when +you know the model's actual PK: ```php -$invoice->invoice_id // ✓ typed access -$invoice->getKey() // ✓ generic access -$invoice->id // ✗ returns null on non-standard PKs +$custom->client_custom_id // ✓ typed access for ClientCustom +$custom->getKey() // ✓ generic access +$custom->id // ✗ returns null — ClientCustom uses client_custom_id ``` -## In Factories: Pass the FK by Name +## Factories: Pass the FK by Name -When a factory creates related records, pass the FK column explicitly: +When creating related records that reference a non-standard PK, pass the FK +column explicitly: ```php -Invoice::factory()->create([ - 'company_id' => $this->companyId, - 'client_id' => $client->client_id, // not $client->id - 'invoice_group_id' => $group->invoice_group_id, // not $group->id - 'user_id' => $user->user_id, // not $user->id +// ClientCustom's PK is client_custom_id, not id +SomeRelated::factory()->create([ + 'client_custom_id' => $custom->client_custom_id, ]); ``` -## In Tests: Use the PK for the Record Parameter +## Filament Edit Page -Filament's Edit page `record` parameter expects the PK value: +The `record` parameter expects the PK value: ```php Livewire::actingAs($this->user) - ->test(EditInvoice::class, [ - 'record' => $invoice->invoice_id, // not $invoice->id - 'tenant' => $this->company, + ->test(EditClientCustom::class, [ + 'record' => $custom->client_custom_id, // not $custom->id + 'tenant' => $this->company->search_code, ]) ``` ## Model Definition -Always declare `$primaryKey` explicitly. Models also set `$timestamps = false` -since they manage date columns manually: +Always declare `$primaryKey` explicitly for non-standard models: ```php -class Invoice extends Model +class ClientCustom extends Model { - protected $table = 'invoices'; - protected $primaryKey = 'invoice_id'; + protected $table = 'client_custom'; + protected $primaryKey = 'client_custom_id'; public $timestamps = false; } ``` -## Relationships Must Specify Keys - -BelongsTo and HasMany must specify the FK and owner key when they differ from -Laravel's convention (`{model}_id` → `id`): - -```php -// On Invoice — points to users.user_id, not users.id -public function user(): BelongsTo -{ - return $this->belongsTo(User::class, 'user_id', 'user_id'); -} - -// On Company — invoice FK column is invoice_group_id, PK is invoice_group_id -public function invoiceGroups(): HasMany -{ - return $this->hasMany(InvoiceGroup::class, 'company_id', 'id'); -} -``` - ---- - -## Never Assume "id" - -Never access: - -$model->id - -Never reference: - -'id' - -unless the model explicitly uses a standard primary key. - -Use: - -$model->getKey() - -or the named primary key property. - ---- +## Adding a New Non-Standard PK -## Fix-One-Fix-All +When you introduce a model with a non-standard PK, update this skill's +**Confirmed Non-Standard PKs** table immediately. -If one test, factory, seeder, relationship, resource, or service incorrectly -uses `id` instead of the model's primary key, search the repository for the -same pattern and correct all occurrences. +## Timestamps -Primary key assumptions tend to fail systematically rather than individually. +Almost all models in this app have `$timestamps = false` — they manage date +columns manually. Do not assume `created_at`/`updated_at` exist. diff --git a/.claude/skills/safe-refactoring-rules/SKILL.md b/.claude/skills/safe-refactoring-rules/SKILL.md index 10ae550b2..1a42b7a4f 100644 --- a/.claude/skills/safe-refactoring-rules/SKILL.md +++ b/.claude/skills/safe-refactoring-rules/SKILL.md @@ -1,3 +1,8 @@ +--- +name: safe-refactoring-rules +description: Ensures all refactoring is deterministic, behavior-preserving, and non-breaking +--- + # Safe Refactoring Rules ## Purpose diff --git a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md index 5e5bd594f..fdfdd161b 100644 --- a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md +++ b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md @@ -1,145 +1,143 @@ --- name: senior-laravel-developer-code-reviewer -description: "Orchestrates existing Laravel skills to produce structured PR reviews" +description: "Orchestrates existing Laravel skills to produce structured PR reviews as a grumpy, no-nonsense Senior Laravel developer" --- -# Purpose +# Senior Laravel Developer — Code Reviewer -This skill does not implement rules. +You are a grumpy Senior Laravel developer. You have seen every anti-pattern twice. +You do not sugarcoat. You do not pad your feedback with compliments. You report +exactly what is wrong and exactly how to fix it. -It orchestrates existing skills to produce a complete pull request review. +You are not unkind — you are precise. You want the code to be correct, not to feel +good about itself. --- # Delegation Model -This reviewer MUST delegate evaluation to existing skills: +Do not invent rules. Delegate evaluation to existing skills: -## Architecture +**Architecture & code quality** +- `application-architecture-standard` +- `service-layer` +- `laravel-modules` +- `non-standard-pks` +- `dto-contract` +- `safe-refactoring-rules` -Delegate to: +**Tests** +- `filament-resource-testing` +- `test-honesty` +- `pest-control` -- application-architecture-standard -- service-layer skill -- laravel-modules skill -- non-standard-pks skill -- dto-contract skill -- safe-refactoring-rules skill +**Security** +- `security-review` +- `spatie-roles` -## Tests -Use: -- filament-resource-testing skill -- test-honesty skill - -## Security -Use: -- security-review skill -- otherwise infer from architecture + test gaps +**Tenancy** +- `filament-multi-tenancy` +- `tenant-middleware` --- # Review Process -When reviewing a PR: - ## 1. Architecture pass -Summarize findings from architecture-related skills. -Do NOT restate rules. +Report violations only. Do not restate rules. -Only report violations. +Bad example of what NOT to write: +> "The service layer principle states that services should not use Filament..." + +Good example: +> "`InvoiceService::create()` calls `Filament::getTenant()` directly. Services must not touch Filament." --- ## 2. Test pass -Summarize findings from test-related skills. Focus on: -- weak tests -- missing coverage -- nondeterministic tests -- missing failure cases +- Tests that pass even when the feature is broken (assertion on wrong thing) +- Missing failure-path tests +- Hardcoded IDs (violates `test-honesty`) +- Pest syntax in a PHPUnit-only project +- Livewire tests that bypass the service layer and assert nothing in the DB --- ## 3. Security pass -Identify: -- missing authorization -- unsafe access paths -- privilege escalation risks -- missing validation + +Report: +- Unguarded resource actions (no policy, no gate, no role check) +- Privilege escalation paths +- Missing input validation at system boundaries --- ## 4. Consolidation -Merge findings into: +Group findings into three buckets — and only three: -- Critical issues (must fix) -- Important issues -- Suggestions +- **Must fix** — production bugs, security holes, data integrity risks, broken tests +- **Should fix** — architecture violations, test gaps, maintainability problems +- **Could fix** — cosmetic improvements, style, naming + +Never let "Could fix" items crowd out "Must fix" items. --- # Output Format +``` ## Summary -Short PR assessment +One paragraph. What does this PR do, and is it shippable? -## Critical Issues -Bullets only +## Must Fix +- : — — -## Important Issues -Bullets only +## Should Fix +- : — -## Suggestions -Bullets only +## Could Fix +- : — -## Test Risk Summary -Only risks, no rule explanation +## Test Risk +- -## Security Notes -Only vulnerabilities, no theory +## Security +- ## Suggested Fixes -Copy/paste code only + +``` --- -# Constraints +# Tone Rules -- Do NOT restate rules defined in other skills -- Do NOT include full explanations of SOLID, DRY, etc. -- Do NOT duplicate test philosophy definitions -- Only report deviations -- Keep output strictly diagnostic +Say: "This bypasses the service layer and writes directly to the model." +Not: "This could potentially be considered a violation of layered architecture..." ---- +Say: "Missing authorization. Any authenticated user can delete any invoice." +Not: "It might be worth considering adding an authorization check here..." -# Prioritization +Say: "This test asserts nothing in the database. It passes whether the record was created or not." +Not: "The test coverage could be improved by adding database assertions..." -Report issues in this order: +If it is wrong, say it is wrong. If it is broken, say it is broken. +If something is genuinely fine, say nothing about it. + +--- + +# Priority Order 1. Production bugs 2. Security issues 3. Data integrity risks -4. Test honesty violations +4. Broken or dishonest tests 5. Architecture violations -6. Maintainability improvements -7. Cosmetic suggestions - -Never allow style issues to obscure correctness issues. - ---- - -# Tone - -Simple, direct, non-verbose. - -Explain issues like: - -> "This bypasses the service layer and writes directly to the model." - -Not: +6. Maintainability +7. Style -> "This violates layered architecture principles..." +Never allow item 7 to appear before items 1–4 are exhausted. diff --git a/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md b/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md index 477de6a83..38e87e9c8 100644 --- a/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md +++ b/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md @@ -29,7 +29,9 @@ Remove completely: ## Path Cleanup -Replace `/home/runner/work/spotivel/` with an empty string. Do not replace any other paths. +Remove the absolute project root path prefix from all file paths so only the +relative path remains (e.g. strip `/home/runner/work//` or +`/var/www//` — whatever the CI runner's working directory is). --- diff --git a/.claude/skills/service-layer/SKILL.md b/.claude/skills/service-layer/SKILL.md index c04fb6c3d..1087f62ab 100644 --- a/.claude/skills/service-layer/SKILL.md +++ b/.claude/skills/service-layer/SKILL.md @@ -8,9 +8,7 @@ metadata: # Service Layer -Services define **business orchestration only**. - -They are framework-agnostic and represent the application’s business operations. +Services are the only place business logic lives. They are framework-agnostic. --- @@ -19,23 +17,22 @@ They are framework-agnostic and represent the application’s business operation Services MUST: - contain business logic -- coordinate models and repositories +- coordinate models - enforce domain rules - return models or DTOs Services MUST NOT: -- use Filament -- use HTTP layer -- depend on request/response objects +- import or use Filament +- accept or return HTTP request/response objects - contain UI logic -- use service locators (`app()`, `resolve()`) +- use `app()` or `resolve()` internally --- # 2. Dependency Rule -Services MUST use constructor injection: +Constructor injection only: ```php public function __construct( @@ -43,25 +40,28 @@ public function __construct( ) {} ``` -No `app()` or service locator usage inside services. - --- -# 3. Filament Boundary Rule +# 3. DTO Rule -Filament is a UI boundary layer with different lifecycle constraints. +DTOs are **not** required for Filament → Service calls. Arrays are fine when the +source is a trusted Filament form. -Allowed patterns: +Use DTOs when: +- crossing system boundaries (API, queues, external integrations) +- multiple services share a contract +- the payload must be stable across refactors -### Pages / Resources -- constructor injection preferred -- `app(Service::class)` allowed as fallback +Skip DTOs when: +- input comes from a single Filament form +- the data is short-lived and not reused + +--- -### Table Actions / Closures -- `app(Service::class)` is allowed -- constructor injection is not guaranteed in closures +# 4. Filament Action Exception -Example: +Filament closures do not support constructor DI. `app()` is the only acceptable +escape hatch — and it belongs in the closure, not inside the service: ```php Action::make('create') @@ -70,32 +70,12 @@ Action::make('create') }); ``` -This is acceptable UI-layer coupling. - --- -# 4. Standard Service Shape +# 5. Standard Shape ``` -Modules/{Name}/src/Services/{Model}Service.php +Modules/{Name}/Services/{Model}Service.php ``` ---- - -# 5. Standard Methods - -- createX -- updateX -- deleteX -- findOrFail -- listForCompany - ---- - -# 6. Core Principle - -Services are pure business units. - -They must not depend on framework execution context. - -UI layers may use service locator as a pragmatic boundary escape hatch. +Standard method names: `createX`, `updateX`, `deleteX`, `findOrFail`, `listForCompany`. diff --git a/.claude/skills/sync-stale-branches/SKILL.md b/.claude/skills/sync-stale-branches/SKILL.md index fbd4d7e4e..134666808 100644 --- a/.claude/skills/sync-stale-branches/SKILL.md +++ b/.claude/skills/sync-stale-branches/SKILL.md @@ -1,3 +1,8 @@ +--- +name: sync-stale-branches +description: Brings diverged remote branches up to date with develop — classifies, rescues unique work, then resets or deletes stale branches +--- + # Skill: sync-stale-branches Bring old/diverged remote branches up to date with `develop`. From b35d91fe539cb10f561e168b2dea34c0792ad568 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 15:48:45 +0000 Subject: [PATCH 12/18] chore(skills): rewrite laravel-modules skill to target internachi/modular Switches the standard to internachi/modular where each module is a real Composer package with src/, database/, tests/ layout and path-repository wiring in the root composer.json. Retains an explicit nwidart (InvoicePlane-v2 legacy) comparison table so the skill stays useful in this repo while guiding future projects toward the correct structure. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RCxcRGA8PyC5eocitHtjKy --- .claude/skills/laravel-modules/SKILL.md | 229 +++++++++++++++++------- 1 file changed, 166 insertions(+), 63 deletions(-) diff --git a/.claude/skills/laravel-modules/SKILL.md b/.claude/skills/laravel-modules/SKILL.md index b3fdf1c61..d8ae7ebdd 100644 --- a/.claude/skills/laravel-modules/SKILL.md +++ b/.claude/skills/laravel-modules/SKILL.md @@ -1,6 +1,6 @@ --- name: laravel-modules -description: "Creates and modifies code inside the Modules/ directory structure. Activates when adding a new module, adding a model/factory/migration/resource/service to an existing module, registering a module with Filament, or when the user mentions modules, modular, or a specific module name (clients, invoices, payments, etc.)." +description: "Creates and modifies code inside a modular Laravel structure. Targets internachi/modular (modules as real Composer packages with src/). Activates when adding a new module, adding a model/factory/migration/resource/service to an existing module, registering a module with Filament, or when the user mentions modules, modular, or a specific module name." license: MIT metadata: author: project @@ -8,99 +8,202 @@ metadata: # Laravel Modules -This project uses `nwidart/laravel-modules` (≥ v12). Modules live under `Modules/`. +## Package Standard: `internachi/modular` -## Directory Layout +New modules use [`internachi/modular`](https://github.com/InterNACHI/modular). +Each module is a **real Composer package** with its own `composer.json`, resolved +from the root via a path repository. This makes modules portable, independently +testable, and properly autoloaded. -Every module follows this exact layout (note uppercase `Database/` and `Tests/`): +> **InvoicePlane-v2 exception:** This project was built with `nwidart/laravel-modules` +> and has **no `src/` layer** — the module root is the PSR-4 root. If you are +> working in this repo, skip the `src/` wrapper and use uppercase `Database/`, +> `Tests/` directly under the module root. See the nwidart section at the bottom. +--- + +## `internachi/modular` Directory Layout + +``` +modules/ + {name}/ ← lowercase, kebab-case + src/ ← PSR-4 root + {Name}ServiceProvider.php + Models/ + Enums/ + Events/ Listeners/ Observers/ + Filament/ + Company/ + Resources/ + {Model}/ + {Model}Resource.php + Pages/ + List{Model}.php + Create{Model}.php + Edit{Model}.php + Schemas/ + {Model}Form.php + Tables/ + {Model}sTable.php + Http/ + Services/ + Traits/ + database/ + factories/ + migrations/ + seeders/ + tests/ + Feature/ + Unit/ + composer.json +``` + +--- + +## Module `composer.json` + +```json +{ + "name": "app/{name}", + "description": "The {Name} module", + "type": "library", + "require": {}, + "autoload": { + "psr-4": { + "App\\{Name}\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\{Name}\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "App\\{Name}\\{Name}ServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} ``` -Modules/{Name}/ - Database/ - Factories/ ← Eloquent factories - Migrations/ ← Module-specific migrations - Seeders/ ← Module seeders (extend AbstractSeeder) - Filament/ - Admin/Resources/ ← Admin panel resources (if any) - Company/ - Resources/ - {Model}/ - {Model}Resource.php ← extends BaseResource - Pages/ - List{Model}.php - Create{Model}.php - Edit{Model}.php - Schemas/ - {Model}Form.php - Tables/ - {Model}sTable.php - Models/ ← Eloquent models - Enums/ - Events/ - Listeners/ - Observers/ - Providers/ ← {Name}ServiceProvider - Services/ ← {Name}Service - Traits/ - Tests/ - Feature/ ← PHPUnit feature tests - Unit/ ← PHPUnit unit tests - resources/ - views/ ← Blade views namespaced as '{name}::' - composer.json - module.json + +--- + +## Root `composer.json` Wiring + +```json +{ + "repositories": [ + { + "type": "path", + "url": "./modules/*", + "options": { "symlink": true } + } + ], + "require": { + "app/core": "*", + "app/invoices": "*" + } +} ``` +Run `composer require app/{name}:*` whenever a new module is added. + +--- + ## Namespace Convention ``` -Modules\{Name}\ -Modules\{Name}\Models\ -Modules\{Name}\Filament\Company\Resources\{Model}\{Model}Resource -Modules\{Name}\Database\Factories\{Model}Factory -Modules\{Name}\Database\Seeders\{Model}Seeder -Modules\{Name}\Services\{Model}Service -Modules\{Name}\Tests\Feature\{Model}Test +App\{Name}\ +App\{Name}\Models\ +App\{Name}\Filament\Company\Resources\{Model}\{Model}Resource +App\{Name}\Database\Factories\{Model}Factory +App\{Name}\Database\Seeders\{Model}Seeder +App\{Name}\Services\{Model}Service +App\{Name}\Tests\Feature\{Model}Test ``` +--- + ## Service Provider -Every module has a `{Name}ServiceProvider` registered in `config/app.php`: +The service provider is auto-discovered via `composer.json`. It only needs to +load migrations and register observers: ```php -class ClientsServiceProvider extends ServiceProvider +namespace App\Invoices; + +use Illuminate\Support\ServiceProvider; + +class InvoicesServiceProvider extends ServiceProvider { public function boot(): void { - $this->loadViewsFrom(__DIR__ . '/../../resources/views', 'clients'); - $this->loadMigrationsFrom(__DIR__ . '/../../Database/Migrations'); - Client::observe(ClientObserver::class); // only if observer exists + $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); + $this->loadViewsFrom(__DIR__ . '/../resources/views', 'invoices'); } } ``` -## Registering a New Module +No manual entry in `config/app.php` — Composer's auto-discovery handles it. -1. Create the directory tree above. -2. Add `{Name}ServiceProvider` to `config/app.php` providers array. -3. Register the factory namespace in `app/Providers/AppServiceProvider.php` - following the pattern used by existing modules (`Factory::guessFactoryNamesUsing`). -4. Add `->discoverResources(...)` to `CompanyPanelProvider`: +--- + +## Filament Resource Registration + +Add `->discoverResources()` per module in `CompanyPanelProvider`: ```php ->discoverResources( - in: base_path('Modules/Mymodule/Filament/Company/Resources'), - for: 'Modules\\Mymodule\\Filament\\Company\\Resources' + in: base_path('modules/invoices/src/Filament/Company/Resources'), + for: 'App\\Invoices\\Filament\\Company\\Resources' ) ``` -## Test Auto-Discovery +--- + +## Test Discovery -`phpunit.xml` discovers tests automatically — no registration needed: +Configure `phpunit.xml` to pick up all module test directories: ```xml -Modules/*/Tests/Unit -Modules/*/Tests/Feature + + modules/*/tests/Unit + + + modules/*/tests/Feature + ``` -Just put tests under `Modules/{Name}/Tests/Feature/` or `Tests/Unit/`. +--- + +## Adding a New Module (Checklist) + +1. Create `modules/{name}/` with the directory tree above. +2. Write `modules/{name}/composer.json` (copy from existing module, change name/namespace). +3. Run `composer require app/{name}:*` from the project root. +4. Add `->discoverResources(...)` to `CompanyPanelProvider`. +5. Run `php artisan migrate` to pick up the new module's migrations. + +--- + +## nwidart/laravel-modules (InvoicePlane-v2 Legacy) + +InvoicePlane-v2 uses `nwidart/laravel-modules` ≥ v12. The key differences: + +| | `internachi/modular` | `nwidart` (InvoicePlane-v2) | +|---|---|---| +| Module root | `modules/{name}/` | `Modules/{Name}/` | +| PSR-4 source | `src/` | module root directly | +| Namespace | `App\{Name}\` | `Modules\{Name}\` | +| Tests | `tests/` (lowercase) | `Tests/` (uppercase) | +| DB files | `database/` (lowercase) | `Database/` (uppercase) | +| Discovery | Composer path repo | `module.json` + manual provider | +| Registration | `composer require` | add to `config/app.php` | + +When working in InvoicePlane-v2, drop the `src/` layer and follow uppercase +`Database/`, `Tests/` conventions. All else (service structure, Filament patterns, +test base classes) remains the same. From 865b53bea77e7dd98e3e73afba441a105a746e9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 16:10:10 +0000 Subject: [PATCH 13/18] fix(skills): use Modules\{Name}\ namespace in laravel-modules skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both internachi/modular and nwidart share the same Modules\{Name}\ namespace. The src/ layer is the only structural difference — namespace stays consistent. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RCxcRGA8PyC5eocitHtjKy --- .claude/skills/laravel-modules/SKILL.md | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.claude/skills/laravel-modules/SKILL.md b/.claude/skills/laravel-modules/SKILL.md index d8ae7ebdd..d6170f3bd 100644 --- a/.claude/skills/laravel-modules/SKILL.md +++ b/.claude/skills/laravel-modules/SKILL.md @@ -70,18 +70,18 @@ modules/ "require": {}, "autoload": { "psr-4": { - "App\\{Name}\\": "src/" + "Modules\\{Name}\\": "src/" } }, "autoload-dev": { "psr-4": { - "App\\{Name}\\Tests\\": "tests/" + "Modules\\{Name}\\Tests\\": "tests/" } }, "extra": { "laravel": { "providers": [ - "App\\{Name}\\{Name}ServiceProvider" + "Modules\\{Name}\\{Name}ServiceProvider" ] } }, @@ -117,13 +117,13 @@ Run `composer require app/{name}:*` whenever a new module is added. ## Namespace Convention ``` -App\{Name}\ -App\{Name}\Models\ -App\{Name}\Filament\Company\Resources\{Model}\{Model}Resource -App\{Name}\Database\Factories\{Model}Factory -App\{Name}\Database\Seeders\{Model}Seeder -App\{Name}\Services\{Model}Service -App\{Name}\Tests\Feature\{Model}Test +Modules\{Name}\ +Modules\{Name}\Models\ +Modules\{Name}\Filament\Company\Resources\{Model}\{Model}Resource +Modules\{Name}\Database\Factories\{Model}Factory +Modules\{Name}\Database\Seeders\{Model}Seeder +Modules\{Name}\Services\{Model}Service +Modules\{Name}\Tests\Feature\{Model}Test ``` --- @@ -134,7 +134,7 @@ The service provider is auto-discovered via `composer.json`. It only needs to load migrations and register observers: ```php -namespace App\Invoices; +namespace Modules\Invoices; use Illuminate\Support\ServiceProvider; @@ -159,7 +159,7 @@ Add `->discoverResources()` per module in `CompanyPanelProvider`: ```php ->discoverResources( in: base_path('modules/invoices/src/Filament/Company/Resources'), - for: 'App\\Invoices\\Filament\\Company\\Resources' + for: 'Modules\\Invoices\\Filament\\Company\\Resources' ) ``` @@ -198,7 +198,7 @@ InvoicePlane-v2 uses `nwidart/laravel-modules` ≥ v12. The key differences: |---|---|---| | Module root | `modules/{name}/` | `Modules/{Name}/` | | PSR-4 source | `src/` | module root directly | -| Namespace | `App\{Name}\` | `Modules\{Name}\` | +| Namespace | `Modules\{Name}\` | `Modules\{Name}\` | | Tests | `tests/` (lowercase) | `Tests/` (uppercase) | | DB files | `database/` (lowercase) | `Database/` (uppercase) | | Discovery | Composer path repo | `module.json` + manual provider | From 45eda86c6b5fdd033d77c45719235967ac99f176 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 16:12:32 +0000 Subject: [PATCH 14/18] chore(skills): enforce Arrange/Act/Assert phase comments in all tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every test method must have // Arrange, // Act, // Assert comments — no exceptions. Added as Rule 7 in pest-control and as a named check in the code reviewer's test pass. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RCxcRGA8PyC5eocitHtjKy --- .claude/skills/pest-control/SKILL.md | 35 ++++++++++++++++++- .../SKILL.md | 1 + 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/.claude/skills/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md index b1eca531e..407daeaae 100644 --- a/.claude/skills/pest-control/SKILL.md +++ b/.claude/skills/pest-control/SKILL.md @@ -171,7 +171,40 @@ When asked to eliminate Pest from a codebase, check and fix all of the following --- -## Rule 7 — Conversion Reference +## Rule 7 — Arrange / Act / Assert + +Every test method MUST be structured in three named phases, each preceded by its +own `// Arrange`, `// Act`, or `// Assert` comment. No exceptions. + +```php +#[Test] +public function it_creates_an_invoice(): void +{ + // Arrange + $client = Relation::factory()->for($this->company)->create(); + $payload = ['customer_id' => $client->getKey(), 'invoice_date' => '2026-01-01']; + + // Act + app(InvoiceService::class)->createInvoice($payload); + + // Assert + $this->assertDatabaseHas('invoices', [ + 'customer_id' => $client->getKey(), + 'company_id' => $this->company->id, + ]); +} +``` + +A test with no `// Arrange` / `// Act` / `// Assert` comments is rejected on +review, no matter how correct the assertions are. + +If a phase is genuinely empty (e.g. a pure-assertion unit test with no setup), +keep the comment and leave a blank line — the structure is the contract, not the +line count. + +--- + +## Rule 8 — Conversion Reference | Pest | PHPUnit equivalent | |------|--------------------| diff --git a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md index fdfdd161b..ac3b17b8d 100644 --- a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md +++ b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md @@ -63,6 +63,7 @@ Focus on: - Hardcoded IDs (violates `test-honesty`) - Pest syntax in a PHPUnit-only project - Livewire tests that bypass the service layer and assert nothing in the DB +- **Missing `// Arrange` / `// Act` / `// Assert` phase comments** — every test method requires all three, no exceptions --- From 51699bf6b0763ead5cebf8c85746a3ce8fa040e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 16:14:29 +0000 Subject: [PATCH 15/18] fix(skills): use /* Arrange */ /* Act */ /* Assert */ block comments Not // single-line comments. The phase markers are block comments. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RCxcRGA8PyC5eocitHtjKy --- .claude/skills/pest-control/SKILL.md | 10 +++++----- .../senior-laravel-developer-code-reviewer/SKILL.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.claude/skills/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md index 407daeaae..4376e4cb4 100644 --- a/.claude/skills/pest-control/SKILL.md +++ b/.claude/skills/pest-control/SKILL.md @@ -174,20 +174,20 @@ When asked to eliminate Pest from a codebase, check and fix all of the following ## Rule 7 — Arrange / Act / Assert Every test method MUST be structured in three named phases, each preceded by its -own `// Arrange`, `// Act`, or `// Assert` comment. No exceptions. +own `/* Arrange */`, `/* Act */`, or `/* Assert */` comment. No exceptions. ```php #[Test] public function it_creates_an_invoice(): void { - // Arrange + /* Arrange */ $client = Relation::factory()->for($this->company)->create(); $payload = ['customer_id' => $client->getKey(), 'invoice_date' => '2026-01-01']; - // Act + /* Act */ app(InvoiceService::class)->createInvoice($payload); - // Assert + /* Assert */ $this->assertDatabaseHas('invoices', [ 'customer_id' => $client->getKey(), 'company_id' => $this->company->id, @@ -195,7 +195,7 @@ public function it_creates_an_invoice(): void } ``` -A test with no `// Arrange` / `// Act` / `// Assert` comments is rejected on +A test with no `/* Arrange */` / `/* Act */` / `/* Assert */` comments is rejected on review, no matter how correct the assertions are. If a phase is genuinely empty (e.g. a pure-assertion unit test with no setup), diff --git a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md index ac3b17b8d..72fcf8347 100644 --- a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md +++ b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md @@ -63,7 +63,7 @@ Focus on: - Hardcoded IDs (violates `test-honesty`) - Pest syntax in a PHPUnit-only project - Livewire tests that bypass the service layer and assert nothing in the DB -- **Missing `// Arrange` / `// Act` / `// Assert` phase comments** — every test method requires all three, no exceptions +- **Missing `/* Arrange */` / `/* Act */` / `/* Assert */` phase comments** — every test method requires all three, no exceptions --- From 6457750074649a01074caba8c17f4df9a22f7b1c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 16:20:24 +0000 Subject: [PATCH 16/18] chore(skills): add it_{verb}_{object} test naming convention as Rule 7 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RCxcRGA8PyC5eocitHtjKy --- .claude/skills/pest-control/SKILL.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.claude/skills/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md index 4376e4cb4..548882fc9 100644 --- a/.claude/skills/pest-control/SKILL.md +++ b/.claude/skills/pest-control/SKILL.md @@ -171,7 +171,30 @@ When asked to eliminate Pest from a codebase, check and fix all of the following --- -## Rule 7 — Arrange / Act / Assert +## Rule 7 — Test Method Naming + +Test methods MUST follow the `it_{verb}_{object}` convention. The name must read +as a sentence describing observable behavior. + +```php +// Correct +it_creates_an_invoice +it_rejects_a_duplicate_email +it_returns_404_for_missing_resource +it_assigns_company_id_to_new_invoices + +// Wrong — noun before verb +it_invoice_creates + +// Wrong — no verb +it_invoice +``` + +Never describe implementation. Describe what the system does from the outside. + +--- + +## Rule 8 — Arrange / Act / Assert Every test method MUST be structured in three named phases, each preceded by its own `/* Arrange */`, `/* Act */`, or `/* Assert */` comment. No exceptions. From 62a47e19fb74e831b9dea8255d488a33701f2de2 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:38:32 +0000 Subject: [PATCH 17/18] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- .claude/skills/filament-resource-pages/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/filament-resource-pages/SKILL.md b/.claude/skills/filament-resource-pages/SKILL.md index 5495baf60..afd2eb0ab 100644 --- a/.claude/skills/filament-resource-pages/SKILL.md +++ b/.claude/skills/filament-resource-pages/SKILL.md @@ -188,9 +188,9 @@ Resources are discovered per module in `CompanyPanelProvider`: ```php ->discoverResources( - in: base_path('Modules/Invoices/Filament/Company/Resources'), + in: base_path('modules/invoices/src/Filament/Company/Resources'), for: 'Modules\\Invoices\\Filament\\Company\\Resources' ) ``` -Both `in` and `for` must exactly match the module's filesystem path and PHP namespace. +The `in` parameter uses the filesystem path (lowercase with `src/`), while `for` uses the PHP namespace. From 272225e6ce24e993fb31a99891688754a60f614a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 00:41:08 +0000 Subject: [PATCH 18/18] Fix pre-existing naming and AAA structure violations in QuotesTest files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing /* Arrange */ comment to it_creates_a_quote_through_a_modal - Add missing /* Act */ and /* Assert */ comments to same method - Add missing /* Arrange */ comment to it_creates_a_quote - Remove duplicate /* Assert */ comment in it_only_lists_quotes_for_the_current_tenant - Rename widget_shows_only_current_tenant_quotes → it_shows_only_current_tenant_quotes_in_the_widget (noun-before-verb naming violation) - Split forbidden /* Act & Assert */ into separate /* Act */ and /* Assert */ in QuoteDuplicateNumberPreventionTest Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01DqwRdtVHYbBGYkENv1D78R --- .../Tests/Feature/QuoteDuplicateNumberPreventionTest.php | 4 +++- Modules/Quotes/Tests/Feature/QuotesTest.php | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php b/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php index 231597813..88ec10b68 100644 --- a/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php +++ b/Modules/Quotes/Tests/Feature/QuoteDuplicateNumberPreventionTest.php @@ -28,7 +28,7 @@ public function it_prevents_duplicate_quote_numbers_within_same_company(): void 'quote_number' => 'QUO-2025-0001', ]); - /* Act & Assert */ + /* Act */ $this->expectException(RuntimeException::class); $this->expectExceptionMessage("Duplicate quote number 'QUO-2025-0001'"); @@ -36,6 +36,8 @@ public function it_prevents_duplicate_quote_numbers_within_same_company(): void 'numbering_id' => $numbering->id, 'quote_number' => 'QUO-2025-0001', ]); + + /* Assert */ } #[Test] diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php index 8f002af03..2fdfdce56 100644 --- a/Modules/Quotes/Tests/Feature/QuotesTest.php +++ b/Modules/Quotes/Tests/Feature/QuotesTest.php @@ -63,6 +63,7 @@ public function it_lists_quotes(): void #[Group('crud')] public function it_creates_a_quote_through_a_modal(): void { + /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); $documentGroup = Numbering::factory()->for($this->company)->create(); @@ -101,12 +102,14 @@ public function it_creates_a_quote_through_a_modal(): void ], ]; + /* Act */ $component = Livewire::actingAs($this->user) ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)]) ->mountAction('create') ->fillForm($payload) ->callMountedAction(); + /* Assert */ $component->assertHasNoFormErrors(); // Patch date fields for DB assertion @@ -343,6 +346,7 @@ public function it_updates_a_quote_through_a_modal(): void */ public function it_creates_a_quote(): void { + /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); $documentGroup = Numbering::factory()->for($this->company)->create(); @@ -579,7 +583,6 @@ public function it_only_lists_quotes_for_the_current_tenant(): void $component = Livewire::actingAs($this->user) ->test(ListQuotes::class); - /* Assert */ /* Assert */ $component->assertSuccessful(); $component->assertSeeText('Q-VISIBLE'); @@ -592,7 +595,7 @@ public function it_only_lists_quotes_for_the_current_tenant(): void #[Test] #[Group('crud')] #[Group('failing')] - public function widget_shows_only_current_tenant_quotes(): void + public function it_shows_only_current_tenant_quotes_in_the_widget(): void { $this->markTestSkipped('No widget route is currently registered for quotes'); }