diff --git a/.claude/skills/abstract-seeder/SKILL.md b/.claude/skills/abstract-seeder/SKILL.md deleted file mode 100644 index aff6abefa..000000000 --- a/.claude/skills/abstract-seeder/SKILL.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -name: abstract-seeder -description: Provides structured seeding workflow for module data initialization ---- - -# Abstract Seeder - -## Purpose - -Provides a structured way to seed database data per module. - ---- - -## Scope - -Seeders are responsible for: - -- creating initial dataset for a company -- using factories to generate valid records -- orchestrating dependency order between models - ---- - -## Ownership Boundary - -Seeders MUST NOT: - -- define validation rules -- define factory structure -- enforce schema constraints -- contain business logic - ---- - -## Factory Dependency Rule - -Seeders MUST rely on factories for object creation. - -Factories are the source of truth for valid model state. - ---- - -## Dependency Resolution - -Seeders MAY resolve dependencies using helper methods: - -- findOrCreateClient -- findOrCreateProject -- findOrCreateUser - -These helpers are convenience utilities, not business logic. - ---- - -## Execution Hooks - -- beforeSeed(): setup state -- afterSeed(): cleanup or summary - ---- - -## Principle - -Seeders assemble data. They do not define data correctness. diff --git a/.claude/skills/application-architecture-standard/SKILL.md b/.claude/skills/application-architecture-standard/SKILL.md deleted file mode 100644 index a0f6e47c5..000000000 --- a/.claude/skills/application-architecture-standard/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: application-architecture-standard -description: Defines structural rules for Laravel architecture, layering, and code organization ---- - -# Purpose - -Single source of truth for application structure and architectural boundaries. - ---- - -# 1. Layering Rules - -## Presentation Layer -- Controllers -- Filament Pages -- Form Requests (validation only) - -No business logic allowed. - -## Application Layer -- Services -- DTOs -- Transformers - -Holds all business logic orchestration. - -## Domain Layer -- Models -Represents state and invariants only. - -## Infrastructure Layer -- API clients -- External services - -Must be replaceable and contain no business logic. - ---- - -## 2. Service Rules - -- Business logic lives in services. -- Services must remain framework-agnostic except for Laravel infrastructure (Eloquent, DB transactions, HTTP client, logging). -- No Filament classes or UI concerns inside services. -- DTOs are used for structured data transfer where transformation, validation, or reuse is required. -- They are optional for internal service calls when validated arrays are sufficient. - ---- - -# 3. Dependency Rules - -- Constructor injection only -- No service locators -- No hidden dependencies - ---- - -# 4. Architecture Integrity - -- No cross-layer leakage -- Strict separation of concerns -- Refactoring must preserve behavior diff --git a/.claude/skills/autonomous-coding-workflow/SKILL.md b/.claude/skills/autonomous-coding-workflow/SKILL.md deleted file mode 100644 index 665630b84..000000000 --- a/.claude/skills/autonomous-coding-workflow/SKILL.md +++ /dev/null @@ -1,137 +0,0 @@ ---- -name: autonomous-coding-workflow -description: Governs safe, incremental, repository-wide development workflow with continuous validation gates ---- - -# Autonomous Coding Workflow - -## Goal - -Perform repository-wide modifications safely, incrementally, and with continuous validation. - ---- - -## 1. Instruction Precedence - -Before doing anything: - -- Check for repository-level instruction files: - - `.github/copilot-instructions.md` - - `AGENTS.md` - - `.junie/*.md` - - `CLAUDE.md` - -If they exist: -- Treat them as higher precedence for architecture and conventions. -- Avoid duplicating rules already defined there. - ---- - -## 2. Preparation - -Before modifying code: - -1. Read existing implementation. -2. Understand current behavior. -3. Identify existing abstractions and reuse them: - - Traits - - Base test cases - - Base resources - - Base seeders - - Services - - DTOs - - Transformers -4. Search explicitly for duplication before introducing new abstractions. -5. Preserve existing architectural patterns. - -Do not modify code that has not been understood. - ---- - -## 3. Refactoring Heuristics - -Apply only when relevant: - -- If repeated patterns exist across many test classes, models, or resources, evaluate abstraction opportunities. -- Prefer centralizing duplicated logic into: - - Traits - - Base classes - - Services -- Do not introduce abstraction unless duplication is confirmed. - ---- - -## 4. Incremental Development - -Work in small, verifiable steps. - -After each change: - -1. Verify syntax: - ```bash - php -l - ``` -2. Run targeted tests. -3. Fix failures immediately. -4. Run code style checks: - ```bash - vendor/bin/pint --dirty --format agent - ``` -5. Continue only if repository is clean. - ---- - -## 5. Validation Gates - -Never proceed if any of the following fail: - -- PHPUnit tests -- Static analysis -- PHP syntax check (php -l) -- Code style (Pint) - -Before completion, additionally ensure: - -- migrate:fresh --seed passes -- smoke tests pass -- targeted tests pass -- full suite passes (unless explicitly excluded) - ---- - -## 6. Module Completion - -After completing a module: - -1. Run targeted test suite. -2. Run `php -l`. -3. Run Pint. -4. Confirm no unintended changes. -5. Commit with clear module description. - -Do not start the next module until the current one is fully stable. - ---- - -## 7. Uncertainty Handling - -If behavior is unclear: - -- Stop immediately. -- Describe ambiguity. -- Request clarification. -- Do not infer or guess missing business rules. - ---- - -## 8. Success Criteria - -The task is complete only when: - -- Behavior is preserved. -- No duplicate logic introduced. -- Changes are idempotent. -- All tests pass. -- Full suite passes. -- Formatting is clean. -- No unintended architectural drift occurred. diff --git a/.claude/skills/ci-schema-invariant-gate/SKILL.md b/.claude/skills/ci-schema-invariant-gate/SKILL.md deleted file mode 100644 index 51dcc7164..000000000 --- a/.claude/skills/ci-schema-invariant-gate/SKILL.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -name: ci-schema-invariant-gate -description: Ensures correct execution order of migrations, seeders, and tests in CI ---- - -# CI Schema Gate - -## Purpose - -Enforces correct execution order of database setup and test execution in CI. - ---- - -# 1. Execution Order (Strict) - -CI MUST run in this order: - -```bash -php artisan migrate:fresh --seed -php artisan test -``` - -No deviations allowed. - ---- - -# 2. Responsibility - -This skill ONLY controls: - -- execution sequencing -- CI pipeline ordering -- ensuring seed runs before tests - -It does NOT validate: -- schema correctness -- factory correctness -- business logic correctness - -These are handled by other skills. - ---- - -# 3. Failure Behavior - -If CI fails: - -- migrations failing → schema issue (handled by test-honesty) -- seed failing → factory/data issue (handled by test-honesty) -- tests failing → behavior issue (handled by test layer) - -CI does NOT interpret or classify failures. - ---- - -# 4. Determinism Requirement - -Test execution MUST always run on a fresh database state created by: - -```bash -migrate:fresh --seed -``` - -No cached or partial state is allowed. - ---- - -# 5. Core Principle - -CI defines execution order only. - -It does not define correctness of the system. diff --git a/.claude/skills/dto-contract/SKILL.md b/.claude/skills/dto-contract/SKILL.md deleted file mode 100644 index 3ce1b87e8..000000000 --- a/.claude/skills/dto-contract/SKILL.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -name: dto-contract -description: Defines DTO structure, lifecycle, and transformation rules across the application -license: MIT -metadata: - author: project ---- - -# DTO Contracts - -DTOs define structured, transport-safe data contracts used between layers of the application. - -They exist to replace unstructured arrays when data shape matters, is reused, or must remain consistent across boundaries. - ---- - -# 1. Responsibility - -DTOs MUST: - -- represent structured application data -- act as transport carriers between layers -- be filled by Transformers -- avoid business logic -- avoid persistence logic - -DTOs MUST NOT: - -- contain ORM logic -- contain validation rules -- contain side effects -- depend on framework components (Filament, Request, etc.) - ---- - -# 2. Structure - -DTOs are simple POPOs with fluent getters and setters. - -Example: - -```php -class InvoiceDto -{ - private int $invoiceId; - private int $companyId; - private float $amount; - - public function getInvoiceId(): int - { - return $this->invoiceId; - } - - public function setInvoiceId(int $invoiceId): self - { - $this->invoiceId = $invoiceId; - return $this; - } - - public function getCompanyId(): int - { - return $this->companyId; - } - - public function setCompanyId(int $companyId): self - { - $this->companyId = $companyId; - return $this; - } - - public function getAmount(): float - { - return $this->amount; - } - - public function setAmount(float $amount): self - { - $this->amount = $amount; - return $this; - } -} -``` - ---- - -# 3. Creation Rule - -DTOs MUST be created via Transformers. - -```php -$dto = InvoiceTransformer::fromModel($invoice); -``` - -or - -```php -$dto = InvoiceTransformer::fromArray($data); -``` - -DTOs MUST NOT be manually assembled inside services unless trivial and explicitly justified. - ---- - -# 4. Transformer Dependency Rule - -Transformers are the ONLY layer allowed to construct DTOs. - -DTOs MUST NOT depend on Transformers. - -Direction is strictly: - -``` -Model / Array → Transformer → DTO → Service -``` - ---- - -# 5. When DTOs are Required - -Use DTOs when: - -- data is shared across multiple services -- structure must remain stable across changes -- transformation logic exists (model → structured output) -- array shape would otherwise be ambiguous or inconsistent - ---- - -# 6. When DTOs are NOT Required - -DTOs MAY be skipped when: - -- data is short-lived within a single method -- input comes from trusted UI layer (Filament forms) -- structure is trivial and not reused elsewhere - ---- - -# 7. Core Principle - -DTOs are **explicit data contracts**, not business logic containers. - -## IDE Hints (Optional) - -DTOs MAY include region markers to improve IDE navigation (e.g. PhpStorm folding). - -These are purely cosmetic and MUST NOT affect runtime behavior or architecture decisions. - -Example: - -```php -class InvoiceDto -{ - #region Properties - private int $invoiceId; - private int $companyId; - private float $amount; - #endregion - - #region Getters - public function getInvoiceId(): int { ... } - public function getCompanyId(): int { ... } - public function getAmount(): float { ... } - #endregion - - #region Setters - public function setInvoiceId(int $invoiceId): self { ... } - public function setCompanyId(int $companyId): self { ... } - public function setAmount(float $amount): self { ... } - #endregion -} -``` - -They exist to stabilize data shape across the system, not to introduce unnecessary abstraction. diff --git a/.claude/skills/factory-contract-system/SKILL.md b/.claude/skills/factory-contract-system/SKILL.md deleted file mode 100644 index c14b8cdd2..000000000 --- a/.claude/skills/factory-contract-system/SKILL.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -name: factory-contract-system -description: Ensures factories generate valid model instances aligned with database schema constraints ---- - -# Factory Contract System - -## Purpose - -Ensures factories produce valid database-ready model instances. - ---- - -## Scope - -Factories MUST: - -- satisfy all NOT NULL columns -- reflect migration constraints -- produce valid default state for persistence - ---- - -## Ownership Boundary - -Factories do NOT: - -- enforce business rules -- define validation rules -- replace service-layer creation logic -- define seeder logic - ---- - -## Schema Alignment Rule - -If a migration introduces a NOT NULL column: - -- factory MUST be updated immediately -- omission is considered invalid state - ---- - -## Minimum Valid State - -Each factory represents the smallest valid persisted entity. - -Not random data. -Not business scenarios. -Only valid schema state. - ---- - -## Service Alignment - -Factories SHOULD align with service-layer expectations but do NOT depend on it. - -Service layer = behavior -Factory = valid structure - ---- - -## Seeder Rule - -Seeders depend on factories. - -Factories MUST NOT depend on seeders. diff --git a/.claude/skills/filament-multi-tenancy/SKILL.md b/.claude/skills/filament-multi-tenancy/SKILL.md deleted file mode 100644 index 290bb29df..000000000 --- a/.claude/skills/filament-multi-tenancy/SKILL.md +++ /dev/null @@ -1,150 +0,0 @@ ---- -name: filament-multi-tenancy -description: "Handles Filament multi-tenancy: tenant scoping, TenantAware trait, observer behaviour, isScopedToTenant, and tenant switching. Activates when adding tenant-aware models, fixing company_id scoping, working with Filament::getTenant, debugging tenant isolation, or when the user mentions company scope, tenant, multi-tenancy, or company_id." -license: MIT -metadata: - author: project ---- - -# Filament Multi-Tenancy - -The tenant model is `Company`. Every per-company record carries `company_id`. - -## TenantAware Trait - -Models that belong to a company use the `TenantAware` trait: - -```php -use Modules\Core\Traits\TenantAware; - -class Invoice extends Model -{ - use TenantAware; -} -``` - -The trait registers a `creating` observer that sets `company_id` from -`Filament::getTenant()` **only when `company_id` is empty**: - -```php -static::creating(function ($model) { - if (empty($model->company_id)) { - $tenant = Filament::getTenant(); - if ($tenant) { - $model->company_id = $tenant->id; - } - } -}); -``` - -## BaseResource Automatic Filtering - -All module resources extend `BaseResource`, which scopes the Eloquent query to -the current tenant and injects `company_id` on create: - -```php -// Modules/core/src/Filament/Resources/BaseResource.php -public static function getEloquentQuery(): Builder -{ - return parent::getEloquentQuery() - ->when(Filament::getTenant(), fn ($q, $t) => $q->where('company_id', $t->id)); -} -``` - -Do NOT add manual `company_id` filtering in resources that extend `BaseResource` — -it is already handled. - -## The Company Resource Exception - -`Company` IS the tenant. It must NOT be scoped to itself: - -```php -class CompanyResource extends Resource -{ - protected static bool $isScopedToTenant = false; - protected static ?string $tenantOwnershipRelationshipName = null; -} -``` - -Any model that should NOT be tenant-scoped (global settings, email templates, etc.) -also sets `$isScopedToTenant = false`. - -## observeTenancyModelCreation Trap - -Filament's `observeTenancyModelCreation` walks every `BelongsTo` relationship on a -model and calls `->associate($tenant)` when creating. This means: - -- If a model has a `BelongsTo` pointing to `Company` (even indirectly), Filament - will set that FK to the current tenant's id. -- A self-referential `BelongsTo` on the Company model itself will cause - `UNIQUE constraint failed: companies.id` because Filament sets `id = currentTenant->id` - on every new Company. - -**Fix:** Remove bogus self-referential relationships and set `$isScopedToTenant = false` -on the offending resource. - -## Tenant Switching in Tests - -When a test creates records for multiple tenants, switch the active tenant before -creating each set — otherwise `TenantAware` assigns all records to the first tenant: - -```php -$companyA = $this->company; // already set in setUp -$companyB = Company::factory()->create(); - -// Create companyA records (tenant already set to companyA) -$invoiceA = Invoice::factory()->create(['company_id' => $companyA->id, ...]); - -// Switch tenant before creating companyB records -Filament::setTenant($companyB, isQuiet: true); -$invoiceB = Invoice::factory()->create(['company_id' => $companyB->id, ...]); - -// Restore original tenant -Filament::setTenant($companyA, isQuiet: true); -``` - -## Tenant Middleware Stack - -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 - -```php -protected function setUp(): void -{ - parent::setUp(); - Filament::setCurrentPanel(Filament::getPanel('company')); - Filament::bootCurrentPanel(); - - $this->company = Company::factory()->create(); - Filament::setTenant($this->company, isQuiet: true); - - $this->user = User::factory()->create(); - $this->user->companies()->syncWithoutDetaching([$this->company->id]); -} -``` - -## Services - -Services must never assign company_id themselves when operating inside the -Filament company panel. - -company_id is supplied by: - -- TenantAware -- BaseResource -- explicit caller input - -Services should only normalize or validate incoming values. - -Hardcoding tenant assignment inside services creates hidden coupling. - - -## Fix-One-Fix-All - -If one tenant-aware resource requires adjustment, -review every tenant-aware resource for the same pattern. - -Tenant scoping inconsistencies are data isolation defects. diff --git a/.claude/skills/filament-panel-setup/SKILL.md b/.claude/skills/filament-panel-setup/SKILL.md deleted file mode 100644 index 7b35cf62c..000000000 --- a/.claude/skills/filament-panel-setup/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: filament-panel-setup -description: "Configures Filament panel providers. Activates when adding a new panel, registering module resources in a panel, configuring tenant middleware, adjusting auth or theme settings, or when the user mentions PanelProvider, viteTheme, discoverResources, or panel configuration." -license: MIT -metadata: - author: project ---- - -# Filament Panel Setup - -This app has three panels: - -| 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 - -Add a `->discoverResources()` call per module in `CompanyPanelProvider`: - -```php -->discoverResources( - in: base_path('Modules/mymodule/src/Filament/Resources'), - for: 'Modules\\Mymodule\\Filament\\Resources' -) -``` - -The `in` path is a filesystem path, `for` is the PHP namespace prefix. Both must -match the module's actual directory and namespace exactly. - -## viteTheme Guard - -`->viteTheme()` calls `app(Vite::class)($theme)` which reads `public/build/manifest.json`. -In test environments there is no built manifest, so wrap it: - -```php -->when( - ! app()->runningUnitTests(), - fn (Panel $panel) => $panel->viteTheme('resources/css/filament/company/nord.css') -) -``` - -`app()->runningUnitTests()` returns `true` when `APP_ENV=testing` (set in `phpunit.xml`). - -## Tenant Panel Required Config - -```php -->tenant(Company::class) // sets the tenant model -->tenantMenu(false) // hides the built-in tenant switcher -->tenantMiddleware([...], isPersistent: true) -``` - -## Auth Flow - -```php -->login(Login::class) // custom login page -->registration() -->passwordReset() -->emailVerification() -``` - -## Colors and Font - -Both panels use: -```php -->colors(['primary' => Color::hex('#88c0d0')]) -``` - -Company panel: `Poppins` via `GoogleFontProvider` -Admin panel: `Albert Sans` via `GoogleFontProvider` - -## SPA Mode - -The admin panel enables SPA mode for fast navigation: -```php -->spa() -``` - -Do NOT add SPA mode to the company panel — it causes issues with tenant middleware -and full-page redirects required for company switching. - -## Panel Responsibilities - -Panels configure: - -- authentication -- navigation -- resources -- middleware -- appearance - -Panels must not contain business logic. - -Business logic belongs in services. - ---- - -## Resource Registration - -If one module registers resources via discoverResources(), -all modules should follow the same convention. - -Avoid mixing manual registration and discovery. diff --git a/.claude/skills/filament-resource-pages/SKILL.md b/.claude/skills/filament-resource-pages/SKILL.md deleted file mode 100644 index afd2eb0ab..000000000 --- a/.claude/skills/filament-resource-pages/SKILL.md +++ /dev/null @@ -1,196 +0,0 @@ ---- -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 ---- - -# Filament Resource Pages - -## Resource Directory Layout - -Every resource lives under `Modules/{Name}/Filament/{Panel}/Resources/{Model}/`: - -``` -{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 -``` - -Schemas and Tables are **separate classes**, never defined inline inside the Resource. - ---- - -## Resource Class - -```php -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'), - ]; - } -} -``` - -`BaseResource` handles tenant-scoped queries automatically — do NOT add manual `company_id` filters. - ---- - -## List Page - -```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); - }), - ]; - } -} -``` - ---- - -## Edit Page - -Override `save()` when you need to route the update through the service layer: - -```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()]; - } -} -``` - ---- - -## Schema Class - -```php -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(), - ]), - ]), - ]); - } -} -``` - ---- - -## Table Class - -```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()]), - ]); - } -} -``` - ---- - -## Action Closure Rule - -Filament action closures do NOT support constructor injection. Always use `app()`: - -```php -->action(function (array $data) { - app(InvoiceService::class)->createInvoice($data); -}) -``` - -This is the only place `app()` is acceptable. Services themselves must never use it. - ---- - -## Panel Registration - -Resources are discovered per module in `CompanyPanelProvider`: - -```php -->discoverResources( - in: base_path('modules/invoices/src/Filament/Company/Resources'), - for: 'Modules\\Invoices\\Filament\\Company\\Resources' -) -``` - -The `in` parameter uses the filesystem path (lowercase with `src/`), while `for` uses the PHP namespace. diff --git a/.claude/skills/filament-resource-testing/SKILL.md b/.claude/skills/filament-resource-testing/SKILL.md deleted file mode 100644 index 22bb97975..000000000 --- a/.claude/skills/filament-resource-testing/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: filament-resource-testing -description: Defines how Filament UI resources are tested using Livewire -license: MIT -metadata: - author: project ---- - -# Filament Resource Testing - -## Purpose - -This skill defines **UI-level testing patterns for Filament resources only**. - -It validates: -- Create/Edit/List pages -- form interaction -- Livewire-based UI flows -- user-visible behavior - -It does NOT define: -- factories -- tenancy rules -- database integrity rules -- security rules -- primary key rules - -These are owned by other skills. - ---- - -# 1. Scope Rule - -This skill ONLY covers: - -- Filament Pages -- Filament Actions -- Livewire interactions -- UI assertions - ---- - -# 2. Test Structure Rule - -Each test MUST validate one UI behavior: - -- listing records -- creating records -- editing records -- deleting records -- validation errors - -No multi-behavior tests allowed. - ---- - -# 3. Livewire Execution Rule - -All Filament tests MUST use Livewire: - -```php -Livewire::actingAs($this->user) - ->test(CreateInvoice::class) -``` - -No direct HTTP testing of Filament pages. - ---- - -# 4. Form Interaction Rule - -Form input MUST use: - -```php -->set('data.field', value) -``` - -Not: -- fillForm -- request payload simulation -- raw HTTP input - ---- - -# 5. Assertion Rule - -Tests MUST assert business outcome: - -- database state change -- UI state change -- form validation error state - -NOT framework internals. - ---- - -# 6. Delete Action Rule - -Delete actions are tested as UI actions only: - -```php -->callAction(DeleteAction::class) -``` - -Outcome MUST be verified via database assertion. - ---- - -# 7. Multi-tenancy Note - -Tenant behavior is NOT owned by this skill. - -If multi-tenancy is present: -- it is assumed to be already configured -- this skill only validates UI behavior within active tenant context - ---- - -# 8. Core Principle - -Filament resource tests verify **what the user sees and does**, not how the system enforces rules internally. diff --git a/.claude/skills/github-actions-php/SKILL.md b/.claude/skills/github-actions-php/SKILL.md deleted file mode 100644 index 644ccb34b..000000000 --- a/.claude/skills/github-actions-php/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: github-actions-php -description: Defines GitHub Actions configuration for running PHP/Laravel CI pipeline ---- - -# GitHub Actions PHP - -## Purpose - -Defines CI workflow structure only. - ---- - -## Scope - -This skill defines: - -- PHP version matrix -- MySQL service setup -- Composer install steps -- test execution trigger -- artifact collection - ---- - -## Non-Scope - -This skill does NOT define: - -- schema validation rules -- factory correctness rules -- test classification logic -- database correctness assumptions - -These belong to domain-specific CI and test skills. - ---- - -## Database Requirement - -CI MUST use MySQL =MariaDB when production uses MySQL / MariaDB. - -SQLite is forbidden in CI when schema integrity matters. - ---- - -## Execution Flow - -CI pipeline MUST follow: - -1. Setup PHP environment -2. Install dependencies -3. Boot MySQL service -4. Run migrations -5. Run seeders -6. Execute tests - ---- - -## Principle - -This skill defines "how CI runs", not "what is correct". diff --git a/.claude/skills/laravel-modules/SKILL.md b/.claude/skills/laravel-modules/SKILL.md deleted file mode 100644 index d6170f3bd..000000000 --- a/.claude/skills/laravel-modules/SKILL.md +++ /dev/null @@ -1,209 +0,0 @@ ---- -name: laravel-modules -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 ---- - -# Laravel Modules - -## Package Standard: `internachi/modular` - -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. - -> **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": { - "Modules\\{Name}\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Modules\\{Name}\\Tests\\": "tests/" - } - }, - "extra": { - "laravel": { - "providers": [ - "Modules\\{Name}\\{Name}ServiceProvider" - ] - } - }, - "minimum-stability": "dev", - "prefer-stable": true -} -``` - ---- - -## 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 -``` - ---- - -## Service Provider - -The service provider is auto-discovered via `composer.json`. It only needs to -load migrations and register observers: - -```php -namespace Modules\Invoices; - -use Illuminate\Support\ServiceProvider; - -class InvoicesServiceProvider extends ServiceProvider -{ - public function boot(): void - { - $this->loadMigrationsFrom(__DIR__ . '/../database/migrations'); - $this->loadViewsFrom(__DIR__ . '/../resources/views', 'invoices'); - } -} -``` - -No manual entry in `config/app.php` — Composer's auto-discovery handles it. - ---- - -## Filament Resource Registration - -Add `->discoverResources()` per module in `CompanyPanelProvider`: - -```php -->discoverResources( - in: base_path('modules/invoices/src/Filament/Company/Resources'), - for: 'Modules\\Invoices\\Filament\\Company\\Resources' -) -``` - ---- - -## Test Discovery - -Configure `phpunit.xml` to pick up all module test directories: - -```xml - - modules/*/tests/Unit - - - modules/*/tests/Feature - -``` - ---- - -## 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 | `Modules\{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. diff --git a/.claude/skills/non-standard-pks/SKILL.md b/.claude/skills/non-standard-pks/SKILL.md deleted file mode 100644 index 900e92a09..000000000 --- a/.claude/skills/non-standard-pks/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -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 a custom primary key instead of id." -license: MIT -metadata: - author: project ---- - -# Non-Standard Primary Keys - -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.** - -## Confirmed Non-Standard PKs - -| Model | Table | Primary Key | -|-------|-------|-------------| -| `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 -$custom->client_custom_id // ✓ typed access for ClientCustom -$custom->getKey() // ✓ generic access -$custom->id // ✗ returns null — ClientCustom uses client_custom_id -``` - -## Factories: Pass the FK by Name - -When creating related records that reference a non-standard PK, pass the FK -column explicitly: - -```php -// ClientCustom's PK is client_custom_id, not id -SomeRelated::factory()->create([ - 'client_custom_id' => $custom->client_custom_id, -]); -``` - -## Filament Edit Page - -The `record` parameter expects the PK value: - -```php -Livewire::actingAs($this->user) - ->test(EditClientCustom::class, [ - 'record' => $custom->client_custom_id, // not $custom->id - 'tenant' => $this->company->search_code, - ]) -``` - -## Model Definition - -Always declare `$primaryKey` explicitly for non-standard models: - -```php -class ClientCustom extends Model -{ - protected $table = 'client_custom'; - protected $primaryKey = 'client_custom_id'; - public $timestamps = false; -} -``` - -## Adding a New Non-Standard PK - -When you introduce a model with a non-standard PK, update this skill's -**Confirmed Non-Standard PKs** table immediately. - -## Timestamps - -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/pest-control/SKILL.md b/.claude/skills/pest-control/SKILL.md deleted file mode 100644 index 548882fc9..000000000 --- a/.claude/skills/pest-control/SKILL.md +++ /dev/null @@ -1,249 +0,0 @@ ---- -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 — 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. - -```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 | -|------|--------------------| -| `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/.claude/skills/safe-refactoring-rules/SKILL.md b/.claude/skills/safe-refactoring-rules/SKILL.md deleted file mode 100644 index 1a42b7a4f..000000000 --- a/.claude/skills/safe-refactoring-rules/SKILL.md +++ /dev/null @@ -1,116 +0,0 @@ ---- -name: safe-refactoring-rules -description: Ensures all refactoring is deterministic, behavior-preserving, and non-breaking ---- - -# Safe Refactoring Rules - -## Purpose - -Ensure all refactoring is deterministic, non-breaking, and behavior-preserving. - -This skill enforces *how changes are made*, not *how the system is structured*. - ---- - -# 1. Behavior Preservation - -- Never change runtime behavior unless explicitly instructed. -- Any refactoring must preserve observable outputs. -- Moving code between layers must not alter execution results. - ---- - -# 2. Existing Code Respect - -- Never overwrite an existing method if it already satisfies part of the requirement. -- Extend existing implementations instead of replacing them. -- Do not delete or rewrite working logic unless required for a fix. - ---- - -# 3. Dependency Integrity - -- Always preserve constructor injection. -- Never replace dependency injection with service locators (`app()`, `resolve()`). -- Do not introduce new dependencies when existing ones suffice. -- Do not change dependency graphs without explicit intent. - ---- - -# 4. Public API Stability - -- Never change public method signatures unless all call sites are updated in the same change. -- Avoid breaking changes at all costs. -- Prefer internal adaptation over external contract modification. - ---- - -# 5. Idempotency Requirement - -- Refactoring must be idempotent. -- Running the same change twice must produce no further diff. -- No duplicate logic, imports, traits, or methods may be introduced. - ---- - -# 6. Uncertainty Handling - -If any of the following is unclear: - -- intended behavior -- service contract -- domain rule -- expected output - -Then: - -- Stop immediately -- Do not guess -- Report ambiguity explicitly -- Request clarification - ---- - -# 7. Abstraction Reuse Rule (Local Scope Only) - -This skill only enforces reuse during refactoring operations. - -Global abstraction policy is defined in application-architecture-standard. - -Before introducing: - -- Trait -- Service -- DTO -- Transformer -- Base class - -Search for an existing implementation. - -Reuse existing abstractions whenever practical. - -Duplicate abstractions are architectural defects. - ---- - -# 8. Scope Discipline - -This skill does NOT define: - -- architecture layering (handled by application-architecture-standard) -- testing strategy (handled by test-honesty / filament-resource-testing) -- security rules (handled separately if present) - -It ONLY defines safe transformation rules. - ---- - -# 9. Enforcement Priority - -If this skill conflicts with others: - -1. application-architecture-standard -2. domain-specific skills -3. execution workflows -4. this skill (always subordinate to architecture) diff --git a/.claude/skills/security-review/SKILL.md b/.claude/skills/security-review/SKILL.md deleted file mode 100644 index 3533725db..000000000 --- a/.claude/skills/security-review/SKILL.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: security-review -description: Static review rules for authorization, validation, and privilege escalation risks ---- - -# Security Review - -## Purpose - -Detect security risks in code during review phase. -This skill does NOT enforce security. It identifies issues. - ---- - -## Scope - -This skill evaluates: - -- authorization checks (missing or bypassed) -- policy usage correctness -- privilege escalation risks -- unsafe controller or action exposure -- validation gaps on external input - ---- - -## Ownership Boundary - -Security Review does NOT: - -- implement policies -- define roles/permissions -- execute middleware logic -- enforce runtime access control - -Those belong to application security layers (Policies, Middleware, Gates). - ---- - -## Rules - -- Every sensitive action MUST have explicit authorization check -- No unguarded resource actions (create/update/delete/view) -- No direct access to privileged operations without policy validation -- Input from external sources MUST be validated before use - ---- - -## Escalation Principle - -If a potential security issue is detected: - -- assume it is a defect until proven otherwise -- prioritize security over architectural convenience diff --git a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md b/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md deleted file mode 100644 index 72fcf8347..000000000 --- a/.claude/skills/senior-laravel-developer-code-reviewer/SKILL.md +++ /dev/null @@ -1,144 +0,0 @@ ---- -name: senior-laravel-developer-code-reviewer -description: "Orchestrates existing Laravel skills to produce structured PR reviews as a grumpy, no-nonsense Senior Laravel developer" ---- - -# Senior Laravel Developer — Code Reviewer - -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. - -You are not unkind — you are precise. You want the code to be correct, not to feel -good about itself. - ---- - -# Delegation Model - -Do not invent rules. Delegate evaluation to existing skills: - -**Architecture & code quality** -- `application-architecture-standard` -- `service-layer` -- `laravel-modules` -- `non-standard-pks` -- `dto-contract` -- `safe-refactoring-rules` - -**Tests** -- `filament-resource-testing` -- `test-honesty` -- `pest-control` - -**Security** -- `security-review` -- `spatie-roles` - -**Tenancy** -- `filament-multi-tenancy` -- `tenant-middleware` - ---- - -# Review Process - -## 1. Architecture pass - -Report violations only. Do not restate rules. - -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 - -Focus on: -- 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 -- **Missing `/* Arrange */` / `/* Act */` / `/* Assert */` phase comments** — every test method requires all three, no exceptions - ---- - -## 3. Security pass - -Report: -- Unguarded resource actions (no policy, no gate, no role check) -- Privilege escalation paths -- Missing input validation at system boundaries - ---- - -## 4. Consolidation - -Group findings into three buckets — and only three: - -- **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 -One paragraph. What does this PR do, and is it shippable? - -## Must Fix -- : — — - -## Should Fix -- : — - -## Could Fix -- : — - -## Test Risk -- - -## Security -- - -## Suggested Fixes - -``` - ---- - -# Tone Rules - -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..." - -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..." - -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. Broken or dishonest tests -5. Architecture violations -6. Maintainability -7. Style - -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 deleted file mode 100644 index 38e87e9c8..000000000 --- a/.claude/skills/senior-laravel-developer-phpunit-interpreter/SKILL.md +++ /dev/null @@ -1,189 +0,0 @@ ---- -name: senior-laravel-developer-phpunit-interpreter -description: Cleans and interprets raw PHPUnit CI logs into a compact, AI-friendly failure report. Use this skill whenever the user pastes or uploads a PHPUnit log, GitHub Actions test output, CI test results, or asks to interpret/summarize/analyze failing tests. Trigger even if the user says things like "here's my test output", "tests are failing", "can you look at my PHPUnit log", or pastes a block of text that contains PHPUnit output. Always use this skill before attempting to diagnose failures. ---- - -# Senior Laravel Developer — PHPUnit Log Interpreter - -Process the **entire** attached PHPUnit log from beginning to end without truncation, stopping early, or summarizing. - -Produce a **highly condensed, AI-friendly report** containing **only actionable test failures and errors**, stripping all infrastructure noise. - ---- - -## General Cleanup - -Remove completely: - -- All timestamps (e.g. `2026-05-14T03:17:38.4840913Z`) -- All ANSI escape sequences and terminal color codes -- GitHub Actions workflow metadata and runner output -- Docker / container startup logs -- Composer commands, dependency installation, download, extraction, and installation output -- Laravel migration, seeding, optimize, cache, bootstrap, and environment setup output -- CI/CD infrastructure noise and progress bars -- All successful tests beginning with `✔` -- Any output unrelated to PHPUnit failures, warnings, deprecations, risky tests, notices, or errors - ---- - -## Path Cleanup - -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). - ---- - -## Stack Trace Processing - -Unless explicitly requested: - -- Remove **all stack traces completely** — every `#0`, `#1`, `#2`, etc. -- Remove all vendor frames, internal frames, and repeated exception rendering - -Keep only: - -- Test name -- Exception type -- Exception message -- Assertion message -- `Caused by` exception (if present) -- `Previous exception` (if present) - ---- - -## Failure Formats - -**Error** — preserve: -``` -Modules\...\Tests\Feature\SomeTest::it_does_something - -ExceptionClass: -Exception message here. -``` - -**Failure** — preserve: -``` -Modules\...\Tests\Feature\SomeTest::it_does_something - -Expected response status code [200] but received 500. - -Failed asserting that 500 is identical to 200. - -UnderlyingException: -Underlying message if present. -``` - ---- - -## Duplicate Removal - -Keep only the first occurrence of: - -- Duplicate exception blocks -- Repeated stack traces -- Repeated "The following exception occurred..." -- Repeated rendering output - ---- - -## Formatting Rules - -- Collapse multiple blank lines into a single blank line -- Do **not** reorder, sort, renumber, or group failures — preserve exact PHPUnit order - ---- - -## Output Structure - -Return the cleaned log as a single Markdown code block: - -````markdown -```text -PHPUnit 11.x by Sebastian Bergmann and contributors. - -Runtime: PHP x.x.x -Configuration: phpunit.xml - - - -Time: xx:xx.xxx, Memory: xx MB - -There were X errors: - -1) FullTestClassName::method_name - -ExceptionClass: -Message. - -2) ... - -There were X failures: - -1) FullTestClassName::method_name - -Assertion message. - -Failed asserting that ... - -UnderlyingException: -Message. - -2) ... - -Tests: N -Assertions: N -Errors: N -Failures: N -Warnings: N (omit if 0) -Skipped: N (omit if 0) -Incomplete: N (omit if 0) -Risky: N (omit if 0) -Deprecations: N (omit if 0) -``` -```` - -Include Warnings, Deprecations, and Risky sections only if present. - ---- - -## Conditional Output Rule - -If the suite has zero errors, failures, warnings, risky tests, and deprecations, output only: - -```text -PHPUnit completed successfully. - -Tests: -Assertions: - -No errors, failures, warnings, risky tests, or deprecations detected. -``` - ---- - -## Objective - -Minimize token usage while preserving **100% of the information required to diagnose failing tests**. Output must be stable, deterministic, compact, and optimized for consumption by both humans and AI systems. - ---- - -## Root Cause Analysis - -When multiple tests fail with the same underlying exception, -identify the earliest failure that explains subsequent failures. - -Do not propose independent fixes for cascading failures. - -Prefer fixing one root cause over many symptoms. - ---- - -## Architectural Diagnosis - -When a failure indicates a missing architectural pattern -(e.g. missing service, missing transaction, missing CoversClass, -missing failure-path tests, missing factory field), - -recommend applying the fix repository-wide rather than only to the failing test. diff --git a/.claude/skills/service-layer/SKILL.md b/.claude/skills/service-layer/SKILL.md deleted file mode 100644 index 1087f62ab..000000000 --- a/.claude/skills/service-layer/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: service-layer -description: Defines application service structure and business orchestration boundaries -license: MIT -metadata: - author: project ---- - -# Service Layer - -Services are the only place business logic lives. They are framework-agnostic. - ---- - -# 1. Responsibility - -Services MUST: - -- contain business logic -- coordinate models -- enforce domain rules -- return models or DTOs - -Services MUST NOT: - -- import or use Filament -- accept or return HTTP request/response objects -- contain UI logic -- use `app()` or `resolve()` internally - ---- - -# 2. Dependency Rule - -Constructor injection only: - -```php -public function __construct( - private InvoiceRepository $repository -) {} -``` - ---- - -# 3. DTO Rule - -DTOs are **not** required for Filament → Service calls. Arrays are fine when the -source is a trusted Filament form. - -Use DTOs when: -- crossing system boundaries (API, queues, external integrations) -- multiple services share a contract -- the payload must be stable across refactors - -Skip DTOs when: -- input comes from a single Filament form -- the data is short-lived and not reused - ---- - -# 4. Filament Action Exception - -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') - ->action(function (array $data) { - app(InvoiceService::class)->createInvoice($data); - }); -``` - ---- - -# 5. Standard Shape - -``` -Modules/{Name}/Services/{Model}Service.php -``` - -Standard method names: `createX`, `updateX`, `deleteX`, `findOrFail`, `listForCompany`. diff --git a/.claude/skills/spatie-roles/SKILL.md b/.claude/skills/spatie-roles/SKILL.md deleted file mode 100644 index 6cb9f504c..000000000 --- a/.claude/skills/spatie-roles/SKILL.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -name: spatie-roles -description: "Implements role-based authorization using Spatie Laravel Permission. Activates when assigning roles, checking permissions, seeding roles, writing canAccessPanel logic, or when the user mentions roles, permissions, super_admin, client_admin, UserRole, assignRole, hasRole, or Spatie." -license: MIT -metadata: - author: project ---- - -# Spatie Roles - -## UserRole Enum - -All roles are defined in `Modules\Core\Enums\UserRole`: - -```php -enum UserRole: string -{ - case SUPER_ADMIN = 'super_admin'; // global — no company required - case ADMIN = 'admin'; // elevated - case ASSIST = 'assist'; // elevated, limited - case CUSTOMER_ADMIN = 'client_admin'; // company admin - case CUSTOMER = 'client'; // regular user -} -``` - -Helper methods: -- `UserRole::elevated()` → `['super_admin', 'admin', 'assist']` -- `UserRole::nonAdmin()` → `['client_admin', 'client']` -- `UserRole::values()` → all values - -**Always use the enum**, never hardcode the string value. - -## Panel Access Logic - -`User::canAccessPanel(Panel $panel)` is the Filament gate: - -```php -public function canAccessPanel(Panel $panel): bool -{ - // Elevated roles can access any panel - if ($this->hasRole(UserRole::SUPER_ADMIN->value) - || $this->hasRole(UserRole::ADMIN->value) - || $this->hasRole(UserRole::ASSIST->value)) { - return true; - } - - // Company-level users only see the company panel - if ($panel->getId() === 'company') { - return $this->hasRole(UserRole::CUSTOMER_ADMIN->value) - || $this->hasRole(UserRole::CUSTOMER->value); - } - - return false; -} -``` - -## Seeding Roles - -Always seed roles before assigning them. `Role::firstOrCreate` is idempotent: - -```php -foreach (UserRole::cases() as $role) { - Role::firstOrCreate( - ['name' => $role->value], - ['guard_name' => 'web'], - ); -} -``` - -## Assigning Roles - -```php -$user->assignRole(UserRole::SUPER_ADMIN->value); -$user->assignRole(UserRole::CUSTOMER_ADMIN->value); -``` - -## Checking Roles - -```php -$user->hasRole(UserRole::SUPER_ADMIN->value); -$user->isSuperAdmin(); // shorthand defined on User model -``` - -## Super Admin - -The super admin is a single global user, not tied to any company. Created in the -seeder as: - -```php -$superAdmin = User::factory()->create([ - 'user_name' => 'Super Admin', - 'user_email' => 'superadmin@example.com', - 'user_active' => true, -]); -$superAdmin->assignRole(UserRole::SUPER_ADMIN->value); -``` - -Super admins bypass `canAccessTenant()` via `isSuperAdmin()`: - -```php -public function canAccessTenant(Model $tenant): bool -{ - if ($this->isSuperAdmin()) { - return true; - } - return $this->companies()->whereKey($tenant->getKey())->exists(); -} -``` - -## Company Users - -Per company: 2 `client_admin` + 8 `client` (set by `UsersSeeder`). -Company admins are regular users who have elevated access within their company. -They do NOT have cross-company access. - -## Guard Name - -The Spatie permission guard is `web`. Always pass `guard_name: 'web'` when creating -roles/permissions programmatically. - ---- - -## Authorization - -Never authorize based on role strings directly when a policy, -permission, or helper method already exists. - -Prefer: - -- can() -- policies -- helper methods -- enum methods - -over repeated role checks. - -Duplicate authorization logic is a security risk. - ---- - -## Enum Rule - -Never compare: - -'user_role' == 'admin' - -Always compare against: - -UserRole::ADMIN->value diff --git a/.claude/skills/sync-stale-branches/SKILL.md b/.claude/skills/sync-stale-branches/SKILL.md deleted file mode 100644 index 134666808..000000000 --- a/.claude/skills/sync-stale-branches/SKILL.md +++ /dev/null @@ -1,133 +0,0 @@ ---- -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`. -Run this periodically to keep the branch list clean and PR-able. - ---- - -## Inputs - -- `EXCLUDE` — branches to leave untouched (space-separated, no `origin/` prefix) - Default: `develop master` - ---- - -## Step 1 — List candidate branches - -```bash -git fetch --prune - -# All remote branches minus the exclude list -git branch -r | grep -v 'origin/HEAD' \ - | sed 's|remotes/||' \ - | grep -v -E '^origin/(develop|master)$' -``` - -Add any other branches to exclude to the grep pattern. - ---- - -## Step 2 — Classify each branch - -For every candidate `origin/`: - -**A — unique file count (three-dot diff from merge-base):** -```bash -git diff --name-only origin/develop...origin/ | wc -l -``` - -**B — files ONLY in the branch (not in develop):** -```bash -git diff --name-only --diff-filter=A origin/develop origin/ -``` - -Classify as: -- **EMPTY** — A = 0 AND B = 0 → branch adds nothing, safe to delete -- **COVERED** — B > 0 but every file in B is already present in a known feature branch → safe to reset -- **HAS_UNIQUE** — B > 0 with at least one file not in any feature branch → must rescue first - ---- - -## Step 3 — Handle EMPTY branches - -These branches were never extended beyond the old fork point. - -```bash -git push origin --delete -``` - ---- - -## Step 4 — Handle COVERED branches - -All unique files are already captured in a feature branch we are keeping. -Reset the branch to develop HEAD so it is current but carries no stale code. - -```bash -git push origin origin/develop:refs/heads/ --force -``` - ---- - -## Step 5 — Handle HAS_UNIQUE branches - -Rescue uncovered files before resetting. - -### 5a — Identify which feature branch the files belong to - -Group uncovered files by module/domain: -- `Modules/Foo/…` → belongs to whatever feature owns Foo -- If unclear, create a new feature branch named after the owning issue/feature - -### 5b — Extract files onto the correct feature branch - -On the target feature branch (must already exist and be ahead of develop): - -```bash -git checkout origin/ -- ... -git add -git commit -m "chore: rescue from stale " -git push origin HEAD --force-with-lease -``` - -If the target feature branch does not yet exist, use the feature-branch-extraction -procedure to create it properly on top of develop HEAD first. - -### 5c — Reset the stale branch to develop - -```bash -git push origin origin/develop:refs/heads/ --force -``` - ---- - -## Step 6 — Verify - -```bash -# Confirm each branch is now equal to develop -for branch in ; do - ahead=$(git rev-list origin/develop..origin/$branch --count) - behind=$(git rev-list origin/$branch..origin/develop --count) - echo "$branch → ahead=$ahead behind=$behind" -done -``` - -Expected: all cleaned branches show `ahead=0 behind=0`. - ---- - -## Notes - -- Only force-push to branches that are NOT open PRs unless the PR is yours and you - intend to update it. -- GitHub Copilot branches (`copilot/*`) are AI-generated; resetting them is safe — - Copilot will recreate them if needed. -- The `--diff-filter=A` flag catches files the branch **adds** that develop lacks. - Files the branch **modifies** relative to develop but which also exist in develop - are not "unique" — develop's version is preferred. -- Run `git fetch --prune` first so local remote-tracking refs are current. diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md deleted file mode 100644 index 5fd2f26cf..000000000 --- a/.claude/skills/tailwindcss-development/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: tailwindcss-development -description: "Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes." -license: MIT -metadata: - author: laravel ---- - -# Tailwind CSS Development - -## When to Apply - -Activate this skill when: - -- Adding styles to components or pages -- Working with responsive design -- Implementing dark mode -- Extracting repeated patterns into components -- Debugging spacing or layout issues - -## Documentation - -Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. - -## Basic Usage - -- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. -- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). -- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. - -## Tailwind CSS v4 Specifics - -- Always use Tailwind CSS v4 and avoid deprecated utilities. -- `corePlugins` is not supported in Tailwind v4. - -### CSS-First Configuration - -In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: - - -```css -@theme { - --color-brand: oklch(0.72 0.11 178); -} -``` - -### Import Syntax - -In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: - - -```diff -- @tailwind base; -- @tailwind components; -- @tailwind utilities; -+ @import "tailwindcss"; -``` - -### Replaced Utilities - -Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. - -| Deprecated | Replacement | -|------------|-------------| -| bg-opacity-* | bg-black/* | -| text-opacity-* | text-black/* | -| border-opacity-* | border-black/* | -| divide-opacity-* | divide-black/* | -| ring-opacity-* | ring-black/* | -| placeholder-opacity-* | placeholder-black/* | -| flex-shrink-* | shrink-* | -| flex-grow-* | grow-* | -| overflow-ellipsis | text-ellipsis | -| decoration-slice | box-decoration-slice | -| decoration-clone | box-decoration-clone | - -## Spacing - -Use `gap` utilities instead of margins for spacing between siblings: - - -```html -
-
Item 1
-
Item 2
-
-``` - -## Dark Mode - -If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: - - -```html -
- Content adapts to color scheme -
-``` - -## Common Patterns - -### Flexbox Layout - - -```html -
-
Left content
-
Right content
-
-``` - -### Grid Layout - - -```html -
-
Card 1
-
Card 2
-
Card 3
-
-``` - -## Common Pitfalls - -- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) -- Using `@tailwind` directives instead of `@import "tailwindcss"` -- Trying to use `tailwind.config.js` instead of CSS `@theme` directive -- Using margins for spacing between siblings instead of gap utilities -- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.claude/skills/tenant-middleware/SKILL.md b/.claude/skills/tenant-middleware/SKILL.md deleted file mode 100644 index cea871f52..000000000 --- a/.claude/skills/tenant-middleware/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: tenant-middleware -description: "Understands and modifies the tenant resolution middleware chain. Activates when debugging tenant switching, company access, session-based tenant resolution, URL-based tenant identification, or when the user mentions ConfigureTenant, EnsureUserCanAccessCompany, SetTenantFromQueryString, search_code, or company switching." -license: MIT -metadata: - author: project ---- - -# Tenant Middleware Chain - -Three middlewares run in order on every company panel request. They are registered -as persistent tenant middleware in `CompanyPanelProvider`. - -## 1. SetTenantFromQueryString - -**Purpose:** Handle explicit `?tenant=` in the URL (used when switching company). - -- Reads the `tenant` query parameter (expects a `search_code` string) -- Looks up the company by `search_code` -- Checks the user has access (elevated role OR company membership) -- Sets Filament tenant and writes `company_id` to session -- Updates the `tenant` route parameter to the lowercase `search_code` - -## 2. ConfigureTenant - -**Purpose:** Resolve the active tenant from multiple sources and persist it. - -Resolution order: -1. Route parameter (`{tenant}`) -2. Query string `?tenant=` -3. Session `current_company_id` -4. User's first company (fallback) - -Writes resolved company to session and shares it with views. - -## 3. EnsureUserCanAccessCompany - -**Purpose:** Enforce that the resolved tenant is accessible to the authenticated user. - -- Elevated roles (`super_admin`, `admin`, `assist`) bypass — they can access all companies. -- Regular users must have the company in their `companies()` pivot relationship. -- Aborts 403 if the user has no access. - -## Company Identification - -Tenants are identified in URLs by `search_code` (a short alphanumeric string), -not by numeric `id`. The session stores the numeric `id` (`current_company_id`). - -```php -// URL: /company/invoices?tenant=ivplv2 -// Session: current_company_id = 22 -// Model: Company::where('search_code', 'ivplv2')->first() → id=22 -``` - -## Switching Companies - -The "Switch Company" user menu action redirects with `?tenant=`: - -```php -Action::make('switch-company') - ->modalContent(fn () => view('filament.company.widgets.switch-company-table')) -``` - -The Livewire component inside that modal dispatches a redirect to the new tenant's URL. - -## Testing Tenant Switching - -```php -Livewire::actingAs($this->user) - ->test(SwitchCompanyComponent::class) - ->callAction('switch', ['company_id' => $otherCompany->id]) - ->assertRedirect(route('filament.company.home', ['tenant' => $otherCompany->search_code])); -``` - - -## Single Source of Truth - -Tenant resolution belongs exclusively in the tenant middleware chain. - -Controllers, Resources, Pages, Services, and Models must never independently -resolve the active tenant from the request, session, or URL. - -They must rely on: - -- Filament::getTenant() -- injected Company model -- resolved route parameter - -Duplicating tenant resolution logic is an architectural defect. - -## Fix-One-Fix-All - -If one middleware requires modification due to a tenant resolution bug, -review all three tenant middlewares for equivalent logic and consistency. - -Tenant resolution behavior must remain uniform across the entire middleware chain. diff --git a/.claude/skills/test-honesty/SKILL.md b/.claude/skills/test-honesty/SKILL.md deleted file mode 100644 index 954eb8d6a..000000000 --- a/.claude/skills/test-honesty/SKILL.md +++ /dev/null @@ -1,77 +0,0 @@ ---- -name: test-honesty -description: Ensures factory, seeder, and schema alignment with production database reality ---- - -# Purpose - -Prevents schema drift between migrations, factories, and seeders. - ---- - -# 1. Schema Contract - -Every NOT NULL column defined in migrations must be supported by: - -- factory definition -- or seeder definition (only for seed data) -- or explicit DB default in migration - -This is a **schema-only rule**, not a validation rule. - ---- - -# 2. Factory Rule - -Factories MUST produce valid database rows for the schema. - -Factories are schema-aligned, not business-logic aware. - ---- - -# 3. Seeder Rule - -Seeders MUST only insert schema-valid data. - -No reliance on implicit database defaults. - ---- - -# 4. Database Parity Rule - -MySQL / MariaDB is the canonical database. - -SQLite differences are invalid for schema validation assumptions. - ---- - -# 5. Drift Triggers - -The following indicate schema drift: - -- migration changes -- factory mismatch -- seeder mismatch -- SQLSTATE constraint violations -- CI vs local DB mismatch - ---- - -# 6. Identity Rule - -Primary keys are non-deterministic. - -Tests MUST NOT rely on hardcoded IDs. - ---- - -# 7. Execution Rule (CI boundary) - -Schema validation requires: - -- migrate:fresh -- seed - -before running test suites. - -This ensures schema correctness before test execution. diff --git a/.claude/skills/user-auth-fields/SKILL.md b/.claude/skills/user-auth-fields/SKILL.md deleted file mode 100644 index 365b68a84..000000000 --- a/.claude/skills/user-auth-fields/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: user-auth-fields -description: "Works with the User model's non-standard authentication fields. Activates when writing queries, factories, tests, or seeders that reference the user's email, name, or password; or when the user mentions user_email, user_name, user_password, authentication, login, or the User model." -license: MIT -metadata: - author: project ---- - -# User Authentication Fields - -This app's `users` table does NOT use Laravel's default `name`, `email`, and -`password` column names. All three are prefixed with `user_`: - -| Laravel default | This app | -|-----------------|----------| -| `name` | `user_name` | -| `email` | `user_email` | -| `password` | `user_password` | - -## Model Overrides - -The `User` model overrides the auth contract methods: - -```php -public function getAuthIdentifierName(): string -{ - return 'user_name'; -} - -public function getAuthPassword(): string -{ - return 'user_password'; -} -``` - -## Never Use the Default Column Names - -```php -// ✗ WRONG — will cause "Column not found" on MySQL -User::factory()->create(['name' => 'Test', 'email' => 'test@example.com']); - -// ✓ CORRECT -User::factory()->create(['user_name' => 'Test', 'user_email' => 'test@example.com']); -``` - -This includes seeders, tests, and any `User::create()` call. - -## Factory Definition - -```php -public function definition(): array -{ - return [ - 'user_name' => fake()->name(), - 'user_email' => fake()->unique()->safeEmail(), - 'user_password' => Hash::make('password'), - 'user_active' => fake()->boolean(90), - 'user_all_clients' => fake()->boolean(90), - 'user_date_created' => now(), - 'user_date_modified' => now(), - ]; -} -``` - -## Additional Non-Standard Fields - -| Standard concept | This app's column | -|------------------|-------------------| -| Timestamps | Manual: `user_date_created`, `user_date_modified` | -| Active flag | `user_active` (boolean) | -| `$timestamps` | `false` — managed manually | - -## Filament Name Display - -Filament uses `getFilamentName()` not `name`: - -```php -public function getFilamentName(): string -{ - return $this->user_name ?? $this->user_email ?? 'User'; -} -``` - ---- - -## Authentication Queries - -Never query using: - -email -name -password - -Always use: - -user_email -user_name -user_password - -including: - -- validation rules -- login logic -- factories -- tests -- seeders -- authentication providers - ---- - -## Fix-One-Fix-All - -If one occurrence of `email`, `name`, or `password` is corrected to the -application's custom fields, search for equivalent usages throughout the -repository and update them consistently. diff --git a/.github/DOCKER.md b/.github/DOCKER.md deleted file mode 100644 index aa80503cf..000000000 --- a/.github/DOCKER.md +++ /dev/null @@ -1,116 +0,0 @@ -# Docker Setup for InvoicePlane V2 - -This guide explains how to run InvoicePlane V2 using Docker — both the web -stack and the standalone CLI image for running tests and artisan commands. - ---- - -## Prerequisites - -- Docker installed (https://www.docker.com/) -- Docker Compose v2+ - ---- - -## Quick Start - -```bash -git clone https://github.com/InvoicePlane/InvoicePlane-v2.git -cd InvoicePlane-v2 - -cp .env.example .env - -# Install dependencies and bootstrap the app through the CLI container — -# no PHP required on the host: -docker compose run --rm cli composer install -docker compose run --rm cli php artisan key:generate -docker compose up -d -docker compose run --rm cli php artisan migrate --seed -``` - -Visit: http://localhost:8080 (override the port with `APP_PORT` in `.env`). - ---- - -## Services - -| Service | Image | Purpose | -|---|---|---| -| `web` | `docker-resources/apache` (httpd 2.4 alpine) | Serves `public/`, proxies PHP to `app` | -| `app` | `docker-resources/php-fpm` (PHP 8.4 fpm alpine) | Laravel application (FPM) | -| `cli` | `docker-resources/php-cli` (PHP 8.4 cli alpine) | One-off runner for tests / artisan / composer — profile `tools`, never auto-started | -| `db` | `mariadb` | Database (port 3306) | -| `mailcatcher` | `sj26/mailcatcher` | Catches outgoing mail — UI on port 1080 | - -Both PHP images ship the full extension set the app needs: `intl`, `gd`, -`pdo_mysql`, `bcmath`, `zip`, `exif`, `soap`, `redis`. The CLI image also has -Composer and a 1G memory limit for the test suite. - ---- - -## Running the test suite - -```bash -docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting -``` - -Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to behave -differently for this app: a raw `vendor/bin/phpunit` run silently drops some submitted field -values in Livewire form tests. `artisan test` is the proven-reliable path and is what CI uses, so -standardize on it. - -**Known issue (see [#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689)):** a -freshly-`docker compose build`'t `cli` image has, at least once, reproduced this same -field-dropping bug at scale (100+ false failures) even under `artisan test`, for reasons not yet -isolated — despite extension/ini parity with a known-good image. Before trusting a full local run -from a rebuilt `cli` image, sanity-check it against a small, known test first, e.g.: -```bash -docker compose run --rm cli php artisan test --filter=ContactsTest -``` -All 11 assertions should pass. If any fail with "field is required" errors on data you know you -supplied, don't trust the rest of that run — see the linked issue. - -`APP_ENV=testing` is the `cli` service default, and it always connects to -the compose stack's real `db` service (MariaDB) for tests — the `cli` -service injects `DB_CONNECTION=mysql`/`DB_HOST=db`/etc. itself, so nothing -in `.env.testing` needs editing. This intentionally does not fall back to -SQLite: SQLite's lenient identifier quoting has masked real bugs before that -only surfaced against MariaDB in CI. - -### File ownership on Linux - -The CLI image creates its user with uid/gid `1000`. If your host user -differs, rebuild with your ids so files written into the mounted repo -(vendor/, storage/, compiled views) stay owned by you: - -```bash -docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) cli -``` - ---- - -## Useful Commands - -| Action | Command | -|---|---| -| Start services | `docker compose up -d` | -| Stop services | `docker compose down` | -| View logs | `docker compose logs -f` | -| Run artisan | `docker compose run --rm cli php artisan ` | -| Run composer | `docker compose run --rm cli composer ` | -| Rebuild containers | `docker compose build --no-cache` | - ---- - -## Troubleshooting - -- **Port already in use**: set `APP_PORT` in `.env` (web) or adjust ports in `docker-compose.yml` -- **Permission issues**: rebuild the `cli` image with your `UID`/`GID` (see above) -- **Missing .env config**: re-run `cp .env.example .env` and adjust -- **Tests fail with `could not find driver` or missing `intl`**: you are running on host PHP — use the `cli` container instead - ---- - -## What's Next? - -Visit CHECKLIST.md if contributing diff --git a/Modules/Core/Filament/Company/Pages/Reports/BaseTabularReportPage.php b/Modules/Core/Filament/Company/Pages/Reports/BaseTabularReportPage.php new file mode 100644 index 000000000..07d91d9ee --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/Reports/BaseTabularReportPage.php @@ -0,0 +1,144 @@ + + */ + abstract protected function reportColumns(): array; + + /** + * @return array + */ + abstract protected function csvHeaders(): array; + + /** + * @return array + */ + abstract protected function csvRow($record): array; + + /** + * One-line totals summary rendered under the table. + */ + abstract public function summaryLine(): string; + + public static function canAccess(): bool + { + return auth()->user()?->hasAnyRole([ + ...UserRole::elevated(), + UserRole::CUSTOMER_ADMIN->value, + ]) ?? false; + } + + public static function getNavigationGroup(): ?string + { + return trans('ip.reports'); + } + + public function mount(): void + { + $this->dateFrom ??= now()->startOfMonth()->toDateString(); + $this->dateTo ??= now()->endOfMonth()->toDateString(); + } + + public function table(Table $table): Table + { + return $table + ->query(fn (): Builder => $this->reportQuery()) + ->columns($this->reportColumns()) + ->paginated([25, 50, 100]) + ->headerActions([ + $this->exportCsvAction(), + ]); + } + + public function exportCsvAction(): Action + { + return Action::make('exportCsv') + ->label(trans('ip.export_csv')) + ->icon('heroicon-o-arrow-down-tray') + ->action(function () { + $filename = static::getSlug() . '-' . now()->toDateString() . '.csv'; + + return response()->streamDownload(function (): void { + $handle = fopen('php://output', 'wb'); + fputcsv($handle, $this->sanitizeCsvRow($this->csvHeaders())); + + foreach ($this->reportQuery()->lazy() as $record) { + fputcsv($handle, $this->sanitizeCsvRow($this->csvRow($record))); + } + + fclose($handle); + }, $filename, ['Content-Type' => 'text/csv']); + }); + } + + private function sanitizeCsvRow(array $row): array + { + return array_map(function ($value): string { + $value = (string) $value; + if (str_starts_with($value, '=') || str_starts_with($value, '+') || + str_starts_with($value, '-') || str_starts_with($value, '@')) { + return "'" . $value; + } + return $value; + }, $row); + } + + /** + * @return array + */ + public function getClientOptions(): array + { + return Relation::query() + ->orderBy('company_name') + ->get(['id', 'company_name']) + ->map(fn (Relation $relation): array => ['id' => $relation->id, 'name' => $relation->company_name]) + ->all(); + } + + protected function dateRange(): array + { + return [ + $this->dateFrom ?? now()->startOfMonth()->toDateString(), + $this->dateTo ?? now()->endOfMonth()->toDateString(), + ]; + } + + protected function money(mixed $amount): string + { + return number_format((float) $amount, 2, '.', ''); + } +} diff --git a/Modules/Core/Filament/Company/Pages/Reports/InvoicedByClientReport.php b/Modules/Core/Filament/Company/Pages/Reports/InvoicedByClientReport.php new file mode 100644 index 000000000..ef1e6263e --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/Reports/InvoicedByClientReport.php @@ -0,0 +1,69 @@ + $query->whereBetween('invoiced_at', $this->dateRange()); + + return Relation::query() + ->when($this->clientId, fn (Builder $query) => $query->whereKey($this->clientId)) + ->whereHas('invoices', $range) + ->withCount(['invoices as invoices_count' => $range]) + ->withSum(['invoices as invoiced_total' => $range], 'invoice_total') + ->orderByDesc('invoiced_total'); + } + + public function summaryLine(): string + { + $rows = $this->reportQuery()->get(); + + return trans('ip.report_summary_invoiced_by_client', [ + 'clients' => $rows->count(), + 'total' => $this->money($rows->sum('invoiced_total')), + ]); + } + + protected function reportColumns(): array + { + return [ + TextColumn::make('company_name')->label(trans('ip.client')), + TextColumn::make('invoices_count')->label(trans('ip.invoices'))->alignRight(), + TextColumn::make('invoiced_total')->label(trans('ip.total'))->numeric(2)->alignRight(), + ]; + } + + protected function csvHeaders(): array + { + return [ + trans('ip.client'), + trans('ip.invoices'), + trans('ip.total'), + ]; + } + + protected function csvRow($record): array + { + return [ + $record->company_name, + $record->invoices_count, + $this->money($record->invoiced_total), + ]; + } +} diff --git a/Modules/Core/Filament/Company/Pages/Reports/InvoicesPerClientReport.php b/Modules/Core/Filament/Company/Pages/Reports/InvoicesPerClientReport.php new file mode 100644 index 000000000..c01f63014 --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/Reports/InvoicesPerClientReport.php @@ -0,0 +1,69 @@ + $query->whereBetween('invoiced_at', $this->dateRange()); + + return Relation::query() + ->when($this->clientId, fn (Builder $query) => $query->whereKey($this->clientId)) + ->whereHas('invoices', $range) + ->withCount(['invoices as invoices_count' => $range]) + ->withAvg(['invoices as average_value' => $range], 'invoice_total') + ->orderByDesc('invoices_count'); + } + + public function summaryLine(): string + { + $rows = $this->reportQuery()->get(); + + return trans('ip.report_summary_invoices_per_client', [ + 'clients' => $rows->count(), + 'invoices' => (int) $rows->sum('invoices_count'), + ]); + } + + protected function reportColumns(): array + { + return [ + TextColumn::make('company_name')->label(trans('ip.client')), + TextColumn::make('invoices_count')->label(trans('ip.invoices'))->alignRight(), + TextColumn::make('average_value')->label(trans('ip.average_value'))->numeric(2)->alignRight(), + ]; + } + + protected function csvHeaders(): array + { + return [ + trans('ip.client'), + trans('ip.invoices'), + trans('ip.average_value'), + ]; + } + + protected function csvRow($record): array + { + return [ + $record->company_name, + $record->invoices_count, + $this->money($record->average_value), + ]; + } +} diff --git a/Modules/Core/Filament/Company/Pages/Reports/InvoicingHistoryReport.php b/Modules/Core/Filament/Company/Pages/Reports/InvoicingHistoryReport.php new file mode 100644 index 000000000..80ce6e8a7 --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/Reports/InvoicingHistoryReport.php @@ -0,0 +1,76 @@ +with('customer') + ->whereBetween('invoiced_at', $this->dateRange()) + ->when($this->clientId, fn (Builder $query) => $query->where('customer_id', $this->clientId)) + ->orderBy('invoiced_at'); + } + + public function summaryLine(): string + { + $query = $this->reportQuery(); + $paid = (clone $query)->where('invoice_status', InvoiceStatus::PAID->value)->sum('invoice_total'); + + return trans('ip.report_summary_invoicing', [ + 'count' => $query->count(), + 'total' => $this->money($query->sum('invoice_total')), + 'paid' => $this->money($paid), + 'unpaid' => $this->money($query->sum('invoice_total') - $paid), + ]); + } + + protected function reportColumns(): array + { + return [ + TextColumn::make('invoice_number')->label(trans('ip.invoice_number')), + TextColumn::make('invoiced_at')->label(trans('ip.invoice_date'))->date(), + TextColumn::make('invoice_status')->label(trans('ip.invoice_status'))->badge(), + TextColumn::make('customer.company_name')->label(trans('ip.client')), + TextColumn::make('invoice_total')->label(trans('ip.total'))->numeric(2)->alignRight(), + ]; + } + + protected function csvHeaders(): array + { + return [ + trans('ip.invoice_number'), + trans('ip.invoice_date'), + trans('ip.invoice_status'), + trans('ip.client'), + trans('ip.total'), + ]; + } + + protected function csvRow($record): array + { + return [ + $record->invoice_number, + $record->invoiced_at?->toDateString(), + $record->invoice_status?->value, + $record->customer?->company_name, + $this->money($record->invoice_total), + ]; + } +} diff --git a/Modules/Core/Filament/Company/Pages/Reports/PaymentHistoryReport.php b/Modules/Core/Filament/Company/Pages/Reports/PaymentHistoryReport.php new file mode 100644 index 000000000..2f07d07c1 --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/Reports/PaymentHistoryReport.php @@ -0,0 +1,76 @@ +with(['invoice', 'customer']) + ->whereBetween('paid_at', $this->dateRange()) + ->when($this->clientId, fn (Builder $query) => $query->where('customer_id', $this->clientId)) + ->orderBy('paid_at'); + } + + public function summaryLine(): string + { + $query = $this->reportQuery(); + + return trans('ip.report_summary_payments', [ + 'count' => $query->count(), + 'total' => $this->money($query->sum('payment_amount')), + ]); + } + + protected function reportColumns(): array + { + return [ + TextColumn::make('paid_at')->label(trans('ip.payment_date'))->date(), + TextColumn::make('payment_number')->label(trans('ip.payment_number')), + TextColumn::make('payment_method')->label(trans('ip.payment_method')), + TextColumn::make('invoice.invoice_number')->label(trans('ip.invoice_number')), + TextColumn::make('customer.company_name')->label(trans('ip.client')), + TextColumn::make('payment_amount')->label(trans('ip.amount'))->numeric(2)->alignRight(), + ]; + } + + protected function csvHeaders(): array + { + return [ + trans('ip.payment_date'), + trans('ip.payment_number'), + trans('ip.payment_method'), + trans('ip.invoice_number'), + trans('ip.client'), + trans('ip.amount'), + ]; + } + + protected function csvRow($record): array + { + return [ + $record->paid_at?->toDateString(), + $record->payment_number, + $record->payment_method instanceof BackedEnum ? $record->payment_method->value : (string) $record->payment_method, + $record->invoice?->invoice_number, + $record->customer?->company_name, + $this->money($record->payment_amount), + ]; + } +} diff --git a/Modules/Core/Filament/Company/Pages/Reports/SalesByDateReport.php b/Modules/Core/Filament/Company/Pages/Reports/SalesByDateReport.php new file mode 100644 index 000000000..403b2d5b6 --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/Reports/SalesByDateReport.php @@ -0,0 +1,69 @@ +selectRaw('MIN(id) as id, invoiced_at, COUNT(*) as invoices_count, SUM(invoice_total) as daily_total') + ->where('invoice_status', InvoiceStatus::PAID->value) + ->whereBetween('invoiced_at', $this->dateRange()) + ->when($this->clientId, fn (Builder $query) => $query->where('customer_id', $this->clientId)) + ->groupBy('invoiced_at') + ->orderBy('invoiced_at'); + } + + public function summaryLine(): string + { + $rows = $this->reportQuery()->get(); + + return trans('ip.report_summary_sales_by_date', [ + 'days' => $rows->count(), + 'total' => $this->money($rows->sum('daily_total')), + ]); + } + + protected function reportColumns(): array + { + return [ + TextColumn::make('invoiced_at')->label(trans('ip.date'))->date(), + TextColumn::make('invoices_count')->label(trans('ip.invoices'))->alignRight(), + TextColumn::make('daily_total')->label(trans('ip.total'))->numeric(2)->alignRight(), + ]; + } + + protected function csvHeaders(): array + { + return [ + trans('ip.date'), + trans('ip.invoices'), + trans('ip.total'), + ]; + } + + protected function csvRow($record): array + { + return [ + $record->invoiced_at?->toDateString(), + $record->invoices_count, + $this->money($record->daily_total), + ]; + } +} diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 506e88438..ee2411b73 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -28,6 +28,11 @@ use Modules\Core\Filament\Company\Pages\CompanySettings; use Modules\Core\Filament\Company\Pages\Dashboard; use Modules\Core\Filament\Company\Pages\MyCompanies; +use Modules\Core\Filament\Company\Pages\Reports\InvoicedByClientReport; +use Modules\Core\Filament\Company\Pages\Reports\InvoicesPerClientReport; +use Modules\Core\Filament\Company\Pages\Reports\InvoicingHistoryReport; +use Modules\Core\Filament\Company\Pages\Reports\PaymentHistoryReport; +use Modules\Core\Filament\Company\Pages\Reports\SalesByDateReport; use Modules\Core\Filament\Company\Resources\CompanyUsers\CompanyUserResource; use Modules\Core\Filament\Company\Resources\EmailTemplates\EmailTemplateResource; use Modules\Core\Filament\Company\Resources\NoteTemplates\NoteTemplateResource; @@ -186,6 +191,11 @@ public function panel(Panel $panel): Panel EditProfile::class, MyCompanies::class, CompanySettings::class, + PaymentHistoryReport::class, + InvoicingHistoryReport::class, + InvoicedByClientReport::class, + SalesByDateReport::class, + InvoicesPerClientReport::class, ]) ->widgets([ RecentQuotesWidget::class, @@ -237,6 +247,15 @@ public function panel(Panel $panel): Panel ...self::withQuickCreate(PaymentResource::class), ]), + NavigationGroup::make(trans('ip.reports')) + ->items([ + ...PaymentHistoryReport::getNavigationItems(), + ...InvoicingHistoryReport::getNavigationItems(), + ...InvoicedByClientReport::getNavigationItems(), + ...SalesByDateReport::getNavigationItems(), + ...InvoicesPerClientReport::getNavigationItems(), + ]), + NavigationGroup::make('Resources') //->icon('heroicon-o-archive-box') ->items([ diff --git a/Modules/Core/Tests/Feature/CompanyTabularReportsTest.php b/Modules/Core/Tests/Feature/CompanyTabularReportsTest.php new file mode 100644 index 000000000..cad2e6828 --- /dev/null +++ b/Modules/Core/Tests/Feature/CompanyTabularReportsTest.php @@ -0,0 +1,158 @@ +for($this->company)->create(); + Invoice::factory()->for($this->company)->for($relation, 'customer')->create([ + 'user_id' => $this->user->id, + 'invoiced_at' => now()->toDateString(), + ]); + + foreach ([ + PaymentHistoryReport::class, + InvoicingHistoryReport::class, + InvoicedByClientReport::class, + SalesByDateReport::class, + InvoicesPerClientReport::class, + ] as $page) { + $this->testLivewire($page)->assertSuccessful(); + } + } + + #[Test] + public function it_lists_payments_in_the_selected_date_range_only(): void + { + /* Arrange */ + $payment = $this->makePayment(['paid_at' => now()->subDays(3)]); + $old = $this->makePayment(['paid_at' => now()->subMonths(2)]); + + /* Act & Assert */ + $this->testLivewire(PaymentHistoryReport::class) + ->set('dateFrom', now()->subMonth()->toDateString()) + ->set('dateTo', now()->toDateString()) + ->assertCanSeeTableRecords([$payment]) + ->assertCanNotSeeTableRecords([$old]); + } + + #[Test] + public function it_sums_the_invoiced_amount_per_client(): void + { + /* Arrange */ + $relation = Relation::factory()->for($this->company)->create(); + Invoice::factory()->for($this->company)->for($relation, 'customer')->create([ + 'user_id' => $this->user->id, + 'invoiced_at' => now()->toDateString(), + 'invoice_total' => 1000, + ]); + Invoice::factory()->for($this->company)->for($relation, 'customer')->create([ + 'user_id' => $this->user->id, + 'invoiced_at' => now()->toDateString(), + 'invoice_total' => 500, + ]); + + /* Act & Assert */ + $this->testLivewire(InvoicedByClientReport::class) + ->assertSee('1500'); + } + + #[Test] + public function it_scopes_reports_to_the_current_company(): void + { + /* Arrange */ + $otherCompany = Company::factory()->create(); + $otherPayment = $this->makePayment(['paid_at' => now()->subDay()], $otherCompany); + + /* Act & Assert */ + $this->testLivewire(PaymentHistoryReport::class) + ->assertCanNotSeeTableRecords([$otherPayment]); + } + + #[Test] + public function it_filters_payments_by_client(): void + { + /* Arrange */ + $clientA = Relation::factory()->for($this->company)->create(); + $clientB = Relation::factory()->for($this->company)->create(); + $paymentA = $this->makePayment(['customer_id' => $clientA->id, 'paid_at' => now()->subDay()]); + $paymentB = $this->makePayment(['customer_id' => $clientB->id, 'paid_at' => now()->subDay()]); + + /* Act & Assert */ + $this->testLivewire(PaymentHistoryReport::class) + ->set('dateFrom', now()->subWeek()->toDateString()) + ->set('dateTo', now()->toDateString()) + ->set('clientId', $clientA->id) + ->assertCanSeeTableRecords([$paymentA]) + ->assertCanNotSeeTableRecords([$paymentB]); + } + + #[Test] + public function it_counts_only_paid_invoices_in_the_sales_by_date_report(): void + { + /* Arrange */ + $relation = Relation::factory()->for($this->company)->create(); + Invoice::factory()->for($this->company)->for($relation, 'customer')->create([ + 'user_id' => $this->user->id, + 'invoiced_at' => now()->toDateString(), + 'invoice_status' => 'paid', + 'invoice_total' => 777, + ]); + Invoice::factory()->for($this->company)->for($relation, 'customer')->create([ + 'user_id' => $this->user->id, + 'invoiced_at' => now()->toDateString(), + 'invoice_status' => 'draft', + 'invoice_total' => 999999, + ]); + + /* Act & Assert */ + $this->testLivewire(SalesByDateReport::class) + ->assertSee('777') + ->assertDontSee('999999'); + } + + #[Test] + public function it_exports_the_filtered_rows_as_csv(): void + { + /* Arrange */ + $this->makePayment(['paid_at' => now()->subDay()]); + + /* Act & Assert */ + $this->testLivewire(PaymentHistoryReport::class) + ->set('dateFrom', now()->subWeek()->toDateString()) + ->set('dateTo', now()->toDateString()) + ->callTableAction('exportCsv') + ->assertHasNoErrors() + ->assertFileDownloaded(); + } + + protected function makePayment(array $attributes = [], ?Company $company = null): Payment + { + $company ??= $this->company; + + $relation = Relation::factory()->for($company)->create(); + $invoice = Invoice::factory()->for($company)->for($relation, 'customer')->create([ + 'user_id' => $this->user->id, + ]); + + return Payment::factory()->for($company)->create(array_merge([ + 'customer_id' => $invoice->customer_id, + 'invoice_id' => $invoice->id, + ], $attributes)); + } +} diff --git a/Modules/Core/resources/views/filament/company/pages/reports/tabular-report.blade.php b/Modules/Core/resources/views/filament/company/pages/reports/tabular-report.blade.php new file mode 100644 index 000000000..bac2c3f4e --- /dev/null +++ b/Modules/Core/resources/views/filament/company/pages/reports/tabular-report.blade.php @@ -0,0 +1,37 @@ + +
+
+ + + + + +
+
+ + {{ $this->table }} + +
+ {{ $this->summaryLine() }} +
+
diff --git a/docker-resources/apache/Dockerfile b/docker-resources/apache/Dockerfile deleted file mode 100644 index 13ef1ee0d..000000000 --- a/docker-resources/apache/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM httpd:2.4-alpine - -# Enable required Apache modules for PHP-FPM proxying -RUN sed -i \ - -e 's/^#\(LoadModule proxy_module modules\/mod_proxy.so\)/\1/' \ - -e 's/^#\(LoadModule proxy_fcgi_module modules\/mod_proxy_fcgi.so\)/\1/' \ - -e 's/^#\(LoadModule rewrite_module modules\/mod_rewrite.so\)/\1/' \ - /usr/local/apache2/conf/httpd.conf - -COPY config/invoiceplane-vhost.conf /usr/local/apache2/conf/extra/invoiceplane-vhost.conf - -RUN echo "Include conf/extra/invoiceplane-vhost.conf" >> /usr/local/apache2/conf/httpd.conf - -EXPOSE 80 diff --git a/docker-resources/apache/config/invoiceplane-vhost.conf b/docker-resources/apache/config/invoiceplane-vhost.conf deleted file mode 100644 index 0d53e1f90..000000000 --- a/docker-resources/apache/config/invoiceplane-vhost.conf +++ /dev/null @@ -1,21 +0,0 @@ - - ServerName localhost - DocumentRoot "/usr/local/apache2/htdocs/public" - - - Options Indexes FollowSymLinks - AllowOverride All - Require all granted - - - # ProxyPass for PHP-FPM with correct document root - - SetHandler "proxy:fcgi://app:9000/var/www/html/public" - - - # Fallback for PATH_INFO - ProxyPassMatch ^/(.*\.php(/.*)?)$ fcgi://app:9000/var/www/html/public/$1 - - ErrorLog /usr/local/apache2/logs/invoiceplane_error.log - CustomLog /usr/local/apache2/logs/invoiceplane_access.log combined - diff --git a/docker-resources/mariadb/init/01-create-test-db.sql b/docker-resources/mariadb/init/01-create-test-db.sql deleted file mode 100644 index f99135b6d..000000000 --- a/docker-resources/mariadb/init/01-create-test-db.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Runs once, on first boot of a fresh `database` volume (mariadb's --- entrypoint executes everything under /docker-entrypoint-initdb.d/). --- Provisions a dedicated test database alongside the dev one (MARIADB_DATABASE) --- so `docker compose run --rm cli vendor/bin/phpunit` works out of the box --- against real MariaDB, matching CI, with no per-developer .env.testing edits. -CREATE DATABASE IF NOT EXISTS invoiceplane_test; diff --git a/docker-resources/node/scripts/entrypoint.sh b/docker-resources/node/scripts/entrypoint.sh deleted file mode 100644 index 99b888402..000000000 --- a/docker-resources/node/scripts/entrypoint.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/sh - -# Copy original vite.config.js to Docker-specific version -mkdir -p /app/.docker -cp /app/vite.config.js /app/.docker/vite.config.docker.js - -# Update resource paths to absolute paths -sed -i "s|'resources/css/app.css'|'/app/resources/css/app.css'|g" /app/.docker/vite.config.docker.js -sed -i "s|'resources/js/app.js'|'/app/resources/js/app.js'|g" /app/.docker/vite.config.docker.js - -# Add Docker-specific server configuration if not already present -if ! grep -q "server:" /app/.docker/vite.config.docker.js; then - # Insert server config before the closing }); of defineConfig - sed -i '/^});$/i\ server: {\n host: '\''0.0.0.0'\'',\n port: 5173,\n hmr: {\n host: '\''localhost'\'',\n },\n },' /app/.docker/vite.config.docker.js -fi - -# Install dependencies and start Vite with Docker config -npm install && npm run dev -- --config .docker/vite.config.docker.js diff --git a/docker-resources/php-cli/Dockerfile b/docker-resources/php-cli/Dockerfile deleted file mode 100644 index d7cb9ec56..000000000 --- a/docker-resources/php-cli/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -FROM php:8.4-cli - -# Debian base, matching the image proven to run this suite reliably — -# the equivalent Alpine (musl) build was found to silently drop form -# fields during Livewire component testing (a real, reproducible bug, -# not a database or CI issue). Don't switch back to -alpine without -# re-verifying UserProfileTest::it_saves_the_user_data_form first. - -# Match the host user so files created in mounted volumes (vendor/, -# storage/, compiled views) keep sane ownership. Override at build time: -# docker compose build --build-arg UID=$(id -u) --build-arg GID=$(id -g) cli -ARG UID=1000 -ARG GID=1000 - -RUN groupadd -g ${GID} dockeruser \ - && useradd -m -s /bin/bash -u ${UID} -g dockeruser dockeruser - -RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - curl \ - zip \ - unzip \ - libicu-dev \ - libpng-dev \ - libjpeg62-turbo-dev \ - libfreetype6-dev \ - libzip-dev \ - # Configure and install PHP extensions — only the ones NOT already - # compiled into the base php:8.4-cli image (which already ships - # mbstring, xml, dom, sodium, opcache, pdo, pdo_sqlite, etc.). - # Re-installing an already-built-in extension via docker-php-ext-install - # was tried and produced a real, reproducible bug: Livewire form tests - # silently lost submitted field values (e.g. - # UserProfileTest::it_saves_the_user_data_form, ContactsTest — required - # fields reported as missing even though fillForm() supplied them). - # Root cause not fully isolated, but the fix is confirmed: stick to this - # minimal set, matching the proven-reliable ip2-test-php:8.4 image. - && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install -j$(nproc) \ - intl \ - gd \ - pdo_mysql \ - bcmath \ - zip \ - exif \ - && rm -rf /var/lib/apt/lists/* - -# PHPUnit needs more than the 128M default on the full suite -RUN echo 'memory_limit=1G' > /usr/local/etc/php/conf.d/memory-limit.ini - -# Install Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -USER dockeruser - -WORKDIR /var/www/html - -CMD ["php", "-a"] diff --git a/docker-resources/php-fpm/Dockerfile b/docker-resources/php-fpm/Dockerfile deleted file mode 100644 index 77d68b92b..000000000 --- a/docker-resources/php-fpm/Dockerfile +++ /dev/null @@ -1,58 +0,0 @@ -FROM php:8.4-fpm-alpine - -RUN adduser -D -s /bin/bash dockeruser - -# Install build dependencies (temporary) -RUN apk add --no-cache --virtual .build-deps \ - autoconf \ - g++ \ - make \ - pkgconf \ - zstd-dev \ - # Install runtime dependencies (permanent) - && apk add --no-cache \ - bash \ - git \ - curl \ - zip \ - unzip \ - icu-dev \ - libxml2-dev \ - oniguruma-dev \ - libzip-dev \ - libpng-dev \ - libjpeg-turbo-dev \ - freetype-dev \ - zstd \ - # Configure and install PHP extensions - && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install -j$(nproc) \ - pdo \ - pdo_mysql \ - mbstring \ - exif \ - pcntl \ - bcmath \ - gd \ - zip \ - intl \ - xml \ - soap \ - opcache \ - # Install PECL extensions - && pecl install redis \ - && docker-php-ext-enable redis \ - # Remove only build dependencies - && apk del .build-deps \ - && rm -rf /var/cache/apk/* - -# Install Composer -RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - -USER dockeruser - -WORKDIR /var/www/html - -EXPOSE 9000 - -CMD ["php-fpm"] diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index cbbb6b0ed..b359b3c00 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -1115,6 +1115,24 @@ 'variable_invoicing_contact_name' => "The client's invoicing contact name (falls back to the primary contact)", 'variable_invoicing_contact_email' => "The client's invoicing contact email (falls back to the primary contact)", + // Tabular reports (#145) + 'export_csv' => 'Export CSV', + 'date_from' => 'From', + 'date_to' => 'To', + 'all_clients' => 'All clients', + 'payment_number' => 'Payment Number', + 'average_value' => 'Average Value', + 'report_payment_history' => 'Payment History', + 'report_invoicing_history' => 'Invoicing History', + 'report_invoiced_by_client' => 'Invoiced Amount by Client', + 'report_sales_by_date' => 'Sales by Date', + 'report_invoices_per_client' => 'Invoices per Client', + 'report_summary_payments' => ':count payments — total :total', + 'report_summary_invoicing' => ':count invoices — total :total (paid :paid, outstanding :unpaid)', + 'report_summary_invoiced_by_client' => ':clients clients — total invoiced :total', + 'report_summary_sales_by_date' => ':days days with paid invoices — revenue :total', + 'report_summary_invoices_per_client' => ':clients clients — :invoices invoices', + // Mason Report Builder 'report_layout' => 'Report Layout', 'report_preview' => 'Report Preview',