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
26 changes: 22 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
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
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
132 changes: 132 additions & 0 deletions Modules/Core/Filament/Company/Pages/Reports/BaseTabularReportPage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace Modules\Core\Filament\Company\Pages\Reports;

use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Modules\Clients\Models\Relation;
use Modules\Core\Enums\UserRole;

/**
* Shared shell for the tabular reports (#145): a date range (defaults to
* the current month), an optional client filter, a read-only Filament
* table with a summary line, and a CSV export of the filtered rows.
* All queries are tenant-scoped through the BelongsToCompany global scope.
*/
abstract class BaseTabularReportPage extends Page implements HasTable
{
use InteractsWithTable;

public ?string $dateFrom = null;

public ?string $dateTo = null;

public ?int $clientId = null;

protected string $view = 'core::filament.company.pages.reports.tabular-report';

/**
* The filtered query behind both the table and the CSV export.
*/
abstract public function reportQuery(): Builder;

/**
* @return array<int, \Filament\Tables\Columns\Column>
*/
abstract protected function reportColumns(): array;

/**
* @return array<int, string>
*/
abstract protected function csvHeaders(): array;

/**
* @return array<int, string|int|float>
*/
abstract protected function csvRow($record): array;

/**
* One-line totals summary rendered under the table.
*/
abstract public function summaryLine(): string;

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

public static function getNavigationGroup(): ?string
{
return trans('ip.reports');
}

public function mount(): void
{
$this->dateFrom ??= now()->startOfMonth()->toDateString();
$this->dateTo ??= now()->endOfMonth()->toDateString();
}

public function table(Table $table): Table
{
return $table
->query(fn (): Builder => $this->reportQuery())
->columns($this->reportColumns())
->paginated([25, 50, 100])
->headerActions([
$this->exportCsvAction(),
]);
}

public function exportCsvAction(): Action
{
return Action::make('exportCsv')
->label(trans('ip.export_csv'))
->icon('heroicon-o-arrow-down-tray')
->action(function () {
$filename = static::getSlug() . '-' . now()->toDateString() . '.csv';

return response()->streamDownload(function (): void {
$handle = fopen('php://output', 'wb');
fputcsv($handle, $this->csvHeaders());

foreach ($this->reportQuery()->get() as $record) {
fputcsv($handle, $this->csvRow($record));
}

fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
});
}

/**
* @return array<int, array{id: int, name: string}>
*/
public function getClientOptions(): array
{
return Relation::query()
->orderBy('company_name')
->get(['id', 'company_name'])
->map(fn (Relation $relation): array => ['id' => $relation->id, 'name' => $relation->company_name])
->all();
}

protected function dateRange(): array
{
return [
$this->dateFrom ?? now()->startOfMonth()->toDateString(),
$this->dateTo ?? now()->endOfMonth()->toDateString(),
];
}

protected function money(mixed $amount): string
{
return number_format((float) $amount, 2, '.', '');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Modules\Core\Filament\Company\Pages\Reports;

use Filament\Tables\Columns\TextColumn;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Modules\Clients\Models\Relation;

class InvoicedByClientReport extends BaseTabularReportPage
{
public static function getNavigationLabel(): string
{
return trans('ip.report_invoiced_by_client');
}

public function getTitle(): string
{
return trans('ip.report_invoiced_by_client');
}

public function reportQuery(): Builder
{
$range = fn (Builder $query) => $query->whereBetween('invoiced_at', $this->dateRange());

return Relation::query()
->when($this->clientId, fn (Builder $query) => $query->whereKey($this->clientId))
->whereHas('invoices', $range)
->withCount(['invoices as invoices_count' => $range])
->withSum(['invoices as invoiced_total' => $range], 'invoice_total')
->orderByDesc('invoiced_total');
}

public function summaryLine(): string
{
$rows = $this->reportQuery()->get();

return trans('ip.report_summary_invoiced_by_client', [
'clients' => $rows->count(),
'total' => $this->money($rows->sum('invoiced_total')),
]);
}

protected function reportColumns(): array
{
return [
TextColumn::make('company_name')->label(trans('ip.client')),
TextColumn::make('invoices_count')->label(trans('ip.invoices'))->alignRight(),
TextColumn::make('invoiced_total')->label(trans('ip.total'))->numeric(2)->alignRight(),
];
}

protected function csvHeaders(): array
{
return ['Client', 'Invoices', 'Invoiced total'];
}

protected function csvRow($record): array
{
return [
$record->company_name,
$record->invoices_count,
$this->money($record->invoiced_total),
];
}
}
Loading
Loading