Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions .github/DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,38 @@ Visit: http://localhost:8080 (override the port with `APP_PORT` in `.env`).

Both PHP images ship the full extension set the app needs: `intl`, `gd`,
`pdo_mysql`, `bcmath`, `zip`, `exif`, `soap`, `redis`. The CLI image also has
Composer, a 1G memory limit for the test suite, and bundled `pdo_sqlite`
(the suite runs on an in-memory sqlite database — no db service needed for
tests).
Composer and a 1G memory limit for the test suite.

---

## Running the test suite

```bash
docker compose run --rm cli vendor/bin/phpunit --exclude-group failing,troubleshooting
docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting
```

`APP_ENV=testing` is the `cli` service default, so `.env.testing`
(sqlite `:memory:`) is picked up automatically. See `RUNNING_TESTS.md` for
filters, groups, and suites.
Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to behave
differently for this app: a raw `vendor/bin/phpunit` run silently drops some submitted field
values in Livewire form tests. `artisan test` is the proven-reliable path and is what CI uses, so
standardize on it.

**Known issue (see [#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689)):** a
freshly-`docker compose build`'t `cli` image has, at least once, reproduced this same
field-dropping bug at scale (100+ false failures) even under `artisan test`, for reasons not yet
isolated — despite extension/ini parity with a known-good image. Before trusting a full local run
from a rebuilt `cli` image, sanity-check it against a small, known test first, e.g.:
```bash
docker compose run --rm cli php artisan test --filter=ContactsTest
```
All 11 assertions should pass. If any fail with "field is required" errors on data you know you
supplied, don't trust the rest of that run — see the linked issue.

`APP_ENV=testing` is the `cli` service default, and it always connects to
the compose stack's real `db` service (MariaDB) for tests — the `cli`
service injects `DB_CONNECTION=mysql`/`DB_HOST=db`/etc. itself, so nothing
in `.env.testing` needs editing. This intentionally does not fall back to
SQLite: SQLite's lenient identifier quoting has masked real bugs before that
only surfaced against MariaDB in CI.

### File ownership on Linux

Expand Down
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,15 @@ actual-real-resolved-issues.md
current-issues.md
merge-order.md
"saving some issues.md"
issues_full.json
refine-issues.json
/plans/
feature-parity.md
issues_index.json
parity-results.md
report-2026-07-18.md
results-transcript.md
summary-feature-parity.md
plan-2026-07-19.md
storage/dompdf_log
untouched.json
5 changes: 2 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,9 @@ Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via `
composer install
cp .env.example .env && php artisan key:generate
php artisan migrate && php artisan db:seed
# Tests (no MySQL locally? use SQLite)
# Tests run against real MariaDB — no SQLite fallback (parity with CI)
cp .env.testing.example .env.testing
# set DB_CONNECTION=sqlite, DB_DATABASE=:memory: in .env.testing
php artisan test
docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting
```

---
Expand Down
31 changes: 27 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@

## What this is

Laravel 11 + Filament v4 + Livewire v3 invoicing app. Modular architecture via `nwidart/laravel-modules`. PHP 8.1+ enums, Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`.
Laravel 13 + Filament v5 + Livewire v4 invoicing app. Modular architecture via `nwidart/laravel-modules` v13. PHP 8.3+ (dev box: 8.4.23), Spatie roles/permissions, multi-tenancy via Filament's built-in tenant system scoped to `Company`.

**Resolved versions** (from `composer.lock`):
- `laravel/framework` 13.15.0 (PHP ^8.3)
- `filament/filament` 5.6.7 (+ 9 sub-packages @ 5.6.7)
- `livewire/livewire` 4.3.1
- `nwidart/laravel-modules` 13.0.0
- `spatie/laravel-permission` 8.0.0
- `danharrin/livewire-rate-limiting` 2.2.0

---

Expand Down Expand Up @@ -187,11 +195,21 @@ User::factory()->create(['is_active' => true, 'email_verified_at' => now()])

### DB for tests

Tests need a DB. Production CI uses MariaDB 11. For local dev without MySQL, set in `.env.testing`:
Tests need a real MariaDB DB — matching CI (MariaDB 11) — not SQLite. SQLite's lenient identifier
quoting has silently masked real bugs before (e.g. `->latest()` defaulting to a nonexistent
`created_at` column on `$timestamps = false` models passed locally, failed on CI). Run via the
`cli` compose service, which points at the stack's `db` service automatically:
```
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
docker compose run --rm cli php artisan test --exclude-group failing,troubleshooting
```
No `.env.testing` edits needed — the `cli` service injects `DB_CONNECTION=mysql`/`DB_HOST=db` etc.
itself. Use `php artisan test`, not `vendor/bin/phpunit` directly — the two have been observed to
behave differently for this app's Livewire form tests; `artisan test` is the reliable one.
**Known issue:** a freshly-rebuilt `cli` image has reproduced false Livewire-form failures at
scale even under `artisan test`, for reasons not yet isolated — see
[#689](https://github.com/InvoicePlane/InvoicePlane-v2/issues/689) and sanity-check with
`--filter=ContactsTest` (should be 11/11 passing) before trusting a full run from a rebuilt image.
See `.github/DOCKER.md`.

### AAA phase comment style

Expand Down Expand Up @@ -299,3 +317,8 @@ 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.
7 changes: 7 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
## InvoicePlane v2 — Development Makefile
## ──────────────────────────────────────────────────────────────────────────────
##
## NOTE: `vendor/bin/phpunit` (used by the targets below) and `php artisan
## test` (make artisan-test) have been observed to behave differently for
## this app — a raw phpunit run has silently dropped submitted field values
## in Livewire form tests in some environments. If a target below reports a
## failure that `make artisan-filter FILTER="..."` doesn't reproduce, prefer
## the artisan-test variant; it matches what CI runs.
##
## QUICK START
## make test Run the full PHPUnit suite (all tests)
## make smoke Run only @group smoke tests (fast sanity check)
Expand Down
47 changes: 47 additions & 0 deletions Modules/Core/Console/ReportsSyncSystemCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Modules\Core\Console;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Modules\Core\Services\ReportTemplateStorage;

/**
* Copies the shipped report templates from resources/report-templates into
* the system scope of the report_templates disk. Idempotent — running it
* again simply rewrites the system copies with the shipped versions.
*/
class ReportsSyncSystemCommand extends Command
{
protected $signature = 'reports:sync-system';

protected $description = 'Sync shipped report templates into the system template storage';

public function handle(): int
{
$source = resource_path('report-templates');

if ( ! File::isDirectory($source)) {
$this->error("Source directory [{$source}] does not exist.");

return self::FAILURE;
}

$disk = Storage::disk(ReportTemplateStorage::DISK);
$synced = 0;

foreach (File::allFiles($source) as $file) {
$disk->put(
ReportTemplateStorage::SCOPE_SYSTEM . '/' . str_replace('\\', '/', $file->getRelativePathname()),
$file->getContents(),
);

$synced++;
}

$this->info("Synced {$synced} report template file(s) into system storage.");

return self::SUCCESS;
}
}
2 changes: 1 addition & 1 deletion Modules/Core/Database/Factories/CompanyUserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

namespace Modules\Core\Database\Factories;

use Modules\Core\Models\CompanyUser;
use Modules\Core\Models\Company;
use Modules\Core\Models\CompanyUser;
use Modules\Core\Models\User;

class CompanyUserFactory extends AbstractFactory
Expand Down
4 changes: 2 additions & 2 deletions Modules/Core/Database/Factories/CustomFieldValueFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ class CustomFieldValueFactory extends AbstractFactory

public function definition(): array
{
$company = $this->resolveCompany() ?? Company::factory()->create();
$company = $this->resolveCompany() ?? Company::factory()->create();
$customField = CustomField::query()->where('company_id', $company->id)->inRandomOrder()->first()
?? CustomField::factory()->for($company)->create();
$fieldable = Relation::factory()->for($company)->create();
$fieldable = Relation::factory()->for($company)->create();

return [
'company_id' => $company->id,
Expand Down
4 changes: 2 additions & 2 deletions Modules/Core/Database/Factories/NoteFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ class NoteFactory extends AbstractFactory

public function definition(): array
{
$company = $this->resolveCompany() ?? Company::factory()->create();
$notable = Relation::factory()->for($company)->create();
$company = $this->resolveCompany() ?? Company::factory()->create();
$notable = Relation::factory()->for($company)->create();

return [
'company_id' => $company->id,
Expand Down
14 changes: 14 additions & 0 deletions Modules/Core/Enums/ReportBand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<self>
*/
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.
*/
Expand Down
24 changes: 24 additions & 0 deletions Modules/Core/Enums/ReportBlockWidth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

namespace Modules\Core\Enums;

enum ReportBlockWidth: string
{
case ONE_THIRD = 'one_third';
case HALF = 'half';
case TWO_THIRDS = 'two_thirds';
case FULL = 'full';

/**
* Get the grid width for this block width (in 12-column grid system).
*/
public function getGridWidth(): int
{
return match ($this) {
self::ONE_THIRD => 4,
self::HALF => 6,
self::TWO_THIRDS => 8,
self::FULL => 12,
};
}
}
32 changes: 32 additions & 0 deletions Modules/Core/Enums/ReportTemplateType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Modules\Core\Enums;

use Modules\Core\Contracts\LabeledEnum;

enum ReportTemplateType: string implements LabeledEnum
{
case INVOICE = 'invoice';
case QUOTE = 'quote';

public static function values(): array
{
return array_column(self::cases(), 'value');
}

public function label(): string
{
return match ($this) {
self::INVOICE => trans('ip.invoice'),
self::QUOTE => trans('ip.quote'),
};
}

public function color(): string
{
return match ($this) {
self::INVOICE => 'success',
self::QUOTE => 'info',
};
}
}
18 changes: 18 additions & 0 deletions Modules/Core/Filament/Admin/Pages/ReportBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Modules\Core\Filament\Admin\Pages;

use Modules\Core\Filament\Pages\Reports\BaseReportBuilderPage;

class ReportBuilder extends BaseReportBuilderPage
{
public function managesSystemScope(): bool
{
return true;
}

public function listPage(): string
{
return ReportTemplates::class;
}
}
18 changes: 18 additions & 0 deletions Modules/Core/Filament/Admin/Pages/ReportTemplates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace Modules\Core\Filament\Admin\Pages;

use Modules\Core\Filament\Pages\Reports\BaseReportTemplatesPage;

class ReportTemplates extends BaseReportTemplatesPage
{
public function managesSystemScope(): bool
{
return true;
}

public function builderPage(): string
{
return ReportBuilder::class;
}
}
27 changes: 27 additions & 0 deletions Modules/Core/Filament/Company/Pages/ReportBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Modules\Core\Filament\Company\Pages;

use Modules\Core\Enums\UserRole;
use Modules\Core\Filament\Pages\Reports\BaseReportBuilderPage;

class ReportBuilder extends BaseReportBuilderPage
{
public static function canAccess(): bool
{
return auth()->user()?->hasAnyRole([
...UserRole::elevated(),
UserRole::CUSTOMER_ADMIN->value,
]) ?? false;
}

public function managesSystemScope(): bool
{
return false;
}

public function listPage(): string
{
return ReportTemplates::class;
}
}
27 changes: 27 additions & 0 deletions Modules/Core/Filament/Company/Pages/ReportTemplates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Modules\Core\Filament\Company\Pages;

use Modules\Core\Enums\UserRole;
use Modules\Core\Filament\Pages\Reports\BaseReportTemplatesPage;

class ReportTemplates extends BaseReportTemplatesPage
{
public static function canAccess(): bool
{
return auth()->user()?->hasAnyRole([
...UserRole::elevated(),
UserRole::CUSTOMER_ADMIN->value,
]) ?? false;
}

public function managesSystemScope(): bool
{
return false;
}

public function builderPage(): string
{
return ReportBuilder::class;
}
}
Loading
Loading