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
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function a_role_receives_a_newly_added_default_permission_on_rerun(): voi
$newPermission = Permission::create(['name' => 'view-a-brand-new-thing', 'guard_name' => 'web']);
app(PermissionRegistrar::class)->forgetCachedPermissions();

$seeder = new class extends RoleHasPermissionsSeeder {
$seeder = new class () extends RoleHasPermissionsSeeder {
protected function getDefaultPermissionsForRole(string $roleName): array
{
$permissions = parent::getDefaultPermissionsForRole($roleName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function table(Table $table): Table
protected function getTableQuery(): Builder|Relation|null
{
/** @var Builder<Expense> $query */
$query = Expense::query()->latest()->limit(10);
$query = Expense::query()->latest('id')->limit(10);

return $query;
}
Expand Down
4 changes: 1 addition & 3 deletions Modules/Expenses/Observers/ExpenseObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,4 @@

use Modules\Core\Observers\AbstractObserver;

class ExpenseObserver extends AbstractObserver
{
}
class ExpenseObserver extends AbstractObserver {}
10 changes: 10 additions & 0 deletions Modules/Invoices/Enums/InvoiceStatus.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ public static function values(): array
return array_column(self::cases(), 'value');
}

/**
* Statuses that still accept payments (i.e. carry an outstanding balance).
*
* @return self[]
*/
public static function payable(): array
{
return [self::SENT, self::VIEWED, self::PARTIALLY_PAID, self::OVERDUE];
}

public function label(): string
{
return match ($this) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
use Modules\Invoices\Services\InvoiceCopyService;
use Modules\Invoices\Services\InvoiceService;
use Modules\Payments\Enums\PaymentMethod;
use Modules\Payments\Enums\PaymentStatus;
use Modules\Payments\Services\PaymentService;

class InvoicesTable
Expand Down
20 changes: 20 additions & 0 deletions Modules/Invoices/Models/Invoice.php
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,18 @@ public function user(): BelongsTo
| Accessors
|--------------------------------------------------------------------------
*/
/**
* The invoice total minus all completed payments recorded against it.
*/
public function getOutstandingBalanceAttribute(): float
{
$paid = $this->payments()
->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)
->sum('payment_amount');

return max(0, (float) $this->invoice_total - (float) $paid);
}

Comment on lines +169 to +180

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Prevent N+1 queries in the outstanding balance accessor.

Using the payments() query builder relationship method executes a new database query every time this accessor is accessed. If the payments relationship has been eager-loaded, you can prevent N+1 query issues by filtering the loaded collection directly instead of hitting the database again.

⚡ Proposed optimization
     public function getOutstandingBalanceAttribute(): float
     {
-        $paid = $this->payments()
-            ->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)
-            ->sum('payment_amount');
+        $paid = $this->relationLoaded('payments')
+            ? $this->payments->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)->sum('payment_amount')
+            : $this->payments()
+                ->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)
+                ->sum('payment_amount');

         return max(0, (float) $this->invoice_total - (float) $paid);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* The invoice total minus all completed payments recorded against it.
*/
public function getOutstandingBalanceAttribute(): float
{
$paid = $this->payments()
->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)
->sum('payment_amount');
return max(0, (float) $this->invoice_total - (float) $paid);
}
/**
* The invoice total minus all completed payments recorded against it.
*/
public function getOutstandingBalanceAttribute(): float
{
$paid = $this->relationLoaded('payments')
? $this->payments->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)->sum('payment_amount')
: $this->payments()
->where('payment_status', \Modules\Payments\Enums\PaymentStatus::COMPLETED->value)
->sum('payment_amount');
return max(0, (float) $this->invoice_total - (float) $paid);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Modules/Invoices/Models/Invoice.php` around lines 169 - 180, Update
getOutstandingBalanceAttribute to use the loaded payments relationship
collection, filtering it for completed PaymentStatus values and summing
payment_amount without invoking the payments() query builder. Preserve the
existing outstanding-balance calculation and non-negative result.

/**
* Get the color intensity for invoice_due_at.
*
Expand Down Expand Up @@ -212,6 +224,14 @@ public function scopeRecent($query, $limit = 25)
->limit($invoiceLimit);
}

/**
* Invoices that still accept payments (Sent, Viewed, Partially Paid, Overdue).
*/
public function scopePayable($query)
{
return $query->whereIn('invoice_status', InvoiceStatus::payable());
}

public function delete(): bool
{
// When called re-entrantly from forceDelete(), delegate straight to Model::delete()
Expand Down
132 changes: 132 additions & 0 deletions Modules/Invoices/Tests/Feature/EnterPaymentActionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace Modules\Invoices\Tests\Feature;

use Filament\Actions\Testing\TestAction;
use Livewire\Livewire;
use Modules\Core\Enums\Permission;
use Modules\Core\Tests\AbstractCompanyPanelTestCase;
use Modules\Core\Tests\Concerns\InteractsWithPermissions;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices;
use Modules\Invoices\Models\Invoice;
use Modules\Payments\Enums\PaymentMethod;
use Modules\Payments\Enums\PaymentStatus;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;

/**
* #32 - "Enter Payment" inline action on the invoice list: opens a modal
* pre-filled with the invoice's outstanding balance, creates a Payment on
* submit, and reconciles the invoice's status against what's been paid.
*/
#[CoversClass(ListInvoices::class)]
class EnterPaymentActionTest extends AbstractCompanyPanelTestCase
{
use InteractsWithPermissions;

protected function setUp(): void
{
parent::setUp();

$this->grantPermission(Permission::CREATE_PAYMENTS);
}

#[Test]
public function it_opens_the_enter_payment_modal_with_the_outstanding_balance_prefilled(): void
{
/* Arrange */
$invoice = Invoice::factory()->for($this->company)->create([
'invoice_total' => 200.00,
'invoice_status' => InvoiceStatus::SENT->value,
]);

/* Act */
$component = Livewire::actingAs($this->user)
->test(ListInvoices::class)
->mountAction(TestAction::make('enter_payment')->table($invoice));

/* Assert */
$component->assertSuccessful();
$component->assertActionDataSet([
'payment_amount' => 200.00,
]);
}

#[Test]
public function it_creates_a_payment_and_marks_the_invoice_paid_when_fully_settled(): void
{
/* Arrange */
$invoice = Invoice::factory()->for($this->company)->create([
'invoice_total' => 150.00,
'invoice_status' => InvoiceStatus::SENT->value,
]);

/* Act */
$component = Livewire::actingAs($this->user)
->test(ListInvoices::class)
->mountAction(TestAction::make('enter_payment')->table($invoice))
->setActionData([
'paid_at' => now()->toDateString(),
'payment_method' => PaymentMethod::BANK_TRANSFER->value,
'payment_status' => PaymentStatus::COMPLETED->value,
'payment_amount' => 150.00,
])
->callMountedAction();

/* Assert */
$component->assertSuccessful();
$this->assertDatabaseHas('payments', [
'invoice_id' => $invoice->id,
'payment_amount' => 150.00,
]);
$this->assertDatabaseHas('invoices', [
'id' => $invoice->id,
'invoice_status' => InvoiceStatus::PAID->value,
]);
}

#[Test]
public function it_marks_the_invoice_partially_paid_when_the_payment_does_not_cover_the_full_balance(): void
{
/* Arrange */
$invoice = Invoice::factory()->for($this->company)->create([
'invoice_total' => 200.00,
'invoice_status' => InvoiceStatus::SENT->value,
]);

/* Act */
Livewire::actingAs($this->user)
->test(ListInvoices::class)
->mountAction(TestAction::make('enter_payment')->table($invoice))
->setActionData([
'paid_at' => now()->toDateString(),
'payment_method' => PaymentMethod::BANK_TRANSFER->value,
'payment_status' => PaymentStatus::COMPLETED->value,
'payment_amount' => 50.00,
])
->callMountedAction()
->assertSuccessful();

/* Assert */
$this->assertDatabaseHas('invoices', [
'id' => $invoice->id,
'invoice_status' => InvoiceStatus::PARTIALLY_PAID->value,
]);
}

#[Test]
public function it_hides_the_enter_payment_action_once_the_invoice_is_fully_paid(): void
{
/* Arrange */
$invoice = Invoice::factory()->for($this->company)->create([
'invoice_total' => 100.00,
'invoice_status' => InvoiceStatus::PAID->value,
]);

/* Act & Assert */
Livewire::actingAs($this->user)
->test(ListInvoices::class)
->assertActionHidden(TestAction::make('enter_payment')->table($invoice));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use Modules\Core\Tests\AbstractAdminPanelTestCase;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;

Expand Down
Loading
Loading