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/.env.example b/.env.example index 48675c689..ec0db43b3 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,8 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +# Chrome's sandbox needs PID/network namespace privileges the ivpldock +# workspace container doesn't grant, so Browsershot-driven PDF rendering +# fails with "Failed to move to new namespace" unless this is true. +IP_BROWSERSHOT_NO_SANDBOX=true diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 6ab72a8fd..391893928 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -1,7 +1,15 @@ name: PHPStan Analysis on: - workflow_dispatch: # Manual only — does not run automatically + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + workflow_dispatch: permissions: contents: read diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index b68b7281e..78ec4bc6c 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -4,7 +4,15 @@ permissions: contents: read on: - workflow_dispatch: # Manual only — does not run automatically + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + workflow_dispatch: jobs: phpunit: diff --git a/.github/workflows/quickstart.yml b/.github/workflows/quickstart.yml index 8b902bebb..9f056c6c0 100644 --- a/.github/workflows/quickstart.yml +++ b/.github/workflows/quickstart.yml @@ -1,7 +1,15 @@ name: Quickstart Smoke Test on: - workflow_dispatch: # Manual only — does not run automatically + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + workflow_dispatch: jobs: quickstart: diff --git a/CLAUDE.md b/CLAUDE.md index 841429cb5..a0fe88f49 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,14 @@ $user->isSuperAdmin() // shorthand All live at `Modules/Core/Tests/`. +### Test method naming + +Every test method **must** start with `it_` and form a grammatically correct English +sentence when you replace underscores with spaces (`it_returns_404_for_a_nonexistent_record`, +not `test_returns_404` or `it_user_can_view_document`). Annotate with `#[Test]`, never a +`test_` prefix. See the `phpunit-test-naming` skill for the full convention, wrong/right +examples, and an audit command. + ### Patterns ```php @@ -326,3 +334,19 @@ No DTO layer — services accept arrays and return Eloquent models. - `Str::lower($company->search_code)` is always the URL tenant parameter - The three tenant middleware classes live at `Modules/Core/Http/Middleware/` - Panel providers live at `Modules/Core/Providers/`, not `app/Providers/` + +# LESSONS + +- Never write `*/` inside a PHP docblock (e.g. glob patterns like `Header*/Detail*`) — it terminates the comment and causes a parse error. +- When running a test suite in the background, redirect FULL output to a file — never pipe through `tail`/`head`, it destroys the failure details and forces a rerun. + +--- + +## Report Builder Guardrails + +- **Blade directive gotcha**: Blade directives (`@if`/`@endif`/etc.) inside report-builder brick preview/render templates (`Modules/Core/resources/views/report-builder/bricks/*/preview.blade.php`) must not be immediately preceded by a word character — Blade's directive-matching regex requires a non-word boundary before `@`. Always wrap literal values in `{{ }}` rather than concatenating raw text directly next to a directive, or the directive silently fails to compile and leaves e.g. an `@if` unclosed, causing a parse error. +- **`toPreviewHtml()` vs `toHtml()` convention**: In `Modules/Core/ReportBuilder/ReportBrickAction.php` and `Modules/Core/ReportBuilder/ReportIframeRenderer.php`, preview-context rendering must always call `toPreviewHtml()`, never `toHtml()` — `toHtml()` needs real entity data and is for print/export output only; using it in a preview context renders fine on load but turns into a near-empty box the moment the brick is inserted or reconfigured. +- **Mandatory regression tests for report-builder changes**: Any change touching `Modules/Core/ReportBuilder/`, `Modules/Core/Filament/Pages/Reports/`, `Modules/Core/Filament/Admin/Pages/ReportTemplates.php`, or `Modules/Core/resources/views/report-builder/bricks/**` must be run against, at minimum: + `php artisan test --filter='AdminReportBuilderTest|CompanyReportBuilderTest|MasonDocumentConverterTest|MasonBricksTest'` + run against a real MySQL/MariaDB connection. Note the single `--filter` with a `|`-joined regex, not repeated `--filter` flags — `php artisan test` silently drops all but the last `--filter` flag when it's passed more than once (verified 2026-09-04: `--filter=A --filter=B --filter=C --filter=D` only ran `D`'s tests). +- **Known non-report-builder issues, do not chase them here**: `UserProfileTest`'s tenant test fails only at full-suite scale (passes in isolation) — a pre-existing order-dependent flake, unrelated to the report builder. Ctrl+Z triggering undo on all 5 Mason iframe editors simultaneously when focus is outside the iframes is an `awcodes/mason` vendor limitation, not something patchable from application code. diff --git a/Modules/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index f4af3d69c..5be75e209 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -118,8 +118,7 @@ public function communications(): MorphMany return $this->morphMany(Communication::class, 'communicationable'); } - /** @return MorphMany */ - public function ccEmailCommunications(): MorphMany + public function ccEmailCommunications() { // @phpstan-ignore return.type (larastan narrows MorphMany::whereIn() to a bare Query\Builder; the runtime object is still the relation) return $this->communications()->whereIn('communication_type', CommunicationType::ccTypes()); diff --git a/Modules/Core/Console/ReportsSyncSystemCommand.php b/Modules/Core/Console/ReportsSyncSystemCommand.php new file mode 100644 index 000000000..643b97332 --- /dev/null +++ b/Modules/Core/Console/ReportsSyncSystemCommand.php @@ -0,0 +1,50 @@ +error("Source directory [{$source}] does not exist."); + + return self::FAILURE; + } + + $disk = Storage::disk(ReportTemplateStorage::DISK); + $synced = 0; + + foreach (File::allFiles($source) as $file) { + $target = ReportTemplateStorage::SCOPE_SYSTEM . '/' . str_replace('\\', '/', $file->getRelativePathname()); + + if ($disk->put($target, $file->getContents()) === false) { + $this->error("Failed to write [{$target}] — system template storage may be incomplete."); + + return self::FAILURE; + } + + $synced++; + } + + $this->info("Synced {$synced} report template file(s) into system storage."); + + return self::SUCCESS; + } +} diff --git a/Modules/Core/Enums/FieldPlacement.php b/Modules/Core/Enums/FieldPlacement.php new file mode 100644 index 000000000..b35cc0f29 --- /dev/null +++ b/Modules/Core/Enums/FieldPlacement.php @@ -0,0 +1,39 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public function label(): string + { + return match ($this) { + self::HIDDEN => trans('ip.placement_hidden'), + self::INLINE_COLUMN => trans('ip.placement_inline_column'), + self::BELOW_ROW => trans('ip.placement_below_row'), + }; + } + + public function color(): string + { + return 'primary'; + } + + public function getLabel(): string + { + return $this->label(); + } +} diff --git a/Modules/Core/Enums/ReportBand.php b/Modules/Core/Enums/ReportBand.php index 90e149cc3..de3747a93 100644 --- a/Modules/Core/Enums/ReportBand.php +++ b/Modules/Core/Enums/ReportBand.php @@ -10,6 +10,20 @@ enum ReportBand: string case GROUP_HEADER = 'group_header'; case HEADER = 'header'; + /** + * Get all bands in document order (header first, footer last). + * + * @return array + */ + public static function ordered(): array + { + $bands = self::cases(); + + usort($bands, fn (self $a, self $b): int => $a->getOrder() <=> $b->getOrder()); + + return $bands; + } + /** * Get the display label for the band. */ diff --git a/Modules/Core/Enums/ReportBlockWidth.php b/Modules/Core/Enums/ReportBlockWidth.php new file mode 100644 index 000000000..c5ff65da5 --- /dev/null +++ b/Modules/Core/Enums/ReportBlockWidth.php @@ -0,0 +1,24 @@ + 4, + self::HALF => 6, + self::TWO_THIRDS => 8, + self::FULL => 12, + }; + } +} diff --git a/Modules/Core/Enums/ReportGroupBy.php b/Modules/Core/Enums/ReportGroupBy.php new file mode 100644 index 000000000..41abd328c --- /dev/null +++ b/Modules/Core/Enums/ReportGroupBy.php @@ -0,0 +1,41 @@ + + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public function label(): string + { + return match ($this) { + self::CATEGORY => trans('ip.group_by_category'), + self::TAX_RATE => trans('ip.group_by_tax_rate'), + self::PRODUCT => trans('ip.group_by_product'), + self::SKU => trans('ip.group_by_sku'), + }; + } + + public function color(): string + { + return 'primary'; + } + + public function getLabel(): string + { + return $this->label(); + } +} diff --git a/Modules/Core/Enums/ReportTemplateType.php b/Modules/Core/Enums/ReportTemplateType.php new file mode 100644 index 000000000..fa5f06349 --- /dev/null +++ b/Modules/Core/Enums/ReportTemplateType.php @@ -0,0 +1,32 @@ + trans('ip.invoice'), + self::QUOTE => trans('ip.quote'), + }; + } + + public function color(): string + { + return match ($this) { + self::INVOICE => 'success', + self::QUOTE => 'info', + }; + } +} diff --git a/Modules/Core/Filament/Admin/Pages/ReportBuilder.php b/Modules/Core/Filament/Admin/Pages/ReportBuilder.php new file mode 100644 index 000000000..0fe176de7 --- /dev/null +++ b/Modules/Core/Filament/Admin/Pages/ReportBuilder.php @@ -0,0 +1,25 @@ +user()?->isSuperAdmin() || (auth()->user()?->hasRole(UserRole::ADMIN->value) ?? false); + } + + public function managesSystemScope(): bool + { + return true; + } + + public function listPage(): string + { + return ReportTemplates::class; + } +} diff --git a/Modules/Core/Filament/Admin/Pages/ReportTemplates.php b/Modules/Core/Filament/Admin/Pages/ReportTemplates.php new file mode 100644 index 000000000..3547918e1 --- /dev/null +++ b/Modules/Core/Filament/Admin/Pages/ReportTemplates.php @@ -0,0 +1,25 @@ +user()?->isSuperAdmin() || (auth()->user()?->hasRole(UserRole::ADMIN->value) ?? false); + } + + public function managesSystemScope(): bool + { + return true; + } + + public function builderPage(): string + { + return ReportBuilder::class; + } +} diff --git a/Modules/Core/Filament/Company/Pages/MyCompanies.php b/Modules/Core/Filament/Company/Pages/MyCompanies.php index bfd5e6cea..96d26d0fd 100644 --- a/Modules/Core/Filament/Company/Pages/MyCompanies.php +++ b/Modules/Core/Filament/Company/Pages/MyCompanies.php @@ -25,15 +25,19 @@ class MyCompanies extends Page implements HasTable public function table(Table $table): Table { - /** @var User $user */ - $user = auth()->user(); + return $table + ->query(function () { + /** @var User|null $user */ + $user = auth()->user(); - $query = $user->hasRole(UserRole::elevated()) - ? Company::query() - : $user->companies()->getQuery(); + if ( ! $user) { + return Company::query()->whereRaw('1 = 0'); + } - return $table - ->query(fn () => $query) + return $user->hasRole(UserRole::elevated()) + ? Company::query() + : $user->companies()->getQuery(); + }) ->columns([ TextColumn::make('name') ->label(trans('ip.name')) @@ -44,7 +48,7 @@ public function table(Table $table): Table TextColumn::make('role') ->label(trans('ip.role')) - ->state(fn (): string => $user->getRoleNames() + ->state(fn (): string => auth()->user()?->getRoleNames() ->map(fn (string $role): string => UserRole::tryFrom($role)?->label() ?? $role) ->implode(', ')), ]) @@ -52,7 +56,10 @@ public function table(Table $table): Table Action::make('switch') ->label(trans('ip.switch')) ->icon('heroicon-o-arrow-right-start-on-rectangle') - ->action(function (Company $record) use ($user): void { + ->action(function (Company $record): void { + /** @var User $user */ + $user = auth()->user(); + try { // Defense in depth: $record comes from Filament's table-action // record resolution, not a value we control directly. Refuse diff --git a/Modules/Core/Filament/Company/Pages/ReportBuilder.php b/Modules/Core/Filament/Company/Pages/ReportBuilder.php new file mode 100644 index 000000000..9d660643e --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/ReportBuilder.php @@ -0,0 +1,27 @@ +user()?->hasAnyRole([ + ...UserRole::elevated(), + UserRole::CUSTOMER_ADMIN->value, + ]) ?? false; + } + + public function managesSystemScope(): bool + { + return false; + } + + public function listPage(): string + { + return ReportTemplates::class; + } +} diff --git a/Modules/Core/Filament/Company/Pages/ReportTemplates.php b/Modules/Core/Filament/Company/Pages/ReportTemplates.php new file mode 100644 index 000000000..1ca419230 --- /dev/null +++ b/Modules/Core/Filament/Company/Pages/ReportTemplates.php @@ -0,0 +1,27 @@ +user()?->hasAnyRole([ + ...UserRole::elevated(), + UserRole::CUSTOMER_ADMIN->value, + ]) ?? false; + } + + public function managesSystemScope(): bool + { + return false; + } + + public function builderPage(): string + { + return ReportBuilder::class; + } +} diff --git a/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php b/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php index 4e3943d5a..fc1af8201 100644 --- a/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php +++ b/Modules/Core/Filament/Company/Resources/CompanyUsers/CompanyUserResource.php @@ -37,21 +37,6 @@ class CompanyUserResource extends Resource // query below scopes manually to the current tenant. protected static bool $isScopedToTenant = false; - /** The company this list and its actions are scoped to; null → fail closed. */ - private static function currentCompany(): ?Company - { - return Filament::getTenant(); - } - - /** Single source of truth for who may see and manage the team roster. */ - private static function userMayManageTeam(): bool - { - return auth()->user()?->hasRole([ - ...UserRole::elevated(), - UserRole::CUSTOMER_ADMIN->value, - ]) ?? false; - } - public static function form(Schema $schema): Schema { return $schema->schema([ @@ -69,7 +54,7 @@ public static function table(Table $table): Table // Fail closed: without a company there is nothing to scope // this list to, so it must show nothing — falling back to // User::query() would leak every user across every company. - return static::currentCompany()?->users() ?? User::query()->whereRaw('1 = 0'); + return self::currentCompany()?->users() ?? User::query()->whereRaw('1 = 0'); }) ->columns([ TextColumn::make('name') @@ -87,7 +72,7 @@ public static function table(Table $table): Table ->label(trans('ip.remove')) ->icon('heroicon-m-trash') ->color('danger') - ->action(fn (User $record) => static::currentCompany()?->users()->detach($record->id)) + ->action(fn (User $record) => self::currentCompany()?->users()->detach($record->id)) ->requiresConfirmation(), ]) ->bulkActions([ @@ -95,7 +80,7 @@ public static function table(Table $table): Table DeleteBulkAction::make() ->label(trans('ip.remove')) ->action(function (EloquentCollection|Collection|LazyCollection $records): void { - $company = static::currentCompany(); + $company = self::currentCompany(); foreach ($records as $record) { $company?->users()->detach($record->id); } @@ -150,4 +135,21 @@ public static function canDelete(Model $record): bool return auth()->user()?->hasRole($roles) ?? false; } + + /** The company this list and its actions are scoped to; null → fail closed. */ + private static function currentCompany(): ?Company + { + $tenant = Filament::getTenant(); + + return $tenant instanceof Company ? $tenant : null; + } + + /** Single source of truth for who may see and manage the team roster. */ + private static function userMayManageTeam(): bool + { + return auth()->user()?->hasRole([ + ...UserRole::elevated(), + UserRole::CUSTOMER_ADMIN->value, + ]) ?? false; + } } diff --git a/Modules/Core/Filament/Pages/Reports/BaseReportBuilderPage.php b/Modules/Core/Filament/Pages/Reports/BaseReportBuilderPage.php new file mode 100644 index 000000000..2b72f69c2 --- /dev/null +++ b/Modules/Core/Filament/Pages/Reports/BaseReportBuilderPage.php @@ -0,0 +1,327 @@ +managesSystemScope() && $scope !== ReportTemplateStorage::SCOPE_SYSTEM) { + abort(404); + } + + $template = app(ReportTemplateStorage::class)->load($scope, $slug, $templateType); + abort_if($template === null, 404); + + $this->scope = $scope; + $this->type = $type; + $this->templateSlug = $slug; + $this->manifest = $template['manifest']; + + $bands = []; + + foreach (ReportBand::ordered() as $band) { + $bands[$band->value] = MasonDocumentConverter::toMasonState($template['bands'][$band->value] ?? []); + } + + $this->form->fill(['bands' => $bands]); + } + + public function getTitle(): string + { + return trans('ip.report_builder') . ': ' . ($this->manifest['name'] ?? $this->templateSlug); + } + + public function form(Schema $schema): Schema + { + $fields = []; + $templateType = ReportTemplateType::tryFrom($this->type); + + foreach (ReportBand::ordered() as $band) { + $section = Section::make($band->getLabel()) + ->collapsible(); + + if (in_array($band, [ReportBand::GROUP_HEADER, ReportBand::GROUP_FOOTER], true)) { + $section->description(trans('ip.group_band_notice')); + } + + $fields[] = $section->schema([ + Mason::make('bands.' . $band->value) + ->hiddenLabel() + ->bricks(ReportBricksCollection::forBand($band, $templateType)) + ->registerActions([ReportBrickAction::make()]) + ->disabled( ! $this->canSave()), + ]); + } + + return $schema->components($fields)->statePath('data'); + } + + public function canSave(): bool + { + return $this->managesSystemScope() + ? $this->scope === ReportTemplateStorage::SCOPE_SYSTEM + : $this->scope === ReportTemplateStorage::SCOPE_COMPANY; + } + + public function save(): void + { + abort_unless($this->canSave(), 403); + + if ($this->managesSystemScope()) { + abort_unless(static::canAccess(), 403); + } + + $state = $this->form->getState(); + $bands = []; + + foreach (ReportBand::ordered() as $band) { + $bands[$band->value] = MasonDocumentConverter::toBandEntries($state['bands'][$band->value] ?? []); + } + + try { + app(ReportTemplateStorage::class)->save( + $this->scope, + $this->templateSlug, + $this->manifest, + $bands, + ReportTemplateType::tryFrom($this->type), + ); + + Notification::make()->title(trans('ip.template_saved'))->success()->send(); + } catch (Throwable $e) { + Log::warning("Report template save failed: {$e->getMessage()}", ['exception' => $e]); + Notification::make()->title(trans('ip.template_save_failed'))->danger()->send(); + + throw $e; + } + } + + public function previewAction(): Action + { + return Action::make('preview') + ->label(trans('ip.report_preview')) + ->icon('heroicon-o-eye') + ->modalHeading(trans('ip.report_preview')) + ->modalSubmitAction(false) + ->modalCancelActionLabel(trans('ip.close')) + ->slideOver() + ->modalContent(fn (): HtmlString => new HtmlString($this->renderPreviewHtml())); + } + + public function moveBrickAction(): Action + { + return Action::make('moveBrick') + ->label(trans('ip.move_to_band')) + ->icon('heroicon-o-arrows-up-down') + ->visible(fn (): bool => $this->canSave()) + ->schema([ + Select::make('from_band') + ->label(trans('ip.from_band')) + ->options($this->bandOptions()) + ->required() + ->live() + /* + * Both dependent selects have to be cleared by hand. + * Filament recomputes their options but keeps whatever + * was picked before, so a stale index would silently + * move a different brick than the one on screen. + */ + ->afterStateUpdated(function (Set $set): void { + $set('position', null); + $set('to_band', null); + }), + Select::make('position') + ->label(trans('ip.brick')) + ->options(function (Get $get): array { + return $this->brickOptionsForBand((string) $get('from_band')); + }) + ->required() + ->live() + ->afterStateUpdated(fn (Set $set) => $set('to_band', null)), + Select::make('to_band') + ->label(trans('ip.to_band')) + ->options(function (Get $get): array { + return $this->targetBandOptions((string) $get('from_band'), $get('position')); + }) + ->required(), + ]) + ->action(function (array $data): void { + $this->moveBrick((string) $data['from_band'], (int) $data['position'], (string) $data['to_band']); + }); + } + + public function moveBrick(string $fromBand, int $position, string $toBand): void + { + abort_unless($this->canSave(), 403); + + if ($this->managesSystemScope()) { + abort_unless(static::canAccess(), 403); + } + + $source = $this->data['bands'][$fromBand] ?? []; + + if ($fromBand === $toBand || ! isset($source[$position])) { + return; + } + + $node = $source[$position]; + $brickClass = ReportBricksCollection::findById((string) ($node['attrs']['id'] ?? '')); + $target = ReportBand::tryFrom($toBand); + + if ($brickClass === null || $target === null || ! in_array($target, $brickClass::allowedBands(), true)) { + Notification::make()->title(trans('ip.brick_not_allowed_in_band'))->danger()->send(); + + return; + } + + array_splice($source, $position, 1); + + $this->data['bands'][$fromBand] = array_values($source); + $this->data['bands'][$toBand][] = $node; + $this->data['bands'][$toBand] = array_values($this->data['bands'][$toBand]); + + Notification::make()->title(trans('ip.brick_moved'))->success()->send(); + } + + protected function getHeaderActions(): array + { + return [ + $this->previewAction(), + $this->moveBrickAction(), + Action::make('save') + ->label(trans('ip.save')) + ->visible(fn (): bool => $this->canSave()) + ->action('save'), + ]; + } + + protected function renderPreviewHtml(): string + { + $bands = []; + + foreach (ReportBand::ordered() as $band) { + $bands[$band->value] = MasonDocumentConverter::toBandEntries($this->data['bands'][$band->value] ?? []); + } + + return app(ReportRenderer::class)->renderPreview($bands); + } + + /** + * @return array + */ + protected function bandOptions(): array + { + $options = []; + + foreach (ReportBand::ordered() as $band) { + $options[$band->value] = $band->getLabel(); + } + + return $options; + } + + /** + * @return array + */ + protected function brickOptionsForBand(string $bandValue): array + { + $options = []; + + foreach ($this->data['bands'][$bandValue] ?? [] as $index => $node) { + $brickClass = ReportBricksCollection::findById((string) ($node['attrs']['id'] ?? '')); + + if ($brickClass !== null) { + $options[$index] = ($index + 1) . '. ' . $brickClass::getLabel(); + } + } + + return $options; + } + + /** + * @return array + */ + protected function targetBandOptions(string $fromBand, mixed $position): array + { + // No brick picked yet — casting null to 0 would offer the bands of + // whichever brick happens to sit first in the source band. + if ($position === null || $position === '') { + return []; + } + + $node = $this->data['bands'][$fromBand][(int) $position] ?? null; + $brickClass = $node ? ReportBricksCollection::findById((string) ($node['attrs']['id'] ?? '')) : null; + + if ($brickClass === null) { + return []; + } + + $options = []; + + foreach ($brickClass::allowedBands() as $band) { + if ($band->value !== $fromBand) { + $options[$band->value] = $band->getLabel(); + } + } + + return $options; + } +} diff --git a/Modules/Core/Filament/Pages/Reports/BaseReportTemplatesPage.php b/Modules/Core/Filament/Pages/Reports/BaseReportTemplatesPage.php new file mode 100644 index 000000000..13312a702 --- /dev/null +++ b/Modules/Core/Filament/Pages/Reports/BaseReportTemplatesPage.php @@ -0,0 +1,234 @@ + + */ + public function getTemplates(): array + { + $storage = $this->storage(); + $templates = []; + + foreach ($storage->listSystem() as $template) { + $template['editable'] = $this->managesSystemScope(); + $templates[] = $template; + } + + if ( ! $this->managesSystemScope()) { + foreach ($storage->listCompany() as $template) { + $template['editable'] = true; + $templates[] = $template; + } + } + + return $templates; + } + + public function builderUrl(array $template): ?string + { + if ( ! $template['editable']) { + return null; + } + + return $this->builderPage()::getUrl([ + 'scope' => $template['scope'], + 'type' => $template['type'], + 'slug' => $template['slug'], + ]); + } + + public function cloneAction(): Action + { + return Action::make('clone') + ->label(trans('ip.clone')) + ->icon('heroicon-o-document-duplicate') + ->schema([ + TextInput::make('name') + ->label(trans('ip.name')) + ->required() + ->maxLength(100), + ]) + ->action(function (array $arguments, array $data): void { + if ($this->managesSystemScope()) { + abort_unless(static::canAccess(), 403); + } + + try { + $clone = $this->storage()->clone( + (string) $arguments['scope'], + (string) $arguments['slug'], + (string) $data['name'], + ReportTemplateType::tryFrom((string) $arguments['type']), + $this->managesSystemScope() ? ReportTemplateStorage::SCOPE_SYSTEM : ReportTemplateStorage::SCOPE_COMPANY, + ); + } catch (InvalidArgumentException) { + /* + * A name that slugifies to '' (e.g. "!!!", emoji-only) — + * required()+maxLength() on the field above don't catch + * this shape, so surface it as a form error instead of a + * 500. + */ + Notification::make() + ->title(trans('ip.invalid_template_name')) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title(trans('ip.template_cloned')) + ->body($clone['manifest']['name']) + ->success() + ->send(); + }); + } + + public function renameAction(): Action + { + return Action::make('rename') + ->label(trans('ip.rename')) + ->icon('heroicon-o-pencil-square') + ->fillForm(fn (array $arguments): array => ['name' => $arguments['name'] ?? '']) + ->schema([ + TextInput::make('name') + ->label(trans('ip.name')) + ->required() + ->maxLength(100), + ]) + ->action(function (array $arguments, array $data): void { + if ($this->managesSystemScope()) { + abort_unless(static::canAccess(), 403); + } + + $template = [ + 'scope' => (string) $arguments['scope'], + 'slug' => (string) $arguments['slug'], + 'type' => (string) $arguments['type'], + ]; + + if ( ! $this->canModify($template)) { + $this->denyModification(); + + return; + } + + $this->storage()->rename( + $template['scope'], + $template['slug'], + (string) $data['name'], + ReportTemplateType::tryFrom($template['type']), + ); + + Notification::make()->title(trans('ip.template_renamed'))->success()->send(); + }); + } + + public function deleteAction(): Action + { + return Action::make('delete') + ->label(trans('ip.delete')) + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->action(function (array $arguments): void { + if ($this->managesSystemScope()) { + abort_unless(static::canAccess(), 403); + } + + $template = [ + 'scope' => (string) $arguments['scope'], + 'slug' => (string) $arguments['slug'], + 'type' => (string) $arguments['type'], + ]; + + if ( ! $this->canModify($template)) { + $this->denyModification(); + + return; + } + + $this->storage()->delete( + $template['scope'], + $template['slug'], + ReportTemplateType::tryFrom($template['type']), + ); + + Notification::make()->title(trans('ip.template_deleted'))->success()->send(); + }); + } + + /** + * Whether this panel may rename or delete the given template. + * + * Editability is always derived from the panel's own scope, never from + * the caller-supplied payload — action arguments come from the browser, + * so an "editable" flag in them would be trivially forgeable. + */ + public function canModify(array $template): bool + { + $scope = (string) ($template['scope'] ?? ''); + + $editable = $this->managesSystemScope() + ? $scope === ReportTemplateStorage::SCOPE_SYSTEM + : $scope === ReportTemplateStorage::SCOPE_COMPANY; + + if ( ! $editable) { + return false; + } + + return ! ($scope === ReportTemplateStorage::SCOPE_SYSTEM && ($template['slug'] ?? '') === 'default'); + } + + protected function denyModification(): void + { + Notification::make()->title(trans('ip.template_not_editable'))->danger()->send(); + } + + protected function storage(): ReportTemplateStorage + { + return app(ReportTemplateStorage::class); + } +} diff --git a/Modules/Core/Jobs/GenerateDocumentPdfJob.php b/Modules/Core/Jobs/GenerateDocumentPdfJob.php new file mode 100644 index 000000000..088bc330c --- /dev/null +++ b/Modules/Core/Jobs/GenerateDocumentPdfJob.php @@ -0,0 +1,61 @@ +document::class . ':' . $this->document->getKey(); + } + + public function handle(PdfGenerationService $service): void + { + if ($this->document instanceof Invoice) { + $service->storeInvoicePdf($this->document); + + return; + } + + $service->storeQuotePdf($this->document); + } + + public function failed(Throwable $e): void + { + Log::error('GenerateDocumentPdfJob failed', [ + 'document' => $this->document::class, + 'id' => $this->document->getKey(), + 'error' => $e->getMessage(), + ]); + } +} diff --git a/Modules/Core/Models/Company.php b/Modules/Core/Models/Company.php index fb504d10a..a664b1061 100644 --- a/Modules/Core/Models/Company.php +++ b/Modules/Core/Models/Company.php @@ -130,7 +130,7 @@ public function shippingAddress() public function communications(): MorphMany { - return $this->morphMany(Communication::class, 'communicable'); + return $this->morphMany(Communication::class, 'communicationable'); } public function companyUsers(): BelongsToMany diff --git a/Modules/Core/Observers/CompanyObserver.php b/Modules/Core/Observers/CompanyObserver.php index b19b2e707..9d57d1de2 100644 --- a/Modules/Core/Observers/CompanyObserver.php +++ b/Modules/Core/Observers/CompanyObserver.php @@ -3,6 +3,7 @@ namespace Modules\Core\Observers; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Modules\Core\Models\Company; use Modules\Core\Services\CompanyDefaultsBootstrapService; @@ -20,7 +21,13 @@ public function created(Company $company): void public function updated(Company $company): void {} - public function deleted(Company $company): void {} + public function deleted(Company $company): void + { + // Per-company report storage lives outside the database; nothing else + // reaps it when the company row goes. + Storage::disk('report_templates')->deleteDirectory((string) $company->id); + Storage::disk('report_pdfs')->deleteDirectory((string) $company->id); + } public function restored(Company $company): void {} diff --git a/Modules/Core/Providers/AdminPanelProvider.php b/Modules/Core/Providers/AdminPanelProvider.php index 5fca3927e..eadccb656 100644 --- a/Modules/Core/Providers/AdminPanelProvider.php +++ b/Modules/Core/Providers/AdminPanelProvider.php @@ -10,6 +10,7 @@ use Filament\Navigation\MenuItem; use Filament\Navigation\NavigationBuilder; use Filament\Navigation\NavigationGroup; +use Filament\Navigation\NavigationItem; use Filament\Panel; use Filament\PanelProvider; use Filament\Support\Enums\Width; @@ -23,6 +24,7 @@ use Illuminate\View\Middleware\ShareErrorsFromSession; use Modules\Core\Filament\Admin\Pages\Dashboard; use Modules\Core\Filament\Admin\Pages\ImportV1Page; +use Modules\Core\Filament\Admin\Pages\ReportTemplates; use Modules\Core\Filament\Admin\Pages\RolePermissionsPage; use Modules\Core\Filament\Admin\Resources\Companies\CompanyResource; use Modules\Core\Filament\Admin\Resources\EmailTemplates\EmailTemplateResource; @@ -134,6 +136,13 @@ public function panel(Panel $panel): Panel ->items([ ...TaxRateResource::getNavigationItems(), ]), + NavigationGroup::make(trans('ip.report_templates')) + ->items([ + NavigationItem::make(trans('ip.report_templates')) + ->icon('heroicon-o-document-duplicate') + ->url(fn (): string => ReportTemplates::getUrl()) + ->isActiveWhen(fn (): bool => request()->routeIs('filament.admin.pages.report-templates')), + ]), /*NavigationGroup::make('System Settings') ->icon('heroicon-o-cog-8-tooth') diff --git a/Modules/Core/Providers/CompanyPanelProvider.php b/Modules/Core/Providers/CompanyPanelProvider.php index 506e88438..96890c0dc 100644 --- a/Modules/Core/Providers/CompanyPanelProvider.php +++ b/Modules/Core/Providers/CompanyPanelProvider.php @@ -28,6 +28,8 @@ 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\ReportBuilder; +use Modules\Core\Filament\Company\Pages\ReportTemplates; 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 +188,8 @@ public function panel(Panel $panel): Panel EditProfile::class, MyCompanies::class, CompanySettings::class, + ReportTemplates::class, + ReportBuilder::class, ]) ->widgets([ RecentQuotesWidget::class, diff --git a/Modules/Core/Providers/CoreServiceProvider.php b/Modules/Core/Providers/CoreServiceProvider.php index fc969407e..2c8d512ad 100644 --- a/Modules/Core/Providers/CoreServiceProvider.php +++ b/Modules/Core/Providers/CoreServiceProvider.php @@ -2,10 +2,13 @@ namespace Modules\Core\Providers; +use Awcodes\Mason\Support\IframeRenderer; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; use Modules\Core\Models\Company; use Modules\Core\Observers\CompanyObserver; +use Modules\Core\ReportBuilder\ReportIframeRenderer; use Nwidart\Modules\Traits\PathNamespace; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -34,6 +37,19 @@ public function register(): void { $this->app->register(EventServiceProvider::class); $this->app->register(RouteServiceProvider::class); + + /* + * Mason's preview iframe must paint report bricks with their + * builder previews, not with the print rendering. MasonController + * resolves the renderer out of the container, so swapping the + * binding is enough. + */ + $this->app->bind( + IframeRenderer::class, + fn ($app, array $parameters): ReportIframeRenderer => new ReportIframeRenderer( + is_array($parameters['blocks'] ?? null) ? $parameters['blocks'] : [], + ), + ); } public function registerViews(): void @@ -47,6 +63,19 @@ public function registerViews(): void $componentNamespace = $this->module_namespace($this->name, $this->app_path(config('modules.paths.generator.component-class.path'))); Blade::componentNamespace($componentNamespace, $this->nameLower); + + /* + * awcodes/mason still registers its views under the 'mason' namespace + * (vendor code, not ours to rename) — Laravel would normally pick up + * an override for that automatically from resources/views/vendor/mason, + * but our override now lives at Modules/Core/resources/views/vendor/ + * report-builder, a path/name Laravel's automatic vendor-override + * convention has no way to find. Register it explicitly instead. + * prependNamespace (not addNamespace) is required so this override is + * checked before the package's own views regardless of provider boot + * order. + */ + View::prependNamespace('mason', module_path($this->name, 'resources/views/vendor/report-builder')); } public function registerTranslations(): void @@ -73,6 +102,7 @@ protected function registerCommands(): void \Modules\Core\Commands\MigrateV1Command::class, \Modules\Core\Commands\MakeUserCommand::class, \Modules\Core\Commands\GenerateObservers::class, + \Modules\Core\Console\ReportsSyncSystemCommand::class, \Modules\Core\Commands\ExportFormDbSchemaCommand::class, ]); } diff --git a/Modules/Core/ReportBuilder/Bricks/AbstractDetailProductBrick.php b/Modules/Core/ReportBuilder/Bricks/AbstractDetailProductBrick.php new file mode 100644 index 000000000..5dfd9e5f0 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/AbstractDetailProductBrick.php @@ -0,0 +1,134 @@ +value + : FieldPlacement::HIDDEN->value; + } + + return $config; + } + + public static function filterConfig(array $config): array + { + $config = static::normalizeLegacyConfig($config); + + return parent::filterConfig($config); + } + + public static function toPreviewHtml(array $config): ?string + { + $config = static::normalizeLegacyConfig($config); + + return view('core::report-builder.bricks.' . static::viewSlug() . '.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + $config = static::normalizeLegacyConfig($config); + + return view('core::report-builder.bricks.' . static::viewSlug() . '.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans(static::configureLabelKey())) + ->modalHeading(trans(static::modalHeadingKey())) + ->slideOver() + ->fillForm(function (array $arguments): ?array { + $config = $arguments['config'] ?? null; + if ($config !== null) { + $config = static::normalizeLegacyConfig($config); + } + + return $config; + }) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_sku') + ->label(trans('ip.show_sku')) + ->default(true), + Select::make('description_placement') + ->label(trans('ip.description_placement')) + ->options(collect(FieldPlacement::cases())->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()])) + ->default(FieldPlacement::INLINE_COLUMN->value), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_unit_price') + ->label(trans('ip.show_unit_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_discount') + ->label(trans('ip.show_discount')) + ->default(false), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('show_table_header') + ->label(trans('ip.show_table_header')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/AbstractFooterTextBrick.php b/Modules/Core/ReportBuilder/Bricks/AbstractFooterTextBrick.php new file mode 100644 index 000000000..5cfa72d0b --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/AbstractFooterTextBrick.php @@ -0,0 +1,109 @@ + $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.' . static::viewSlug() . '.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + [$min, $max] = static::fontSizeRange(); + + return $action + ->label(trans(static::configureLabelKey())) + ->modalHeading(trans(static::modalHeadingKey())) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + RichEditor::make(static::contentField()) + ->label(trans(static::contentLabelKey())) + ->columnSpanFull() + ->toolbarButtons([ + 'bold', + 'italic', + 'underline', + 'bulletList', + 'orderedList', + ]), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(static::defaultFontSize()) + ->minValue($min) + ->maxValue($max), + ]); + } + + protected static function defaultFontSize(): int + { + return 8; + } + + /** + * @return array{0: int, 1: int} + */ + protected static function fontSizeRange(): array + { + return [6, 12]; + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailColumnLabelsBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailColumnLabelsBrick.php new file mode 100644 index 000000000..3e226a02d --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailColumnLabelsBrick.php @@ -0,0 +1,105 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.column_labels'); + } + + /** + * @return array + */ + public static function allowedBands(): array + { + return [ + ReportBand::HEADER, + ReportBand::GROUP_HEADER, + ReportBand::DETAILS, + ]; + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.detail-column-labels.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.detail-column-labels.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_column_labels')) + ->modalHeading(trans('ip.column_labels_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_sku') + ->label(trans('ip.show_sku')) + ->default(false), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_price') + ->label(trans('ip.show_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_discount') + ->label(trans('ip.show_discount')) + ->default(false), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailCustomerAgingBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailCustomerAgingBrick.php new file mode 100644 index 000000000..f2c561dee --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailCustomerAgingBrick.php @@ -0,0 +1,110 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.customer_aging_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.detail-customer-aging.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.detail-customer-aging.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function allowedTypes(): array + { + return [ReportTemplateType::INVOICE]; + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_customer_aging')) + ->modalHeading(trans('ip.customer_aging_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_invoice_number') + ->label(trans('ip.show_invoice_number')) + ->default(true), + Checkbox::make('show_invoice_date') + ->label(trans('ip.show_invoice_date')) + ->default(true), + Checkbox::make('show_due_date') + ->label(trans('ip.show_due_date')) + ->default(true), + Checkbox::make('show_current') + ->label(trans('ip.show_current')) + ->default(true), + Checkbox::make('show_30_days') + ->label(trans('ip.show_30_days')) + ->default(true), + Checkbox::make('show_60_days') + ->label(trans('ip.show_60_days')) + ->default(true), + Checkbox::make('show_90_days') + ->label(trans('ip.show_90_days')) + ->default(true), + Checkbox::make('show_over_90_days') + ->label(trans('ip.show_over_90_days')) + ->default(true), + Checkbox::make('show_total_due') + ->label(trans('ip.show_total_due')) + ->default(true), + Checkbox::make('highlight_overdue') + ->label(trans('ip.highlight_overdue')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailExpenseBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailExpenseBrick.php new file mode 100644 index 000000000..7586ce65f --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailExpenseBrick.php @@ -0,0 +1,101 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.expense_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.detail-expense.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.detail-expense.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function allowedTypes(): array + { + return [ReportTemplateType::INVOICE]; + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_expense_details')) + ->modalHeading(trans('ip.expense_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_expense_number') + ->label(trans('ip.show_expense_number')) + ->default(true), + Checkbox::make('show_expense_date') + ->label(trans('ip.show_expense_date')) + ->default(true), + Checkbox::make('show_category') + ->label(trans('ip.show_category')) + ->default(true), + Checkbox::make('show_vendor') + ->label(trans('ip.show_vendor')) + ->default(false), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_amount') + ->label(trans('ip.show_amount')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailInvoiceProductBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailInvoiceProductBrick.php new file mode 100644 index 000000000..a7e03626d --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailInvoiceProductBrick.php @@ -0,0 +1,45 @@ +'); + } + + public static function allowedTypes(): array + { + return [ReportTemplateType::INVOICE]; + } + + protected static function viewSlug(): string + { + return 'detail-invoice-product'; + } + + protected static function labelKey(): string + { + return 'ip.invoice_product_details'; + } + + protected static function configureLabelKey(): string + { + return 'ip.configure_invoice_product_details'; + } + + protected static function modalHeadingKey(): string + { + return 'ip.invoice_product_details_settings'; + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailInvoiceProjectBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailInvoiceProjectBrick.php new file mode 100644 index 000000000..36cb683b9 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailInvoiceProjectBrick.php @@ -0,0 +1,101 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_project_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.detail-invoice-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.detail-invoice-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_project_details')) + ->modalHeading(trans('ip.invoice_project_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_hours') + ->label(trans('ip.show_hours')) + ->default(true), + Checkbox::make('show_rate') + ->label(trans('ip.show_rate')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('group_by_project') + ->label(trans('ip.group_by_project')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailItemsBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailItemsBrick.php new file mode 100644 index 000000000..fd97d3e4a --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailItemsBrick.php @@ -0,0 +1,123 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.line_items_table'); + } + + public static function normalizeLegacyConfig(array $config): array + { + if ( ! isset($config['description_placement']) && isset($config['show_description'])) { + $config['description_placement'] = $config['show_description'] + ? FieldPlacement::INLINE_COLUMN->value + : FieldPlacement::HIDDEN->value; + } + + return $config; + } + + public static function filterConfig(array $config): array + { + $config = static::normalizeLegacyConfig($config); + + return parent::filterConfig($config); + } + + public static function toPreviewHtml(array $config): ?string + { + $config = static::normalizeLegacyConfig($config); + + return view('core::report-builder.bricks.detail-items.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + $config = static::normalizeLegacyConfig($config); + + return view('core::report-builder.bricks.detail-items.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_line_items')) + ->modalHeading(trans('ip.line_items_settings')) + ->slideOver() + ->fillForm(function (array $arguments): ?array { + $config = $arguments['config'] ?? null; + if ($config !== null) { + $config = static::normalizeLegacyConfig($config); + } + + return $config; + }) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Select::make('description_placement') + ->label(trans('ip.description_placement')) + ->options(collect(FieldPlacement::cases())->mapWithKeys(fn ($case) => [$case->value => $case->getLabel()])) + ->default(FieldPlacement::INLINE_COLUMN->value), + Checkbox::make('show_quantity') + ->label(trans('ip.show_quantity')) + ->default(true), + Checkbox::make('show_price') + ->label(trans('ip.show_price')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('show_table_header') + ->label(trans('ip.show_table_header')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailQuoteProductBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailQuoteProductBrick.php new file mode 100644 index 000000000..1919c31ce --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailQuoteProductBrick.php @@ -0,0 +1,45 @@ +'); + } + + public static function allowedTypes(): array + { + return [ReportTemplateType::QUOTE]; + } + + protected static function viewSlug(): string + { + return 'detail-quote-product'; + } + + protected static function labelKey(): string + { + return 'ip.quote_product_details'; + } + + protected static function configureLabelKey(): string + { + return 'ip.configure_quote_product_details'; + } + + protected static function modalHeadingKey(): string + { + return 'ip.quote_product_details_settings'; + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailQuoteProjectBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailQuoteProjectBrick.php new file mode 100644 index 000000000..54721a29a --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailQuoteProjectBrick.php @@ -0,0 +1,101 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_project_details'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.detail-quote-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.detail-quote-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_project_details')) + ->modalHeading(trans('ip.quote_project_details_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_hours') + ->label(trans('ip.show_hours')) + ->default(true), + Checkbox::make('show_rate') + ->label(trans('ip.show_rate')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('group_by_project') + ->label(trans('ip.group_by_project')) + ->default(true), + Checkbox::make('alternating_rows') + ->label(trans('ip.alternating_rows')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(7) + ->maxValue(14), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/DetailTasksBrick.php b/Modules/Core/ReportBuilder/Bricks/DetailTasksBrick.php new file mode 100644 index 000000000..87b3472d9 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/DetailTasksBrick.php @@ -0,0 +1,97 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.tasks_table'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.detail-tasks.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.detail-tasks.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_tasks')) + ->modalHeading(trans('ip.tasks_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_task_number') + ->label(trans('ip.show_task_number')) + ->default(true), + Checkbox::make('show_task_name') + ->label(trans('ip.show_task_name')) + ->default(true), + Checkbox::make('show_description') + ->label(trans('ip.show_description')) + ->default(true), + Checkbox::make('show_due_at') + ->label(trans('ip.show_due_at')) + ->default(false), + Checkbox::make('show_task_price') + ->label(trans('ip.show_task_price')) + ->default(true), + Checkbox::make('show_task_status') + ->label(trans('ip.show_task_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(9) + ->minValue(6) + ->maxValue(12), + Select::make('header_style') + ->label(trans('ip.header_style')) + ->options([ + 'normal' => trans('ip.normal'), + 'bold' => trans('ip.bold'), + 'italic' => trans('ip.italic'), + ]) + ->default('bold'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/FooterNotesBrick.php b/Modules/Core/ReportBuilder/Bricks/FooterNotesBrick.php new file mode 100644 index 000000000..7788f7dfd --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/FooterNotesBrick.php @@ -0,0 +1,49 @@ +'); + } + + protected static function viewSlug(): string + { + return 'footer-notes'; + } + + protected static function contentField(): string + { + return 'footer_content'; + } + + protected static function labelKey(): string + { + return 'ip.footer'; + } + + protected static function configureLabelKey(): string + { + return 'ip.configure_notes'; + } + + protected static function modalHeadingKey(): string + { + return 'ip.notes_settings'; + } + + protected static function contentLabelKey(): string + { + return 'ip.footer_content'; + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/FooterSummaryBrick.php b/Modules/Core/ReportBuilder/Bricks/FooterSummaryBrick.php new file mode 100644 index 000000000..376bb360d --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/FooterSummaryBrick.php @@ -0,0 +1,59 @@ +'); + } + + protected static function viewSlug(): string + { + return 'footer-summary'; + } + + protected static function contentField(): string + { + return 'summary_content'; + } + + protected static function labelKey(): string + { + return 'ip.summary'; + } + + protected static function configureLabelKey(): string + { + return 'ip.configure_summary'; + } + + protected static function modalHeadingKey(): string + { + return 'ip.summary_settings'; + } + + protected static function contentLabelKey(): string + { + return 'ip.summary_content'; + } + + protected static function defaultFontSize(): int + { + return 9; + } + + protected static function fontSizeRange(): array + { + return [6, 14]; + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/FooterTermsBrick.php b/Modules/Core/ReportBuilder/Bricks/FooterTermsBrick.php new file mode 100644 index 000000000..56f45984a --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/FooterTermsBrick.php @@ -0,0 +1,49 @@ +'); + } + + protected static function viewSlug(): string + { + return 'footer-terms'; + } + + protected static function contentField(): string + { + return 'terms_content'; + } + + protected static function labelKey(): string + { + return 'ip.terms_conditions'; + } + + protected static function configureLabelKey(): string + { + return 'ip.configure_terms'; + } + + protected static function modalHeadingKey(): string + { + return 'ip.terms_settings'; + } + + protected static function contentLabelKey(): string + { + return 'ip.terms_content'; + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/FooterTotalsBrick.php b/Modules/Core/ReportBuilder/Bricks/FooterTotalsBrick.php new file mode 100644 index 000000000..1998cf439 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/FooterTotalsBrick.php @@ -0,0 +1,97 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.totals_section'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.footer-totals.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.footer-totals.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_totals')) + ->modalHeading(trans('ip.totals_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_subtotal') + ->label(trans('ip.show_subtotal')) + ->default(true), + Checkbox::make('show_tax') + ->label(trans('ip.show_tax')) + ->default(true), + Checkbox::make('show_total') + ->label(trans('ip.show_total')) + ->default(true), + Checkbox::make('show_paid') + ->label(trans('ip.show_paid')) + ->default(false), + Checkbox::make('show_balance') + ->label(trans('ip.show_balance')) + ->default(false), + Checkbox::make('highlight_total') + ->label(trans('ip.highlight_total')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/HeaderClientBrick.php b/Modules/Core/ReportBuilder/Bricks/HeaderClientBrick.php new file mode 100644 index 000000000..85ab7eb46 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/HeaderClientBrick.php @@ -0,0 +1,88 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.client_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.header-client.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.header-client.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_client_header')) + ->modalHeading(trans('ip.client_header_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_phone') + ->label(trans('ip.show_phone')) + ->default(true), + Checkbox::make('show_email') + ->label(trans('ip.show_email')) + ->default(true), + Checkbox::make('show_address') + ->label(trans('ip.show_address')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/HeaderCompanyBrick.php b/Modules/Core/ReportBuilder/Bricks/HeaderCompanyBrick.php new file mode 100644 index 000000000..7e412ab17 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/HeaderCompanyBrick.php @@ -0,0 +1,98 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.company_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.header-company.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.header-company.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_company_header')) + ->modalHeading(trans('ip.company_header_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_vat_id') + ->label(trans('ip.show_vat_id')) + ->default(true), + Checkbox::make('show_phone') + ->label(trans('ip.show_phone')) + ->default(true), + Checkbox::make('show_email') + ->label(trans('ip.show_email')) + ->default(true), + Checkbox::make('show_address') + ->label(trans('ip.show_address')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('font_weight') + ->label(trans('ip.font_weight')) + ->options([ + 'normal' => trans('ip.font_weight_normal'), + 'bold' => trans('ip.font_weight_bold'), + ]) + ->default('bold'), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('left'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/HeaderInvoiceMetaBrick.php b/Modules/Core/ReportBuilder/Bricks/HeaderInvoiceMetaBrick.php new file mode 100644 index 000000000..9e22af789 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/HeaderInvoiceMetaBrick.php @@ -0,0 +1,97 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.invoice_metadata'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.header-invoice-meta.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.header-invoice-meta.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function allowedTypes(): array + { + return [ReportTemplateType::INVOICE]; + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_invoice_metadata')) + ->modalHeading(trans('ip.invoice_metadata_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_invoice_number') + ->label(trans('ip.show_invoice_number')) + ->default(true), + Checkbox::make('show_invoice_date') + ->label(trans('ip.show_invoice_date')) + ->default(true), + Checkbox::make('show_due_date') + ->label(trans('ip.show_due_date')) + ->default(true), + Checkbox::make('show_po_number') + ->label(trans('ip.show_po_number')) + ->default(false), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(8) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/HeaderProjectBrick.php b/Modules/Core/ReportBuilder/Bricks/HeaderProjectBrick.php new file mode 100644 index 000000000..82c45422e --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/HeaderProjectBrick.php @@ -0,0 +1,94 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.project_header'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.header-project.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.header-project.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_project')) + ->modalHeading(trans('ip.project_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_project_number') + ->label(trans('ip.show_project_number')) + ->default(true), + Checkbox::make('show_project_name') + ->label(trans('ip.show_project_name')) + ->default(true), + Checkbox::make('show_start_date') + ->label(trans('ip.show_start_date')) + ->default(true), + Checkbox::make('show_end_date') + ->label(trans('ip.show_end_date')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(6) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('left'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/HeaderQuoteMetaBrick.php b/Modules/Core/ReportBuilder/Bricks/HeaderQuoteMetaBrick.php new file mode 100644 index 000000000..66f1efb76 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/HeaderQuoteMetaBrick.php @@ -0,0 +1,97 @@ +'); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.quote_metadata'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.header-quote-meta.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.header-quote-meta.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function allowedTypes(): array + { + return [ReportTemplateType::QUOTE]; + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_quote_meta')) + ->modalHeading(trans('ip.quote_meta_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + Checkbox::make('show_quote_number') + ->label(trans('ip.show_quote_number')) + ->default(true), + Checkbox::make('show_quoted_at') + ->label(trans('ip.show_quoted_at')) + ->default(true), + Checkbox::make('show_expires_at') + ->label(trans('ip.show_expires_at')) + ->default(true), + Checkbox::make('show_status') + ->label(trans('ip.show_status')) + ->default(true), + TextInput::make('font_size') + ->label(trans('ip.font_size')) + ->numeric() + ->default(10) + ->minValue(6) + ->maxValue(16), + Select::make('text_align') + ->label(trans('ip.text_align')) + ->options([ + 'left' => trans('ip.align_left'), + 'center' => trans('ip.align_center'), + 'right' => trans('ip.align_right'), + ]) + ->default('right'), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/PageBreakBrick.php b/Modules/Core/ReportBuilder/Bricks/PageBreakBrick.php new file mode 100644 index 000000000..c85035782 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/PageBreakBrick.php @@ -0,0 +1,58 @@ +'); + } + + public static function allowedBands(): array + { + return ReportBand::cases(); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.page_break'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.page-break.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.page-break.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->modalHidden(); + } +} diff --git a/Modules/Core/ReportBuilder/Bricks/SpacerBrick.php b/Modules/Core/ReportBuilder/Bricks/SpacerBrick.php new file mode 100644 index 000000000..8a81d7c12 --- /dev/null +++ b/Modules/Core/ReportBuilder/Bricks/SpacerBrick.php @@ -0,0 +1,76 @@ +'); + } + + public static function allowedBands(): array + { + return ReportBand::cases(); + } + + public static function getPreviewLabel(array $config): string + { + return trans('ip.spacer'); + } + + public static function toPreviewHtml(array $config): ?string + { + return view('core::report-builder.bricks.spacer.preview', [ + 'config' => $config, + ])->render(); + } + + public static function toHtml(array $config, ?array $data = null): ?string + { + return view('core::report-builder.bricks.spacer.index', [ + 'config' => $config, + 'data' => $data ?? [], + ])->render(); + } + + public static function configureBrickAction(Action $action): Action + { + return $action + ->label(trans('ip.configure_spacer')) + ->modalHeading(trans('ip.spacer_settings')) + ->slideOver() + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->schema([ + Select::make('_width') + ->label(trans('ip.width')) + ->options(collect(ReportBlockWidth::cases())->mapWithKeys(fn ($case) => [$case->value => trans("ip.{$case->value}_width")])) + ->default(ReportBlockWidth::FULL->value), + TextInput::make('height') + ->label(trans('ip.spacer_height')) + ->numeric() + ->default(20) + ->minValue(1) + ->maxValue(500), + ]); + } +} diff --git a/Modules/Core/ReportBuilder/MasonDocumentConverter.php b/Modules/Core/ReportBuilder/MasonDocumentConverter.php new file mode 100644 index 000000000..2ed5a393e --- /dev/null +++ b/Modules/Core/ReportBuilder/MasonDocumentConverter.php @@ -0,0 +1,90 @@ + $entries + */ + public static function toMasonState(array $entries): array + { + $state = []; + + foreach ($entries as $entry) { + $brickClass = ReportBricksCollection::findById((string) ($entry['brick'] ?? '')); + + if ($brickClass === null) { + continue; + } + + $config = is_array($entry['config'] ?? null) ? $entry['config'] : []; + $config[self::WIDTH_KEY] = $entry['width'] ?? ReportBlockWidth::FULL->value; + + $state[] = [ + 'type' => 'masonBrick', + 'attrs' => [ + 'id' => $brickClass::getId(), + 'config' => $config, + 'label' => $brickClass::getLabel(), + 'preview' => base64_encode((string) $brickClass::toPreviewHtml($config)), + ], + ]; + } + + return $state; + } + + /** + * Mason editor state → band entries (width lifted out of config). + * + * @return array + */ + public static function toBandEntries(mixed $state): array + { + if ( ! is_array($state)) { + return []; + } + + if (array_key_exists('content', $state)) { + $state = $state['content']; + } + + $entries = []; + + foreach ($state as $node) { + if ( ! is_array($node) || ($node['type'] ?? null) !== 'masonBrick') { + continue; + } + + $attrs = $node['attrs'] ?? []; + $config = is_array($attrs['config'] ?? null) ? $attrs['config'] : []; + + $width = ReportBlockWidth::tryFrom((string) ($config[self::WIDTH_KEY] ?? '')) ?? ReportBlockWidth::FULL; + unset($config[self::WIDTH_KEY]); + + $entries[] = [ + 'brick' => (string) ($attrs['id'] ?? ''), + 'width' => $width->value, + 'config' => $config, + ]; + } + + return $entries; + } +} diff --git a/Modules/Core/ReportBuilder/ReportBrick.php b/Modules/Core/ReportBuilder/ReportBrick.php new file mode 100644 index 000000000..c688e1a4d --- /dev/null +++ b/Modules/Core/ReportBuilder/ReportBrick.php @@ -0,0 +1,134 @@ +> + */ + protected static array $configKeysCache = []; + + /** + * The bands this brick may be placed in. + * + * Defaults are inferred from the class name prefix (Header, Detail, Footer); + * override for bricks that do not follow the prefix convention. + * + * @return array + */ + public static function allowedBands(): array + { + $basename = class_basename(static::class); + + return match (true) { + str_starts_with($basename, 'Header') => [ReportBand::HEADER, ReportBand::GROUP_HEADER], + str_starts_with($basename, 'Detail') => [ReportBand::DETAILS], + str_starts_with($basename, 'Footer') => [ReportBand::GROUP_FOOTER, ReportBand::FOOTER], + default => ReportBand::cases(), + }; + } + + /** + * The document types this brick may be offered for. Defaults to every + * type; override for bricks whose data only exists on one document type + * (e.g. an invoice-only metadata brick has nothing to render on a quote). + * + * @return array + */ + public static function allowedTypes(): array + { + return ReportTemplateType::cases(); + } + + /** + * The config keys this brick accepts, derived from its configure action + * schema. Used to filter persisted config against the brick's own schema. + * + * @return array + */ + public static function configKeys(): array + { + if (isset(static::$configKeysCache[static::class])) { + return static::$configKeysCache[static::class]; + } + + $action = static::configureBrickAction(Action::make('configure')); + + $property = new ReflectionProperty(Action::class, 'schema'); + $schema = $property->getValue($action); + + $keys = []; + + if (is_array($schema)) { + foreach ($schema as $component) { + if (method_exists($component, 'getName')) { + $keys[] = $component->getName(); + } + } + } + + return static::$configKeysCache[static::class] = $keys; + } + + /** + * Filter a persisted config array down to the keys this brick declares, + * then coerce the well-known presentational values to safe shapes. + */ + public static function filterConfig(array $config): array + { + return static::coerceConfigValues( + array_intersect_key($config, array_flip(static::configKeys())), + ); + } + + /** + * The Filament configure form validates numeric/enum fields client-side + * only. A crafted Livewire payload or a hand-edited template JSON can + * still put arbitrary strings on keys that end up inside a style="" + * attribute, so the presentational keys are coerced (or dropped) here — + * on every save and every render. + * + * @param array $config + * + * @return array + */ + protected static function coerceConfigValues(array $config): array + { + foreach ($config as $key => $value) { + if ($key === 'font_size' || str_ends_with((string) $key, '_font_size')) { + $config[$key] = max(4, min(96, (int) $value)); + } + } + + $enums = [ + 'text_align' => ['left', 'center', 'right', 'justify'], + 'font_weight' => ['normal', 'bold', 'bolder', 'lighter'], + 'font_style' => ['normal', 'italic'], + 'description_placement' => ['inline_column', 'below_row', 'hidden'], + ]; + + foreach ($enums as $key => $allowed) { + if (array_key_exists($key, $config) && ! in_array($config[$key], $allowed, true)) { + unset($config[$key]); + } + } + + return $config; + } +} diff --git a/Modules/Core/ReportBuilder/ReportBrickAction.php b/Modules/Core/ReportBuilder/ReportBrickAction.php new file mode 100644 index 000000000..73d203b90 --- /dev/null +++ b/Modules/Core/ReportBuilder/ReportBrickAction.php @@ -0,0 +1,106 @@ +bootUsing(function (Action $action, array $arguments, Mason $component): ?Action { + $brick = $component->getBrick($arguments['id']); + + if (blank($brick)) { + return null; + } + + return $brick::configureBrickAction($action); + }) + ->fillForm(fn (array $arguments): ?array => $arguments['config'] ?? null) + ->modalHeading(function (array $arguments, Mason $component): ?string { + $brick = $component->getBrick($arguments['id']); + + if (blank($brick)) { + return null; + } + + return $brick::getLabel(); + }) + ->modalWidth(Width::Large) + ->modalSubmitActionLabel(fn (array $arguments): ?string => match ($arguments['mode'] ?? 'insert') { + 'insert' => __('mason::mason.actions.brick.modal.actions.insert.label'), + 'edit' => __('mason::mason.actions.brick.modal.actions.save.label'), + default => null, + }) + ->action(function (array $arguments, array $data, Mason $component): void { + $brick = $component->getBrick($arguments['id']); + + if (blank($brick)) { + Notification::make() + ->title(trans('ip.brick_not_allowed_in_band')) + ->danger() + ->send(); + + return; + } + + $brickContent = [ + 'type' => 'masonBrick', + 'attrs' => [ + 'config' => $data, + 'id' => $arguments['id'], + 'label' => $brick::getLabel(), + 'preview' => base64_encode((string) $brick::toPreviewHtml($data)), + ], + ]; + + $mode = $arguments['mode'] ?? 'insert'; + $state = $component->getState() ?? []; + + if ( ! is_array($state)) { + $state = []; + } + + if ($mode === 'edit' && isset($arguments['blockIndex'])) { + $component->executeCommands([ + BrickCommand::updateBrick((int) $arguments['blockIndex'], $brickContent), + ]); + + return; + } + + $position = filled($arguments['dragPosition'] ?? null) + ? (int) $arguments['dragPosition'] + : count($state); + + $component->executeCommands([ + BrickCommand::insertBrick($brickContent, $position), + ]); + }); + } +} diff --git a/Modules/Core/ReportBuilder/ReportBricksCollection.php b/Modules/Core/ReportBuilder/ReportBricksCollection.php new file mode 100644 index 000000000..73878cd63 --- /dev/null +++ b/Modules/Core/ReportBuilder/ReportBricksCollection.php @@ -0,0 +1,146 @@ + + */ + public static function all(): array + { + return [ + ...self::header(), + ...self::detail(), + ...self::footer(), + ...self::utility(), + ]; + } + + /** + * Get utility bricks allowed in every band. + * + * @return array + */ + public static function utility(): array + { + return [ + PageBreakBrick::class, + SpacerBrick::class, + ]; + } + + /** + * Get the bricks allowed in the given band, optionally narrowed to those + * that also apply to the given document type (e.g. an invoice template's + * builder shouldn't offer a quote-only metadata brick). + * + * @return array + */ + public static function forBand(ReportBand $band, ?ReportTemplateType $type = null): array + { + return array_values(array_filter( + self::all(), + fn (string $brick): bool => in_array($band, $brick::allowedBands(), true) + && ($type === null || in_array($type, $brick::allowedTypes(), true)), + )); + } + + /** + * Find a brick class by its brick id. + * + * @return class-string|null + */ + public static function findById(string $id): ?string + { + foreach (self::all() as $brick) { + if ($brick::getId() === $id) { + return $brick; + } + } + + return null; + } + + /** + * Get header section bricks. + * + * @return array + */ + public static function header(): array + { + return [ + HeaderCompanyBrick::class, + HeaderClientBrick::class, + HeaderInvoiceMetaBrick::class, + HeaderQuoteMetaBrick::class, + HeaderProjectBrick::class, + ]; + } + + /** + * Get detail section bricks. + * + * @return array + */ + public static function detail(): array + { + return [ + DetailColumnLabelsBrick::class, + DetailItemsBrick::class, + DetailInvoiceProductBrick::class, + DetailQuoteProductBrick::class, + DetailInvoiceProjectBrick::class, + DetailQuoteProjectBrick::class, + DetailTasksBrick::class, + DetailCustomerAgingBrick::class, + DetailExpenseBrick::class, + ]; + } + + /** + * Get footer section bricks. + * + * @return array + */ + public static function footer(): array + { + return [ + FooterTotalsBrick::class, + FooterNotesBrick::class, + FooterTermsBrick::class, + FooterSummaryBrick::class, + ]; + } +} diff --git a/Modules/Core/ReportBuilder/ReportIframeRenderer.php b/Modules/Core/ReportBuilder/ReportIframeRenderer.php new file mode 100644 index 000000000..334be579b --- /dev/null +++ b/Modules/Core/ReportBuilder/ReportIframeRenderer.php @@ -0,0 +1,78 @@ + $block + */ + public function getBlockHtml(array $block): ?string + { + if (($block['type'] ?? null) !== 'masonBrick') { + return null; + } + + $id = $block['attrs']['id'] ?? null; + + if (blank($id)) { + return null; + } + + $config = is_array($block['attrs']['config'] ?? null) ? $block['attrs']['config'] : []; + + foreach ($this->getBricks() as $brick) { + if (is_string($brick) && $brick::getId() === $id) { + return is_subclass_of($brick, ReportBrick::class) + ? $brick::toPreviewHtml($config) + : $brick::toHtml($config); + } + } + + return view('mason::components.unregistered-brick', ['label' => $id])->render(); + } + + public function toHtml(?string $layout = null): string + { + $blocks = array_map(function (array $block, int $index): array { + $html = $this->getBlockHtml($block); + $id = $block['attrs']['id'] ?? null; + + return [ + 'index' => $index, + 'id' => $id, + 'config' => $block['attrs']['config'] ?? [], + 'html' => $html, + 'preview' => base64_encode((string) $html), + 'label' => $this->getBlockLabel($id), + ]; + }, $this->blocks, array_keys($this->blocks)); + + $layoutToUse = $layout ?? config('mason.iframe.layout'); + + if ($layoutToUse) { + return view($layoutToUse, ['blocks' => $blocks])->render(); + } + + return view('mason::iframe-preview', ['blocks' => $blocks])->render(); + } +} diff --git a/Modules/Core/Services/PdfGenerationService.php b/Modules/Core/Services/PdfGenerationService.php new file mode 100644 index 000000000..a7dbf0899 --- /dev/null +++ b/Modules/Core/Services/PdfGenerationService.php @@ -0,0 +1,313 @@ +}|null> */ + private array $templateCache = []; + + public function __construct( + protected ReportTemplateStorage $storage, + protected ReportRenderer $renderer, + protected ReportDataMapper $mapper, + ) {} + + public function renderInvoiceHtml(Invoice $invoice): string + { + $template = $this->resolveTemplate($invoice); + + return $this->renderer->render( + $template, + $this->mapper->forInvoice($invoice, $this->brickIdsOf($template)), + ); + } + + public function renderQuoteHtml(Quote $quote): string + { + $template = $this->resolveTemplate($quote); + + return $this->renderer->render( + $template, + $this->mapper->forQuote($quote, $this->brickIdsOf($template)), + ); + } + + public function invoicePdf(Invoice $invoice): string + { + $this->guardRenderTime(); + + return PDFFactory::create()->getOutput($this->renderInvoiceHtml($invoice)); + } + + public function quotePdf(Quote $quote): string + { + $this->guardRenderTime(); + + return PDFFactory::create()->getOutput($this->renderQuoteHtml($quote)); + } + + public function downloadInvoice(Invoice $invoice): Response + { + return response($this->invoicePdf($invoice)) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'attachment; filename="' . $this->filename('invoice', (string) ($invoice->invoice_number ?: $invoice->id)) . '"'); + } + + public function downloadQuote(Quote $quote): Response + { + return response($this->quotePdf($quote)) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'attachment; filename="' . $this->filename('quote', (string) ($quote->quote_number ?: $quote->id)) . '"'); + } + + /** + * Entry point for the "Download PDF" table actions. + * + * Default: render inline and return the streamed response. When + * config('ip.report.queue') is on: return a fresh stored copy if one + * exists, otherwise dispatch a render job and return null so the caller + * can tell the user it is being prepared. + */ + public function handleInvoiceDownload(Invoice $invoice): ?Response + { + if ( ! config('ip.report.queue')) { + return $this->downloadInvoice($invoice); + } + + $path = $this->storedPathFor('invoice', $invoice); + + if ($this->storedPdfIsFresh($path, $invoice)) { + return $this->streamStored($path, $this->filename('invoice', (string) ($invoice->invoice_number ?: $invoice->id))); + } + + GenerateDocumentPdfJob::dispatch($invoice); + + return null; + } + + public function handleQuoteDownload(Quote $quote): ?Response + { + if ( ! config('ip.report.queue')) { + return $this->downloadQuote($quote); + } + + $path = $this->storedPathFor('quote', $quote); + + if ($this->storedPdfIsFresh($path, $quote)) { + return $this->streamStored($path, $this->filename('quote', (string) ($quote->quote_number ?: $quote->id))); + } + + GenerateDocumentPdfJob::dispatch($quote); + + return null; + } + + public function storeInvoicePdf(Invoice $invoice): string + { + $path = $this->storedPathFor('invoice', $invoice); + + if (Storage::disk('report_pdfs')->put($path, $this->invoicePdf($invoice)) === false) { + throw new RuntimeException("Failed to write stored PDF to [{$path}]."); + } + + return $path; + } + + public function storeQuotePdf(Quote $quote): string + { + $path = $this->storedPathFor('quote', $quote); + + if (Storage::disk('report_pdfs')->put($path, $this->quotePdf($quote)) === false) { + throw new RuntimeException("Failed to write stored PDF to [{$path}]."); + } + + return $path; + } + + /** + * @return array{manifest: array, bands: array} + */ + public function resolveTemplate(Invoice|Quote $document): array + { + $type = $document instanceof Invoice ? ReportTemplateType::INVOICE : ReportTemplateType::QUOTE; + + $companyDefault = $document instanceof Invoice + ? $document->company?->invoice_template + : $document->company?->quote_template; + + foreach (array_filter([(string) $document->template, (string) $companyDefault, 'default']) as $slug) { + if (($template = $this->loadBySlug($slug, $type)) !== null) { + return $template; + } + } + + throw new RuntimeException( + "No report template found for {$type->value} documents. Run \"php artisan reports:sync-system\".", + ); + } + + public function filename(string $prefix, string $number): string + { + $number = preg_replace('/[^A-Za-z0-9\-_]/', '-', $number) ?: 'document'; + + return $prefix . '-' . $number . '.pdf'; + } + + /** + * @param array{manifest: array, bands: array} $template + * + * @return list + */ + protected function brickIdsOf(array $template): array + { + $ids = []; + + foreach ($template['bands'] as $entries) { + foreach ($entries as $entry) { + if (isset($entry['brick'])) { + $ids[] = (string) $entry['brick']; + } + } + } + + return array_values(array_unique($ids)); + } + + protected function loadBySlug(string $slug, ReportTemplateType $type): ?array + { + // The same slug/type resolves to the same template for every document + // of a tenant in one request — don't re-read the JSON per document. + $key = ((string) session('current_company_id')) . "\0" . $slug . "\0" . $type->value; + + if ( ! array_key_exists($key, $this->templateCache)) { + $this->templateCache[$key] = $this->resolveBySlug($slug, $type); + } + + return $this->templateCache[$key]; + } + + protected function resolveBySlug(string $slug, ReportTemplateType $type): ?array + { + try { + $template = $this->storage->load(ReportTemplateStorage::SCOPE_COMPANY, $slug); + } catch (Throwable) { + $template = null; + } + + if ($template !== null && ($template['manifest']['type'] ?? null) === $type->value) { + return $template; + } + + try { + $template = $this->storage->load(ReportTemplateStorage::SCOPE_SYSTEM, $slug, $type); + if ($template !== null) { + return $template; + } + } catch (Throwable) { + // fall through to resource fallback + } + + return $this->loadFromResources($slug, $type); + } + + protected function loadFromResources(string $slug, ReportTemplateType $type): ?array + { + if ($slug === '' || preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug) !== 1) { + return null; + } + + $base = resource_path("report-templates/{$type->value}/{$slug}"); + $manifestPath = $base . '/manifest.json'; + $bandsPath = $base . '/bands.json'; + + if ( ! File::exists($manifestPath)) { + return null; + } + + try { + $manifest = json_decode((string) File::get($manifestPath), true, 64, JSON_THROW_ON_ERROR); + + if ( ! is_array($manifest)) { + return null; + } + + $bands = []; + if (File::exists($bandsPath)) { + $decodedBands = json_decode((string) File::get($bandsPath), true, 64, JSON_THROW_ON_ERROR); + if (is_array($decodedBands)) { + $bands = $decodedBands; + } + } + + return [ + 'manifest' => $this->storage->sanitizeManifest($manifest), + 'bands' => $this->storage->sanitizeBands($bands, $type), + ]; + } catch (Throwable) { + return null; + } + } + + protected function storedPathFor(string $prefix, Invoice|Quote $document): string + { + $number = $document instanceof Invoice + ? (string) ($document->invoice_number ?: $document->id) + : (string) ($document->quote_number ?: $document->id); + + return ((int) $document->company_id) . '/' . $this->filename($prefix, $number); + } + + protected function storedPdfIsFresh(string $path, Invoice|Quote $document): bool + { + $disk = Storage::disk('report_pdfs'); + + if ( ! $disk->exists($path)) { + return false; + } + + return $disk->lastModified($path) >= ($document->updated_at?->timestamp ?? 0); + } + + protected function streamStored(string $path, string $filename): Response + { + return response((string) Storage::disk('report_pdfs')->get($path)) + ->header('Content-Type', 'application/pdf') + ->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); + } + + protected function guardRenderTime(): void + { + // set_time_limit() resets PHP's execution-time counter and installs a + // ceiling for the rest of the process. Inside the single-process test + // run that arms a time bomb for every test that follows a render. + if (app()->runningUnitTests()) { + return; + } + + $limit = (int) config('ip.report.render_time_limit', 120); + + if ($limit > 0 && function_exists('set_time_limit')) { + @set_time_limit($limit); + } + } +} diff --git a/Modules/Core/Services/ReportDataMapper.php b/Modules/Core/Services/ReportDataMapper.php new file mode 100644 index 000000000..c7627d279 --- /dev/null +++ b/Modules/Core/Services/ReportDataMapper.php @@ -0,0 +1,534 @@ +value, + InvoiceStatus::VIEWED->value, + InvoiceStatus::PARTIALLY_PAID->value, + InvoiceStatus::OVERDUE->value, + ]; + + /** + * Bricks that read the customer's project/task tree. If none are in the + * template, the whole project/task eager-load and data build is skipped. + */ + private const PROJECT_BRICKS = [ + 'detail_invoice_project', + 'detail_quote_project', + 'detail_tasks', + 'header_project', + ]; + + /** + * @param list $brickIds bricks in the resolved template; an empty + * list means "unknown — build everything" + */ + public function forInvoice(Invoice $invoice, array $brickIds = []): array + { + $wantsProject = $this->wantsProject($brickIds); + $wantsExpenses = $this->wantsExpenses($brickIds); + + $relations = [ + 'company.addresses', + 'company.communications', + 'customer.addresses', + 'customer.communications', + 'invoiceItems.product.productCategory', + 'invoiceItems.taxRate', + 'payments', + ]; + + if ($wantsProject) { + array_push($relations, 'customer.projects.tasks', 'customer.tasks', 'invoiceItems.task.project'); + } + + if ($wantsExpenses) { + array_push($relations, 'expenses.expenseCategory', 'expenses.vendor'); + } + + $invoice->loadMissing($relations); + + $paid = (float) $invoice->payments->sum('payment_amount'); + + $maxRows = $this->maxRows(); + $truncated = $invoice->invoiceItems->count() > $maxRows + || ($wantsExpenses && $invoice->expenses->count() > $maxRows); + + return [ + 'company' => $this->companyData($invoice->company), + 'client' => $this->clientData($invoice->customer), + 'invoice' => [ + 'number' => (string) $invoice->invoice_number, + 'date' => $invoice->invoiced_at?->format('Y-m-d') ?? '', + 'due_date' => $invoice->invoice_due_at?->format('Y-m-d') ?? '', + 'po_number' => '', + 'status' => $invoice->invoice_status?->value ?? '', + ], + 'items' => $this->cap($invoice->invoiceItems->map(fn ($item): array => $this->itemData($item))->all()), + 'invoice_items' => $this->cap($invoice->invoiceItems->map(fn ($item): array => $this->productItemData($item))->all()), + 'expense_items' => $wantsExpenses + ? $this->cap($invoice->expenses->map(fn ($expense): array => $this->expenseItemData($expense))->all()) + : [], + 'items_truncated' => $truncated, + 'project' => $wantsProject ? $this->projectData($invoice->invoiceItems, $invoice->customer) : $this->emptyProject(), + 'tasks' => $wantsProject ? $this->cap($this->tasksData($invoice->invoiceItems, $invoice->customer)) : [], + 'project_items' => $wantsProject ? $this->cap($this->projectItemsData($invoice->invoiceItems, $invoice->customer)) : [], + 'totals' => [ + 'subtotal' => $this->money($invoice->invoice_item_subtotal), + 'tax' => $this->money($invoice->invoice_tax_total), + 'total' => $this->money($invoice->invoice_total), + 'paid' => $this->money($paid), + 'balance' => $this->money((float) $invoice->invoice_total - $paid), + ], + 'summary' => (string) $invoice->summary, + 'terms' => (string) $invoice->terms, + 'footer' => (string) $invoice->footer, + // The aging report runs its own query per invoice; skip it unless + // the template actually has the aging brick. + ...$this->agingData($this->wantsAging($brickIds) ? $invoice->customer : null), + ]; + } + + /** + * @param list $brickIds bricks in the resolved template; an empty + * list means "unknown — build everything" + */ + public function forQuote(Quote $quote, array $brickIds = []): array + { + $wantsProject = $this->wantsProject($brickIds); + + $relations = [ + 'company.addresses', + 'company.communications', + 'prospect.addresses', + 'prospect.communications', + 'quoteItems.product.productCategory', + 'quoteItems.taxRate', + ]; + + if ($wantsProject) { + array_push($relations, 'prospect.projects.tasks', 'prospect.tasks', 'quoteItems.task.project'); + } + + $quote->loadMissing($relations); + + return [ + 'company' => $this->companyData($quote->company), + 'client' => $this->clientData($quote->prospect), + 'quote' => [ + 'quote_number' => (string) $quote->quote_number, + 'quoted_at' => $quote->quoted_at?->format('Y-m-d') ?? '', + 'quote_expires_at' => $quote->quote_expires_at?->format('Y-m-d') ?? '', + 'quote_status' => $quote->quote_status?->value ?? '', + ], + 'items' => $this->cap($quote->quoteItems->map(fn ($item): array => $this->itemData($item))->all()), + 'quote_items' => $this->cap($quote->quoteItems->map(fn ($item): array => $this->productItemData($item))->all()), + 'items_truncated' => $quote->quoteItems->count() > $this->maxRows(), + 'project' => $wantsProject ? $this->projectData($quote->quoteItems, $quote->prospect) : $this->emptyProject(), + 'tasks' => $wantsProject ? $this->cap($this->tasksData($quote->quoteItems, $quote->prospect)) : [], + 'project_items' => $wantsProject ? $this->cap($this->projectItemsData($quote->quoteItems, $quote->prospect)) : [], + 'totals' => [ + 'subtotal' => $this->money($quote->quote_item_subtotal), + 'tax' => $this->money($quote->quote_tax_total), + 'total' => $this->money($quote->quote_total), + 'paid' => $this->money(0), + 'balance' => $this->money($quote->quote_total), + ], + 'summary' => (string) $quote->summary, + 'terms' => (string) $quote->terms, + 'footer' => (string) $quote->footer, + ]; + } + + /** + * @param list $brickIds + */ + protected function wantsAging(array $brickIds): bool + { + return $brickIds === [] || in_array('detail_customer_aging', $brickIds, true); + } + + /** + * @param list $brickIds + */ + protected function wantsProject(array $brickIds): bool + { + return $brickIds === [] || array_intersect($brickIds, self::PROJECT_BRICKS) !== []; + } + + /** + * @param list $brickIds + */ + protected function wantsExpenses(array $brickIds): bool + { + return $brickIds === [] || in_array('detail_expense', $brickIds, true); + } + + /** + * @return array + */ + protected function emptyProject(): array + { + return [ + 'project_number' => '', + 'project_name' => '', + 'start_at' => '', + 'end_at' => '', + 'project_status' => '', + ]; + } + + protected function companyData(?Company $company): array + { + if ($company === null) { + return []; + } + + $address = $company->addresses->first(); + + return [ + 'name' => (string) $company->name, + 'vat_id' => (string) $company->vat_number, + 'address' => (string) ($address?->address_1 ?? ''), + 'city' => (string) ($address?->city ?? ''), + 'postal_code' => (string) ($address?->postal_code ?? ''), + 'phone' => $this->communication($company, 'phone'), + 'email' => $this->communication($company, 'email'), + 'logo_path' => $this->logoPath($company), + ]; + } + + protected function clientData(?Relation $client): array + { + if ($client === null) { + return []; + } + + $address = $client->addresses->first(); + + return [ + 'name' => (string) $client->company_name, + 'address' => (string) ($address?->address_1 ?? ''), + 'city' => (string) ($address?->city ?? ''), + 'postal_code' => (string) ($address?->postal_code ?? ''), + 'phone' => $this->communication($client, 'phone'), + 'email' => $this->communication($client, 'email'), + ]; + } + + protected function itemData($item): array + { + return [ + 'description' => (string) ($item->item_name ?: $item->description), + 'quantity' => (float) $item->quantity, + 'price' => $this->money($item->price), + 'tax' => $this->money($item->tax_total), + 'total' => $this->money($item->total), + 'category' => (string) ($item->product?->productCategory?->category_name ?? ''), + 'tax_rate' => (string) ($item->taxRate?->name ?? ''), + 'product' => (string) ($item->product?->product_name ?? ($item->item_name ?: '')), + 'sku' => (string) ($item->product?->code ?? ''), + ]; + } + + /** + * Row shape for the per-document-type product tables (detail-invoice-product, + * detail-quote-product) — a superset of itemData() with the sku/unit_price/ + * discount columns those tables render. + */ + protected function productItemData($item): array + { + return [ + 'sku' => (string) ($item->product?->code ?? ''), + 'description' => (string) ($item->item_name ?: $item->description), + 'quantity' => (float) $item->quantity, + 'unit_price' => $this->money($item->price), + 'tax' => $this->money($item->tax_total), + 'discount' => $this->money($item->discount ?? 0), + 'total' => $this->money($item->total), + 'category' => (string) ($item->product?->productCategory?->category_name ?? ''), + 'tax_rate' => (string) ($item->taxRate?->name ?? ''), + 'product' => (string) ($item->product?->product_name ?? ($item->item_name ?: '')), + ]; + } + + protected function expenseItemData($expense): array + { + return [ + 'expense_number' => (string) $expense->expense_number, + 'expense_date' => $expense->expensed_at?->format('Y-m-d') ?? '', + 'category' => (string) ($expense->expenseCategory?->category_name ?? ''), + 'vendor' => (string) ($expense->vendor?->company_name ?? ''), + 'description' => (string) $expense->description, + 'amount' => $this->money($expense->expense_amount), + 'status' => $expense->expense_status?->label() ?? '', + ]; + } + + /** + * Aging report for the client's still-open invoices, bucketed by how + * many days past due each one is. One row per invoice; each row's + * balance lands in exactly one bucket column, the rest '-'. + * + * @return array{aging_items: array, aging_totals: array} + */ + protected function agingData(?Relation $client): array + { + $totals = ['current' => 0.0, 'days_30' => 0.0, 'days_60' => 0.0, 'days_90' => 0.0, 'over_90' => 0.0, 'total_due' => 0.0]; + + if ($client === null) { + return ['aging_items' => [], 'aging_totals' => $this->formatAgingTotals($totals)]; + } + + $now = now(); + $items = []; + + $openInvoices = Invoice::query() + ->where('customer_id', $client->id) + ->whereIn('invoice_status', self::OPEN_INVOICE_STATUSES) + ->with('payments') + ->get(); + + foreach ($openInvoices as $openInvoice) { + $due = (float) $openInvoice->invoice_total - (float) $openInvoice->payments->sum('payment_amount'); + + if ($due <= 0.0) { + continue; + } + + $dueDate = $openInvoice->invoice_due_at; + $daysOverdue = $dueDate ? (int) $dueDate->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false) : 0; + + $bucket = match (true) { + $daysOverdue <= 0 => 'current', + $daysOverdue <= 30 => 'days_30', + $daysOverdue <= 60 => 'days_60', + $daysOverdue <= 90 => 'days_90', + default => 'over_90', + }; + + $row = [ + 'invoice_number' => (string) $openInvoice->invoice_number, + 'invoice_date' => $openInvoice->invoiced_at?->format('Y-m-d') ?? '', + 'due_date' => $dueDate?->format('Y-m-d') ?? '', + 'current' => '-', + 'days_30' => '-', + 'days_60' => '-', + 'days_90' => '-', + 'over_90' => '-', + 'total_due' => $this->money($due), + 'days_overdue' => max(0, $daysOverdue), + ]; + $row[$bucket] = $this->money($due); + + $items[] = $row; + + $totals[$bucket] += $due; + $totals['total_due'] += $due; + } + + return ['aging_items' => $this->cap($items), 'aging_totals' => $this->formatAgingTotals($totals)]; + } + + protected function formatAgingTotals(array $totals): array + { + return array_map(fn (float $amount): string => $this->money($amount), $totals); + } + + /** + * Logo is embedded as a base64 data URI so both dompdf and Browsershot + * can render it without requiring local file access in the browser. + */ + protected function logoPath(Company $company): string + { + if (blank($company->logo)) { + return ''; + } + + try { + $disk = Storage::disk('public'); + + if ( ! $disk->exists((string) $company->logo)) { + return ''; + } + + $path = $disk->path((string) $company->logo); + + if ( ! is_file($path)) { + return ''; + } + + $mimeType = mime_content_type($path) ?: 'image/png'; + $contents = file_get_contents($path); + + if ($contents === false) { + return ''; + } + + return 'data:' . $mimeType . ';base64,' . base64_encode($contents); + } catch (Throwable) { + return ''; + } + } + + protected function communication($model, string $type): string + { + $matching = $model->communications + ->filter(function ($entry) use ($type): bool { + $commType = (string) $entry->communication_type; + + if ($type === 'phone') { + return str_contains($commType, 'phone') || str_contains($commType, 'mobile'); + } + + return str_contains($commType, $type); + }); + + $primary = $matching->firstWhere('is_primary', true) ?? $matching->first(); + + return (string) ($primary?->communication_value ?? ''); + } + + protected function money(mixed $amount): string + { + return number_format((float) $amount, 2, '.', ''); + } + + /** + * Cap a rows array so one document cannot force an unbounded render. + * + * @param array $rows + * + * @return array + */ + protected function cap(array $rows): array + { + return array_slice($rows, 0, $this->maxRows()); + } + + protected function maxRows(): int + { + return max(1, (int) config('ip.report.max_rows', 2000)); + } + + /** + * Data array for header_project brick. + * + * @param \Illuminate\Support\Collection $items + */ + protected function projectData($items, ?Relation $client): array + { + $project = $items->first(fn ($item): bool => $item->task?->project !== null)?->task?->project + ?? collect($client?->projects)->first(); + + if ($project === null) { + return $this->emptyProject(); + } + + return [ + 'project_number' => (string) ($project->project_number ?? ''), + 'project_name' => (string) ($project->project_name ?? ''), + 'start_at' => $project->start_at?->format('Y-m-d') ?? '', + 'end_at' => $project->end_at?->format('Y-m-d') ?? '', + 'project_status' => (string) ($project->project_status?->label() ?? ($project->project_status?->value ?? '')), + ]; + } + + /** + * Data array for detail_tasks brick. + * + * @param \Illuminate\Support\Collection $items + */ + protected function tasksData($items, ?Relation $client): array + { + $billedTasks = $items->map(fn ($item) => $item->task)->filter()->unique('id'); + + if ($billedTasks->isNotEmpty()) { + $tasks = $billedTasks; + } elseif ($client !== null) { + $clientTasks = collect($client->tasks); + $clientProjects = collect($client->projects); + $tasks = $clientTasks->isNotEmpty() + ? $clientTasks + : $clientProjects->flatMap(fn ($project) => collect($project->tasks))->unique('id'); + } else { + $tasks = collect(); + } + + return $tasks->map(fn ($task): array => [ + 'task_number' => (string) ($task->task_number ?? ''), + 'task_name' => (string) ($task->task_name ?? ''), + 'description' => (string) ($task->description ?? ''), + 'due_at' => $task->due_at?->format('Y-m-d') ?? '', + 'task_price' => $this->money($task->task_price ?? 0), + 'task_status' => (string) ($task->task_status?->label() ?? ($task->task_status?->value ?? '')), + ])->values()->all(); + } + + /** + * Data array for detail_invoice_project and detail_quote_project bricks. + * + * @param \Illuminate\Support\Collection $items + */ + protected function projectItemsData($items, ?Relation $client): array + { + $itemsWithTask = $items->filter(fn ($item): bool => $item->task !== null); + + if ($itemsWithTask->isNotEmpty()) { + return $itemsWithTask->map(fn ($item): array => [ + 'project_name' => (string) ($item->task?->project?->project_name ?? ''), + 'task_name' => (string) ($item->task?->task_name ?? ($item->item_name ?: '')), + 'description' => (string) ($item->description ?: ($item->task?->description ?? '')), + 'hours' => (float) $item->quantity, + 'rate' => $this->money($item->price), + 'total' => $this->money($item->total), + ])->values()->all(); + } + + $clientProjects = collect($client?->projects); + + if ($clientProjects->isNotEmpty()) { + $rows = []; + foreach ($clientProjects as $project) { + foreach (collect($project->tasks) as $task) { + $price = (float) ($task->task_price ?? 0); + $rows[] = [ + 'project_name' => (string) ($project->project_name ?? ''), + 'task_name' => (string) ($task->task_name ?? ''), + 'description' => (string) ($task->description ?? ''), + 'hours' => 1.0, + 'rate' => $this->money($price), + 'total' => $this->money($price), + ]; + } + } + + return $rows; + } + + return []; + } +} diff --git a/Modules/Core/Services/ReportRenderer.php b/Modules/Core/Services/ReportRenderer.php new file mode 100644 index 000000000..7a6aadb6d --- /dev/null +++ b/Modules/Core/Services/ReportRenderer.php @@ -0,0 +1,404 @@ +} $template + */ + public function render(array $template, array $data): string + { + $manifest = $template['manifest'] ?? []; + $groupBy = $this->groupBy($manifest); + + if ($groupBy === null) { + $body = ''; + + foreach (ReportBand::ordered() as $band) { + $body .= $this->renderBand($band, $template, $data); + } + + return $this->wrapDocument($body, (string) ($manifest['name'] ?? 'Report')); + } + + $body = $this->renderGroupedDocument($template, $data, $groupBy); + + return $this->wrapDocument($body, (string) ($manifest['name'] ?? 'Report')); + } + + /** + * Render band entries for the builder's live preview. Same band iteration, + * row packing and per-brick isolation as render(), but each brick shows + * its toPreviewHtml() (no entity data) and there is no document wrapper — + * so the preview and the print output can never drift apart on layout. + * + * @param array> $bands + */ + public function renderPreview(array $bands): string + { + $template = ['manifest' => [], 'bands' => $bands]; + $body = ''; + + foreach (ReportBand::ordered() as $band) { + $body .= $this->renderBand($band, $template, [], preview: true); + } + + return $body; + } + + protected function renderGroupedDocument(array $template, array $data, ReportGroupBy $groupBy): string + { + $body = ''; + + // Document-level header + $body .= $this->renderBand(ReportBand::HEADER, $template, $data); + + // Partition items into groups preserving first-seen order + $groupKey = $groupBy->value; + $groups = $this->extractGroupKeys($data, $groupKey); + + if ($groups === []) { + $groupHtml = $this->renderBand(ReportBand::GROUP_HEADER, $template, $data) + . $this->renderBand(ReportBand::DETAILS, $template, $data) + . $this->renderBand(ReportBand::GROUP_FOOTER, $template, $data); + + if ($groupHtml !== '') { + $style = $this->keepsGroupTogether($template['manifest'] ?? []) ? ' style="page-break-inside: avoid;"' : ''; + $body .= '
' . $groupHtml . '
'; + } + } else { + foreach ($groups as $groupValue) { + $groupData = $this->buildGroupData($data, $groupKey, $groupValue); + + $groupHtml = $this->renderBand(ReportBand::GROUP_HEADER, $template, $groupData) + . $this->renderBand(ReportBand::DETAILS, $template, $groupData) + . $this->renderBand(ReportBand::GROUP_FOOTER, $template, $groupData); + + if ($groupHtml !== '') { + $style = $this->keepsGroupTogether($template['manifest'] ?? []) ? ' style="page-break-inside: avoid;"' : ''; + $body .= '
' . $groupHtml . '
'; + } + } + } + + // Document-level footer + $body .= $this->renderBand(ReportBand::FOOTER, $template, $data); + + return $body; + } + + /** + * @return array + */ + protected function extractGroupKeys(array $data, string $groupKey): array + { + $seen = []; + + $collections = [ + $data['items'] ?? [], + $data['invoice_items'] ?? [], + $data['quote_items'] ?? [], + $data['expense_items'] ?? [], + ]; + + foreach ($collections as $collection) { + if ( ! is_array($collection)) { + continue; + } + + foreach ($collection as $item) { + if ( ! is_array($item)) { + continue; + } + + $val = (string) ($item[$groupKey] ?? ''); + + if ( ! in_array($val, $seen, true)) { + $seen[] = $val; + } + } + } + + return $seen; + } + + /** + * @param array $data + * + * @return array + */ + protected function buildGroupData(array $data, string $groupKey, string $groupValue): array + { + $groupData = $data; + + $filter = fn (mixed $collection): array => is_array($collection) + ? array_values(array_filter($collection, fn ($item): bool => is_array($item) && (string) ($item[$groupKey] ?? '') === $groupValue)) + : []; + + $groupItems = $filter($data['items'] ?? []); + $groupInvoiceItems = $filter($data['invoice_items'] ?? []); + $groupQuoteItems = $filter($data['quote_items'] ?? []); + $groupExpenseItems = $filter($data['expense_items'] ?? []); + + $groupData['items'] = $groupItems; + $groupData['invoice_items'] = $groupInvoiceItems; + $groupData['quote_items'] = $groupQuoteItems; + $groupData['expense_items'] = $groupExpenseItems; + + $label = $groupValue !== '' ? $groupValue : trans('ip.none'); + + $groupData['group'] = [ + 'field' => $groupKey, + 'key' => $groupValue, + 'name' => $label, + 'label' => $label, + 'value' => $groupValue, + ]; + + $itemsToSum = $groupItems !== [] + ? $groupItems + : ($groupInvoiceItems !== [] ? $groupInvoiceItems : ($groupQuoteItems !== [] ? $groupQuoteItems : $groupExpenseItems)); + + $groupTotals = $this->calculateGroupTotals($itemsToSum); + + $groupData['group_totals'] = $groupTotals; + $groupData['document_totals'] = $data['totals'] ?? []; + + return $groupData; + } + + /** + * @param array> $items + * + * @return array + */ + protected function calculateGroupTotals(array $items): array + { + $subtotal = 0.0; + $tax = 0.0; + $total = 0.0; + + foreach ($items as $item) { + $itemTax = (float) ($item['tax'] ?? 0); + $itemTotal = (float) ($item['total'] ?? ($item['amount'] ?? 0)); + $tax += $itemTax; + $total += $itemTotal; + + if (isset($item['subtotal'])) { + $subtotal += (float) $item['subtotal']; + } elseif (isset($item['unit_price'])) { + $subtotal += ((float) ($item['quantity'] ?? 1)) * (float) $item['unit_price']; + } elseif (isset($item['price'])) { + $subtotal += ((float) ($item['quantity'] ?? 1)) * (float) $item['price']; + } elseif (isset($item['amount'])) { + $subtotal += (float) $item['amount']; + } else { + $subtotal += $itemTotal - $itemTax; + } + } + + return [ + 'subtotal' => number_format($subtotal, 2, '.', ''), + 'tax' => number_format($tax, 2, '.', ''), + 'total' => number_format($total, 2, '.', ''), + ]; + } + + protected function groupBy(array $manifest): ?ReportGroupBy + { + $value = $manifest['band_options'][ReportBand::DETAILS->value]['group_by'] ?? null; + + if ($value instanceof ReportGroupBy) { + return $value; + } + + return is_string($value) ? ReportGroupBy::tryFrom($value) : null; + } + + protected function keepsGroupTogether(array $manifest): bool + { + return (bool) ($manifest['band_options']['group']['keep_together'] + ?? $manifest['band_options'][ReportBand::DETAILS->value]['keep_together'] + ?? false); + } + + protected function renderBand(ReportBand $band, array $template, array $data, bool $preview = false): string + { + $entries = $template['bands'][$band->value] ?? []; + + if ($entries === []) { + return ''; + } + + $style = 'width: 100%;'; + + if ($this->keepsTogether($band, $template['manifest'])) { + $style .= ' page-break-inside: avoid;'; + } + + $html = '
'; + + /* + * page-break bricks must live at block level between the row + * tables — dompdf ignores page-break CSS inside table cells. + */ + $segment = []; + + foreach ($entries as $entry) { + if (($entry['brick'] ?? null) === 'page_break') { + $html .= $this->renderRows($segment, $data, $preview); + $html .= $this->renderBrickSafely(\Modules\Core\ReportBuilder\Bricks\PageBreakBrick::class, $entry['config'] ?? [], $data, $preview); + $segment = []; + + continue; + } + + $segment[] = $entry; + } + + $html .= $this->renderRows($segment, $data, $preview); + $html .= '
'; + + return $html; + } + + protected function renderRows(array $entries, array $data, bool $preview = false): string + { + $html = ''; + + foreach ($this->chunkIntoRows($entries) as $row) { + $html .= $this->renderRow($row, $data, $preview); + } + + return $html; + } + + /** + * Group consecutive entries into rows on a 12-column grid — dompdf + * cannot lay out floats reliably, so each row becomes a table. + * + * @return array> + */ + protected function chunkIntoRows(array $entries): array + { + $rows = []; + $row = []; + $rowWidth = 0; + + foreach ($entries as $entry) { + if ( ! is_array($entry) || ReportBricksCollection::findById((string) ($entry['brick'] ?? '')) === null) { + continue; + } + + $width = ReportBlockWidth::tryFrom((string) ($entry['width'] ?? '')) ?? ReportBlockWidth::FULL; + + if ($row !== [] && $rowWidth + $width->getGridWidth() > 12) { + $rows[] = $row; + $row = []; + $rowWidth = 0; + } + + $row[] = ['entry' => $entry, 'width' => $width]; + $rowWidth += $width->getGridWidth(); + } + + if ($row !== []) { + $rows[] = $row; + } + + return $rows; + } + + /** + * @param array $row + */ + protected function renderRow(array $row, array $data, bool $preview = false): string + { + $cells = ''; + + foreach ($row as $block) { + $brickClass = ReportBricksCollection::findById((string) $block['entry']['brick']); + + if ($brickClass === null) { + continue; + } + + $config = is_array($block['entry']['config'] ?? null) ? $block['entry']['config'] : []; + $inner = $this->renderBrickSafely($brickClass, $config, $data, $preview); + $percent = (int) round($block['width']->getGridWidth() / 12 * 100); + + $cells .= '' + . $inner + . ''; + } + + return '' . $cells . '
'; + } + + /** + * @param class-string<\Modules\Core\ReportBuilder\ReportBrick>|string $brickClass + */ + protected function renderBrickSafely(string $brickClass, array $config, array $data, bool $preview = false): string + { + $id = method_exists($brickClass, 'getId') ? $brickClass::getId() : $brickClass; + + try { + $filteredConfig = method_exists($brickClass, 'filterConfig') ? $brickClass::filterConfig($config) : $config; + + if ($preview && method_exists($brickClass, 'toPreviewHtml')) { + return (string) $brickClass::toPreviewHtml($filteredConfig); + } + + return (string) $brickClass::toHtml($filteredConfig, $data); + } catch (Throwable $e) { + Log::warning("Report brick {$id} failed: {$e->getMessage()}", ['exception' => $e]); + + return ""; + } + } + + protected function keepsTogether(ReportBand $band, array $manifest): bool + { + return (bool) ($manifest['band_options'][$band->value]['keep_together'] ?? false); + } + + protected function wrapDocument(string $body, string $title): string + { + return << + + + + {$this->escape($title)} + + + + {$body} + + + HTML; + } + + protected function escape(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/Modules/Core/Services/ReportTemplateStorage.php b/Modules/Core/Services/ReportTemplateStorage.php new file mode 100644 index 000000000..d8fb53c08 --- /dev/null +++ b/Modules/Core/Services/ReportTemplateStorage.php @@ -0,0 +1,465 @@ + + */ + public function listSystem(?ReportTemplateType $type = null): array + { + $templates = []; + $types = $type ? [$type->value] : ReportTemplateType::values(); + + foreach ($types as $typeValue) { + foreach (Storage::disk(self::DISK)->directories(self::SCOPE_SYSTEM . '/' . $typeValue) as $directory) { + $manifest = $this->readJson($directory . '/manifest.json'); + + if ($manifest === null) { + continue; + } + + $templates[] = [ + 'scope' => self::SCOPE_SYSTEM, + 'type' => $typeValue, + 'slug' => basename($directory), + 'manifest' => $this->sanitizeManifest($manifest), + ]; + } + } + + return $templates; + } + + /** + * List the current company's templates, optionally filtered by type. + * + * @return array + */ + public function listCompany(?ReportTemplateType $type = null): array + { + $templates = []; + + foreach (Storage::disk(self::DISK)->directories((string) $this->companyId()) as $directory) { + $manifest = $this->readJson($directory . '/manifest.json'); + + if ($manifest === null) { + continue; + } + + if ($type !== null && ($manifest['type'] ?? null) !== $type->value) { + continue; + } + + $templates[] = [ + 'scope' => self::SCOPE_COMPANY, + 'type' => (string) ($manifest['type'] ?? ''), + 'slug' => basename($directory), + 'manifest' => $this->sanitizeManifest($manifest), + ]; + } + + return $templates; + } + + /** + * Slug => display name options for template pickers. Company clones + * shadow system templates that share a slug, matching the resolution + * order used when rendering. + * + * @return array + */ + public function optionsForType(ReportTemplateType $type): array + { + $options = []; + + foreach ($this->listSystem($type) as $template) { + $options[$template['slug']] = (string) ($template['manifest']['name'] ?? $template['slug']); + } + + foreach ($this->listCompany($type) as $template) { + $options[$template['slug']] = (string) ($template['manifest']['name'] ?? $template['slug']); + } + + ksort($options); + + return $options; + } + + public function exists(string $scope, string $slug, ?ReportTemplateType $type = null): bool + { + return Storage::disk(self::DISK)->exists($this->path($scope, $slug, $type) . '/manifest.json'); + } + + /** + * Load a template. Bands are validated on load: unknown brick ids and + * bricks not allowed in their band are skipped, widths fall back to + * full, and configs are filtered against each brick's own schema. + * + * @return array{manifest: array, bands: array}|null + */ + public function load(string $scope, string $slug, ?ReportTemplateType $type = null): ?array + { + $base = $this->path($scope, $slug, $type); + $manifest = $this->readJson($base . '/manifest.json'); + + if ($manifest === null) { + return null; + } + + $bands = $this->readJson($base . '/bands.json') ?? []; + + return [ + 'manifest' => $this->sanitizeManifest($manifest), + 'bands' => $this->sanitizeBands($bands, $type), + ]; + } + + /** + * Persist a template's manifest and bands. Bands are sanitized before + * writing so only known bricks, valid widths, and schema-filtered + * configs ever reach disk. + */ + public function save(string $scope, string $slug, array $manifest, array $bands, ?ReportTemplateType $type = null): void + { + $base = $this->path($scope, $slug, $type); + $disk = Storage::disk(self::DISK); + + $manifestJson = $this->encodeJson($this->sanitizeManifest($manifest)); + $bandsJson = $this->encodeJson($this->sanitizeBands($bands, $type)); + + $maxBytes = max(1, (int) config('ip.report.max_template_bytes', 262144)); + + if (mb_strlen($manifestJson) + mb_strlen($bandsJson) > $maxBytes) { + throw new RuntimeException("Report template [{$scope}/{$slug}] exceeds the maximum size of {$maxBytes} bytes."); + } + + $manifestPath = $base . '/manifest.json'; + $manifestWritten = $disk->put($manifestPath, $manifestJson); + + if ( ! $manifestWritten) { + Log::warning("Failed to write report template manifest to disk at [{$manifestPath}]."); + + throw new RuntimeException("Failed to write report template [{$scope}/{$slug}] to disk."); + } + + $bandsPath = $base . '/bands.json'; + $bandsWritten = $disk->put($bandsPath, $bandsJson); + + if ( ! $bandsWritten) { + Log::warning("Failed to write report template bands to disk at [{$bandsPath}]."); + + throw new RuntimeException("Failed to write report template [{$scope}/{$slug}] to disk."); + } + } + + /** + * Sanitize manifest options (e.g. band_options.details.group_by against allowed enums). + * + * @param array $manifest + * + * @return array + */ + public function sanitizeManifest(array $manifest): array + { + if (isset($manifest['band_options']) && is_array($manifest['band_options'])) { + $sanitizedOptions = []; + + foreach ($manifest['band_options'] as $bandKey => $options) { + if ( ! is_array($options)) { + continue; + } + + $sanitized = []; + + if (isset($options['keep_together'])) { + $sanitized['keep_together'] = (bool) $options['keep_together']; + } + + if (isset($options['group_by'])) { + $groupBy = is_string($options['group_by']) + ? ReportGroupBy::tryFrom($options['group_by']) + : ($options['group_by'] instanceof ReportGroupBy ? $options['group_by'] : null); + + if ($groupBy !== null) { + $sanitized['group_by'] = $groupBy->value; + } + } + + if ($sanitized !== []) { + $sanitizedOptions[$bandKey] = $sanitized; + } + } + + $manifest['band_options'] = $sanitizedOptions; + } + + return $manifest; + } + + /** + * Clone a template into the current company (or into the system scope). + * Cloning copies the folder and rewrites the manifest. + * + * @return array{scope: string, type: string, slug: string, manifest: array} + */ + public function clone( + string $fromScope, + string $fromSlug, + string $newName, + ?ReportTemplateType $type = null, + string $toScope = self::SCOPE_COMPANY, + ): array { + $source = $this->load($fromScope, $fromSlug, $type); + + if ($source === null) { + throw new RuntimeException("Report template [{$fromScope}/{$fromSlug}] does not exist."); + } + + $manifest = $source['manifest']; + $templateType = $type ?? ReportTemplateType::tryFrom((string) ($manifest['type'] ?? '')); + $newSlug = $this->uniqueSlug($toScope, Str::slug($newName), $templateType); + + $manifest['name'] = $newName; + $manifest['slug'] = $newSlug; + $manifest['cloned_from'] = $this->path($fromScope, $fromSlug, $type); + + $this->save($toScope, $newSlug, $manifest, $source['bands'], $templateType); + + return [ + 'scope' => $toScope, + 'type' => (string) ($manifest['type'] ?? ''), + 'slug' => $newSlug, + 'manifest' => $manifest, + ]; + } + + /** + * Rename a template's display name (the slug is stable once created). + */ + public function rename(string $scope, string $slug, string $newName, ?ReportTemplateType $type = null): void + { + if ($scope === self::SCOPE_SYSTEM && $slug === 'default') { + throw new RuntimeException('System default templates cannot be renamed.'); + } + + $template = $this->load($scope, $slug, $type); + + if ($template === null) { + throw new RuntimeException("Report template [{$scope}/{$slug}] does not exist."); + } + + $manifest = $template['manifest']; + $manifest['name'] = $newName; + + $path = $this->path($scope, $slug, $type) . '/manifest.json'; + $written = Storage::disk(self::DISK)->put( + $path, + $this->encodeJson($manifest), + ); + + if ( ! $written) { + Log::warning("Failed to write report template manifest to disk at [{$path}]."); + + throw new RuntimeException("Failed to write report template [{$scope}/{$slug}] to disk."); + } + } + + /** + * Delete a template folder. Shipped system defaults are protected. + */ + public function delete(string $scope, string $slug, ?ReportTemplateType $type = null): bool + { + if ($scope === self::SCOPE_SYSTEM && $slug === 'default') { + throw new RuntimeException('System default templates cannot be deleted.'); + } + + $base = $this->path($scope, $slug, $type); + + if ( ! Storage::disk(self::DISK)->exists($base . '/manifest.json')) { + return false; + } + + return Storage::disk(self::DISK)->deleteDirectory($base); + } + + /** + * Reduce arbitrary decoded band data to the valid five-band structure. + * When a document type is given, bricks that don't apply to that type + * (e.g. a quote-only brick surviving in an invoice template) are pruned + * too — matches the filtering already applied to the picker itself. + * + * @return array> + */ + public function sanitizeBands(array $bands, ?ReportTemplateType $type = null): array + { + $sanitized = []; + $maxPerBand = max(1, (int) config('ip.report.max_bricks_per_band', 50)); + + foreach (ReportBand::ordered() as $band) { + $sanitized[$band->value] = []; + + $entries = $bands[$band->value] ?? []; + + if ( ! is_array($entries)) { + continue; + } + + foreach ($entries as $entry) { + if ( ! is_array($entry)) { + continue; + } + + $brickClass = ReportBricksCollection::findById((string) ($entry['brick'] ?? '')); + + if ($brickClass === null || ! in_array($band, $brickClass::allowedBands(), true)) { + continue; + } + + if ($type !== null && ! in_array($type, $brickClass::allowedTypes(), true)) { + continue; + } + + $width = ReportBlockWidth::tryFrom((string) ($entry['width'] ?? '')) ?? ReportBlockWidth::FULL; + $config = is_array($entry['config'] ?? null) ? $entry['config'] : []; + + $sanitized[$band->value][] = [ + 'brick' => $brickClass::getId(), + 'width' => $width->value, + 'config' => $brickClass::filterConfig($config), + ]; + } + + $sanitized[$band->value] = array_slice($sanitized[$band->value], 0, $maxPerBand); + } + + return $sanitized; + } + + /** + * Resolve the disk path for a template. Slugs are strictly validated + * ([a-z0-9-] only) so path traversal is impossible; company paths come + * from the tenant context exclusively. + */ + public function path(string $scope, string $slug, ?ReportTemplateType $type = null): string + { + $this->assertValidSlug($slug); + + if ($scope === self::SCOPE_SYSTEM) { + if ($type === null) { + throw new InvalidArgumentException('System template paths require a document type.'); + } + + return self::SCOPE_SYSTEM . '/' . $type->value . '/' . $slug; + } + + if ($scope === self::SCOPE_COMPANY) { + return $this->companyId() . '/' . $slug; + } + + throw new InvalidArgumentException("Unknown report template scope [{$scope}]."); + } + + protected function assertValidSlug(string $slug): void + { + if ($slug === '' || preg_match('/^[a-z0-9][a-z0-9-]*$/', $slug) !== 1) { + throw new InvalidArgumentException("Invalid report template slug [{$slug}]."); + } + } + + /** + * Current company id from the tenant context (Filament tenant, then + * session, then the user's first company). Never caller-supplied. + */ + protected function companyId(): int + { + $tenant = Filament::getTenant(); + + if ($tenant !== null) { + return (int) $tenant->getKey(); + } + + if (session()?->has('current_company_id')) { + return (int) session('current_company_id'); + } + + $company = Auth::user()?->companies()->first(); + + if ($company !== null) { + return (int) $company->id; + } + + throw new RuntimeException('No company context available for report template storage.'); + } + + protected function uniqueSlug(string $scope, string $slug, ?ReportTemplateType $type): string + { + $this->assertValidSlug($slug); + + $candidate = $slug; + $suffix = 2; + + while ($this->exists($scope, $candidate, $type)) { + $candidate = $slug . '-' . $suffix++; + } + + return $candidate; + } + + protected function readJson(string $path): ?array + { + if ( ! Storage::disk(self::DISK)->exists($path)) { + return null; + } + + try { + $decoded = json_decode((string) Storage::disk(self::DISK)->get($path), true, 64, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + Log::warning("Corrupt JSON in report template at [{$path}]: " . $e->getMessage()); + + return null; + } + + return is_array($decoded) ? $decoded : null; + } + + protected function encodeJson(array $data): string + { + return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/Modules/Core/Support/PDF/Drivers/Browsershot.php b/Modules/Core/Support/PDF/Drivers/Browsershot.php new file mode 100644 index 000000000..53cbf5790 --- /dev/null +++ b/Modules/Core/Support/PDF/Drivers/Browsershot.php @@ -0,0 +1,54 @@ +getOutput($html)); + } + + public function getOutput($html) + { + return $this->getEngine($html)->pdf(); + } + + public function getEngine($html): BrowsershotEngine + { + $engine = BrowsershotEngine::html($html) + ->format($this->paperSize) + ->showBackground(); + + if ($this->paperOrientation === 'landscape') { + $engine->landscape(); + } + + if (config('ip.browsershot.node_binary')) { + $engine->setNodeBinary(config('ip.browsershot.node_binary')); + } + + if (config('ip.browsershot.npm_binary')) { + $engine->setNpmBinary(config('ip.browsershot.npm_binary')); + } + + if (config('ip.browsershot.chrome_path')) { + $engine->setChromePath(config('ip.browsershot.chrome_path')); + } + + if (config('ip.browsershot.no_sandbox')) { + $engine->noSandbox(); + } + + return $engine; + } +} diff --git a/Modules/Core/Support/PDF/Drivers/domPDF.php b/Modules/Core/Support/PDF/Drivers/domPDF.php index 84e18b35b..9bfde4603 100644 --- a/Modules/Core/Support/PDF/Drivers/domPDF.php +++ b/Modules/Core/Support/PDF/Drivers/domPDF.php @@ -2,8 +2,9 @@ namespace Modules\Core\Support\PDF\Drivers; -use Dompdf\Dompdf as PDF; +use Dompdf\Dompdf as DompdfEngine; use Dompdf\Options; +use Illuminate\Support\Facades\File; use Modules\Core\Support\PDF\PDFAbstract; class domPDF extends PDFAbstract @@ -20,29 +21,30 @@ public function getOutput($html) return $pdf->output(); } - public function download($html, $filename) + protected function buildOptions(): Options { - $response = response($this->getOutput($html)); + $workDir = storage_path('app/dompdf'); + File::ensureDirectoryExists($workDir); - $response->header('Content-Type', 'application/pdf'); - $response->header('Content-Disposition', 'attachment; filename="' . $filename . '"'); - - return $response->send(); - } - - private function getPdf($html) - { $options = new Options(); - $options->setTempDir(storage_path('/')); - $options->setFontDir(storage_path('/')); - $options->setFontCache(storage_path('/')); - $options->setLogOutputFile(storage_path('dompdf_log')); - $options->setIsRemoteEnabled(true); + $options->setTempDir($workDir); + $options->setFontDir($workDir); + $options->setFontCache($workDir); + $options->setLogOutputFile($workDir . '/dompdf.log'); + // Remote fetching stays disabled: images must resolve to local paths. + $options->setIsRemoteEnabled(false); + // dompdf defaults JavaScript on; invoice/quote rendering never needs it. + $options->setIsJavascriptEnabled(false); $options->setIsHtml5ParserEnabled(true); $options->setIsFontSubsettingEnabled(true); - $pdf = new PDF($options); + return $options; + } + + private function getPdf($html) + { + $pdf = new DompdfEngine($this->buildOptions()); $pdf->setPaper($this->paperSize, $this->paperOrientation); $pdf->loadHtml($html); diff --git a/Modules/Core/Support/PDF/PDFInterface.php b/Modules/Core/Support/PDF/PDFInterface.php index c881de24a..54d74d281 100644 --- a/Modules/Core/Support/PDF/PDFInterface.php +++ b/Modules/Core/Support/PDF/PDFInterface.php @@ -6,8 +6,6 @@ interface PDFInterface { public function save($html, $filename); - public function download($html, $filename); - public function setPaperSize($paperSize); public function setPaperOrientation($paperOrientation); diff --git a/Modules/Core/Tests/Concerns/AssertsRenderedPdf.php b/Modules/Core/Tests/Concerns/AssertsRenderedPdf.php new file mode 100644 index 000000000..8231023b5 --- /dev/null +++ b/Modules/Core/Tests/Concerns/AssertsRenderedPdf.php @@ -0,0 +1,22 @@ +assertStringStartsWith('%PDF', $bytes, 'output is not a PDF'); + $this->assertStringContainsString('%%EOF', $bytes, 'PDF end-of-file trailer missing — the render was truncated'); + $this->assertGreaterThan( + $minBytes, + mb_strlen($bytes, '8bit'), + 'rendered PDF is only ' . mb_strlen($bytes, '8bit') . ' bytes — likely a valid but empty document', + ); + } +} diff --git a/Modules/Core/Tests/E2E/auth-helpers.js b/Modules/Core/Tests/E2E/auth-helpers.js index f6a0320ae..38a6e1c6f 100644 --- a/Modules/Core/Tests/E2E/auth-helpers.js +++ b/Modules/Core/Tests/E2E/auth-helpers.js @@ -27,3 +27,13 @@ export async function logout(page) { await page.getByRole('button', { name: /log ?out/i }).click(); await page.waitForURL(/\/login/); } + +export async function isAuthenticated(page) { + try { + await page.goto(tenantPath('/dashboard')); + + return !page.url().includes('/login'); + } catch { + return false; + } +} diff --git a/Modules/Core/Tests/E2E/lang-helper.js b/Modules/Core/Tests/E2E/lang-helper.js new file mode 100644 index 000000000..d09a6fda2 --- /dev/null +++ b/Modules/Core/Tests/E2E/lang-helper.js @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const IP_LANG_PATH = path.resolve(__dirname, '../../../../resources/lang/en/ip.php'); + +let translationsCache = null; + +/** + * Parses resources/lang/en/ip.php into a JavaScript key-value map. + * Matches PHP associative array entries: 'key' => 'value' or 'key' => "value" + */ +export function loadIpTranslations() { + if (translationsCache) { + return translationsCache; + } + + const content = fs.readFileSync(IP_LANG_PATH, 'utf-8'); + const translations = {}; + + // Match lines like: 'key' => 'value' or 'key' => "value" + // Handles escaped quotes and multiline / single line strings. + const regex = /'([a-zA-Z0-9_]+)'\s*=>\s*(?:'((?:\\'|[^'])*)'|"((?:\\"|[^"])*)")/g; + let match; + while ((match = regex.exec(content)) !== null) { + const key = match[1]; + const value = (match[2] !== undefined ? match[2].replace(/\\'/g, "'") : match[3].replace(/\\"/g, '"')); + translations[key] = value; + } + + translationsCache = translations; + return translations; +} + +/** + * Returns translated string for an ip key, e.g. trans('vat_id') => 'Vat id' + */ +export function trans(key) { + const dict = loadIpTranslations(); + if (!(key in dict)) { + throw new Error(`Translation key "ip.${key}" not found in resources/lang/en/ip.php`); + } + return dict[key]; +} diff --git a/Modules/Core/Tests/E2E/report-builder-brick-config.spec.js b/Modules/Core/Tests/E2E/report-builder-brick-config.spec.js new file mode 100644 index 000000000..8cbe6bb06 --- /dev/null +++ b/Modules/Core/Tests/E2E/report-builder-brick-config.spec.js @@ -0,0 +1,149 @@ +import { test, expect } from './test.js'; +import { tenantPath } from './tenant-path.js'; +import { + writeReportTemplateFixture, + deleteReportTemplateFixture, + brickEntry, + bandFrame, +} from './report-builder-fixture.js'; +import { trans } from './lang-helper.js'; + +/** + * Helper to test a single checkbox configuration change on a placed brick. + * Sets up fixture, visits builder, verifies initial state in band iframe, + * opens slideover, toggles checkbox, saves, and verifies updated preview state. + */ +async function testBrickCheckboxToggle({ + page, + slug, + band, + brick, + initialConfig, + checkboxLabel, + targetCheckedState, + modalHeading, + assertInitial, + assertUpdated, +}) { + writeReportTemplateFixture(slug, { + [band]: [brickEntry(brick, initialConfig)], + }); + + try { + await page.goto(tenantPath(`/report-builder/company/invoice/${slug}`)); + const frame = bandFrame(page, band); + await expect(frame.locator('.mason-block')).toHaveCount(1); + + if (assertInitial) { + await assertInitial(frame); + } + + // Block controls (including Edit) appear when block is selected + await frame.locator('.mason-block').click(); + await frame.locator('.mason-block [data-action="edit"]').click(); + + // The slideover opens on the top-level page + if (modalHeading) { + await expect(page.getByRole('heading', { name: modalHeading })).toBeVisible({ timeout: 15000 }); + } + + const checkbox = page.getByLabel(checkboxLabel, { exact: true }); + await expect(checkbox).toBeVisible({ timeout: 15000 }); + await checkbox.setChecked(targetCheckedState); + + const saveButton = page.getByRole('button', { name: 'Save Changes', exact: true }); + await saveButton.click(); + + if (modalHeading) { + await expect(page.getByRole('heading', { name: modalHeading })).toBeHidden({ timeout: 15000 }); + } + + if (assertUpdated) { + await assertUpdated(frame); + } + } finally { + deleteReportTemplateFixture(slug); + } +} + +test.describe('HeaderCompanyBrick — checkbox configuration', () => { + const BAND = 'header'; + const BRICK = 'header_company'; + const HEADING = trans('company_header_settings'); + + test('show_vat_id: unchecking removes VAT ID from preview', async ({ page }) => { + await testBrickCheckboxToggle({ + page, + slug: 'e2e-company-vat-id', + band: BAND, + brick: BRICK, + initialConfig: { show_vat_id: true, show_phone: true, show_email: true, show_address: true }, + checkboxLabel: trans('show_vat_id'), + targetCheckedState: false, + modalHeading: HEADING, + assertInitial: async (frame) => { + await expect(frame.locator('.mason-block-content')).toContainText(trans('vat_id')); + }, + assertUpdated: async (frame) => { + await expect(frame.locator('.mason-block-content')).not.toContainText(trans('vat_id')); + }, + }); + }); + + test('show_phone: unchecking removes Phone from preview', async ({ page }) => { + await testBrickCheckboxToggle({ + page, + slug: 'e2e-company-phone', + band: BAND, + brick: BRICK, + initialConfig: { show_vat_id: true, show_phone: true, show_email: true, show_address: true }, + checkboxLabel: trans('show_phone'), + targetCheckedState: false, + modalHeading: HEADING, + assertInitial: async (frame) => { + await expect(frame.locator('.mason-block-content')).toContainText(trans('phone')); + }, + assertUpdated: async (frame) => { + await expect(frame.locator('.mason-block-content')).not.toContainText(trans('phone')); + }, + }); + }); + + test('show_email: unchecking removes Email from preview', async ({ page }) => { + await testBrickCheckboxToggle({ + page, + slug: 'e2e-company-email', + band: BAND, + brick: BRICK, + initialConfig: { show_vat_id: true, show_phone: true, show_email: true, show_address: true }, + checkboxLabel: trans('show_email'), + targetCheckedState: false, + modalHeading: HEADING, + assertInitial: async (frame) => { + await expect(frame.locator('.mason-block-content')).toContainText(trans('email')); + }, + assertUpdated: async (frame) => { + await expect(frame.locator('.mason-block-content')).not.toContainText(trans('email')); + }, + }); + }); + + test('show_address: unchecking removes Address from preview', async ({ page }) => { + await testBrickCheckboxToggle({ + page, + slug: 'e2e-company-address', + band: BAND, + brick: BRICK, + initialConfig: { show_vat_id: true, show_phone: true, show_email: true, show_address: true }, + checkboxLabel: trans('show_address'), + targetCheckedState: false, + modalHeading: HEADING, + assertInitial: async (frame) => { + await expect(frame.locator('.mason-block-content')).toContainText(trans('company_address')); + }, + assertUpdated: async (frame) => { + await expect(frame.locator('.mason-block-content')).not.toContainText(trans('company_address')); + }, + }); + }); +}); diff --git a/Modules/Core/Tests/E2E/report-builder-fixture.js b/Modules/Core/Tests/E2E/report-builder-fixture.js new file mode 100644 index 000000000..a86aef6d8 --- /dev/null +++ b/Modules/Core/Tests/E2E/report-builder-fixture.js @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Report templates are pure-file storage (see + * Modules/Core/Services/ReportTemplateStorage.php) — a company template is + * just a folder of manifest.json + bands.json under + * storage/app/report_templates/{company_id}/{slug}/. Writing those two files + * directly is far more reliable than building fixture content by drag-and- + * dropping bricks from the sidebar through the UI (the only way to add a + * brick to an *empty* band, since Mason's sidebar bricks are drag-only), and + * keeps the actual drag/drop interactions under test the sole responsibility + * of the browser rather than the fixture setup. + */ + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Matches database/seeders/DatabaseSeeder.php: the `ivplv2` company is always +// seeded at id=22. +const E2E_COMPANY_ID = process.env.E2E_COMPANY_ID || '22'; + +const REPORT_TEMPLATES_ROOT = path.resolve(__dirname, '../../../../storage/app/report_templates'); + +export function reportTemplatePath(slug) { + return path.join(REPORT_TEMPLATES_ROOT, E2E_COMPANY_ID, slug); +} + +/** + * @param {string} slug + * @param {Record>} bandEntries + */ +export function writeReportTemplateFixture(slug, bandEntries = {}) { + const dir = reportTemplatePath(slug); + fs.mkdirSync(dir, { recursive: true }); + + const manifest = { + name: `E2E ${slug}`, + slug, + type: 'invoice', + version: 1, + cloned_from: null, + }; + + const bands = { + header: [], + group_header: [], + details: [], + group_footer: [], + footer: [], + ...bandEntries, + }; + + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 4)); + fs.writeFileSync(path.join(dir, 'bands.json'), JSON.stringify(bands, null, 4)); +} + +export function deleteReportTemplateFixture(slug) { + fs.rmSync(reportTemplatePath(slug), { recursive: true, force: true }); +} + +/** A `spacer` brick entry — allowed in every band, minimal config. */ +export function spacerBrick(height, width = 'full') { + return { brick: 'spacer', width, config: { height } }; +} + +/** General brick entry helper */ +export function brickEntry(brick, config = {}, width = 'full') { + return { brick, width, config }; +} + +/** Get band iframe handle by band name */ +export function bandFrame(page, band) { + return page.frame({ name: `mason-preview-iframe-data.bands.${band}` }); +} diff --git a/Modules/Core/Tests/E2E/report-builder-mason.spec.js b/Modules/Core/Tests/E2E/report-builder-mason.spec.js new file mode 100644 index 000000000..5c28c7c97 --- /dev/null +++ b/Modules/Core/Tests/E2E/report-builder-mason.spec.js @@ -0,0 +1,281 @@ +import { test, expect } from './test.js'; +import { tenantPath } from './tenant-path.js'; +import { captureConsoleErrors } from './error-capture.js'; +import { writeReportTemplateFixture, deleteReportTemplateFixture, spacerBrick } from './report-builder-fixture.js'; + +/** + * Regression coverage for 5 Mason report-builder bugs fixed alongside the + * banded report builder (see CLAUDE.md's "Report Builder Guardrails"). Every + * band renders its own iframe (name="mason-preview-iframe-data.bands." + * — see resources/views/vendor/mason/iframe-preview-content.blade.php and + * vendor/awcodes/mason/resources/views/mason.blade.php), so every locator + * that touches drag/drop or block content must go through + * `page.frame({ name })`, never `page` directly. + * + * Fixture bricks are written straight to the report_templates disk (see + * report-builder-fixture.js) rather than built by dragging bricks in from + * the sidebar — that's the one thing Mason has no non-drag way to do, and + * it would make the fixture setup exercise the very code path some of these + * tests exist to check. + */ + +function bandFrame(page, band) { + return page.frame({ name: `mason-preview-iframe-data.bands.${band}` }); +} + +async function dragAndDrop(frame, source, target) { + const dataTransfer = await frame.evaluateHandle(() => new DataTransfer()); + await source.dispatchEvent('dragstart', { dataTransfer }); + await target.dispatchEvent('dragover', { dataTransfer }); + await target.dispatchEvent('drop', { dataTransfer }); + await source.dispatchEvent('dragend', { dataTransfer }); +} + +test.describe('Report Builder — Mason drop-zone regressions', () => { + const SLUG = 'e2e-drop-zone-cursor'; + + test.beforeEach(() => { + writeReportTemplateFixture(SLUG, { + details: [spacerBrick(10), spacerBrick(20), spacerBrick(30)], + }); + }); + + test.afterEach(() => { + deleteReportTemplateFixture(SLUG); + }); + + test('the drop zone nearest the cursor is highlighted, not always the last one in the band (#4)', async ({ page }) => { + /* Arrange */ + await page.goto(tenantPath(`/report-builder/company/invoice/${SLUG}`)); + const frame = bandFrame(page, 'details'); + await expect(frame.locator('.mason-block')).toHaveCount(3); + + const source = frame.locator('.mason-block[data-block-index="0"]'); + // Drop zone between block 1 (height 20) and block 2 (height 30) — the + // middle of the canvas, nowhere near the bottom. + const middleZone = frame.locator('.mason-drop-zone[data-drop-index="2"]'); + const lastZone = frame.locator('.mason-drop-zone[data-drop-index="3"]'); + const lastBlock = frame.locator('.mason-block[data-block-index="2"]'); + + /* Act */ + const dataTransfer = await frame.evaluateHandle(() => new DataTransfer()); + await source.dispatchEvent('dragstart', { dataTransfer }); + await middleZone.dispatchEvent('dragover', { dataTransfer }); + + /* Assert */ + await expect(middleZone).toHaveClass(/active/); + await expect(lastZone).not.toHaveClass(/active/); + + // The regression: every drop zone used to carry `order: 9999`, an order + // value shared by every zone equally — the relative order *among drop + // zones* is unaffected (same-order flex items keep document order among + // themselves), but it does move every one of them after every block + // (order 0), collapsing them all below the last block regardless of + // which is active. The middle zone must render above the last block + // (block 2, height 30) — not pushed below it to the bottom of the canvas. + const middleBox = await middleZone.boundingBox(); + const lastBlockBox = await lastBlock.boundingBox(); + expect(middleBox.y).toBeLessThan(lastBlockBox.y); + + await source.dispatchEvent('dragend', { dataTransfer }); + }); + + test('dropping a brick one slot below itself is a no-op (#5)', async ({ page }) => { + /* Arrange */ + const errors = captureConsoleErrors(page); + await page.goto(tenantPath(`/report-builder/company/invoice/${SLUG}`)); + const frame = bandFrame(page, 'details'); + await expect(frame.locator('.mason-block')).toHaveCount(3); + + const before = await frame.locator('.mason-block-content').allTextContents(); + + const source = frame.locator('.mason-block[data-block-index="0"]'); + // data-drop-index="1" === draggedBlockIndex + 1 — the slot immediately + // after the dragged block's own current position. + const noOpZone = frame.locator('.mason-drop-zone[data-drop-index="1"]'); + + /* Act */ + await dragAndDrop(frame, source, noOpZone); + // Give any (incorrect) moveBlockRequest round-trip a chance to land + // before asserting nothing changed. + await page.waitForTimeout(500); + + /* Assert */ + const after = await frame.locator('.mason-block-content').allTextContents(); + expect(after).toEqual(before); + expect(errors, `unexpected error(s) during a self-drop:\n${errors.join('\n')}`).toHaveLength(0); + }); + + test('deleting the only brick in a band re-shows the empty placeholder and accepts a new brick (#6)', async ({ page }) => { + /* Arrange */ + const emptySlug = 'e2e-empty-band-placeholder'; + writeReportTemplateFixture(emptySlug, { details: [spacerBrick(42)] }); + + try { + await page.goto(tenantPath(`/report-builder/company/invoice/${emptySlug}`)); + const frame = bandFrame(page, 'details'); + await expect(frame.locator('.mason-block')).toHaveCount(1); + + /* Act */ + // Block controls (including Delete) are only shown once a block is + // selected — see vendor/awcodes/mason/resources/css/preview.css. + await frame.locator('.mason-block').click(); + await frame.locator('.mason-block [data-action="delete"]').click(); + + /* Assert */ + await expect(frame.locator('.mason-block')).toHaveCount(0); + await expect(frame.locator('.mason-drop-zone--empty')).toBeVisible(); + + // And the band can actually be refilled. The sidebar's own drag + // origin (a real cross-frame native HTML5 drag) is generic browser + // plumbing untouched by this fix — what the fix actually governs is + // the canvas's `drop` handler, so drive that directly with a + // DataTransfer carrying the same 'brick' payload the sidebar's + // dragstart would set, built inside the iframe's own JS context + // (dispatchEvent needs the handle to belong to the target frame). + const dropZone = frame.locator('.mason-drop-zone--empty'); + const dataTransfer = await frame.evaluateHandle((brickId) => { + const dt = new DataTransfer(); + dt.setData('brick', brickId); + + return dt; + }, 'spacer'); + await dropZone.dispatchEvent('dragover', { dataTransfer }); + await dropZone.dispatchEvent('drop', { dataTransfer }); + + // Inserting opens the brick's config modal (ReportBrickAction) — + // confirm with its defaults to actually land the brick. Label comes + // from vendor/awcodes/mason/resources/lang/en/mason.php ("Insert + // Brick", not just "Insert"). + const insertButton = page.getByRole('button', { name: 'Insert Brick', exact: true }); + await expect(insertButton).toBeVisible({ timeout: 10000 }); + await insertButton.click(); + + await expect(frame.locator('.mason-block')).toHaveCount(1, { timeout: 10000 }); + } finally { + deleteReportTemplateFixture(emptySlug); + } + }); +}); + +test.describe('Report Builder — band editor isolation (#7)', () => { + const SLUG = 'e2e-band-cross-talk'; + + test.beforeEach(() => { + writeReportTemplateFixture(SLUG, { + header: [spacerBrick(11)], + group_header: [spacerBrick(22)], + details: [spacerBrick(33)], + group_footer: [spacerBrick(44)], + footer: [spacerBrick(55)], + }); + }); + + test.afterEach(() => { + deleteReportTemplateFixture(SLUG); + }); + + test('deleting a brick in one band leaves every other band untouched', async ({ page }) => { + /* Arrange */ + await page.goto(tenantPath(`/report-builder/company/invoice/${SLUG}`)); + + const bands = ['header', 'group_header', 'details', 'group_footer', 'footer']; + const frames = Object.fromEntries(bands.map((band) => [band, bandFrame(page, band)])); + + for (const band of bands) { + await expect(frames[band].locator('.mason-block')).toHaveCount(1); + } + + /* Act */ + await frames.details.locator('.mason-block').click(); + await frames.details.locator('.mason-block [data-action="delete"]').click(); + await expect(frames.details.locator('.mason-block')).toHaveCount(0); + + /* Assert */ + for (const band of ['header', 'group_header', 'group_footer', 'footer']) { + await expect(frames[band].locator('.mason-block')).toHaveCount(1); + } + await expect(frames.header.locator('.mason-block-content')).toContainText('11px'); + await expect(frames.group_header.locator('.mason-block-content')).toContainText('22px'); + await expect(frames.group_footer.locator('.mason-block-content')).toContainText('44px'); + await expect(frames.footer.locator('.mason-block-content')).toContainText('55px'); + }); +}); + +test.describe('Report Builder — move to band respects the open brick (#8)', () => { + const SLUG = 'e2e-move-to-band-stale-selection'; + + test.beforeEach(() => { + // Two bricks with different allowedBands() sets in the same band: spacer + // is valid everywhere, header_company only in header/group_header. This + // makes a stale `to_band` value observable — `group_header` is a valid + // (silent, no-error) target for the spacer too, so if `to_band` were not + // cleared when `position` changes, the wrong brick would move there + // without Filament ever rejecting the submission. + writeReportTemplateFixture(SLUG, { + header: [spacerBrick(10), { brick: 'header_company', width: 'full', config: {} }], + }); + }); + + test.afterEach(() => { + deleteReportTemplateFixture(SLUG); + }); + + test('changing the selected brick clears a previously chosen target band', async ({ page }) => { + /* Arrange */ + await page.goto(tenantPath(`/report-builder/company/invoice/${SLUG}`)); + const headerFrame = bandFrame(page, 'header'); + const footerFrame = bandFrame(page, 'footer'); + const groupHeaderFrame = bandFrame(page, 'group_header'); + await expect(headerFrame.locator('.mason-block')).toHaveCount(2); + + await page.getByRole('button', { name: 'Move to band…' }).click(); + const modal = page.getByRole('dialog', { name: 'Move to band…' }); + // The role="dialog" element itself renders with no box of its own — its + // content is a `position: fixed` child — so Playwright's visibility + // check on the wrapper always reports "hidden" even once it's genuinely + // on screen. Assert on real, boxed content instead. Mounting a Filament + // action also round-trips through Livewire before Alpine shows the + // modal, and this dev environment pays real Xdebug step-debug overhead + // per request (see global-setup.js), hence the generous timeout. + await expect(modal.getByRole('heading', { name: 'Move to band…' })).toBeVisible({ timeout: 15000 }); + + const fromBand = modal.getByLabel('From band'); + const position = modal.getByLabel('Brick'); + const toBand = modal.getByLabel('To band'); + + /* Act */ + await fromBand.selectOption({ label: 'Header' }); + // "2. Company Header" — the second brick in the band. + await position.selectOption({ label: '2. Company Header' }); + await expect(toBand).toBeVisible(); + // Company Header is only allowed in header/group_header — with `header` + // as the source, `group_header` is the only offered target. + await toBand.selectOption({ label: 'Group Header' }); + await expect(toBand).toHaveValue('group_header'); + + // Change our mind about which brick to move. + await position.selectOption({ label: '1. Spacer' }); + + /* Assert */ + // The fix: `to_band` must reset, not silently keep the previous band — + // spacer is valid in `group_header` too, so a stale value here would + // submit successfully and move the wrong brick. + await expect(toBand).toHaveValue(''); + + await toBand.selectOption({ label: 'Footer' }); + await modal.getByRole('button', { name: 'Submit' }).click(); + await expect(page.getByText('Brick moved')).toBeVisible(); + + // Identify bricks by data-brick-id/data-config rather than rendered + // text — ip.company_name's shipped translation is literally "Customer + // Name", so the Company Header brick's own preview never contains the + // string "Company Header". + await expect(headerFrame.locator('.mason-block')).toHaveCount(1); + await expect(headerFrame.locator('.mason-block')).toHaveAttribute('data-brick-id', 'header_company'); + await expect(footerFrame.locator('.mason-block')).toHaveCount(1); + await expect(footerFrame.locator('.mason-block')).toHaveAttribute('data-brick-id', 'spacer'); + await expect(footerFrame.locator('.mason-block-content')).toContainText('10px'); + await expect(groupHeaderFrame.locator('.mason-block')).toHaveCount(0); + }); +}); diff --git a/Modules/Core/Tests/E2E/report-builder-smoke.spec.js b/Modules/Core/Tests/E2E/report-builder-smoke.spec.js new file mode 100644 index 000000000..fbc13dae4 --- /dev/null +++ b/Modules/Core/Tests/E2E/report-builder-smoke.spec.js @@ -0,0 +1,74 @@ +import { test, expect } from './test.js'; +import { tenantPath } from './tenant-path.js'; +import { trans } from './lang-helper.js'; +import { + writeReportTemplateFixture, + deleteReportTemplateFixture, + spacerBrick, + bandFrame, +} from './report-builder-fixture.js'; + +/** + * RB-10 (#763) — one golden-path smoke through the report builder: mount the + * Livewire page + Mason canvas + per-band iframes, persist the template, open + * the preview slide-over. This is the wiring (asset build, canvas JS, action + * routes, the S3-1 ReportRenderer::renderPreview path) that the PHP suite + * structurally cannot exercise. The download itself is covered by + * InvoiceDownloadPdfActionTest and ReportBuilderSecurityTest; here we only + * assert the row action is wired onto the list. + */ + +const SLUG = 'smoke-e2e'; + +test.describe('Report Builder — save / preview smoke', () => { + test.beforeEach(() => { + writeReportTemplateFixture(SLUG, { footer: [spacerBrick(20)] }); + }); + + test.afterEach(() => { + deleteReportTemplateFixture(SLUG); + }); + + test('mounts the canvas, saves the template, and opens the preview slide-over', async ({ page }) => { + const errors = []; + page.on('pageerror', (err) => errors.push(err.message)); + + await page.goto(tenantPath(`/report-builder/company/invoice/${SLUG}`)); + + /* the fixture brick painted into its band iframe */ + await expect(bandFrame(page, 'footer').locator('.mason-block')).toHaveCount(1); + + /* page-level Save */ + await page.getByRole('button', { name: trans('save'), exact: true }).click(); + await expect(page.getByText(trans('template_saved'))).toBeVisible({ timeout: 15000 }); + + /* Preview slide-over renders a non-empty document */ + await page.getByRole('button', { name: trans('report_preview'), exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { name: trans('report_preview') })).toBeVisible({ timeout: 15000 }); + await expect(dialog.locator('.report-row, .report-block').first()).toBeVisible({ timeout: 15000 }); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden({ timeout: 15000 }); + + expect(errors, `page errors during the smoke flow:\n${errors.join('\n')}`).toHaveLength(0); + }); + + test('the invoice list exposes the Download PDF row action', async ({ page }) => { + await page.goto(tenantPath('/invoices')); + + const firstRow = page.locator('table tbody tr').first(); + await expect(firstRow).toBeVisible({ timeout: 15000 }); + + /* + * The row actions sit inside a Filament ActionGroup: every row's dropdown + * panel is pre-rendered into and stays hidden until its trigger is + * clicked. Open the first row's trigger, then assert only against the + * items in the panel that is now visible — a page-wide getByText() matches + * every rendered row's copy of the item and trips strict mode. + */ + await firstRow.getByRole('button').last().click(); + await expect( + page.locator('.fi-dropdown-list-item-label:visible', { hasText: trans('download_pdf') }), + ).toHaveCount(1, { timeout: 15000 }); + }); +}); diff --git a/Modules/Core/Tests/Feature/AdminReportBuilderTest.php b/Modules/Core/Tests/Feature/AdminReportBuilderTest.php new file mode 100644 index 000000000..0b9dc8053 --- /dev/null +++ b/Modules/Core/Tests/Feature/AdminReportBuilderTest.php @@ -0,0 +1,442 @@ +storage = new ReportTemplateStorage(); + + $this->artisan('reports:sync-system'); + } + + public static function bandWidthsProvider(): array + { + return [ + 'half and half' => [ + [ + ['brick' => 'header_company', 'width' => 'half', 'config' => []], + ['brick' => 'header_client', 'width' => 'half', 'config' => []], + ], + ['width: 50%'], + ], + 'one third and two thirds' => [ + [ + ['brick' => 'header_company', 'width' => 'one_third', 'config' => []], + ['brick' => 'header_client', 'width' => 'two_thirds', 'config' => []], + ], + ['width: 33%', 'width: 67%'], + ], + ]; + } + + #[Test] + public function it_lists_system_templates_on_the_admin_list_page(): void + { + /* Act & Assert */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportTemplates::class) + ->assertSuccessful() + ->assertSee('Default Invoice') + ->assertSee('Default Quote'); + } + + #[Test] + public function it_opens_the_builder_for_a_system_template_with_five_bands(): void + { + /* Act */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']) + ->assertSuccessful(); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame( + ['header', 'group_header', 'details', 'group_footer', 'footer'], + array_keys($bands), + ); + $this->assertSame('header_company', $bands['header'][0]['attrs']['id']); + $this->assertSame('detail_items', $bands['details'][0]['attrs']['id']); + } + + #[Test] + public function it_renders_all_bands_as_collapsible_sections(): void + { + /* Act */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /** @var ReportBuilder $instance */ + $instance = $component->instance(); + $schema = $instance->getForm('form'); + $components = $schema->getComponents(); + + $this->assertCount(5, $components); + foreach ($components as $section) { + $this->assertInstanceOf(\Filament\Schemas\Components\Section::class, $section); + $this->assertTrue($section->isCollapsible()); + if (in_array($section->getHeading(), ['Group Header', 'Group Footer'], true)) { + $this->assertSame(trans('ip.group_band_notice'), $section->getDescription()); + } + } + } + + #[Test] + public function it_returns_not_found_for_an_unknown_template(): void + { + /* Assert */ + $this->expectException(\Symfony\Component\HttpKernel\Exception\NotFoundHttpException::class); + + /* Act */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'nope']); + } + + #[Test] + public function it_saves_the_edited_bands_back_to_disk(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + $bands = $component->get('data.bands'); + $bands['header'][] = [ + 'type' => 'masonBrick', + 'attrs' => ['id' => 'spacer', 'config' => ['height' => 33]], + ]; + + /* Act */ + $component->set('data.bands', $bands)->call('save')->assertHasNoErrors(); + + /* Assert */ + $saved = $this->storage->load('system', 'default', ReportTemplateType::INVOICE); + $lastHeader = end($saved['bands']['header']); + $this->assertSame('spacer', $lastHeader['brick']); + $this->assertSame(['height' => 33], $lastHeader['config']); + } + + #[Test] + public function it_lifts_the_block_width_out_of_the_config_when_saving(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + $bands = $component->get('data.bands'); + $bands['header'][] = [ + 'type' => 'masonBrick', + 'attrs' => ['id' => 'spacer', 'config' => ['height' => 12, '_width' => 'two_thirds']], + ]; + + /* Act */ + $component->set('data.bands', $bands)->call('save')->assertHasNoErrors(); + + /* Assert */ + $saved = $this->storage->load('system', 'default', ReportTemplateType::INVOICE); + $lastHeader = end($saved['bands']['header']); + + $this->assertSame('two_thirds', $lastHeader['width']); + $this->assertArrayNotHasKey('_width', $lastHeader['config']); + } + + #[Test] + public function it_sends_danger_notification_and_logs_when_template_save_fails(): void + { + /* Arrange */ + $fakeDisk = Mockery::mock(\Illuminate\Contracts\Filesystem\Filesystem::class); + $fakeDisk->shouldReceive('put')->andReturn(false); + $fakeDisk->shouldReceive('exists')->andReturn(true); + $fakeDisk->shouldReceive('get')->andReturn(json_encode(['name' => 'Default Invoice', 'type' => 'invoice'])); + Storage::set(ReportTemplateStorage::DISK, $fakeDisk); + \Illuminate\Support\Facades\Log::shouldReceive('warning')->atLeast()->once(); + + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Assert */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to write report template'); + + /* Act */ + $component->call('save'); + } + + #[Test] + public function it_hands_the_stored_width_back_to_the_canvas_on_load(): void + { + /* Arrange */ + $this->storage->save( + 'system', + 'default', + ['name' => 'Default Invoice', 'type' => 'invoice'], + ['header' => [['brick' => 'header_company', 'width' => 'half', 'config' => []]]], + ReportTemplateType::INVOICE, + ); + + /* Act */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame('half', $bands['header'][0]['attrs']['config']['_width']); + } + + #[Test] + public function it_previews_an_inserted_brick_with_the_builder_rendering(): void + { + /* Arrange */ + $config = ['_width' => 'half', 'height' => 24]; + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Act */ + $component->callAction( + TestAction::make('handleBrick')->schemaComponent('bands.header'), + data: $config, + arguments: ['id' => 'spacer', 'mode' => 'insert', 'dragPosition' => 0], + ); + + /* Assert — the canvas preview must be the builder rendering, never + toHtml(), which is the print output and needs entity data. */ + $inserted = $component->get('data.bands')['header'][0]; + + $this->assertSame('spacer', $inserted['attrs']['id']); + $this->assertSame( + SpacerBrick::toPreviewHtml($config), + base64_decode($inserted['attrs']['preview']), + ); + $this->assertNotSame( + SpacerBrick::toHtml($config), + base64_decode($inserted['attrs']['preview']), + ); + } + + #[Test] + public function it_previews_template_via_modal_action_using_builder_rendering(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Act */ + $component->mountAction('preview'); + + /* Assert */ + $action = $component->instance()->previewAction(); + $modalContent = (string) $action->getModalContent(); + + $headerCompanyConfig = $component->get('data.bands')['header'][0]['attrs']['config'] ?? []; + + $this->assertStringContainsString( + (string) HeaderCompanyBrick::toPreviewHtml($headerCompanyConfig), + $modalContent, + ); + $this->assertStringNotContainsString( + (string) HeaderCompanyBrick::toHtml($headerCompanyConfig), + $modalContent, + ); + } + + #[Test] + #[DataProvider('bandWidthsProvider')] + public function it_renders_preview_modal_with_correct_grid_width_wrappers(array $bandEntries, array $expectedFlexStyles): void + { + /* Arrange */ + $this->storage->save( + 'system', + 'custom-layout', + ['name' => 'Custom Layout', 'type' => 'invoice'], + ['header' => $bandEntries], + ReportTemplateType::INVOICE, + ); + + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'custom-layout']); + + /* Act */ + $component->mountAction('preview'); + + /* Assert */ + $action = $component->instance()->previewAction(); + $modalContent = (string) $action->getModalContent(); + + foreach ($expectedFlexStyles as $style) { + $this->assertStringContainsString($style, $modalContent); + } + } + + #[Test] + public function it_moves_a_brick_to_an_allowed_band(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Act — header_company may live in header or group_header */ + $component->call('moveBrick', 'header', 0, 'group_header'); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame('header_client', $bands['header'][0]['attrs']['id']); + $this->assertSame('header_company', end($bands['group_header'])['attrs']['id']); + } + + #[Test] + public function it_refuses_to_move_a_brick_into_a_disallowed_band(): void + { + /* Arrange */ + $component = Livewire::actingAs($this->superAdmin()) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']); + + /* Act — detail_items must never land in the header band */ + $component->call('moveBrick', 'details', 0, 'header'); + + /* Assert */ + $bands = $component->get('data.bands'); + $this->assertSame('detail_items', $bands['details'][0]['attrs']['id']); + + foreach ($bands['header'] as $node) { + $this->assertNotSame('detail_items', $node['attrs']['id']); + } + } + + #[Test] + public function it_clones_a_system_template_into_the_system_scope_from_the_admin_panel(): void + { + /* Act */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportTemplates::class) + ->callAction('clone', data: ['name' => 'Modern Invoice'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]) + ->assertHasNoErrors(); + + /* Assert */ + $clone = $this->storage->load('system', 'modern-invoice', ReportTemplateType::INVOICE); + $this->assertNotNull($clone); + $this->assertSame('Modern Invoice', $clone['manifest']['name']); + } + + #[Test] + public function it_shows_a_form_error_instead_of_a_server_error_when_cloning_with_an_unslugifiable_name(): void + { + /* Act & Assert — "!!!" slugifies to '', which used to bubble up as an + * uncaught InvalidArgumentException instead of a handled form error. */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportTemplates::class) + ->callAction('clone', data: ['name' => '!!!'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]); + + /* No exception means the action was caught and handled — nothing to + * assert on the response itself beyond that (see withoutExceptionHandling + * in AbstractAdminPanelTestCase: an uncaught exception here would fail + * the test outright). Confirm no bogus clone was written either. */ + $this->assertCount(1, $this->storage->listSystem(ReportTemplateType::INVOICE)); + } + + #[Test] + public function it_refuses_to_rename_the_system_default_template_from_the_admin_panel(): void + { + /* Act */ + Livewire::actingAs($this->superAdmin()) + ->test(ReportTemplates::class) + ->callAction('rename', data: ['name' => 'Hacked Name'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + 'name' => 'Default', + ]); + + /* Assert — canModify() already blocks this at the UI layer, but the + * storage layer must refuse it too (see ReportTemplateStorageTest:: + * it_refuses_to_rename_a_system_default_template for the direct case). */ + $template = $this->storage->load('system', 'default', ReportTemplateType::INVOICE); + $this->assertNotSame('Hacked Name', $template['manifest']['name']); + } + + #[Test] + public function it_forbids_assist_user_from_accessing_admin_report_templates_and_builder(): void + { + /* Arrange */ + $this->withExceptionHandling(); + + /** @var \Modules\Core\Models\User $assistUser */ + $assistUser = \Modules\Core\Models\User::factory()->create(); + $assistUser->assignRole(\Modules\Core\Enums\UserRole::ASSIST->value); + + /* Act & Assert */ + Livewire::actingAs($assistUser) + ->test(ReportTemplates::class) + ->assertForbidden(); + + Livewire::actingAs($assistUser) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']) + ->assertForbidden(); + } + + #[Test] + public function it_allows_admin_to_access_and_save_admin_report_builder(): void + { + /* Arrange */ + /** @var \Modules\Core\Models\User $adminUser */ + $adminUser = \Modules\Core\Models\User::factory()->create(); + $adminUser->assignRole(\Modules\Core\Enums\UserRole::ADMIN->value); + + /* Act & Assert */ + Livewire::actingAs($adminUser) + ->test(ReportTemplates::class) + ->assertSuccessful(); + + $component = Livewire::actingAs($adminUser) + ->test(ReportBuilder::class, ['scope' => 'system', 'type' => 'invoice', 'slug' => 'default']) + ->assertSuccessful(); + + $component->call('save')->assertHasNoErrors(); + } + + #[Test] + public function it_registers_report_templates_in_admin_panel_navigation(): void + { + /* Arrange */ + $this->actingAs($this->superAdmin()); + filament()->setCurrentPanel(filament()->getPanel('admin')); + + /* Act */ + $navigation = filament()->getPanel('admin')->getNavigation(); + $urls = collect($navigation) + ->flatMap(fn ($group) => $group->getItems()) + ->map(fn ($item) => $item->getUrl()) + ->all(); + + /* Assert */ + $this->assertContains(ReportTemplates::getUrl(), $urls); + } +} diff --git a/Modules/Core/Tests/Feature/CompanyReportBuilderTest.php b/Modules/Core/Tests/Feature/CompanyReportBuilderTest.php new file mode 100644 index 000000000..4a5dd2aaf --- /dev/null +++ b/Modules/Core/Tests/Feature/CompanyReportBuilderTest.php @@ -0,0 +1,213 @@ +storage = new ReportTemplateStorage(); + + $this->artisan('reports:sync-system'); + } + + #[Test] + public function it_lists_system_and_company_templates_on_the_company_list_page(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Our Invoice', ReportTemplateType::INVOICE); + + /* Act & Assert */ + $this->testLivewire(ReportTemplates::class) + ->assertSuccessful() + ->assertSee('Default Invoice') + ->assertSee('Our Invoice'); + } + + #[Test] + public function it_treats_system_templates_as_read_only_in_the_company_panel(): void + { + /* Act */ + $component = $this->testLivewire(ReportBuilder::class, [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ])->assertSuccessful(); + + /* Assert */ + $this->assertFalse($component->instance()->canSave()); + + $schema = $component->instance()->getForm('form'); + $components = $schema->getComponents(); + + $this->assertCount(5, $components); + foreach ($components as $section) { + $this->assertInstanceOf(\Filament\Schemas\Components\Section::class, $section); + $this->assertTrue($section->isCollapsible()); + } + } + + #[Test] + public function it_blocks_saving_a_system_template_from_the_company_panel(): void + { + /* Arrange */ + $component = $this->testLivewire(ReportBuilder::class, [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]); + + /* Assert */ + $this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class); + + /* Act */ + $component->call('save'); + } + + #[Test] + public function it_clones_a_system_template_and_saves_the_editable_company_copy(): void + { + /* Arrange */ + $this->testLivewire(ReportTemplates::class) + ->callAction('clone', data: ['name' => 'Our Invoice'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + ]) + ->assertHasNoErrors(); + + /* Act */ + $component = $this->testLivewire(ReportBuilder::class, [ + 'scope' => 'company', + 'type' => 'invoice', + 'slug' => 'our-invoice', + ])->assertSuccessful(); + + $this->assertTrue($component->instance()->canSave()); + + $bands = $component->get('data.bands'); + $bands['footer'][] = [ + 'type' => 'masonBrick', + 'attrs' => ['id' => 'page_break', 'config' => []], + ]; + + $component->set('data.bands', $bands)->call('save')->assertHasNoErrors(); + + /* Assert */ + $saved = $this->storage->load('company', 'our-invoice'); + $lastFooter = end($saved['bands']['footer']); + $this->assertSame('page_break', $lastFooter['brick']); + } + + #[Test] + public function it_deletes_a_company_template_from_the_list_page(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Doomed', ReportTemplateType::INVOICE); + + /* Act */ + $this->testLivewire(ReportTemplates::class) + ->callAction('delete', arguments: [ + 'scope' => 'company', + 'type' => 'invoice', + 'slug' => 'doomed', + ]) + ->assertHasNoErrors(); + + /* Assert */ + $this->assertNull($this->storage->load('company', 'doomed')); + } + + #[Test] + public function it_renames_a_company_template_from_the_list_page(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Old Name', ReportTemplateType::INVOICE); + + /* Act */ + $this->testLivewire(ReportTemplates::class) + ->callAction('rename', data: ['name' => 'New Name'], arguments: [ + 'scope' => 'company', + 'type' => 'invoice', + 'slug' => 'old-name', + ]) + ->assertHasNoErrors(); + + /* Assert */ + $this->assertSame('New Name', $this->storage->load('company', 'old-name')['manifest']['name']); + } + + #[Test] + public function it_refuses_to_rename_a_system_template_from_the_company_panel(): void + { + /* Act */ + $this->testLivewire(ReportTemplates::class) + ->callAction('rename', data: ['name' => 'Hijacked'], arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'default', + 'editable' => true, + ]) + ->assertHasNoErrors(); + + /* Assert */ + $this->assertSame( + 'Default Invoice', + $this->storage->load('system', 'default', ReportTemplateType::INVOICE)['manifest']['name'], + ); + } + + #[Test] + public function it_refuses_to_delete_a_system_template_from_the_company_panel(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Spare', ReportTemplateType::INVOICE, 'system'); + + /* Act */ + $this->testLivewire(ReportTemplates::class) + ->callAction('delete', arguments: [ + 'scope' => 'system', + 'type' => 'invoice', + 'slug' => 'spare', + 'editable' => true, + ]) + ->assertHasNoErrors(); + + /* Assert */ + $this->assertNotNull($this->storage->load('system', 'spare', ReportTemplateType::INVOICE)); + } + + #[Test] + public function it_does_not_take_the_editable_flag_from_action_arguments(): void + { + /* Arrange */ + $page = $this->testLivewire(ReportTemplates::class)->instance(); + + /* Act & Assert */ + $this->assertFalse($page->canModify([ + 'scope' => 'system', + 'slug' => 'anything', + 'type' => 'invoice', + 'editable' => true, + ])); + $this->assertTrue($page->canModify([ + 'scope' => 'company', + 'slug' => 'anything', + 'type' => 'invoice', + ])); + } +} diff --git a/Modules/Core/Tests/Feature/CompanyReportStorageCleanupTest.php b/Modules/Core/Tests/Feature/CompanyReportStorageCleanupTest.php new file mode 100644 index 000000000..48b724f5b --- /dev/null +++ b/Modules/Core/Tests/Feature/CompanyReportStorageCleanupTest.php @@ -0,0 +1,55 @@ +create(); + Storage::disk(ReportTemplateStorage::DISK)->put("{$company->id}/custom/manifest.json", '{}'); + Storage::disk('report_pdfs')->put("{$company->id}/invoice-1.pdf", '%PDF'); + + /* Act */ + $company->delete(); + + /* Assert */ + $this->assertFalse(Storage::disk(ReportTemplateStorage::DISK)->exists((string) $company->id)); + $this->assertFalse(Storage::disk('report_pdfs')->exists((string) $company->id)); + } + + #[Test] + public function it_leaves_other_companies_report_storage_intact(): void + { + /* Arrange */ + Storage::fake(ReportTemplateStorage::DISK); + Storage::fake('report_pdfs'); + $doomed = Company::factory()->create(); + $kept = Company::factory()->create(); + Storage::disk(ReportTemplateStorage::DISK)->put("{$doomed->id}/t/manifest.json", '{}'); + Storage::disk(ReportTemplateStorage::DISK)->put("{$kept->id}/t/manifest.json", '{}'); + Storage::disk('report_pdfs')->put("{$kept->id}/invoice-9.pdf", '%PDF'); + + /* Act */ + $doomed->delete(); + + /* Assert */ + $this->assertFalse(Storage::disk(ReportTemplateStorage::DISK)->exists((string) $doomed->id)); + $this->assertTrue(Storage::disk(ReportTemplateStorage::DISK)->exists("{$kept->id}/t/manifest.json")); + $this->assertTrue(Storage::disk('report_pdfs')->exists("{$kept->id}/invoice-9.pdf")); + } +} diff --git a/Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php b/Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php new file mode 100644 index 000000000..299fcb38a --- /dev/null +++ b/Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php @@ -0,0 +1,121 @@ +storage = new ReportTemplateStorage(); + + $this->artisan('reports:sync-system'); + } + + #[Test] + public function it_lists_disk_templates_as_options_for_the_invoice_type(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Fancy', ReportTemplateType::INVOICE); + + /* Act */ + $options = $this->storage->optionsForType(ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertSame(['default' => 'Default Invoice', 'fancy' => 'Fancy'], $options); + } + + #[Test] + public function it_does_not_offer_quote_templates_for_invoices(): void + { + /* Act */ + $options = $this->storage->optionsForType(ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertArrayHasKey('default', $options); + $this->assertNotContains('Default Quote', $options); + } + + #[Test] + public function it_shadows_a_system_template_with_a_company_clone_of_the_same_slug(): void + { + /* Arrange — clone, then rename the clone's manifest name */ + $clone = $this->storage->clone('system', 'default', 'Default', ReportTemplateType::INVOICE); + $this->storage->rename('company', $clone['slug'], 'Our House Default'); + + /* Act */ + $options = $this->storage->optionsForType(ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertSame('Our House Default', $options['default']); + } + + #[Test] + public function it_persists_the_selected_template_slug_on_the_invoice(): void + { + /* Arrange */ + $this->storage->clone('system', 'default', 'Fancy', ReportTemplateType::INVOICE); + $invoice = $this->invoice(); + + /* Act */ + Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->fillForm(['template' => 'fancy']) + ->call('save') + ->assertHasNoErrors(); + + /* Assert */ + $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'template' => 'fancy']); + } + + #[Test] + public function it_allows_clearing_the_template_back_to_the_company_default(): void + { + /* Arrange */ + $invoice = $this->invoice(); + $invoice->update(['template' => 'default']); + + /* Act */ + Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->fillForm(['template' => null]) + ->call('save') + ->assertHasNoErrors(); + + /* Assert */ + $this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'template' => null]); + } + + protected function invoice(): Invoice + { + $relation = Relation::factory()->for($this->company)->create(); + $numbering = \Modules\Core\Models\Numbering::factory()->for($this->company)->create([ + 'type' => \Modules\Core\Enums\NumberingType::INVOICE->value, + ]); + + /** @var Invoice $invoice */ + $invoice = Invoice::factory()->for($this->company)->create([ + 'customer_id' => $relation->id, + 'user_id' => $this->user->id, + 'numbering_id' => $numbering->id, + 'template' => null, + ]); + + return $invoice; + } +} diff --git a/Modules/Core/Tests/Feature/PdfGenerationServiceTest.php b/Modules/Core/Tests/Feature/PdfGenerationServiceTest.php new file mode 100644 index 000000000..60b6cf3c0 --- /dev/null +++ b/Modules/Core/Tests/Feature/PdfGenerationServiceTest.php @@ -0,0 +1,519 @@ +artisan('reports:sync-system'); + + $this->service = app(PdfGenerationService::class); + } + + #[Test] + public function it_resolves_the_system_default_template_when_nothing_is_configured(): void + { + /* Act */ + $template = $this->service->resolveTemplate($this->goldenInvoice()); + + /* Assert */ + $this->assertSame('default', $template['manifest']['slug']); + $this->assertSame('invoice', $template['manifest']['type']); + } + + #[Test] + public function it_prefers_the_documents_own_template_slug(): void + { + /* Arrange */ + $storage = new ReportTemplateStorage(); + $storage->clone('system', 'default', 'Special', \Modules\Core\Enums\ReportTemplateType::INVOICE); + + $invoice = $this->goldenInvoice(); + $invoice->update(['template' => 'special']); + + /* Act */ + $template = $this->service->resolveTemplate($invoice->fresh()); + + /* Assert */ + $this->assertSame('special', $template['manifest']['slug']); + } + + #[Test] + public function it_falls_back_to_the_company_default_template(): void + { + /* Arrange */ + $storage = new ReportTemplateStorage(); + $storage->clone('system', 'default', 'House Style', \Modules\Core\Enums\ReportTemplateType::INVOICE); + + $this->company->update(['invoice_template' => 'house-style']); + + /* Act */ + $template = $this->service->resolveTemplate($this->goldenInvoice()); + + /* Assert */ + $this->assertSame('house-style', $template['manifest']['slug']); + } + + #[Test] + public function it_falls_back_to_default_for_an_unknown_template_slug(): void + { + /* Arrange */ + $invoice = $this->goldenInvoice(); + $invoice->update(['template' => 'never-existed']); + + /* Act */ + $template = $this->service->resolveTemplate($invoice->fresh()); + + /* Assert */ + $this->assertSame('default', $template['manifest']['slug']); + } + + #[Test] + public function it_renders_invoice_and_quote_pdf_falling_back_to_resources_when_disk_is_empty(): void + { + /* Arrange */ + Storage::fake(ReportTemplateStorage::DISK); + + /* Act */ + $invoicePdf = $this->service->invoicePdf($this->goldenInvoice()); + $quotePdf = $this->service->quotePdf($this->goldenQuote()); + + /* Assert */ + $this->assertRenderedPdf($invoicePdf); + $this->assertRenderedPdf($quotePdf); + } + + #[Test] + public function it_throws_runtime_exception_when_template_cannot_be_found_anywhere(): void + { + /* Arrange */ + Storage::fake(ReportTemplateStorage::DISK); + $service = new class ( + new ReportTemplateStorage(), + app(\Modules\Core\Services\ReportRenderer::class), + app(\Modules\Core\Services\ReportDataMapper::class), + ) extends PdfGenerationService { + protected function loadFromResources(string $slug, \Modules\Core\Enums\ReportTemplateType $type): ?array + { + return null; + } + }; + + $invoice = $this->goldenInvoice(); + + /* Assert */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No report template found for invoice documents.'); + + /* Act */ + $service->resolveTemplate($invoice); + } + + #[Test] + public function it_renders_invoice_html_containing_the_invoice_data(): void + { + /* Act */ + $html = $this->service->renderInvoiceHtml($this->goldenInvoice()); + + /* Assert */ + $this->assertStringContainsString('INV-GOLD-0001', $html); + $this->assertStringContainsString('Golden Client Ltd', $html); + $this->assertStringContainsString('Golden Widget', $html); + $this->assertStringContainsString('121.00', $html); + $this->assertStringContainsString('Golden footer', $html); + } + + #[Test] + public function it_produces_a_well_formed_pdf_for_an_invoice(): void + { + /* Act */ + $pdf = $this->service->invoicePdf($this->goldenInvoice()); + + /* Assert */ + $this->assertRenderedPdf($pdf); + } + + #[Test] + public function it_produces_a_well_formed_pdf_for_a_quote(): void + { + /* Act */ + $pdf = $this->service->quotePdf($this->goldenQuote()); + + /* Assert */ + $this->assertRenderedPdf($pdf); + } + + /** + * RB-11 / S3-8 (#764) — the same slug/type resolves once per request, not + * once per rendered document. + */ + #[Test] + public function it_reads_a_template_from_storage_only_once_across_renders(): void + { + /* Arrange */ + $countingStorage = new class () extends ReportTemplateStorage { + public int $loadCalls = 0; + + public function load(string $scope, string $slug, ?\Modules\Core\Enums\ReportTemplateType $type = null): ?array + { + $this->loadCalls++; + + return parent::load($scope, $slug, $type); + } + }; + $service = new PdfGenerationService( + $countingStorage, + app(\Modules\Core\Services\ReportRenderer::class), + app(\Modules\Core\Services\ReportDataMapper::class), + ); + $invoice = $this->goldenInvoice(); + + /* Act */ + $service->invoicePdf($invoice); + $afterFirst = $countingStorage->loadCalls; + $service->invoicePdf($invoice); + $service->invoicePdf($invoice); + + /* Assert — the extra renders resolve the template from cache, not disk */ + $this->assertGreaterThan(0, $afterFirst); + $this->assertSame($afterFirst, $countingStorage->loadCalls); + } + + #[Test] + public function it_matches_the_golden_html_snapshot_for_the_default_quote_template(): void + { + /* Arrange */ + $fixture = __DIR__ . '/../Fixtures/report-templates/quote-default.html'; + + /* Act */ + $html = $this->service->renderQuoteHtml($this->goldenQuote()); + + $this->assertStringContainsString('Q-GOLD-0001', $html); + $this->assertStringContainsString('Golden Quote Widget', $html); + + if ( ! is_file($fixture)) { + @mkdir(dirname($fixture), 0775, true); + file_put_contents($fixture, $html); + $this->markTestIncomplete('Golden fixture created — rerun to verify.'); + } + + /* Assert */ + $this->assertSame(file_get_contents($fixture), $html); + } + + #[Test] + public function it_matches_the_golden_html_snapshot_for_the_default_invoice_template(): void + { + /* Arrange */ + $fixture = __DIR__ . '/../Fixtures/report-templates/invoice-default.html'; + + /* Act */ + $html = $this->service->renderInvoiceHtml($this->goldenInvoice()); + + if ( ! is_file($fixture)) { + @mkdir(dirname($fixture), 0775, true); + file_put_contents($fixture, $html); + $this->markTestIncomplete('Golden fixture created — rerun to verify.'); + } + + /* Assert */ + $this->assertSame(file_get_contents($fixture), $html); + } + + #[Test] + public function it_matches_the_golden_html_snapshot_for_the_grouped_invoice_template(): void + { + /* Arrange */ + $fixture = __DIR__ . '/../Fixtures/report-templates/invoice-grouped-by-category.html'; + $invoice = $this->goldenGroupedInvoice(); + + /* Act */ + $html = $this->service->renderInvoiceHtml($invoice); + + if ( ! is_file($fixture)) { + @mkdir(dirname($fixture), 0775, true); + file_put_contents($fixture, $html); + $this->markTestIncomplete('Golden fixture created — rerun to verify.'); + } + + /* Assert */ + $this->assertSame(file_get_contents($fixture), $html); + } + + protected function goldenInvoice(): Invoice + { + $this->company->update(['vat_number' => 'VAT-GOLD-1', 'logo' => null]); + + /* Create a 21% tax rate for this company */ + $taxRate = \Modules\Core\Models\TaxRate::factory()->for($this->company)->create([ + 'name' => 'VAT Standard', + 'rate' => 21.00, + 'is_active' => true, + ]); + + $relation = Relation::factory()->for($this->company)->create([ + 'company_name' => 'Golden Client Ltd', + ]); + + $relation->addresses()->delete(); + $relation->addresses()->create([ + 'company_id' => $this->company->id, + 'address_type' => 'billing', + 'address_1' => 'Golden Street 1', + 'postal_code' => '1234 AB', + 'city' => 'Goldenburg', + 'country' => 'NL', + ]); + + $invoice = Invoice::factory()->for($this->company)->create([ + 'customer_id' => $relation->id, + 'user_id' => $this->user->id, + 'invoice_number' => 'INV-GOLD-0001', + 'invoice_status' => 'sent', + 'invoice_sign' => '1', + 'invoiced_at' => '2026-01-01', + 'invoice_due_at' => '2026-01-31', + 'invoice_discount_amount' => 0, + 'invoice_discount_percent' => 0, + 'invoice_item_subtotal' => 100.0000, + 'item_tax_total' => 21.0000, + 'invoice_tax_total' => 21.0000, + 'invoice_total' => 121.0000, + 'template' => null, + 'summary' => 'Golden summary', + 'terms' => 'Golden terms', + 'footer' => 'Golden footer', + ]); + + /* Create item directly without factory to avoid afterMaking recalculation */ + InvoiceItem::create([ + 'company_id' => $this->company->id, + 'invoice_id' => $invoice->id, + 'tax_rate_id' => $taxRate->id, + 'item_name' => 'Golden Widget', + 'quantity' => 2, + 'price' => 50.0000, + 'subtotal' => 100.0000, + 'tax_1' => 21.0000, + 'tax_2' => 0, + 'tax_total' => 21.0000, + 'total' => 121.0000, + ]); + + /** @var Invoice $fresh */ + $fresh = $invoice->fresh(); + + return $fresh; + } + + protected function goldenQuote(): \Modules\Quotes\Models\Quote + { + $this->company->update(['vat_number' => 'VAT-GOLD-1', 'logo' => null]); + + /* Create a 21% tax rate for this company */ + $taxRate = \Modules\Core\Models\TaxRate::factory()->for($this->company)->create([ + 'name' => 'VAT Standard', + 'rate' => 21.00, + 'is_active' => true, + ]); + + $relation = Relation::factory()->for($this->company)->create([ + 'company_name' => 'Golden Client Ltd', + ]); + $relation->addresses()->delete(); + + $quote = \Modules\Quotes\Models\Quote::factory()->for($this->company)->create([ + 'prospect_id' => $relation->id, + 'user_id' => $this->user->id, + 'quote_number' => 'Q-GOLD-0001', + 'quote_status' => 'sent', + 'quoted_at' => '2026-01-01', + 'quote_expires_at' => '2026-01-31', + 'quote_discount_amount' => 0, + 'quote_discount_percent' => 0, + 'quote_item_subtotal' => 100.0000, + 'item_tax_total' => 21.0000, + 'quote_tax_total' => 21.0000, + 'quote_total' => 121.0000, + 'template' => null, + 'summary' => 'Golden quote summary', + 'terms' => 'Golden quote terms', + 'footer' => 'Golden quote footer', + ]); + + $quote->quoteItems()->delete(); + /* Create item directly without factory to avoid afterMaking recalculation */ + \Modules\Quotes\Models\QuoteItem::create([ + 'company_id' => $this->company->id, + 'quote_id' => $quote->id, + 'tax_rate_id' => $taxRate->id, + 'item_name' => 'Golden Quote Widget', + 'quantity' => 2, + 'price' => 50.0000, + 'subtotal' => 100.0000, + 'tax_1' => 21.0000, + 'tax_2' => 0, + 'tax_total' => 21.0000, + 'total' => 121.0000, + ]); + + /** @var \Modules\Quotes\Models\Quote $fresh */ + $fresh = $quote->fresh(); + + return $fresh; + } + + protected function goldenGroupedInvoice(): Invoice + { + $this->company->update(['vat_number' => 'VAT-GOLD-1', 'logo' => null]); + + $taxRate = \Modules\Core\Models\TaxRate::factory()->for($this->company)->create([ + 'name' => 'VAT Standard', + 'rate' => 21.00, + 'is_active' => true, + ]); + + $catHardware = \Modules\Products\Models\ProductCategory::factory()->for($this->company)->create([ + 'category_name' => 'Hardware', + ]); + $catServices = \Modules\Products\Models\ProductCategory::factory()->for($this->company)->create([ + 'category_name' => 'Services', + ]); + + $prodHardware = \Modules\Products\Models\Product::factory()->for($this->company)->create([ + 'category_id' => $catHardware->id, + 'product_name' => 'Golden Server', + 'code' => 'SRV-01', + ]); + $prodService = \Modules\Products\Models\Product::factory()->for($this->company)->create([ + 'category_id' => $catServices->id, + 'product_name' => 'Setup Service', + 'code' => 'SRV-SETUP', + ]); + + $relation = Relation::factory()->for($this->company)->create([ + 'company_name' => 'Golden Client Ltd', + ]); + + $relation->addresses()->delete(); + $relation->addresses()->create([ + 'company_id' => $this->company->id, + 'address_type' => 'billing', + 'address_1' => 'Golden Street 1', + 'postal_code' => '1234 AB', + 'city' => 'Goldenburg', + 'country' => 'NL', + ]); + + app(ReportTemplateStorage::class)->save( + ReportTemplateStorage::SCOPE_COMPANY, + 'grouped-by-category', + [ + 'name' => 'Grouped by Category', + 'slug' => 'grouped-by-category', + 'type' => 'invoice', + 'band_options' => [ + 'details' => ['group_by' => 'category'], + ], + ], + [ + 'header' => [ + ['brick' => 'header_company', 'width' => 'half', 'config' => []], + ['brick' => 'header_client', 'width' => 'half', 'config' => []], + ['brick' => 'header_invoice_meta', 'width' => 'full', 'config' => []], + ], + 'group_header' => [ + ['brick' => 'detail_column_labels', 'width' => 'full', 'config' => []], + ], + 'details' => [ + ['brick' => 'detail_items', 'width' => 'full', 'config' => ['show_table_header' => false]], + ], + 'group_footer' => [ + ['brick' => 'footer_totals', 'width' => 'full', 'config' => []], + ], + 'footer' => [ + ['brick' => 'footer_totals', 'width' => 'full', 'config' => []], + ['brick' => 'footer_notes', 'width' => 'full', 'config' => []], + ], + ], + \Modules\Core\Enums\ReportTemplateType::INVOICE, + ); + + $invoice = Invoice::factory()->for($this->company)->create([ + 'customer_id' => $relation->id, + 'user_id' => $this->user->id, + 'invoice_number' => 'INV-GOLD-GRP-0001', + 'invoice_status' => 'sent', + 'invoice_sign' => '1', + 'invoiced_at' => '2026-01-01', + 'invoice_due_at' => '2026-01-31', + 'invoice_discount_amount' => 0, + 'invoice_discount_percent' => 0, + 'invoice_item_subtotal' => 300.0000, + 'item_tax_total' => 63.0000, + 'invoice_tax_total' => 63.0000, + 'invoice_total' => 363.0000, + 'template' => 'grouped-by-category', + 'summary' => 'Golden grouped summary', + 'terms' => 'Golden grouped terms', + 'footer' => 'Golden grouped footer', + ]); + + $invoice->invoiceItems()->delete(); + + InvoiceItem::create([ + 'company_id' => $this->company->id, + 'invoice_id' => $invoice->id, + 'tax_rate_id' => $taxRate->id, + 'product_id' => $prodHardware->id, + 'item_name' => 'Golden Server', + 'quantity' => 1, + 'price' => 200.0000, + 'subtotal' => 200.0000, + 'tax_1' => 42.0000, + 'tax_2' => 0, + 'tax_total' => 42.0000, + 'total' => 242.0000, + ]); + + InvoiceItem::create([ + 'company_id' => $this->company->id, + 'invoice_id' => $invoice->id, + 'tax_rate_id' => $taxRate->id, + 'product_id' => $prodService->id, + 'item_name' => 'Setup Service', + 'quantity' => 1, + 'price' => 100.0000, + 'subtotal' => 100.0000, + 'tax_1' => 21.0000, + 'tax_2' => 0, + 'tax_total' => 21.0000, + 'total' => 121.0000, + ]); + + /** @var Invoice $fresh */ + $fresh = $invoice->fresh(); + + return $fresh; + } +} diff --git a/Modules/Core/Tests/Feature/ReportBuilderSecurityTest.php b/Modules/Core/Tests/Feature/ReportBuilderSecurityTest.php new file mode 100644 index 000000000..b5f5931cd --- /dev/null +++ b/Modules/Core/Tests/Feature/ReportBuilderSecurityTest.php @@ -0,0 +1,435 @@ +artisan('reports:sync-system'); + } + + /** + * @return array + */ + public static function footerTextBrickProvider(): array + { + return [ + 'notes' => [FooterNotesBrick::class, 'footer_content'], + 'terms' => [FooterTermsBrick::class, 'terms_content'], + 'summary' => [FooterSummaryBrick::class, 'summary_content'], + ]; + } + + /** + * M1 — the rich-text footer bricks must not emit script, event handlers, + * remote /, or non-https schemes: Purify runs with a locked-down + * config set. + */ + #[Test] + #[DataProvider('footerTextBrickProvider')] + public function m1_footer_text_bricks_strip_dangerous_markup(string $brick, string $field): void + { + /* Arrange */ + $payload = '

ok bold

' + . '' + . '' + . '' + . '
x' + . 'x' + . ''; + + /* Act */ + $html = (string) $brick::toHtml([$field => $payload], []); + + /* Assert */ + $this->assertStringContainsString('bold', $html, 'benign formatting must survive'); + $this->assertStringNotContainsStringIgnoringCase('assertStringNotContainsStringIgnoringCase('onerror', $html); + $this->assertStringNotContainsStringIgnoringCase('assertStringNotContainsStringIgnoringCase('javascript:', $html); + $this->assertStringNotContainsStringIgnoringCase('assertStringNotContainsStringIgnoringCase('assertStringNotContainsString('169.254.169.254', $html); + } + + /** + * L1 — brick config values are coerced server-side. A crafted CSS payload + * on a presentational key must not survive into the rendered style="". + */ + #[Test] + public function l1_brick_config_values_are_coerced_server_side(): void + { + /* Arrange */ + $crafted = [ + 'font_size' => '10pt;background-image:url(http://169.254.169.254/)', + 'text_align' => 'left">', + 'font_weight' => 'bold;behavior:url(#x)', + 'show_email' => true, + ]; + + /* Act */ + $filtered = HeaderCompanyBrick::filterConfig($crafted); + $html = (string) HeaderCompanyBrick::toHtml($filtered, ['company' => ['name' => 'Acme']]); + + /* Assert */ + $this->assertSame(10, $filtered['font_size']); + $this->assertArrayNotHasKey('text_align', $filtered, 'non-enum text_align is dropped'); + $this->assertArrayNotHasKey('font_weight', $filtered, 'non-enum font_weight is dropped'); + $this->assertTrue($filtered['show_email'], 'unrelated keys pass through'); + $this->assertStringNotContainsString('169.254.169.254', $html); + $this->assertStringNotContainsStringIgnoringCase('assertStringContainsString('font-size: 10pt', $html); + } + + /** + * L3 — the drivers no longer expose an unsanitised download() that + * interpolates a caller-supplied filename into a response header. + */ + #[Test] + public function l3_pdf_drivers_do_not_expose_a_raw_download_method(): void + { + $this->assertFalse( + method_exists(\Modules\Core\Support\PDF\Drivers\domPDF::class, 'download'), + 'domPDF::download() was unreachable dead code with an unsanitised Content-Disposition — remove it', + ); + $this->assertFalse( + method_exists(\Modules\Core\Support\PDF\Drivers\Browsershot::class, 'download'), + 'Browsershot::download() was unreachable dead code with an unsanitised Content-Disposition — remove it', + ); + $this->assertFalse( + (new ReflectionClass(PDFInterface::class))->hasMethod('download'), + 'PDFInterface must not declare download() any more', + ); + } + + /** + * M2 (caps) — row fan-out is capped so one tenant cannot pin a render + * worker with a document carrying thousands of line items. + */ + #[Test] + public function m2_report_data_mapper_caps_rendered_rows(): void + { + /* Arrange */ + config()->set('ip.report.max_rows', 3); + $invoice = $this->makeInvoice(items: 7); + + /* Act */ + $data = app(ReportDataMapper::class)->forInvoice($invoice); + + /* Assert */ + $this->assertCount(3, $data['items']); + $this->assertCount(3, $data['invoice_items']); + $this->assertTrue($data['items_truncated']); + } + + /** + * M2 (caps) — bricks per band are capped when a template is persisted. + */ + #[Test] + public function m2_sanitize_bands_caps_bricks_per_band(): void + { + /* Arrange */ + config()->set('ip.report.max_bricks_per_band', 2); + $storage = app(ReportTemplateStorage::class); + $bands = ['header' => array_fill(0, 5, ['brick' => 'header_company', 'width' => 'full', 'config' => []])]; + + /* Act */ + $sanitized = $storage->sanitizeBands($bands, ReportTemplateType::INVOICE); + + /* Assert */ + $this->assertCount(2, $sanitized['header']); + } + + /** + * M2 (caps) — an oversized template payload is rejected on save rather + * than written to disk. + */ + #[Test] + public function m2_save_rejects_an_oversized_template(): void + { + /* Arrange */ + config()->set('ip.report.max_template_bytes', 256); + $storage = app(ReportTemplateStorage::class); + $bands = ['footer' => [[ + 'brick' => 'footer_notes', + 'width' => 'full', + 'config' => ['footer_content' => str_repeat('A', 2000)], + ]]]; + + /* Assert */ + $this->expectException(RuntimeException::class); + + /* Act */ + $storage->save('company', 'huge', ['name' => 'Huge', 'type' => 'invoice'], $bands, ReportTemplateType::INVOICE); + } + + /** + * M2 (queue option, default off) — the download handler returns a + * synchronous PDF response. + */ + #[Test] + public function m2_queue_disabled_returns_a_synchronous_pdf_response(): void + { + /* Arrange */ + config()->set('ip.report.queue', false); + + /* Act */ + $response = app(PdfGenerationService::class)->handleInvoiceDownload($this->makeInvoice()); + + /* Assert */ + $this->assertInstanceOf(SymfonyResponse::class, $response); + $this->assertStringStartsWith('%PDF', (string) $response->getContent()); + } + + /** + * M2 (queue option, enabled) — the download handler dispatches a job and + * hands back no response; the acting user is notified instead. + */ + #[Test] + public function m2_queue_enabled_dispatches_a_job_instead_of_rendering_inline(): void + { + /* Arrange */ + Queue::fake(); + config()->set('ip.report.queue', true); + Storage::fake('report_pdfs'); + + /* Act */ + $response = app(PdfGenerationService::class)->handleInvoiceDownload($this->makeInvoice()); + + /* Assert */ + $this->assertNull($response); + Queue::assertPushed(GenerateDocumentPdfJob::class); + } + + /** + * M2 (queue option) — the job renders and stores the PDF on the + * report_pdfs disk. + */ + #[Test] + public function m2_queue_job_stores_the_rendered_pdf(): void + { + /* Arrange */ + config()->set('ip.report.queue', true); + Storage::fake('report_pdfs'); + $invoice = $this->makeInvoice(); + + /* Act */ + (new GenerateDocumentPdfJob($invoice))->handle(app(PdfGenerationService::class)); + + /* Assert */ + $files = Storage::disk('report_pdfs')->allFiles(); + $this->assertNotEmpty($files); + $this->assertStringStartsWith('%PDF', Storage::disk('report_pdfs')->get($files[0])); + } + + /** + * RB-03 (#755) — a failed write of the stored PDF must abort the job, not + * report success, or the queue path loops on an endless "being prepared". + */ + #[Test] + public function m2_stored_pdf_write_failure_throws_instead_of_reporting_success(): void + { + /* Arrange */ + config()->set('ip.report.queue', true); + $failing = Mockery::mock(Filesystem::class); + $failing->shouldReceive('put')->andReturn(false); + Storage::set('report_pdfs', $failing); + $invoice = $this->makeInvoice(); + + /* Act & Assert */ + $this->expectException(RuntimeException::class); + app(PdfGenerationService::class)->storeInvoicePdf($invoice); + } + + /** + * RB-02 (#756) — a failed render leaves a log line instead of vanishing + * silently into failed_jobs with the user still told "being prepared". + */ + #[Test] + public function m2_queue_job_logs_an_error_when_it_fails(): void + { + /* Arrange */ + Log::spy(); + $invoice = $this->makeInvoice(); + $job = new GenerateDocumentPdfJob($invoice); + + /* Act */ + $job->failed(new RuntimeException('render blew up')); + + /* Assert */ + Log::shouldHaveReceived('error')->withArgs( + fn (string $message, array $context): bool => str_contains($message, 'GenerateDocumentPdfJob') + && $context['id'] === $invoice->getKey() + && $context['error'] === 'render blew up', + ); + } + + /** + * RB-02 (#756) — repeat Download clicks on the same document collapse to a + * single render job; distinct documents still queue independently. + */ + #[Test] + public function m2_queue_deduplicates_jobs_per_document(): void + { + /* Arrange */ + Queue::fake(); + config()->set('ip.report.queue', true); + Storage::fake('report_pdfs'); + $service = app(PdfGenerationService::class); + $invoiceA = $this->makeInvoice(); + $invoiceB = $this->makeInvoice(); + + /* Act */ + $service->handleInvoiceDownload($invoiceA); + $service->handleInvoiceDownload($invoiceA); + $service->handleInvoiceDownload($invoiceB); + + /* Assert */ + Queue::assertPushed( + GenerateDocumentPdfJob::class, + fn (GenerateDocumentPdfJob $job): bool => $job->document->is($invoiceA), + ); + Queue::assertPushed(GenerateDocumentPdfJob::class, 2); + } + + /** + * RB-04 (#757) — queue on, a fresh stored copy present: the handler streams + * it back and queues nothing. + */ + #[Test] + public function m2_queue_serves_a_fresh_stored_pdf_without_re_rendering(): void + { + /* Arrange */ + Queue::fake(); + config()->set('ip.report.queue', true); + $invoice = $this->makeInvoice(); + $disk = Mockery::mock(Filesystem::class); + $disk->shouldReceive('exists')->andReturn(true); + $disk->shouldReceive('lastModified')->andReturn(($invoice->updated_at?->timestamp ?? 0) + 10); + $disk->shouldReceive('get')->andReturn('%PDF-cached-copy'); + Storage::set('report_pdfs', $disk); + + /* Act */ + $response = app(PdfGenerationService::class)->handleInvoiceDownload($invoice); + + /* Assert */ + $this->assertInstanceOf(SymfonyResponse::class, $response); + $this->assertSame('%PDF-cached-copy', (string) $response->getContent()); + Queue::assertNothingPushed(); + } + + /** + * RB-04 (#757) — queue on, the stored copy is older than the document: it + * is not served; a fresh render is queued instead. + */ + #[Test] + public function m2_queue_re_renders_when_the_stored_pdf_is_stale(): void + { + /* Arrange */ + Queue::fake(); + config()->set('ip.report.queue', true); + $invoice = $this->makeInvoice(); + $disk = Mockery::mock(Filesystem::class); + $disk->shouldReceive('exists')->andReturn(true); + $disk->shouldReceive('lastModified')->andReturn(($invoice->updated_at?->timestamp ?? 0) - 10); + Storage::set('report_pdfs', $disk); + + /* Act */ + $response = app(PdfGenerationService::class)->handleInvoiceDownload($invoice); + + /* Assert */ + $this->assertNull($response); + Queue::assertPushed(GenerateDocumentPdfJob::class); + } + + /** + * RB-04 (#757) — the fresh-stored-copy path works the same for quotes. + */ + #[Test] + public function m2_queue_serves_a_fresh_stored_quote_pdf(): void + { + /* Arrange */ + Queue::fake(); + config()->set('ip.report.queue', true); + $quote = Quote::factory()->for($this->company)->create(['quote_number' => 'Q-SEC-' . uniqid()]); + $disk = Mockery::mock(Filesystem::class); + $disk->shouldReceive('exists')->andReturn(true); + $disk->shouldReceive('lastModified')->andReturn(($quote->updated_at?->timestamp ?? 0) + 10); + $disk->shouldReceive('get')->andReturn('%PDF-cached-quote'); + Storage::set('report_pdfs', $disk); + + /* Act */ + $response = app(PdfGenerationService::class)->handleQuoteDownload($quote); + + /* Assert */ + $this->assertInstanceOf(SymfonyResponse::class, $response); + $this->assertSame('%PDF-cached-quote', (string) $response->getContent()); + Queue::assertNothingPushed(); + } + + protected function makeInvoice(int $items = 1): Invoice + { + $relation = Relation::factory()->for($this->company)->create(['company_name' => 'Sec Client']); + + $invoice = Invoice::factory()->for($this->company)->create([ + 'customer_id' => $relation->id, + 'user_id' => $this->user->id, + 'invoice_number' => 'INV-SEC-' . uniqid(), + 'invoice_status' => 'sent', + ]); + + for ($n = 1; $n <= $items; $n++) { + InvoiceItem::create([ + 'company_id' => $this->company->id, + 'invoice_id' => $invoice->id, + 'item_name' => "Item {$n}", + 'quantity' => 1, + 'price' => 1, + 'subtotal' => 1, + 'tax_1' => 0, + 'tax_2' => 0, + 'tax_total' => 0, + 'total' => 1, + ]); + } + + /** @var Invoice $fresh */ + $fresh = $invoice->fresh(); + + return $fresh; + } +} diff --git a/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php b/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php new file mode 100644 index 000000000..172a5252b --- /dev/null +++ b/Modules/Core/Tests/Feature/ReportsSyncSystemCommandTest.php @@ -0,0 +1,71 @@ +artisan('reports:sync-system')->assertSuccessful(); + + /* Assert */ + $disk = Storage::disk(ReportTemplateStorage::DISK); + $this->assertTrue($disk->exists('system/invoice/default/manifest.json')); + $this->assertTrue($disk->exists('system/invoice/default/bands.json')); + $this->assertTrue($disk->exists('system/quote/default/manifest.json')); + $this->assertTrue($disk->exists('system/quote/default/bands.json')); + } + + #[Test] + public function it_is_idempotent_when_run_twice(): void + { + /* Act */ + $this->artisan('reports:sync-system')->assertSuccessful(); + $firstRun = Storage::disk(ReportTemplateStorage::DISK)->allFiles('system'); + + $this->artisan('reports:sync-system')->assertSuccessful(); + $secondRun = Storage::disk(ReportTemplateStorage::DISK)->allFiles('system'); + + /* Assert */ + $this->assertSame($firstRun, $secondRun); + + $storage = new ReportTemplateStorage(); + $this->assertCount(2, $storage->listSystem()); + } + + #[Test] + public function it_loads_a_synced_default_template_with_valid_bands(): void + { + /* Arrange */ + $this->artisan('reports:sync-system')->assertSuccessful(); + + /* Act */ + $storage = new ReportTemplateStorage(); + $template = $storage->load( + ReportTemplateStorage::SCOPE_SYSTEM, + 'default', + \Modules\Core\Enums\ReportTemplateType::INVOICE, + ); + + /* Assert */ + $this->assertNotNull($template); + $this->assertSame('invoice', $template['manifest']['type']); + $this->assertNotEmpty($template['bands']['header']); + $this->assertNotEmpty($template['bands']['details']); + $this->assertNotEmpty($template['bands']['footer']); + } +} diff --git a/Modules/Core/Tests/Fixtures/report-templates/invoice-default.html b/Modules/Core/Tests/Fixtures/report-templates/invoice-default.html new file mode 100644 index 000000000..19852f169 --- /dev/null +++ b/Modules/Core/Tests/Fixtures/report-templates/invoice-default.html @@ -0,0 +1,96 @@ + + + + + Default Invoice + + + +
+ + + + +
+ InvoicePlane Corporation
+
+
+ Phone:
+ Email:
+ Vat id: VAT-GOLD-1
+
+
+
+ Bill To
+ Golden Client Ltd
+ Golden Street 1
+ Goldenburg 1234 AB
+ Phone:
+ Email:
+
+
+
+ + + + + + + + + + + + + + + + + + + +
Task descriptionQuantityPriceTaxTotal
Golden Widget250.0021.00121.00
+
+
+ + \ No newline at end of file diff --git a/Modules/Core/Tests/Fixtures/report-templates/invoice-grouped-by-category.html b/Modules/Core/Tests/Fixtures/report-templates/invoice-grouped-by-category.html new file mode 100644 index 000000000..375ead1d0 --- /dev/null +++ b/Modules/Core/Tests/Fixtures/report-templates/invoice-grouped-by-category.html @@ -0,0 +1,158 @@ + + + + + Grouped by Category + + + +
+ + + + +
+ InvoicePlane Corporation
+
+
+ Phone:
+ Email:
+ Vat id: VAT-GOLD-1
+
+
+
+ Bill To
+ Golden Client Ltd
+ Golden Street 1
+ Goldenburg 1234 AB
+ Phone:
+ Email:
+
+
+
+ + + + + + + + + + +
Task descriptionQuantityPriceTaxTotal
+
+
+ + + + + + + + + + +
Golden Server1200.0042.00242.00
+
+
+ + + + + + + + + + +
Task descriptionQuantityPriceTaxTotal
+
+
+ + + + + + + + + + +
Setup Service1100.0021.00121.00
+
+
+ + \ No newline at end of file diff --git a/Modules/Core/Tests/Fixtures/report-templates/quote-default.html b/Modules/Core/Tests/Fixtures/report-templates/quote-default.html new file mode 100644 index 000000000..d21e5958e --- /dev/null +++ b/Modules/Core/Tests/Fixtures/report-templates/quote-default.html @@ -0,0 +1,84 @@ + + + + + Default Quote + + + +
+ + + + +
+ InvoicePlane Corporation
+
+
+ Phone:
+ Email:
+ Vat id: VAT-GOLD-1
+
+
+
+ Bill To
+ Golden Client Ltd
+
+
+ Phone:
+ Email:
+
+
+
Quote Number: Q-GOLD-0001
+
Quote Date: 2026-01-01
+
Expiry Date: 2026-01-31
+
Status: sent
+
+
+ + + + + + + + + + + + + + + + + + + +
Task descriptionQuantityPriceTaxTotal
Golden Quote Widget250.0021.00121.00
+
+
+ + \ No newline at end of file diff --git a/Modules/Core/Tests/Unit/BrowsershotDriverTest.php b/Modules/Core/Tests/Unit/BrowsershotDriverTest.php new file mode 100644 index 000000000..b182d4094 --- /dev/null +++ b/Modules/Core/Tests/Unit/BrowsershotDriverTest.php @@ -0,0 +1,144 @@ +set('ip.pdfDriver', 'Browsershot'); + + /* Assert */ + $this->assertInstanceOf(Browsershot::class, PDFFactory::create()); + } + + #[Test] + public function it_does_not_include_allow_file_access_from_files_argument(): void + { + /* Arrange */ + $driver = new Browsershot(); + + /* Act */ + $engine = $driver->getEngine('

Test

'); + + /* Assert */ + $args = $this->engineOptions($engine)['args'] ?? []; + $this->assertNotContains('allow-file-access-from-files', $args); + $this->assertNotContains('--allow-file-access-from-files', $args); + } + + #[Test] + public function it_produces_pdf_bytes_from_html_when_chromium_is_available(): void + { + /* Arrange — opt-in driver: skip on hosts without Node/Chromium */ + if (mb_trim((string) shell_exec('command -v node 2>/dev/null')) === '') { + $this->markTestSkipped('Node is not available on this host.'); + } + + try { + $output = (new Browsershot())->getOutput('

Hello Chromium PDF

'); + } catch (Throwable $e) { + $this->markTestSkipped('Chromium/Puppeteer is not available: ' . mb_substr($e->getMessage(), 0, 120)); + } + + /* Assert */ + $this->assertNotEmpty($output); + $this->assertStringStartsWith('%PDF', $output); + } + + /** + * RB-12 (#753) — getEngine() is pure config-to-engine mapping. Assert it + * without launching Chromium (no ->pdf() call). + */ + #[Test] + public function it_maps_the_paper_format_and_keeps_portrait_by_default(): void + { + /* Arrange */ + config()->set('ip.paperSize', 'A4'); + config()->set('ip.paperOrientation', 'portrait'); + + /* Act */ + $options = $this->engineOptions((new Browsershot())->getEngine('

x

')); + + /* Assert */ + $this->assertSame('A4', $options['format'] ?? null); + $this->assertArrayNotHasKey('landscape', $options); + } + + #[Test] + public function it_applies_landscape_for_landscape_orientation(): void + { + /* Arrange */ + config()->set('ip.paperOrientation', 'landscape'); + + /* Act */ + $options = $this->engineOptions((new Browsershot())->getEngine('

x

')); + + /* Assert */ + $this->assertTrue($options['landscape'] ?? false); + } + + #[Test] + public function it_maps_the_configured_binaries_and_no_sandbox(): void + { + /* Arrange */ + config()->set('ip.browsershot.node_binary', '/usr/bin/node'); + config()->set('ip.browsershot.npm_binary', '/usr/bin/npm'); + config()->set('ip.browsershot.chrome_path', '/usr/bin/chromium'); + config()->set('ip.browsershot.no_sandbox', true); + + /* Act */ + $engine = (new Browsershot())->getEngine('

x

'); + + /* Assert */ + $this->assertSame('/usr/bin/node', $this->engineProp($engine, 'nodeBinary')); + $this->assertSame('/usr/bin/npm', $this->engineProp($engine, 'npmBinary')); + $this->assertSame('/usr/bin/chromium', $this->engineOptions($engine)['executablePath'] ?? null); + $this->assertTrue($this->engineProp($engine, 'noSandbox')); + } + + #[Test] + public function it_omits_the_optional_binaries_when_unconfigured(): void + { + /* Arrange */ + config()->set('ip.browsershot.node_binary', null); + config()->set('ip.browsershot.npm_binary', null); + config()->set('ip.browsershot.chrome_path', null); + config()->set('ip.browsershot.no_sandbox', false); + + /* Act */ + $engine = (new Browsershot())->getEngine('

x

'); + + /* Assert */ + $this->assertNull($this->engineProp($engine, 'nodeBinary')); + $this->assertNull($this->engineProp($engine, 'npmBinary')); + $this->assertFalse($this->engineProp($engine, 'noSandbox')); + $this->assertArrayNotHasKey('executablePath', $this->engineOptions($engine)); + } + + /** + * @return array + */ + private function engineOptions(BrowsershotEngine $engine): array + { + return $this->engineProp($engine, 'additionalOptions') ?? []; + } + + private function engineProp(BrowsershotEngine $engine, string $name): mixed + { + $property = new ReflectionProperty(BrowsershotEngine::class, $name); + $property->setAccessible(true); + + return $property->getValue($engine); + } +} diff --git a/Modules/Core/Tests/Unit/CoreServiceProviderCommandsTest.php b/Modules/Core/Tests/Unit/CoreServiceProviderCommandsTest.php new file mode 100644 index 000000000..f3f7c323c --- /dev/null +++ b/Modules/Core/Tests/Unit/CoreServiceProviderCommandsTest.php @@ -0,0 +1,23 @@ +assertContains('ip:migrate-v1', $registered); + $this->assertContains('make:filament-user', $registered); + $this->assertContains('ip:generate-observers', $registered); + $this->assertContains('reports:sync-system', $registered); + } +} diff --git a/Modules/Core/Tests/Unit/DomPdfDriverTest.php b/Modules/Core/Tests/Unit/DomPdfDriverTest.php new file mode 100644 index 000000000..0614404d8 --- /dev/null +++ b/Modules/Core/Tests/Unit/DomPdfDriverTest.php @@ -0,0 +1,65 @@ +getOutput('

Hello PDF

'); + + /* Assert */ + $this->assertNotEmpty($output); + $this->assertStringStartsWith('%PDF', $output); + } + + #[Test] + public function it_is_the_configured_default_driver(): void + { + /* Assert */ + $this->assertInstanceOf(domPDF::class, PDFFactory::create()); + } + + /** + * RB-07 (#759) — the "SSRF is Browsershot-only" posture from the security + * review rests on this flag. A test fails if it is ever flipped. + */ + #[Test] + public function it_builds_dompdf_options_with_remote_fetching_disabled(): void + { + /* Arrange */ + $driver = new class () extends domPDF { + public function exposeOptions(): Options + { + return $this->buildOptions(); + } + }; + + /* Act */ + $options = $driver->exposeOptions(); + + /* Assert */ + $this->assertFalse($options->getIsRemoteEnabled(), 'remote fetching must stay disabled'); + $this->assertFalse($options->getIsJavascriptEnabled(), 'JS execution must stay disabled'); + } + + #[Test] + public function it_renders_cleanly_when_html_references_an_unreachable_remote_image(): void + { + /* Act */ + $start = microtime(true); + $output = (new domPDF())->getOutput('

x

'); + + /* Assert */ + $this->assertStringStartsWith('%PDF', $output); + $this->assertLessThan(5, microtime(true) - $start, 'no remote fetch should have been attempted'); + } +} diff --git a/Modules/Core/Tests/Unit/MasonBricksTest.php b/Modules/Core/Tests/Unit/MasonBricksTest.php new file mode 100644 index 000000000..c29f8877f --- /dev/null +++ b/Modules/Core/Tests/Unit/MasonBricksTest.php @@ -0,0 +1,816 @@ + [ReportBlockWidth::ONE_THIRD->value], + 'half' => [ReportBlockWidth::HALF->value], + 'two_thirds' => [ReportBlockWidth::TWO_THIRDS->value], + 'full' => [ReportBlockWidth::FULL->value], + ]; + } + + #[Test] + public function it_header_company_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderCompanyBrick::getId(); + + /* Assert */ + $this->assertEquals('header_company', $id); + } + + #[Test] + public function it_header_company_brick_generates_preview_html(): void + { + /* Arrange */ + $config = [ + 'show_vat_id' => true, + 'show_phone' => true, + 'font_size' => 10, + ]; + + /* Act */ + $html = HeaderCompanyBrick::toPreviewHtml($config); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString(trans('ip.company_name'), $html); + } + + #[Test] + public function it_header_company_brick_generates_render_html(): void + { + /* Arrange */ + $config = ['show_vat_id' => true]; + $data = [ + 'company' => [ + 'name' => 'Test Company', + 'vat_id' => '123456', + ], + ]; + + /* Act */ + $html = HeaderCompanyBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Company', $html); + } + + #[Test] + public function it_header_client_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderClientBrick::getId(); + + /* Assert */ + $this->assertEquals('header_client', $id); + } + + #[Test] + public function it_header_client_brick_generates_html(): void + { + /* Arrange */ + $config = ['show_phone' => true]; + $data = [ + 'client' => [ + 'name' => 'Test Client', + 'phone' => '555-1234', + ], + ]; + + /* Act */ + $html = HeaderClientBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Test Client', $html); + } + + #[Test] + public function it_header_invoice_meta_brick_has_correct_id(): void + { + /* Act */ + $id = HeaderInvoiceMetaBrick::getId(); + + /* Assert */ + $this->assertEquals('header_invoice_meta', $id); + } + + #[Test] + public function it_header_invoice_meta_brick_shows_configured_fields(): void + { + /* Arrange */ + $config = [ + 'show_invoice_number' => true, + 'show_invoice_date' => true, + 'show_due_date' => false, + ]; + $data = [ + 'invoice' => [ + 'number' => 'INV-001', + 'date' => '2024-01-01', + ], + ]; + + /* Act */ + $html = HeaderInvoiceMetaBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('INV-001', $html); + } + + #[Test] + public function it_detail_column_labels_brick_has_correct_id(): void + { + /* Act */ + $id = DetailColumnLabelsBrick::getId(); + + /* Assert */ + $this->assertEquals('detail_column_labels', $id); + } + + #[Test] + public function it_detail_column_labels_brick_generates_preview_html(): void + { + /* Arrange */ + $config = ['show_description' => true, 'show_quantity' => true, 'show_price' => true]; + + /* Act */ + $html = DetailColumnLabelsBrick::toPreviewHtml($config); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString(trans('ip.description'), $html); + $this->assertStringContainsString(trans('ip.quantity'), $html); + $this->assertStringContainsString(trans('ip.price'), $html); + } + + #[Test] + public function it_detail_column_labels_brick_generates_render_html(): void + { + /* Arrange */ + $config = ['show_description' => true, 'show_total' => true]; + $data = []; + + /* Act */ + $html = DetailColumnLabelsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString(trans('ip.description'), $html); + $this->assertStringContainsString(trans('ip.total'), $html); + } + + #[Test] + public function it_detail_items_brick_has_correct_id(): void + { + /* Act */ + $id = DetailItemsBrick::getId(); + + /* Assert */ + $this->assertEquals('detail_items', $id); + } + + #[Test] + public function it_detail_items_brick_renders_items_table(): void + { + /* Arrange */ + $config = [ + 'show_description' => true, + 'show_quantity' => true, + 'show_price' => true, + ]; + $data = [ + 'items' => [ + [ + 'description' => 'Item 1', + 'quantity' => 2, + 'price' => '100.00', + ], + ], + ]; + + /* Act */ + $html = DetailItemsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Item 1', $html); + } + + #[Test] + public function it_footer_totals_brick_has_correct_id(): void + { + /* Act */ + $id = FooterTotalsBrick::getId(); + + /* Assert */ + $this->assertEquals('footer_totals', $id); + } + + #[Test] + public function it_footer_totals_brick_displays_configured_totals(): void + { + /* Arrange */ + $config = [ + 'show_subtotal' => true, + 'show_tax' => true, + 'show_total' => true, + ]; + $data = [ + 'totals' => [ + 'subtotal' => '100.00', + 'tax' => '10.00', + 'total' => '110.00', + ], + ]; + + /* Act */ + $html = FooterTotalsBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('110.00', $html); + } + + #[Test] + public function it_footer_notes_brick_has_correct_id(): void + { + /* Act */ + $id = FooterNotesBrick::getId(); + + /* Assert */ + $this->assertEquals('footer_notes', $id); + } + + #[Test] + public function it_footer_notes_brick_renders_custom_content(): void + { + /* Arrange */ + $config = [ + 'footer_content' => '

Custom payment terms

', + ]; + $data = []; + + /* Act */ + $html = FooterNotesBrick::toHtml($config, $data); + + /* Assert */ + $this->assertIsString($html); + $this->assertStringContainsString('Custom payment terms', $html); + } + + #[Test] + public function it_escapes_user_authored_data_fields_in_footer_bricks(): void + { + /* Arrange */ + $malicious = 'ok'; + + /* Act */ + $notesHtml = FooterNotesBrick::toHtml([], ['footer' => $malicious]); + $termsHtml = FooterTermsBrick::toHtml([], ['terms' => $malicious]); + $summaryHtml = FooterSummaryBrick::toHtml([], ['summary' => $malicious]); + + /* Assert */ + $this->assertStringContainsString('<script>alert(1)</script>', $notesHtml); + $this->assertStringNotContainsString('allowed bold

paragraph

'; + + /* Act */ + $notesHtml = FooterNotesBrick::toHtml(['footer_content' => $richContent], []); + $notesPreview = FooterNotesBrick::toPreviewHtml(['footer_content' => $richContent]); + $termsHtml = FooterTermsBrick::toHtml(['terms_content' => $richContent], []); + $termsPreview = FooterTermsBrick::toPreviewHtml(['terms_content' => $richContent]); + $summaryHtml = FooterSummaryBrick::toHtml(['summary_content' => $richContent], []); + $summaryPreview = FooterSummaryBrick::toPreviewHtml(['summary_content' => $richContent]); + + /* Assert */ + foreach ([$notesHtml, $notesPreview, $termsHtml, $termsPreview, $summaryHtml, $summaryPreview] as $html) { + $this->assertStringNotContainsString(' diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php index fdefbdcf9..56e857b91 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php @@ -19,8 +19,10 @@ use Modules\Clients\Enums\RelationType; use Modules\Clients\Services\RelationService; use Modules\Core\Enums\NumberingType; +use Modules\Core\Enums\ReportTemplateType; use Modules\Core\Filament\Company\Actions\InsertNoteTemplateAction; use Modules\Core\Models\Setting; +use Modules\Core\Services\ReportTemplateStorage; use Modules\Core\Support\DateHelpers; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Models\Invoice; @@ -170,6 +172,13 @@ public static function configure(Schema $schema): Schema TextInput::make('invoice_password') ->label(trans('ip.invoice_password')), + + Select::make('template') + ->label(trans('ip.pdf_template')) + ->options(fn (): array => app(ReportTemplateStorage::class)->optionsForType(ReportTemplateType::INVOICE)) + ->placeholder(trans('ip.company_default_template')) + ->native(false) + ->nullable(), ]), ]), ]), diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php index 2d065714f..169529327 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php @@ -191,11 +191,17 @@ public static function configure(Table $table): Table Action::make('download pdf') ->visible(fn () => auth()->user()?->can(Permission::DOWNLOAD_INVOICES->value)) ->label(trans('ip.download_pdf')) - ->modalDescription( - 'todo: make sure we can download the PDF of the Invoice through an action, - so need for modal anymore' - ) - ->action(function (Invoice $record): void {}), + ->action(function (Invoice $record) { + $response = app(\Modules\Core\Services\PdfGenerationService::class)->handleInvoiceDownload($record); + + if ($response === null) { + Notification::make()->title(trans('ip.report_pdf_queued'))->success()->send(); + + return; + } + + return $response; + }), EmailInvoiceAction::make() ->visible(fn () => auth()->user()?->can(Permission::EMAIL_INVOICES->value)) ->disabled(fn (Invoice $record): bool => blank(app(InvoiceService::class)->resolveEmailDefaults($record)['recipient'])) diff --git a/Modules/Invoices/Tests/Feature/InvoiceDownloadPdfActionTest.php b/Modules/Invoices/Tests/Feature/InvoiceDownloadPdfActionTest.php new file mode 100644 index 000000000..47d5a6230 --- /dev/null +++ b/Modules/Invoices/Tests/Feature/InvoiceDownloadPdfActionTest.php @@ -0,0 +1,144 @@ +run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + + /** @var Relation $customer */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $this->customer = $customer; + + /** @var Numbering $numbering */ + $numbering = Numbering::factory() + ->for($this->company) + ->state(['type' => NumberingType::INVOICE->value]) + ->create(); + $this->numbering = $numbering; + } + + #[Test] + public function it_downloads_invoice_pdf_for_permitted_user(): void + { + /* Arrange */ + $invoice = $this->createInvoice(['invoice_number' => 'INV-2026-001']); + + /* Act */ + $response = app(PdfGenerationService::class)->downloadInvoice($invoice); + + /* Assert */ + $this->assertSame('application/pdf', $response->headers->get('Content-Type')); + $this->assertStringContainsString('attachment; filename="invoice-INV-2026-001.pdf"', (string) $response->headers->get('Content-Disposition')); + $this->assertStringStartsWith('%PDF', (string) $response->getContent()); + // The invoice number is verified in the HTML layer + // (PdfGenerationServiceTest::it_renders_invoice_html_containing_the_invoice_data); + // the dompdf byte stream is zlib-compressed, so it is not asserted here. + + // The action returns a raw binary Response, which Livewire's callAction + // test harness cannot serialise — assert the permitted user sees it. + Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertActionVisible(TestAction::make('download pdf')->table($invoice)); + } + + #[Test] + public function it_sanitizes_slashes_in_invoice_number_for_download_filename(): void + { + /* Arrange */ + $invoice = $this->createInvoice(['invoice_number' => 'INV/2026/001']); + + /* Act */ + $response = app(PdfGenerationService::class)->downloadInvoice($invoice); + + /* Assert */ + $disposition = (string) $response->headers->get('Content-Disposition'); + $this->assertStringNotContainsString('/', $disposition); + $this->assertStringNotContainsString('\\', $disposition); + $this->assertStringNotContainsString('%', $disposition); + $this->assertSame('attachment; filename="invoice-INV-2026-001.pdf"', $disposition); + } + + #[Test] + public function it_hides_download_action_for_user_without_permission(): void + { + /* Arrange */ + $this->user->syncRoles([]); + $this->user->givePermissionTo([ + Permission::VIEW_INVOICES->value, + ]); + $invoice = $this->createInvoice(); + + /* Act & Assert */ + Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertActionHidden(TestAction::make('download pdf')->table($invoice)); + } + + #[Test] + public function it_propagates_render_failure_when_template_resolution_fails(): void + { + /* Arrange */ + $invoice = $this->createInvoice(['template' => 'nonexistent-template-slug']); + + $mockService = Mockery::mock(PdfGenerationService::class); + $mockService->shouldReceive('handleInvoiceDownload') + ->andThrow(new RuntimeException('Template resolution error')); + $this->app->instance(PdfGenerationService::class, $mockService); + + /* Assert */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Template resolution error'); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]) + ->callAction(TestAction::make('download pdf')->table($invoice)); + } + + private function createInvoice(array $attributes = []): Invoice + { + /** @var Invoice $invoice */ + $invoice = Invoice::factory()->for($this->company)->create(array_merge([ + 'invoice_number' => 'INV-987654', + 'customer_id' => $this->customer->getKey(), + 'numbering_id' => $this->numbering->getKey(), + 'user_id' => $this->user->id, + 'invoice_status' => InvoiceStatus::SENT->value, + 'is_read_only' => false, + 'invoiced_at' => '2025-05-10', + 'invoice_due_at' => '2025-06-09', + ], $attributes)); + + return $invoice; + } +} diff --git a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php index 58879b8d8..f65a8db17 100644 --- a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php @@ -62,7 +62,7 @@ public function it_renders_invoice_html_with_number_and_customer(): void /* Assert */ $this->assertStringContainsString('INV-987654', $html); - $this->assertStringContainsString($invoice->customer->company_name, $html); + $this->assertStringContainsString(e($invoice->customer->company_name), $html); $this->assertStringContainsString('Widget', $html); $this->assertStringNotContainsString('label(trans('ip.work_order')) ->maxLength(255), + + Select::make('template') + ->label(trans('ip.pdf_template')) + ->options(fn (): array => app(ReportTemplateStorage::class)->optionsForType(ReportTemplateType::QUOTE)) + ->placeholder(trans('ip.company_default_template')) + ->native(false) + ->nullable(), ]) ->columns(2), ]) diff --git a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php index 269051159..666bf2c7d 100644 --- a/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php +++ b/Modules/Quotes/Filament/Company/Resources/Quotes/Tables/QuotesTable.php @@ -8,6 +8,7 @@ use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Notifications\Notification; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use InvalidArgumentException; @@ -83,13 +84,18 @@ public static function configure(Table $table): Table ->successNotificationTitle(trans('ip.quote_duplicated')), Action::make('download pdf') ->visible(fn () => auth()->user()?->can(Permission::DOWNLOAD_QUOTES->value)) - ->label(trans('ip.download_pdf')) - ->modalDescription( - 'todo: make sure we can download the PDF of the Quote through an action, - so need for modal anymore' - ) - ->action(function (Quote $record): void {}), + ->action(function (Quote $record) { + $response = app(\Modules\Core\Services\PdfGenerationService::class)->handleQuoteDownload($record); + + if ($response === null) { + Notification::make()->title(trans('ip.report_pdf_queued'))->success()->send(); + + return; + } + + return $response; + }), EmailQuoteAction::make() ->visible(fn () => auth()->user()?->can(Permission::EMAIL_QUOTES->value)) ->disabled(function (Quote $record): bool { diff --git a/Modules/Quotes/Models/QuoteItem.php b/Modules/Quotes/Models/QuoteItem.php index f3d07c07b..4c00cd25a 100644 --- a/Modules/Quotes/Models/QuoteItem.php +++ b/Modules/Quotes/Models/QuoteItem.php @@ -10,6 +10,7 @@ use Modules\Core\Models\TaxRate; use Modules\Products\Models\Product; use Modules\Products\Models\ProductUnit; +use Modules\Projects\Models\Task; use Modules\Quotes\Database\Factories\QuoteItemFactory; /** @@ -70,6 +71,11 @@ public function productUnit(): BelongsTo return $this->belongsTo(ProductUnit::class, 'product_unit_id'); } + public function task(): BelongsTo + { + return $this->belongsTo(Task::class, 'task_id'); + } + public function quote(): BelongsTo { return $this->belongsTo(Quote::class, 'quote_id'); diff --git a/Modules/Quotes/Tests/Feature/QuoteDownloadPdfActionTest.php b/Modules/Quotes/Tests/Feature/QuoteDownloadPdfActionTest.php new file mode 100644 index 000000000..2a8f4918a --- /dev/null +++ b/Modules/Quotes/Tests/Feature/QuoteDownloadPdfActionTest.php @@ -0,0 +1,143 @@ +run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + + /** @var Relation $prospect */ + $prospect = Relation::factory()->for($this->company)->prospect()->create(); + $this->prospect = $prospect; + + /** @var Numbering $numbering */ + $numbering = Numbering::factory() + ->for($this->company) + ->state(['type' => NumberingType::QUOTE->value]) + ->create(); + $this->numbering = $numbering; + } + + #[Test] + public function it_downloads_quote_pdf_for_permitted_user(): void + { + /* Arrange */ + $quote = $this->createQuote(['quote_number' => 'Q-2026-001']); + + /* Act */ + $response = app(PdfGenerationService::class)->downloadQuote($quote); + + /* Assert */ + $this->assertSame('application/pdf', $response->headers->get('Content-Type')); + $this->assertStringContainsString('attachment; filename="quote-Q-2026-001.pdf"', (string) $response->headers->get('Content-Disposition')); + $this->assertStringStartsWith('%PDF', (string) $response->getContent()); + // The quote number is verified in the HTML layer + // (PdfGenerationServiceTest); the dompdf byte stream is zlib-compressed, + // so it is not asserted here. + + // The action returns a raw binary Response, which Livewire's callAction + // test harness cannot serialise — assert the permitted user sees it. + Livewire::actingAs($this->user) + ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertActionVisible(TestAction::make('download pdf')->table($quote)); + } + + #[Test] + public function it_sanitizes_slashes_in_quote_number_for_download_filename(): void + { + /* Arrange */ + $quote = $this->createQuote(['quote_number' => 'Q/2026/001']); + + /* Act */ + $response = app(PdfGenerationService::class)->downloadQuote($quote); + + /* Assert */ + $disposition = (string) $response->headers->get('Content-Disposition'); + $this->assertStringNotContainsString('/', $disposition); + $this->assertStringNotContainsString('\\', $disposition); + $this->assertStringNotContainsString('%', $disposition); + $this->assertSame('attachment; filename="quote-Q-2026-001.pdf"', $disposition); + } + + #[Test] + public function it_hides_download_action_for_user_without_permission(): void + { + /* Arrange */ + $this->user->syncRoles([]); + $this->user->givePermissionTo([ + Permission::VIEW_QUOTES->value, + ]); + $quote = $this->createQuote(); + + /* Act & Assert */ + Livewire::actingAs($this->user) + ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)]) + ->assertActionHidden(TestAction::make('download pdf')->table($quote)); + } + + #[Test] + public function it_propagates_render_failure_when_template_resolution_fails(): void + { + /* Arrange */ + $quote = $this->createQuote(['template' => 'nonexistent-template-slug']); + + $mockService = Mockery::mock(PdfGenerationService::class); + $mockService->shouldReceive('handleQuoteDownload') + ->andThrow(new RuntimeException('Template resolution error')); + $this->app->instance(PdfGenerationService::class, $mockService); + + /* Assert */ + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Template resolution error'); + + /* Act */ + Livewire::actingAs($this->user) + ->test(ListQuotes::class, ['tenant' => Str::lower($this->company->search_code)]) + ->callAction(TestAction::make('download pdf')->table($quote)); + } + + private function createQuote(array $attributes = []): Quote + { + /** @var Quote $quote */ + $quote = Quote::factory()->for($this->company)->create(array_merge([ + 'quote_number' => 'Q-987654', + 'prospect_id' => $this->prospect->getKey(), + 'numbering_id' => $this->numbering->getKey(), + 'user_id' => $this->user->id, + 'quote_status' => QuoteStatus::DRAFT->value, + 'quoted_at' => '2025-05-10', + 'quote_expires_at' => '2025-06-09', + ], $attributes)); + + return $quote; + } +} diff --git a/README.md b/README.md index 7e6dd5646..22cbe77fa 100644 --- a/README.md +++ b/README.md @@ -402,7 +402,7 @@ If you're looking to contribute Peppol support, start with that issue and branch - [ ] Set up Redis for cache and queue - [ ] Configure queue workers with Supervisor - [ ] Set up proper mail configuration -- [ ] Configure backups +- [ ] Configure backups (include `storage/app/report_templates` — user-authored report layouts are stored on disk, not in the database) - [ ] Set up SSL/TLS certificates - [ ] Configure firewall rules - [ ] Set up monitoring and logging diff --git a/composer.json b/composer.json index 199aa96de..bab77edc8 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,9 @@ "maatwebsite/excel": ">=3.1", "maennchen/zipstream-php": ">=3.2", "nwidart/laravel-modules": ">=12.0", - "spatie/laravel-permission": ">=8.0" + "spatie/browsershot": "^5.4", + "spatie/laravel-permission": ">=8.0", + "stevebauman/purify": "^6.3" }, "require-dev": { "barryvdh/laravel-debugbar": ">=4.3", @@ -64,7 +66,8 @@ "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi", - "@php artisan filament:upgrade" + "@php artisan filament:upgrade", + "@php artisan reports:sync-system --ansi" ], "post-update-cmd": [ "@php artisan vendor:publish --tag=laravel-assets --ansi --force" diff --git a/composer.lock b/composer.lock index a4fce47ef..d81d45c6b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "83bc52fdadd78321504641d838ad1eae", + "content-hash": "b8628d4ab5b40afc2f7164f74324c9fb", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1531,6 +1531,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "filament/actions", "version": "v5.7.6", @@ -6118,6 +6179,74 @@ ], "time": "2022-12-17T21:53:22+00:00" }, + { + "name": "spatie/browsershot", + "version": "5.4.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/browsershot.git", + "reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/dcf7a65fd1d0fc8fd113739b84982377728d0b2f", + "reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "ext-json": "*", + "php": "^8.2", + "spatie/temporary-directory": "^2.0", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "pestphp/pest": "^3.0|^4.0", + "spatie/image": "^3.6", + "spatie/pdf-to-text": "^1.52", + "spatie/phpunit-snapshot-assertions": "^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Browsershot\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://github.com/freekmurze", + "role": "Developer" + } + ], + "description": "Convert a webpage to an image or pdf using headless Chrome", + "homepage": "https://github.com/spatie/browsershot", + "keywords": [ + "chrome", + "convert", + "headless", + "image", + "pdf", + "puppeteer", + "screenshot", + "webpage" + ], + "support": { + "source": "https://github.com/spatie/browsershot/tree/5.4.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-05-26T13:13:33+00:00" + }, { "name": "spatie/invade", "version": "2.1.0", @@ -6391,6 +6520,133 @@ ], "time": "2026-04-27T14:27:52+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/32cbb9645b28839cf4f476708e99a2c70e6802c9", + "reference": "32cbb9645b28839cf4f476708e99a2c70e6802c9", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.4.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-06-22T07:55:44+00:00" + }, + { + "name": "stevebauman/purify", + "version": "v6.3.2", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/deba4aa55a45a7593c369b52d481c87b545a5bf8", + "reference": "deba4aa55a45a7593c369b52d481c87b545a5bf8", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3|^12.5.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.3.2" + }, + "time": "2026-03-18T16:42:42+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", diff --git a/config/filesystems.php b/config/filesystems.php index 79ec6d612..63089c964 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -53,6 +53,14 @@ 'report' => false, ], + 'report_pdfs' => [ + 'driver' => 'local', + 'root' => storage_path('app/report_pdfs'), + 'visibility' => 'private', + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', 'key' => env('AWS_ACCESS_KEY_ID'), diff --git a/config/ip.php b/config/ip.php index e5dabf816..af176e19a 100644 --- a/config/ip.php +++ b/config/ip.php @@ -43,4 +43,32 @@ 'pdfDriver' => env('IP_PDF_DRIVER', 'domPDF'), 'paperSize' => env('IP_PDF_PAPER_SIZE', 'a4'), 'paperOrientation' => env('IP_PDF_PAPER_ORIENTATION', 'portrait'), + + // Only used when IP_PDF_DRIVER=Browsershot (headless Chromium; needs Node + Puppeteer) + 'browsershot' => [ + 'node_binary' => env('IP_BROWSERSHOT_NODE_BINARY'), + 'npm_binary' => env('IP_BROWSERSHOT_NPM_BINARY'), + 'chrome_path' => env('IP_BROWSERSHOT_CHROME_PATH'), + 'no_sandbox' => env('IP_BROWSERSHOT_NO_SANDBOX', false), + ], + + /* + * Report builder resource ceilings. These bound the work a single tenant + * can force onto a shared render worker. + * + * - queue: when true, "Download PDF" renders in a queued job and stores + * the file instead of rendering inline in the web request. Off by + * default (the download is a direct response). + * - max_rows: line items rendered per detail brick before truncation. + * - max_bricks_per_band: bricks kept per band when a template is saved. + * - max_template_bytes: rejected on save above this encoded size. + * - render_time_limit: set_time_limit() guard around an inline render. + */ + 'report' => [ + 'queue' => (bool) env('IP_REPORT_QUEUE', false), + 'max_rows' => (int) env('IP_REPORT_MAX_ROWS', 2000), + 'max_bricks_per_band' => (int) env('IP_REPORT_MAX_BRICKS_PER_BAND', 50), + 'max_template_bytes' => (int) env('IP_REPORT_MAX_TEMPLATE_BYTES', 262144), + 'render_time_limit' => (int) env('IP_REPORT_RENDER_TIME_LIMIT', 120), + ], ]; diff --git a/config/purify.php b/config/purify.php new file mode 100644 index 000000000..19099a5c5 --- /dev/null +++ b/config/purify.php @@ -0,0 +1,61 @@ + 'default', + + /* + |-------------------------------------------------------------------------- + | Config sets + |-------------------------------------------------------------------------- + | + | `default` mirrors the package default. `report` is the locked-down set + | used for the report-builder rich-text bricks (footer notes / terms / + | summary): body-copy formatting only — no , no
, no style + | attributes, and no external resource fetching. This closes the blind + | SSRF that a headless-Chromium (Browsershot) render would otherwise allow + | via . + | + */ + + 'configs' => [ + 'default' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,a[href|title],ul,ol,li,p[style],br,span,img[width|height|alt|src],blockquote', + 'HTML.ForbiddenElements' => '', + 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + ], + + 'report' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,ul,ol,li,p,br,span,blockquote', + 'HTML.ForbiddenElements' => 'script,style,iframe,object,embed,form,input,link,base', + 'CSS.AllowedProperties' => '', + 'URI.AllowedSchemes' => ['https' => true, 'mailto' => true], + 'URI.DisableExternalResources' => true, + 'URI.DisableResources' => true, + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => true, + ], + ], + + 'definitions' => Html5Definition::class, + + 'css-definitions' => null, + + 'serializer' => [ + 'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')), + 'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class, + ], +]; diff --git a/docker-compose.yml b/docker-compose.yml index 394579432..8de353bc4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,10 +57,11 @@ services: DB_DATABASE: invoiceplane_test DB_USERNAME: root DB_PASSWORD: "" - depends_on: - - db volumes: - .:/var/www/html + depends_on: + db: + condition: service_healthy networks: - laravel @@ -75,6 +76,10 @@ services: MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" MARIADB_DATABASE: "${DB_DATABASE}" TZ: "Europe/London" + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + timeout: 20s + retries: 10 volumes: - database:/var/lib/mysql # Only runs on first boot of a fresh volume — provisions the 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/filament-commands.txt b/filament-commands.txt deleted file mode 100644 index 9ec9c4a96..000000000 --- a/filament-commands.txt +++ /dev/null @@ -1,49 +0,0 @@ - ⇂ filament:about - ⇂ filament:assets - ⇂ filament:cache-components - ⇂ filament:check-translations - ⇂ filament:clear-cached-components - ⇂ filament:cluster - ⇂ filament:column - ⇂ filament:component - ⇂ filament:entry - ⇂ filament:exporter - ⇂ filament:field - ⇂ filament:form - ⇂ filament:form-field - ⇂ filament:form-layout - ⇂ filament:importer - ⇂ filament:infolist - ⇂ filament:infolist-entry - ⇂ filament:infolist-layout - ⇂ filament:infolist-schema - ⇂ filament:install - ⇂ filament:issue - ⇂ filament:layout - ⇂ filament:livewire-form - ⇂ filament:livewire-schema - ⇂ filament:livewire-table - ⇂ filament:make-cluster - ⇂ filament:make-issue - ⇂ filament:make-page - ⇂ filament:make-panel - ⇂ filament:make-relation-manager - ⇂ filament:make-resource - ⇂ filament:make-theme - ⇂ filament:make-user - ⇂ filament:make-widget - ⇂ filament:optimize - ⇂ filament:optimize-clear - ⇂ filament:page - ⇂ filament:panel - ⇂ filament:relation-manager - ⇂ filament:resource - ⇂ filament:schema - ⇂ filament:schema-component - ⇂ filament:schema-layout - ⇂ filament:table - ⇂ filament:table-column - ⇂ filament:theme - ⇂ filament:upgrade - ⇂ filament:user - ⇂ filament:widget diff --git a/oldvplv2.txt b/oldvplv2.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 43779ca0b..e69de29bb 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,13 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Cannot access property \$value on string\.$#' - identifier: property.nonObject - count: 1 - path: Modules/Core/Tests/Unit/ReportBlockWidthTest.php - - - - message: '#^Cannot call method getGridWidth\(\) on string\.$#' - identifier: method.nonObject - count: 1 - path: Modules/Core/Tests/Unit/ReportBlockWidthTest.php diff --git a/rector.php b/rector.php index 4aa5b327d..85f943c19 100644 --- a/rector.php +++ b/rector.php @@ -5,11 +5,9 @@ return RectorConfig::configure() ->withImportNames() ->withSkip([ - '*/Modules/*/Http/*', ]) ->withPaths([ __DIR__ . '/Modules', ]) ->withRules([ - ImportModelIfMissingRector::class, ]); diff --git a/resources/css/filament/company/invoiceplane-blue.css b/resources/css/filament/company/invoiceplane-blue.css index cbaaccd09..c5961cfaa 100644 --- a/resources/css/filament/company/invoiceplane-blue.css +++ b/resources/css/filament/company/invoiceplane-blue.css @@ -1,8 +1,10 @@ @import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../../../vendor/awcodes/mason/resources/css/plugin.css'; @source '../../../../Modules/**/resources/views/**/*'; @source '../../../../Modules/**/*.php'; +@source '../../../../vendor/awcodes/mason/resources/**/*.blade.php'; @source '../../../../resources/views/filament/tenant/**/*'; /* diff --git a/resources/css/filament/company/invoiceplane.css b/resources/css/filament/company/invoiceplane.css index cd97219e5..30abcedb4 100644 --- a/resources/css/filament/company/invoiceplane.css +++ b/resources/css/filament/company/invoiceplane.css @@ -1,10 +1,12 @@ @import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../../../vendor/awcodes/mason/resources/css/plugin.css'; @source '../../../../app/Filament/Tenant/**/*'; @source '../../../../resources/views/filament/tenant/**/*'; @source '../../../../Modules/**/resources/views/**/*'; @source '../../../../Modules/**/*.php'; +@source '../../../../vendor/awcodes/mason/resources/**/*.blade.php'; .fi-bg-color-600 { @apply bg-primary-700; diff --git a/resources/css/filament/company/nord.css b/resources/css/filament/company/nord.css index 695ac829e..22ffdce29 100644 --- a/resources/css/filament/company/nord.css +++ b/resources/css/filament/company/nord.css @@ -1,8 +1,10 @@ @import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../../../vendor/awcodes/mason/resources/css/plugin.css'; @source '../../../../Modules/**/resources/views/**/*'; @source '../../../../Modules/**/*.php'; +@source '../../../../vendor/awcodes/mason/resources/**/*.blade.php'; @source '../../../../resources/views/filament/tenant/**/*'; @theme { diff --git a/resources/css/filament/company/orange.css b/resources/css/filament/company/orange.css index 8e832fb8f..f11d223f2 100644 --- a/resources/css/filament/company/orange.css +++ b/resources/css/filament/company/orange.css @@ -1,10 +1,12 @@ @import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../../../vendor/awcodes/mason/resources/css/plugin.css'; @source '../../../../app/Filament/Tenant/**/*'; @source '../../../../resources/views/filament/tenant/**/*'; @source '../../../../Modules/**/resources/views/**/*'; @source '../../../../Modules/**/*.php'; +@source '../../../../vendor/awcodes/mason/resources/**/*.blade.php'; .fi-bg-color-600 { @apply bg-orange-700; diff --git a/resources/css/filament/company/reddit.css b/resources/css/filament/company/reddit.css index 4436211e1..b3e244542 100644 --- a/resources/css/filament/company/reddit.css +++ b/resources/css/filament/company/reddit.css @@ -1,10 +1,12 @@ @import 'tailwindcss'; @import '../../../../vendor/filament/filament/resources/css/theme.css'; +@import '../../../../vendor/awcodes/mason/resources/css/plugin.css'; @source '../../../../app/Filament/Tenant/**/*'; @source '../../../../resources/views/filament/tenant/**/*'; @source '../../../../Modules/**/resources/views/**/*'; @source '../../../../Modules/**/*.php'; +@source '../../../../vendor/awcodes/mason/resources/**/*.blade.php'; .fi-bg-color-600 { @apply bg-[#FF4500]; diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index ba8d79e24..1887cd915 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -355,6 +355,8 @@ 'remove' => 'Remove', 'remove_logo' => 'Remove Logo', 'report_options' => 'Report Options', + 'report_pdf_queued' => 'The PDF is being prepared and will be available shortly.', + 'report_rows_truncated' => 'Some rows were not shown because this document exceeds the display limit.', 'reports' => 'Reports', 'reset' => 'Reset', 'reset_password' => 'Reset password', @@ -599,7 +601,7 @@ 'quote_templates' => 'Quote Templates', 'quote_to_invoice' => 'Quote to Invoice', 'quote_duplicated' => 'Quote duplicated successfully', - + 'email_quote_default_subject' => 'Quote #:number', 'quote_email_sent_successfully' => 'The quote email has been queued for delivery.', 'quote_sent_template_default_subject' => 'Quote #{{ quote.number }}', @@ -1161,10 +1163,23 @@ 'show_due_date' => 'Show Due Date', 'show_po_number' => 'Show PO Number', 'show_description' => 'Show Description', + 'description_placement' => 'Description Placement', + 'placement_hidden' => 'Hidden', + 'placement_inline_column' => 'Inline Column', + 'placement_below_row' => 'Below Row', 'show_quantity' => 'Show Quantity', 'show_price' => 'Show Price', 'show_tax' => 'Show Tax', 'show_total' => 'Show Total', + 'show_table_header' => 'Show Table Header', + 'column_labels' => 'Column Labels', + 'configure_column_labels' => 'Configure Column Labels', + 'column_labels_settings' => 'Column Labels Settings', + 'group_by' => 'Group By', + 'group_by_category' => 'Product Category', + 'group_by_tax_rate' => 'Tax Rate', + 'group_by_product' => 'Product', + 'group_by_sku' => 'Product Code / SKU', 'alternating_rows' => 'Alternating Row Colors', 'show_subtotal' => 'Show Subtotal', 'show_paid' => 'Show Paid Amount', @@ -1220,6 +1235,39 @@ 'footer_content' => 'Footer Content', 'footer_placeholder' => 'Add footer notes here...', + // Report Builder utility bricks + 'page_break' => 'Page Break', + 'page_break_hint' => 'Starts a new page at this position', + 'spacer' => 'Spacer', + 'configure_spacer' => 'Configure Spacer', + 'spacer_settings' => 'Spacer Settings', + 'spacer_height' => 'Height (px)', + + // Report Builder pages + 'report_templates' => 'Report Templates', + 'report_builder' => 'Report Builder', + 'group_band_notice' => 'Group break detection is not yet active. Blocks in this band render sequentially without per-group repeating.', + 'rename' => 'Rename', + 'open_builder' => 'Open Builder', + 'company_template' => 'Company Template', + 'no_report_templates' => 'No report templates found. Run "php artisan reports:sync-system" to install the defaults.', + 'system_template_read_only' => 'System templates are read-only. Clone this template to customize it.', + 'template_cloned' => 'Template cloned', + 'template_renamed' => 'Template renamed', + 'template_deleted' => 'Template deleted', + 'template_saved' => 'Template saved', + 'template_save_failed' => 'Template save failed', + 'template_not_editable' => 'This template cannot be modified from this panel', + 'invalid_template_name' => 'That name cannot be turned into a valid template slug. Please use at least one letter or number.', + 'move_to_band' => 'Move to band…', + 'from_band' => 'From band', + 'to_band' => 'To band', + 'brick' => 'Brick', + 'brick_moved' => 'Brick moved', + 'brick_not_allowed_in_band' => 'This brick is not allowed in the selected band', + 'pdf_template' => 'PDF Template', + 'company_default_template' => 'Company default', + // New brick translations 'invoice_product_details' => 'Invoice Product Details', 'configure_invoice_product_details' => 'Configure Invoice Product Details', diff --git a/resources/report-templates/invoice/default/bands.json b/resources/report-templates/invoice/default/bands.json new file mode 100644 index 000000000..0b303a152 --- /dev/null +++ b/resources/report-templates/invoice/default/bands.json @@ -0,0 +1,40 @@ +{ + "header": [ + { + "brick": "header_company", + "width": "half", + "config": {} + }, + { + "brick": "header_client", + "width": "half", + "config": {} + }, + { + "brick": "header_invoice_meta", + "width": "full", + "config": {} + } + ], + "group_header": [], + "details": [ + { + "brick": "detail_items", + "width": "full", + "config": {} + } + ], + "group_footer": [], + "footer": [ + { + "brick": "footer_totals", + "width": "full", + "config": {} + }, + { + "brick": "footer_notes", + "width": "full", + "config": {} + } + ] +} diff --git a/resources/report-templates/invoice/default/manifest.json b/resources/report-templates/invoice/default/manifest.json new file mode 100644 index 000000000..e69d691f4 --- /dev/null +++ b/resources/report-templates/invoice/default/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Default Invoice", + "slug": "default", + "type": "invoice", + "version": 1, + "cloned_from": null +} diff --git a/resources/report-templates/quote/default/bands.json b/resources/report-templates/quote/default/bands.json new file mode 100644 index 000000000..51cf7e2b4 --- /dev/null +++ b/resources/report-templates/quote/default/bands.json @@ -0,0 +1,40 @@ +{ + "header": [ + { + "brick": "header_company", + "width": "half", + "config": {} + }, + { + "brick": "header_client", + "width": "half", + "config": {} + }, + { + "brick": "header_quote_meta", + "width": "full", + "config": {} + } + ], + "group_header": [], + "details": [ + { + "brick": "detail_items", + "width": "full", + "config": {} + } + ], + "group_footer": [], + "footer": [ + { + "brick": "footer_totals", + "width": "full", + "config": {} + }, + { + "brick": "footer_terms", + "width": "full", + "config": {} + } + ] +} diff --git a/resources/report-templates/quote/default/manifest.json b/resources/report-templates/quote/default/manifest.json new file mode 100644 index 000000000..a29489eac --- /dev/null +++ b/resources/report-templates/quote/default/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Default Quote", + "slug": "default", + "type": "quote", + "version": 1, + "cloned_from": null +} diff --git a/resources/views/mason/bricks/detail-customer-aging/preview.blade.php b/resources/views/mason/bricks/detail-customer-aging/preview.blade.php deleted file mode 100644 index 35e8b762b..000000000 --- a/resources/views/mason/bricks/detail-customer-aging/preview.blade.php +++ /dev/null @@ -1,95 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_invoice_number'] ?? true) - - @endif - @if($config['show_invoice_date'] ?? true) - - @endif - @if($config['show_due_date'] ?? true) - - @endif - @if($config['show_current'] ?? true) - - @endif - @if($config['show_30_days'] ?? true) - - @endif - @if($config['show_60_days'] ?? true) - - @endif - @if($config['show_90_days'] ?? true) - - @endif - @if($config['show_over_90_days'] ?? true) - - @endif - @if($config['show_total_due'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_invoice_number'] ?? true) - - @endif - @if($config['show_invoice_date'] ?? true) - - @endif - @if($config['show_due_date'] ?? true) - - @endif - @if($config['show_current'] ?? true) - - @endif - @if($config['show_30_days'] ?? true) - - @endif - @if($config['show_60_days'] ?? true) - - @endif - @if($config['show_90_days'] ?? true) - - @endif - @if($config['show_over_90_days'] ?? true) - - @endif - @if($config['show_total_due'] ?? true) - - @endif - - @endfor - - - - - @if($config['show_current'] ?? true) - - @endif - @if($config['show_30_days'] ?? true) - - @endif - @if($config['show_60_days'] ?? true) - - @endif - @if($config['show_90_days'] ?? true) - - @endif - @if($config['show_over_90_days'] ?? true) - - @endif - @if($config['show_total_due'] ?? true) - - @endif - - -
{{ trans('ip.invoice') }}{{ trans('ip.date') }}{{ trans('ip.due_date') }}{{ trans('ip.current') }}{{ trans('ip.days_30') }}{{ trans('ip.days_60') }}{{ trans('ip.days_90') }}{{ trans('ip.over_90') }}{{ trans('ip.total_due') }}
INV-{{ str_pad($i, 4, '0', STR_PAD_LEFT) }}{{ now()->subDays($i * 30)->format('Y-m-d') }}{{ now()->subDays(($i * 30) - 30)->format('Y-m-d') }}{{ $i == 1 ? '$1,500.00' : '-' }}{{ $i == 2 ? '$2,300.00' : '-' }}{{ $i == 3 ? '$800.00' : '-' }}--{{ $i == 1 ? '$1,500.00' : ($i == 2 ? '$2,300.00' : '$800.00') }}
{{ trans('ip.total') }}$1,500.00$2,300.00$800.00$0.00$0.00$4,600.00
-
diff --git a/resources/views/mason/bricks/detail-expense/preview.blade.php b/resources/views/mason/bricks/detail-expense/preview.blade.php deleted file mode 100644 index f11a14fcf..000000000 --- a/resources/views/mason/bricks/detail-expense/preview.blade.php +++ /dev/null @@ -1,60 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_expense_number'] ?? true) - - @endif - @if($config['show_expense_date'] ?? true) - - @endif - @if($config['show_category'] ?? true) - - @endif - @if($config['show_vendor'] ?? false) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_amount'] ?? true) - - @endif - @if($config['show_status'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_expense_number'] ?? true) - - @endif - @if($config['show_expense_date'] ?? true) - - @endif - @if($config['show_category'] ?? true) - - @endif - @if($config['show_vendor'] ?? false) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_amount'] ?? true) - - @endif - @if($config['show_status'] ?? true) - - @endif - - @endfor - -
{{ trans('ip.expense_number') }}{{ trans('ip.date') }}{{ trans('ip.category') }}{{ trans('ip.vendor') }}{{ trans('ip.description') }}{{ trans('ip.amount') }}{{ trans('ip.status') }}
EXP-{{ str_pad($i, 4, '0', STR_PAD_LEFT) }}{{ now()->subDays($i * 5)->format('Y-m-d') }}{{ trans('ip.category') }} {{ $i }}{{ trans('ip.vendor') }} {{ $i }}{{ trans('ip.expense_description') }}${{ $i * 250 }}.00{{ $i % 2 == 0 ? trans('ip.paid') : trans('ip.pending') }}
-
diff --git a/resources/views/mason/bricks/detail-invoice-product/index.blade.php b/resources/views/mason/bricks/detail-invoice-product/index.blade.php deleted file mode 100644 index 5f0c6ed41..000000000 --- a/resources/views/mason/bricks/detail-invoice-product/index.blade.php +++ /dev/null @@ -1,61 +0,0 @@ -@props([ - 'config' => [], - 'data' => [] -]) - -
- - - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @foreach(($data['invoice_items'] ?? []) as $index => $item) - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endforeach - -
{{ trans('ip.sku') }}{{ trans('ip.description') }}{{ trans('ip.quantity') }}{{ trans('ip.unit_price') }}{{ trans('ip.tax') }}{{ trans('ip.discount') }}{{ trans('ip.total') }}
{{ $item['sku'] ?? '' }}{{ $item['description'] ?? '' }}{{ $item['quantity'] ?? 0 }}{{ $item['unit_price'] ?? '0.00' }}{{ $item['tax'] ?? '0.00' }}{{ $item['discount'] ?? '0.00' }}{{ $item['total'] ?? '0.00' }}
-
diff --git a/resources/views/mason/bricks/detail-invoice-product/preview.blade.php b/resources/views/mason/bricks/detail-invoice-product/preview.blade.php deleted file mode 100644 index cfd8c75d9..000000000 --- a/resources/views/mason/bricks/detail-invoice-product/preview.blade.php +++ /dev/null @@ -1,60 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endfor - -
{{ trans('ip.sku') }}{{ trans('ip.description') }}{{ trans('ip.quantity') }}{{ trans('ip.unit_price') }}{{ trans('ip.tax') }}{{ trans('ip.discount') }}{{ trans('ip.total') }}
SKU-{{ str_pad($i, 3, '0', STR_PAD_LEFT) }}{{ trans('ip.product') }} {{ $i }}{{ $i }}$100.00$10.00$0.00$110.00
-
diff --git a/resources/views/mason/bricks/detail-invoice-project/preview.blade.php b/resources/views/mason/bricks/detail-invoice-project/preview.blade.php deleted file mode 100644 index 2ceccfb28..000000000 --- a/resources/views/mason/bricks/detail-invoice-project/preview.blade.php +++ /dev/null @@ -1,54 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_project_name'] ?? true) - - @endif - @if($config['show_task_name'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_hours'] ?? true) - - @endif - @if($config['show_rate'] ?? true) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_project_name'] ?? true) - - @endif - @if($config['show_task_name'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_hours'] ?? true) - - @endif - @if($config['show_rate'] ?? true) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endfor - -
{{ trans('ip.project') }}{{ trans('ip.task') }}{{ trans('ip.description') }}{{ trans('ip.hours') }}{{ trans('ip.rate') }}{{ trans('ip.total') }}
{{ trans('ip.project') }} {{ $i }}{{ trans('ip.task') }} {{ $i }}{{ trans('ip.task_description') }}{{ $i * 5 }}$75.00${{ $i * 5 * 75 }}.00
-
diff --git a/resources/views/mason/bricks/detail-items/preview.blade.php b/resources/views/mason/bricks/detail-items/preview.blade.php deleted file mode 100644 index 43e43e8e0..000000000 --- a/resources/views/mason/bricks/detail-items/preview.blade.php +++ /dev/null @@ -1,48 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endfor - -
{{ trans('ip.description') }}{{ trans('ip.quantity') }}{{ trans('ip.price') }}{{ trans('ip.tax') }}{{ trans('ip.total') }}
{{ trans('ip.item') }} {{ $i }}{{ $i }}$100.00$10.00$110.00
-
diff --git a/resources/views/mason/bricks/detail-quote-product/index.blade.php b/resources/views/mason/bricks/detail-quote-product/index.blade.php deleted file mode 100644 index 58320fa00..000000000 --- a/resources/views/mason/bricks/detail-quote-product/index.blade.php +++ /dev/null @@ -1,61 +0,0 @@ -@props([ - 'config' => [], - 'data' => [] -]) - -
- - - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @foreach(($data['quote_items'] ?? []) as $index => $item) - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endforeach - -
{{ trans('ip.sku') }}{{ trans('ip.description') }}{{ trans('ip.quantity') }}{{ trans('ip.unit_price') }}{{ trans('ip.tax') }}{{ trans('ip.discount') }}{{ trans('ip.total') }}
{{ $item['sku'] ?? '' }}{{ $item['description'] ?? '' }}{{ $item['quantity'] ?? 0 }}{{ $item['unit_price'] ?? '0.00' }}{{ $item['tax'] ?? '0.00' }}{{ $item['discount'] ?? '0.00' }}{{ $item['total'] ?? '0.00' }}
-
diff --git a/resources/views/mason/bricks/detail-quote-product/preview.blade.php b/resources/views/mason/bricks/detail-quote-product/preview.blade.php deleted file mode 100644 index cfd8c75d9..000000000 --- a/resources/views/mason/bricks/detail-quote-product/preview.blade.php +++ /dev/null @@ -1,60 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_sku'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_quantity'] ?? true) - - @endif - @if($config['show_unit_price'] ?? true) - - @endif - @if($config['show_tax'] ?? true) - - @endif - @if($config['show_discount'] ?? false) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endfor - -
{{ trans('ip.sku') }}{{ trans('ip.description') }}{{ trans('ip.quantity') }}{{ trans('ip.unit_price') }}{{ trans('ip.tax') }}{{ trans('ip.discount') }}{{ trans('ip.total') }}
SKU-{{ str_pad($i, 3, '0', STR_PAD_LEFT) }}{{ trans('ip.product') }} {{ $i }}{{ $i }}$100.00$10.00$0.00$110.00
-
diff --git a/resources/views/mason/bricks/detail-quote-project/preview.blade.php b/resources/views/mason/bricks/detail-quote-project/preview.blade.php deleted file mode 100644 index 2ceccfb28..000000000 --- a/resources/views/mason/bricks/detail-quote-project/preview.blade.php +++ /dev/null @@ -1,54 +0,0 @@ -@props([ - 'config' => [] -]) - -
- - - - @if($config['show_project_name'] ?? true) - - @endif - @if($config['show_task_name'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_hours'] ?? true) - - @endif - @if($config['show_rate'] ?? true) - - @endif - @if($config['show_total'] ?? true) - - @endif - - - - @for($i = 1; $i <= 3; $i++) - - @if($config['show_project_name'] ?? true) - - @endif - @if($config['show_task_name'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_hours'] ?? true) - - @endif - @if($config['show_rate'] ?? true) - - @endif - @if($config['show_total'] ?? true) - - @endif - - @endfor - -
{{ trans('ip.project') }}{{ trans('ip.task') }}{{ trans('ip.description') }}{{ trans('ip.hours') }}{{ trans('ip.rate') }}{{ trans('ip.total') }}
{{ trans('ip.project') }} {{ $i }}{{ trans('ip.task') }} {{ $i }}{{ trans('ip.task_description') }}{{ $i * 5 }}$75.00${{ $i * 5 * 75 }}.00
-
diff --git a/resources/views/mason/bricks/detail-tasks/preview.blade.php b/resources/views/mason/bricks/detail-tasks/preview.blade.php deleted file mode 100644 index 24b9bedf6..000000000 --- a/resources/views/mason/bricks/detail-tasks/preview.blade.php +++ /dev/null @@ -1,49 +0,0 @@ -
-
{{ trans('ip.tasks_table') }}
- - - - @if($config['show_task_number'] ?? true) - - @endif - @if($config['show_task_name'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_due_at'] ?? false) - - @endif - @if($config['show_task_price'] ?? true) - - @endif - @if($config['show_task_status'] ?? true) - - @endif - - - - - @if($config['show_task_number'] ?? true) - - @endif - @if($config['show_task_name'] ?? true) - - @endif - @if($config['show_description'] ?? true) - - @endif - @if($config['show_due_at'] ?? false) - - @endif - @if($config['show_task_price'] ?? true) - - @endif - @if($config['show_task_status'] ?? true) - - @endif - - -
{{ trans('ip.number') }}{{ trans('ip.task_name') }}{{ trans('ip.description') }}{{ trans('ip.due_date') }}{{ trans('ip.price') }}{{ trans('ip.status') }}
TASK-001Sample TaskTask description{{ now()->addDays(7)->format('Y-m-d') }}$100.00{{ trans('ip.pending') }}
-
diff --git a/resources/views/mason/bricks/footer-notes/index.blade.php b/resources/views/mason/bricks/footer-notes/index.blade.php deleted file mode 100644 index f79b54aac..000000000 --- a/resources/views/mason/bricks/footer-notes/index.blade.php +++ /dev/null @@ -1,16 +0,0 @@ -@props([ - 'config' => [], - 'data' => [] -]) - - diff --git a/resources/views/mason/bricks/footer-notes/preview.blade.php b/resources/views/mason/bricks/footer-notes/preview.blade.php deleted file mode 100644 index 968a770e8..000000000 --- a/resources/views/mason/bricks/footer-notes/preview.blade.php +++ /dev/null @@ -1,15 +0,0 @@ -@props([ - 'config' => [] -]) - -
-
- @if(!empty($config['footer_content'])) -
- {{ $config['footer_content'] }} -
- @else -

{{ trans('ip.footer_placeholder') }}

- @endif -
-
diff --git a/resources/views/mason/bricks/footer-terms/index.blade.php b/resources/views/mason/bricks/footer-terms/index.blade.php deleted file mode 100644 index 3d5768603..000000000 --- a/resources/views/mason/bricks/footer-terms/index.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -
- @if(!empty($config['terms_content'])) - {{ $config['terms_content'] }} - @elseif(!empty($data['terms'])) - {{ $data['terms'] }} - @endif -
diff --git a/resources/views/mason/bricks/footer-terms/preview.blade.php b/resources/views/mason/bricks/footer-terms/preview.blade.php deleted file mode 100644 index 463ac695e..000000000 --- a/resources/views/mason/bricks/footer-terms/preview.blade.php +++ /dev/null @@ -1,10 +0,0 @@ -
-
{{ trans('ip.terms_conditions') }}
-
- @if(!empty($config['terms_content'])) - {{ $config['terms_content'] }} - @else -

{{ trans('ip.terms_placeholder') }}

- @endif -
-
diff --git a/resources/views/mason/bricks/header-client/preview.blade.php b/resources/views/mason/bricks/header-client/preview.blade.php deleted file mode 100644 index cf0178b29..000000000 --- a/resources/views/mason/bricks/header-client/preview.blade.php +++ /dev/null @@ -1,19 +0,0 @@ -@props([ - 'config' => [] -]) - -
-
-

{{ trans('ip.bill_to') }}

-

{{ trans('ip.client_name') }}

- @if($config['show_address'] ?? true) -

{{ trans('ip.client_address') }}

- @endif - @if($config['show_phone'] ?? true) -

{{ trans('ip.phone') }}: +1 555 123 4567

- @endif - @if($config['show_email'] ?? true) -

{{ trans('ip.email') }}: client@example.com

- @endif -
-
diff --git a/resources/views/mason/bricks/header-company/preview.blade.php b/resources/views/mason/bricks/header-company/preview.blade.php deleted file mode 100644 index 9f49ac144..000000000 --- a/resources/views/mason/bricks/header-company/preview.blade.php +++ /dev/null @@ -1,30 +0,0 @@ -@props([ - 'config' => [] -]) - -
-
- @if($config['show_logo'] ?? true) -
- - - -
- @endif -
-

{{ trans('ip.company_name') }}

- @if($config['show_address'] ?? true) -

{{ trans('ip.company_address') }}

- @endif - @if($config['show_phone'] ?? true) -

{{ trans('ip.phone') }}: +1 234 567 890

- @endif - @if($config['show_email'] ?? true) -

{{ trans('ip.email') }}: info@company.com

- @endif - @if($config['show_vat_id'] ?? true) -

{{ trans('ip.vat_id') }}: 12345678

- @endif -
-
-
diff --git a/resources/views/mason/bricks/header-invoice-meta/preview.blade.php b/resources/views/mason/bricks/header-invoice-meta/preview.blade.php deleted file mode 100644 index 5ff202eda..000000000 --- a/resources/views/mason/bricks/header-invoice-meta/preview.blade.php +++ /dev/null @@ -1,34 +0,0 @@ -@props([ - 'config' => [] -]) - -
-
- - @if($config['show_invoice_number'] ?? true) - - - - - @endif - @if($config['show_invoice_date'] ?? true) - - - - - @endif - @if($config['show_due_date'] ?? true) - - - - - @endif - @if($config['show_po_number'] ?? false) - - - - - @endif -
{{ trans('ip.invoice_number') }}:INV-2024-001
{{ trans('ip.invoice_date') }}:{{ date('Y-m-d') }}
{{ trans('ip.due_date') }}:{{ date('Y-m-d', strtotime('+30 days')) }}
{{ trans('ip.po_number') }}:PO-12345
-
-
diff --git a/resources/views/mason/bricks/header-project/preview.blade.php b/resources/views/mason/bricks/header-project/preview.blade.php deleted file mode 100644 index 105cbb428..000000000 --- a/resources/views/mason/bricks/header-project/preview.blade.php +++ /dev/null @@ -1,20 +0,0 @@ -
-
{{ trans('ip.project_header') }}
-
- @if($config['show_project_number'] ?? true) -
{{ trans('ip.project_number') }}: PROJECT-001
- @endif - @if($config['show_project_name'] ?? true) -
{{ trans('ip.project_name') }}: Sample Project
- @endif - @if($config['show_start_date'] ?? true) -
{{ trans('ip.start_date') }}: {{ now()->format('Y-m-d') }}
- @endif - @if($config['show_end_date'] ?? true) -
{{ trans('ip.end_date') }}: {{ now()->addDays(30)->format('Y-m-d') }}
- @endif - @if($config['show_status'] ?? true) -
{{ trans('ip.status') }}: {{ trans('ip.in_progress') }}
- @endif -
-
diff --git a/resources/views/mason/bricks/header-quote-meta/preview.blade.php b/resources/views/mason/bricks/header-quote-meta/preview.blade.php deleted file mode 100644 index ca6b5909d..000000000 --- a/resources/views/mason/bricks/header-quote-meta/preview.blade.php +++ /dev/null @@ -1,17 +0,0 @@ -
-
{{ trans('ip.quote_metadata') }}
-
- @if($config['show_quote_number'] ?? true) -
{{ trans('ip.quote_number') }}: QUO-001
- @endif - @if($config['show_quoted_at'] ?? true) -
{{ trans('ip.quoted_at') }}: {{ now()->format('Y-m-d') }}
- @endif - @if($config['show_expires_at'] ?? true) -
{{ trans('ip.expires_at') }}: {{ now()->addDays(30)->format('Y-m-d') }}
- @endif - @if($config['show_status'] ?? true) -
{{ trans('ip.status') }}: {{ trans('ip.draft') }}
- @endif -
-
diff --git a/screenshot_problem_icon_heroicon.png b/screenshot_problem_icon_heroicon.png deleted file mode 100644 index cd35d133b..000000000 Binary files a/screenshot_problem_icon_heroicon.png and /dev/null differ diff --git a/screenshot_report_builder.png b/screenshot_report_builder.png deleted file mode 100644 index c454b6d2e..000000000 Binary files a/screenshot_report_builder.png and /dev/null differ diff --git a/screenshot_something_wrong.png b/screenshot_something_wrong.png deleted file mode 100644 index ea1a3a5d8..000000000 Binary files a/screenshot_something_wrong.png and /dev/null differ