# SaaS invoicing — Filament v5 implementation plan

## 1. Scope and decisions to confirm

Build customer and product management, draft invoice creation/editing with line items, invoice sending, and manual payment recording/tracking in the existing Filament panel. This is a plan, not a feature implementation.

**Status: proposed design, pending business confirmation.** The supplied request establishes the capabilities, but not the ownership, accounting, delivery, or retention rules below. Please answer these questions before implementing the dependent migrations and workflows. The concrete specification that follows uses the proposed baseline in this table; it is not a claim that these choices have been approved.

| Gate                    | Question for the user                                                                                                       | Proposed baseline and consequence                                                                                                                                                                                                                                                                                                                                            |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| G1 — ownership/access   | Does each user run a separate business, or do multiple users collaborate in organizations? Are there different permissions? | One authenticated user owns one independent set of books; no sharing or staff roles. Use `user_id` ownership, not Filament's team switcher. If collaboration is required, replace this with an organization/membership model and confirm roles before implementation.                                                                                                        |
| G2 — money              | Which currencies, quantity precision, taxes, discounts, and rounding rules are required?                                    | One explicitly configured two-decimal currency, integer quantities, no tax/discount calculation. Currency code must be supplied, not inferred from the user's timezone. This baseline is unsuitable for taxable invoices unless the user confirms that tax is out of scope.                                                                                                  |
| G3 — historical records | Can issued invoices be edited or deleted? Must customers/products be retained? How are erroneous payments corrected?        | Drafts are editable; issuance freezes invoice contents. Do not expose deletion of customers, products, invoices, or payments. Catalog edits do not rewrite invoice snapshots. Refunds, credits, voids, and payment corrections require a separate agreed accounting workflow; this is a launch gate, not a claim that immutable mistakes are an acceptable finished product. |
| G4 — sending            | Is email sufficient? Is a PDF or customer portal required? What does “sent” mean, and which provider will be used?          | Send an HTML/text invoice by email. “Sent” means accepted by the configured mail transport, not delivered/read. No PDF or public portal. Keep ambiguous transport outcomes visible rather than automatically sending again. Confirm whether duplicate-tolerant email delivery is acceptable or a provider with idempotency/reconciliation is required.                       |
| G5 — payment rules      | Are partial payments, overpayments, advance payments, or online payment collection required?                                | Manually record positive payments after successful sending; partial payments supported; reject amounts above the remaining balance. No payment gateway, allocations across invoices, overpayment credits, or payments on drafts.                                                                                                                                             |
| G6 — invoice identity   | What numbering, issuer details, legal fields, dates, and payment instructions are mandatory?                                | Assign a unique non-gapless number at issuance, using the invoice's database ID with an `INV-` prefix. Snapshot configured issuer details and instructions. Required legal fields and actual configuration values must be confirmed before launch.                                                                                                                           |

Do not implement provisional schema/rules as settled business requirements. Resolve G1–G6 and revise the affected design before coding if the answers differ. Subscription billing for the SaaS itself, onboarding, organization administration, recurring invoices, imports/exports, dashboards, reminders, and accounting integrations are not included in this plan.

## 2. Existing application and implementation order

The supplied application is a minimal Laravel 13.31.0 application running Filament 5.8.1, Livewire 4.4.4, and PHPUnit 12.5.35. It has an `admin` panel at `/admin`, login, the default dashboard/widgets, a `User` model, and only the default users/cache/jobs migrations. No customer, product, invoice, or payment implementation exists. Preserve the current panel provider and discovery conventions.

Work from `app/`. Retain `composer.lock`; no package updates or new Composer dependencies are needed for the proposed baseline. The supplied Blueprint directory is guidance, not a Composer dependency.

Implementation sequence after the decision gates are resolved:

1. Add the domain schema, ownership policies, currency validation/calculation, and factories.
2. Add Customer and Product Resources, then Invoice Resource and draft save behavior.
3. Add issuance/delivery, then the payment ledger and Payments Relation Manager.
4. Exercise actual Filament actions and reactive field transitions, then mail/queue failure and concurrency cases.
5. Configure the confirmed currency, issuer details, mail transport, queue worker, and production database. Verify business/legal readiness; do not ship the prototype merely because its tests pass.

### Commands

These are implementation commands to run later, not commands executed to produce this plan. Scaffold without `--generate`, since the forms below deliberately differ from raw database fields. Run model commands first, fill in the schema/relationships, then scaffold Resources and the Relation Manager.

```sh
php artisan make:model Customer -mf --no-interaction
php artisan make:model Product -mf --no-interaction
php artisan make:model Invoice -mf --no-interaction
php artisan make:model InvoiceItem -mf --no-interaction
php artisan make:model InvoiceDelivery -mf --no-interaction
php artisan make:model Payment -mf --no-interaction
php artisan make:policy CustomerPolicy --model=Customer --no-interaction
php artisan make:policy ProductPolicy --model=Product --no-interaction
php artisan make:policy InvoicePolicy --model=Invoice --no-interaction
php artisan make:policy PaymentPolicy --model=Payment --no-interaction
php artisan make:filament-resource Customer --panel=admin --record-title-attribute=name --no-interaction
php artisan make:filament-resource Product --panel=admin --record-title-attribute=name --no-interaction
php artisan make:filament-resource Invoice --panel=admin --record-title-attribute=number --view --no-interaction
php artisan make:filament-relation-manager InvoiceResource payments reference --panel=admin --related-model='App\Models\Payment' --no-interaction
php artisan make:job SendInvoice --no-interaction
php artisan make:mail InvoiceMail --no-interaction
```

Create the enums, calculation/domain operation classes, configuration, and tests at the locations specified below without adding extra scaffolding dependencies. After editing migrations, run `php artisan migrate --no-interaction` against the local development database. Run `vendor/bin/pint --dirty`, `php artisan test --filter=Invoicing`, and the full `php artisan test` suite after implementation. Do not run production migrations or send real customer email during development verification.

## 3. Models and invariants

All proposed models live under `app/Models` in `App\Models` and use `Illuminate\Database\Eloquent\Factories\HasFactory`. Each table has `id` bigint primary key and nullable Laravel timestamps `created_at` and `updated_at`. Unless marked nullable/defaulted, attributes below are required. Do not add soft deletion. Use restrictive foreign-key deletion for financial references and user ownership; there are no delete UI actions. Order migrations so parents exist before children.

| Model / table                          | Attributes beyond the common columns                                                                                                                                                                                                                                                                                                                                                                                                                          | Relationships                                                                                                                                                                  |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Customer / `customers`                 | `user_id` FK users, indexed; `name` string(255); `email` string(255); `billing_address` text nullable                                                                                                                                                                                                                                                                                                                                                         | `user()` belongsTo User; `invoices()` hasMany Invoice                                                                                                                          |
| Product / `products`                   | `user_id` FK users, indexed; `name` string(255); `description` text nullable; `unit_price_minor` bigint, nonnegative                                                                                                                                                                                                                                                                                                                                          | `user()` belongsTo User; `invoiceItems()` hasMany InvoiceItem                                                                                                                  |
| Invoice / `invoices`                   | `user_id` FK users, indexed; `customer_id` FK customers; `number` string(64) nullable, unique when non-null; `currency` char(3); `issue_date` date; `due_date` date; `notes` text nullable; `issued_at` timestamp nullable; `customer_name` string(255) nullable; `customer_email` string(255) nullable; `customer_billing_address` text nullable; `issuer_snapshot` JSON nullable; `total_minor` bigint default 0; `lock_version` unsigned integer default 0 | `user()` belongsTo User; `customer()` belongsTo Customer; `items()` hasMany InvoiceItem ordered by position; `payments()` hasMany Payment; `delivery()` hasOne InvoiceDelivery |
| InvoiceItem / `invoice_items`          | `invoice_id` FK invoices, indexed; `product_id` FK products nullable; `position` unsigned integer; `description` string(255); `quantity` unsigned integer; `unit_price_minor` bigint nonnegative; `line_total_minor` bigint nonnegative; unique `(invoice_id, position)`                                                                                                                                                                                      | `invoice()` belongsTo Invoice; `product()` belongsTo Product                                                                                                                   |
| InvoiceDelivery / `invoice_deliveries` | `invoice_id` FK invoices, unique; `status` string(16) default `queued`; `attempts` unsigned integer default 0; `last_attempt_at` timestamp nullable; `sent_at` timestamp nullable; `last_error` text nullable                                                                                                                                                                                                                                                 | `invoice()` belongsTo Invoice                                                                                                                                                  |
| Payment / `payments`                   | `invoice_id` FK invoices, indexed; `recorded_by` FK users; `amount_minor` bigint positive; `paid_on` date; `method` string(100); `reference` string(255) nullable; `submission_key` UUID unique                                                                                                                                                                                                                                                               | `invoice()` belongsTo Invoice; `recorder()` belongsTo User via recorded_by                                                                                                     |

Add `customers()`, `products()`, and `invoices()` hasMany relationships to User. Under G1, `User` remains the ownership boundary; no organization or membership tables and no `->tenant()` panel configuration are proposed.

Use `casts()` for dates, timestamps, integer amounts/quantities/version, issuer JSON array, and `InvoiceDelivery.status` to `App\Enums\DeliveryStatus`. Delivery cases: queued, sending, sent, failed, unknown. Implement `Filament\Support\Contracts\HasLabel` and `HasColor`: queued/sending warning, sent success, failed/unknown danger. Keep full transport errors in protected logs; store/display a sanitized error summary only.

Invoice status is derived, not an editable database enum:

- No `issued_at`: Draft.
- Issued with delivery queued/sending/failed/unknown: Queued / Sending / Send failed / Delivery unknown.
- Delivery sent and no payments: Sent.
- Delivery sent and payment sum between zero and total: Partially paid.
- Delivery sent and payment sum equal to total: Paid.

`paid_minor` is the sum of recorded payments; `balance_minor = total_minor - paid_minor`. Do not store a second mutable paid flag or balance. “Overdue” is an independent condition: sent, balance positive, and due date before today in the confirmed business timezone. It does not replace payment/delivery state. Exclude zero-total invoices from issuance in this baseline.

### Money and historical values

Use `app/Support/InvoiceAmounts.php` for one shared fixed-point conversion/calculation implementation. Accept decimal money strings with at most two fractional digits; reject exponent notation, thousands separators, negative values, and extra precision. Pad fractional digits to two and convert by string/integer operations, not binary floating point. UI money inputs permit 0 through 999999.99; quantity permits integers 1 through 10000; invoices permit 1 through 100 lines. Validate aggregate totals fit the database integer range before writing. These are proposed input limits, not approved commercial restrictions.

`line_total_minor = quantity × unit_price_minor`; invoice total is the sum of line totals. For example, 3 × 19.95 plus 2 × 7.10 is 74.05, stored as 7405. Display values with the configured invoice currency, never an assumed dollar/pound symbol. No tax/discount arithmetic is concealed in these totals.

Product selection copies its current description (or name when description is empty) and price into the draft line. Users may override those draft values. Thereafter the line stores its own description/price; hydration and unchanged saves never refresh them from the product. Changing the selection deliberately replaces those values with the newly selected product's current defaults. Clearing the product retains description/price as a custom line. Product/customer IDs must belong to the current owner.

At issuance, snapshot the current customer name/email/address and the confirmed issuer configuration; assign number, freeze dates, notes, items, currency, and total. Emails and issued displays use these snapshots, not mutable customer/product relationships. Draft line records are internal draft details: this proposal replaces them atomically on each draft save, using array order as position. If line identity/history must persist before issuance, confirm that requirement under G3 and use an identity-preserving update design instead.

## 4. Filament Resources and schemas

Use `Filament\Schemas\Schema` for forms/infolists and `Filament\Tables\Table` for tables. All forms and infolists use `->columns(1)`; line repeaters use one-column child schemas. Use the existing panel's styling. Navigation group: “Invoicing”; sort Customers 1, Products 2, Invoices 3. No new standalone Page or widget is needed: generated Resource Pages own these flows.

All Resources below use the corresponding Commands in section 2 and documentation at https://filamentphp.com/docs/5.x/resources/overview. Keep generated schema/table classes under the Resource's `Schemas` and `Tables` folders. Disable global search initially with `protected static bool $isGloballySearchable = false`; explicitly scope table searches and relationship option queries to the owner.

### CustomerResource

- Location: `/home/user/workspace/app/app/Filament/Resources/Customers/CustomerResource.php`, class `App\Filament\Resources\Customers\CustomerResource`.
- Pages: `ListCustomers` (ListRecords), `CreateCustomer` (CreateRecord), `EditCustomer` (EditRecord), under `Customers/Pages`; routes `/`, `/create`, `/{record}/edit`.
- Flow: Customers → Create → enter name, invoice email and address → save → return to listing; choose Edit to maintain those details. Changes affect future issuance, not issued invoices.

| Field           | Component                             | Docs                                              | Validation                           | Config                                  |
| --------------- | ------------------------------------- | ------------------------------------------------- | ------------------------------------ | --------------------------------------- |
| name            | `Filament\Forms\Components\TextInput` | https://filamentphp.com/docs/5.x/forms/text-input | required, string, max:255            | `->required()->maxLength(255)`          |
| email           | `Filament\Forms\Components\TextInput` | https://filamentphp.com/docs/5.x/forms/text-input | required, email, max:255; not unique | `->email()->required()->maxLength(255)` |
| billing_address | `Filament\Forms\Components\Textarea`  | https://filamentphp.com/docs/5.x/forms/textarea   | nullable, string, max:2000           | `->rows(3)->maxLength(2000)`            |

| Column | Component                            | Docs                                                 | Config                       |
| ------ | ------------------------------------ | ---------------------------------------------------- | ---------------------------- |
| name   | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->searchable()->sortable()` |
| email  | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->searchable()`             |

Default sort name ascending. No filters, bulk actions, deletion, or Relation Managers. Invoice history is available by customer filter on Invoices rather than duplicated here.

### ProductResource

- Location: `/home/user/workspace/app/app/Filament/Resources/Products/ProductResource.php`, class `App\Filament\Resources\Products\ProductResource`.
- Pages: `ListProducts`, `CreateProduct`, `EditProduct` under `Products/Pages`; the same three route patterns as Customers.
- Flow: Products → Create → name, optional description and default price → save. Edit changes future product selections only.

| Field       | Component                             | Docs                                              | Validation                                        | Config                                                                                                         |
| ----------- | ------------------------------------- | ------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| name        | `Filament\Forms\Components\TextInput` | https://filamentphp.com/docs/5.x/forms/text-input | required, string, max:255                         | `->required()->maxLength(255)`                                                                                 |
| description | `Filament\Forms\Components\Textarea`  | https://filamentphp.com/docs/5.x/forms/textarea   | nullable, string, max:255                         | `->rows(3)->maxLength(255)`                                                                                    |
| unit_price  | `Filament\Forms\Components\TextInput` | https://filamentphp.com/docs/5.x/forms/text-input | required, canonical money rule, range 0–999999.99 | `->required()->inputMode('decimal')`; suffix configured currency; hydrate from minor units and convert on save |

| Column           | Component                            | Docs                                                 | Config                                                             |
| ---------------- | ------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------------------ |
| name             | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->searchable()->sortable()`                                       |
| unit_price_minor | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->money(config('invoicing.currency'), divideBy: 100)->sortable()` |

Default sort name ascending. No filters, bulk actions, deletion, or product stock tracking. Map the non-database `unit_price` field through create/edit data mutation hooks, excluding it from model fillable data.

### InvoiceResource

- Location: `/home/user/workspace/app/app/Filament/Resources/Invoices/InvoiceResource.php`, class `App\Filament\Resources\Invoices\InvoiceResource`.
- Pages: `ListInvoices` (ListRecords), `CreateInvoice` (CreateRecord), `EditInvoice` (EditRecord), `ViewInvoice` (ViewRecord), under `Invoices/Pages`. Routes `/`, `/create`, `/{record}/edit`, `/{record}`. Creation and Save redirect to ViewInvoice so Send operates on saved data only.
- Record title: number, falling back to `Draft #{id}`. Edit page only permits drafts. View page always gives read-only review and valid workflow actions.

| Field                | Component                              | Docs                                                    | Validation                                        | Config                                                                                                                                                          |
| -------------------- | -------------------------------------- | ------------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| customer_id          | `Filament\Forms\Components\Select`     | https://filamentphp.com/docs/5.x/forms/select           | required; customer exists for authenticated owner | `->relationship('customer', 'name', modifyQueryUsing: owner-scoped query)->searchable()->required()`; scoped existence validation with explicit owner predicate |
| issue_date           | `Filament\Forms\Components\DatePicker` | https://filamentphp.com/docs/5.x/forms/date-time-picker | required, date                                    | `->required()->default(today())` in confirmed business timezone                                                                                                 |
| due_date             | `Filament\Forms\Components\DatePicker` | https://filamentphp.com/docs/5.x/forms/date-time-picker | required, date, after_or_equal:issue_date         | `->required()->afterOrEqual('issue_date')`; no guessed credit terms/default                                                                                     |
| notes                | `Filament\Forms\Components\Textarea`   | https://filamentphp.com/docs/5.x/forms/textarea         | nullable, string, max:2000                        | `->rows(3)->maxLength(2000)`                                                                                                                                    |
| items                | `Filament\Forms\Components\Repeater`   | https://filamentphp.com/docs/5.x/forms/repeater         | required array, 1–100 validated lines             | `->minItems(1)->maxItems(100)->defaultItems(1)->columns(1)->reorderable()->live()`; ordinary array state, **no `->relationship()`**                             |
| items.\*.product_id  | `Filament\Forms\Components\Select`     | https://filamentphp.com/docs/5.x/forms/select           | nullable; product exists for current owner        | `->options(owner-scoped product names)->searchable()->live()`; not `relationship()` because the child schema has no related model                               |
| items.\*.description | `Filament\Forms\Components\TextInput`  | https://filamentphp.com/docs/5.x/forms/text-input       | required, string, max:255                         | `->required()->maxLength(255)`                                                                                                                                  |
| items.\*.quantity    | `Filament\Forms\Components\TextInput`  | https://filamentphp.com/docs/5.x/forms/text-input       | required, integer, min:1, max:10000               | `->required()->integer()->minValue(1)->maxValue(10000)->default(1)->live()`                                                                                     |
| items.\*.unit_price  | `Filament\Forms\Components\TextInput`  | https://filamentphp.com/docs/5.x/forms/text-input       | required, canonical money rule, range 0–999999.99 | `->required()->inputMode('decimal')->live()`; suffix invoice currency                                                                                           |

Reactive imports: `Filament\Schemas\Components\Utilities\Get` and `Filament\Schemas\Components\Utilities\Set`. In product `afterStateUpdated`, fetch the authorized product and set sibling description/price; do not accept a client-supplied product price as the catalog default. `$set()` does not run other update hooks by default in the resolved version. Therefore compute the total display directly from the full current repeater state rather than assuming quantity/price callbacks cascade. During incomplete/invalid input, display “Complete valid line items to calculate total”, not a falsely authoritative zero.

Display the preview with `Filament\Schemas\Components\Text`, Docs https://filamentphp.com/docs/5.x/schemas/overview, Config content closure using `Get` and `InvoiceAmounts`; no persisted or hidden client total. Hydrate edit repeater state from stored line snapshots in `mutateFormDataBeforeFill()`; never overwrite it in `afterStateHydrated()` from today's catalog. Roundtrip selection behavior is deliberate: product A → product B → product A applies A's current defaults again; unchanged Save preserves the snapshot and any override.

Draft persistence lives in `app/Actions/Invoices/SaveDraftInvoice.php`. CreateRecord `handleRecordCreation()` and EditRecord `handleRecordUpdate()` call it. Because the repeater is not relationship-bound, Filament will not write invoice items during `getState()`. In a database transaction: authorize the user; on edit re-query owned invoice with `lockForUpdate`, reject issued/stale version; revalidate customer/product ownership and canonical money; write only the whitelisted invoice fields; replace draft items in supplied order; calculate line totals and invoice total on the server; increment `lock_version`; commit. On create inject owner/currency server-side. Hold the original edit version in a locked Livewire property, initialized on mount and refreshed after Save; never trust a hidden version input. A stale form receives a validation error asking the user to reload; it must not overwrite a newer draft or an issued invoice. Same-owner state conflicts use validation errors, ownership/policy failures use 403; foreign resource IDs resolve to 404.

| Column         | Component                            | Docs                                                 | Config                                                                                                                             |
| -------------- | ------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| number         | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->searchable()->sortable()->placeholder('Draft')`                                                                                 |
| customer.name  | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->label('Customer')->searchable()`; table is a current-customer lookup; issued snapshot appears in View                           |
| display_status | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->badge()`; derived rules above; Draft gray, Sent info, Partially paid warning, Paid success, failed/unknown danger; not sortable |
| due_date       | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->date()->sortable()`                                                                                                             |
| total_minor    | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->money(fn ($record) => $record->currency, divideBy: 100)->sortable()`                                                            |
| balance_minor  | `Filament\Tables\Columns\TextColumn` | https://filamentphp.com/docs/5.x/tables/columns/text | `->money(fn ($record) => $record->currency, divideBy: 100)`; not sortable                                                          |

Eager-load customer/delivery and aggregate payments with `withSum('payments', 'amount_minor')`, avoiding a payment query per row. Default sort `created_at` descending. No bulk actions.

| Filter      | Component                              | Docs                                                   | Config                                                                                                         |
| ----------- | -------------------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- |
| customer_id | `Filament\Tables\Filters\SelectFilter` | https://filamentphp.com/docs/5.x/tables/filters/select | `->relationship('customer', 'name', modifyQueryUsing: owner-scoped query)->searchable()`                       |
| outstanding | `Filament\Tables\Filters\Filter`       | https://filamentphp.com/docs/5.x/tables/filters/custom | `->query(...)`: sent delivery AND correlated payment sum (coalesced to zero) below total; preserve owner scope |
| overdue     | `Filament\Tables\Filters\Filter`       | https://filamentphp.com/docs/5.x/tables/filters/custom | `->query(...)`: outstanding predicate AND due_date strictly before business-local today                        |

### ViewInvoice infolist and payment relation

Infolist Columns: 1. Every scalar entry below uses `Filament\Infolists\Components\TextEntry`, Docs https://filamentphp.com/docs/5.x/infolists/text-entry:

| Entry                                                                   | Config                                                                                                                         |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| number                                                                  | `->placeholder('Draft')`                                                                                                       |
| display_status                                                          | `->badge()` with the same derivation/colors as the list                                                                        |
| customer_display_name, customer_display_email, customer_display_address | `->state(...)`: current customer for draft; frozen invoice customer fields for issued; address `->placeholder('Not provided')` |
| issue_date, due_date                                                    | `->date()`                                                                                                                     |
| currency                                                                | plain text                                                                                                                     |
| notes                                                                   | `->placeholder('None')`; escaped text                                                                                          |
| total_minor, paid_minor, balance_minor                                  | `->money(fn ($record) => $record->currency, divideBy: 100)`                                                                    |
| delivery.sent_at                                                        | `->dateTime()->placeholder('Not sent')`                                                                                        |
| delivery.last_error                                                     | escaped sanitized text; `->visible(...)` only for failed/unknown                                                               |

Entry items uses `Filament\Infolists\Components\RepeatableEntry`, Docs https://filamentphp.com/docs/5.x/infolists/repeatable-entry, Config `->schema([...])->columns(1)`. Children each use `Filament\Infolists\Components\TextEntry`, Docs https://filamentphp.com/docs/5.x/infolists/text-entry: description (plain), quantity (`->numeric(0)`), unit_price_minor and line_total_minor (`->money(invoice currency, divideBy: 100)`). Use stored line order/values.

Relation Manager: `App\Filament\Resources\Invoices\RelationManagers\PaymentsRelationManager`, file `/home/user/workspace/app/app/Filament/Resources/Invoices/RelationManagers/PaymentsRelationManager.php`; relationship `payments`, title attribute `reference`; register in InvoiceResource `getRelations()`. Docs https://filamentphp.com/docs/5.x/resources/managing-relationships. Read-only on ViewInvoice: no create/edit/delete/associate/dissociate/reorder actions; payment creation is a ViewInvoice header action, avoiding a second mutation path.

Its columns all use `Filament\Tables\Columns\TextColumn`, Docs https://filamentphp.com/docs/5.x/tables/columns/text: paid_on (`->date()->sortable()`), amount_minor (`->money(owner invoice currency, divideBy: 100)`), method (plain), reference (`->placeholder('None')`), created_at (`->dateTime()->sortable()`). Default sort paid_on descending, then id descending. No filters or totals that combine currencies. No standalone Payment Resource or InvoiceItem Resource.

## 5. Actions and end-to-end transitions

All actions require server-side authorization; visibility is only presentation. Use `Filament\Actions\Action` for custom actions and explicit domain operations. Standard action documentation: https://filamentphp.com/docs/5.x/actions/overview; modal documentation: https://filamentphp.com/docs/5.x/actions/modals.

### Customer/product creation and editing

- Action: Create customer / Create product. Component: `Filament\Actions\CreateAction`. Docs: https://filamentphp.com/docs/5.x/actions/create. Location: corresponding ListRecords header. Visibility: authenticated user permitted by create policy. Authorization: corresponding `create`. Behavior: open CreateRecord page, validate specified fields, inject authenticated owner, persist, show generated success notification, redirect to list.
- Action: Edit customer / Edit product. Component: `Filament\Actions\EditAction`. Docs: https://filamentphp.com/docs/5.x/actions/edit. Location: table `recordActions()`. Visibility: owner authorized to update. Authorization: corresponding `update`. Behavior: open EditRecord page, hydrate existing fields, validate/save editable fields only, show saved notification; never change existing invoice snapshots. Generated Save form action submits the page. Remove generated Delete actions.

### Create/edit/review invoice

- Action: Create invoice. Component: `Filament\Actions\CreateAction`. Docs: https://filamentphp.com/docs/5.x/actions/create. Location: ListInvoices header. Visibility/Authorization: InvoicePolicy.create for authenticated owner. Behavior: open CreateInvoice → select customer and dates → add/reorder/remove inline repeater lines, optionally select products → review calculated amount → submit generated Create form action → SaveDraftInvoice transaction → success → ViewInvoice in Draft state. No email is sent by Save.
- Action: Edit invoice. Component: `Filament\Actions\EditAction`. Docs: https://filamentphp.com/docs/5.x/actions/edit. Location: ViewInvoice header, visible only for owned draft. Authorization: InvoicePolicy.update. Behavior: open EditInvoice → hydrate snapshots → edit lines/details → submit generated Save form action → SaveDraftInvoice → success → ViewInvoice. Reject stale/issued writes even if the edit page was opened earlier.
- Action: View invoice. Component: `Filament\Actions\ViewAction`. Docs: https://filamentphp.com/docs/5.x/actions/view. Location: ListInvoices row. Visibility/Authorization: InvoicePolicy.view. Behavior: open ViewInvoice, show immutable review, delivery outcome, balance, and payment ledger; no mutation.
- Repeater add/delete/reorder controls modify unsaved draft array state only. They are not independent financial Actions. Their changes persist exclusively through the authorized Create/Save transaction.

### Send invoice

- Action: Send invoice (`sendInvoice`). Component: `Filament\Actions\Action`. Docs: https://filamentphp.com/docs/5.x/actions/modals. Location: ViewInvoice header. Visibility: owned draft. Authorization: InvoicePolicy.send plus fresh transactional ownership/draft check. Config: `->requiresConfirmation()`; confirmation names recipient, amount/currency, and warns that issuance locks editing. Behavior:
    1. `app/Actions/Invoices/IssueInvoice.php` re-queries/locks the owned invoice; validate at least one valid line, positive total, date ordering, deliverable customer email, issuer/currency configuration, and ownership of every referenced record.
    2. Recalculate from saved items, snapshot issuer/customer, assign unique number, set issued_at, increment version, and create the unique queued delivery row in one transaction. Never issue from unsaved browser fields.
    3. Commit, then dispatch `App\Jobs\SendInvoice` with the delivery ID. Success notification says “Invoice queued”, not “Invoice sent”. Refresh ViewInvoice.
    4. Job claims only a queued row atomically, records sending/attempt timestamp, loads the invoice by explicit delivery ownership context (no dependency on an authenticated HTTP user), and sends `App\Mail\InvoiceMail` using frozen content. Email includes number, issuer, customer, dates, item amounts, currency, total, notes, and agreed payment instructions; use escaped Blade HTML and text templates under `resources/views/mail`.
    5. A successful transport return sets delivery sent and sent_at. A proven failure before acceptance sets failed; an ambiguous timeout/crash after the send may have begun is unknown. Never call transport acceptance proof of inbox delivery.

Keep the delivery row as the durable dispatch intent: a scheduled queued-row dispatcher can recover a process that crashes after commit but before job dispatch. The job's atomic claim prevents duplicate workers sending the same queued row. A stale sending row becomes unknown rather than automatically queued. Configure operational timeout/worker settings to distinguish an active worker from a lost attempt. Do not hold a database transaction open around a network send.

- Action: Retry failed send (`retrySend`). Component: `Filament\Actions\Action`. Docs: https://filamentphp.com/docs/5.x/actions/overview. Location: ViewInvoice header. Visibility: owned invoice with delivery failed. Authorization: InvoicePolicy.retrySend plus locked failed-row check. Config: `->requiresConfirmation()`. Behavior: move proven failed delivery to queued, clear sanitized error, commit, dispatch same delivery ID, show “Invoice queued”; reuse exactly the existing number/content, do not issue a second invoice. Not available for sent or unknown outcomes. Unknown outcomes need provider evidence/reconciliation before retry; the concrete reconciliation integration depends on G4 and must be resolved before unattended production sending.

### Record and track payment

- Action: Record payment (`recordPayment`). Component: `Filament\Actions\Action`. Docs: https://filamentphp.com/docs/5.x/actions/modals. Location: ViewInvoice header. Visibility: owned invoice, delivery sent, positive balance. Authorization: InvoicePolicy.recordPayment and PaymentPolicy.create with the owning invoice; repeat checks on a fresh locked invoice in the transaction. Config: `->schema([...])`, heading “Record payment”, submit label “Record payment”. Explain that this records money already received and does not charge the customer.

| Modal field | Component                              | Docs                                                    | Validation                                                       | Config                                                                                           |
| ----------- | -------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| amount      | `Filament\Forms\Components\TextInput`  | https://filamentphp.com/docs/5.x/forms/text-input       | required, positive canonical money, no more than current balance | `->required()->inputMode('decimal')`; default formatted current balance, invoice currency suffix |
| paid_on     | `Filament\Forms\Components\DatePicker` | https://filamentphp.com/docs/5.x/forms/date-time-picker | required date, no later than business-local today                | `->required()->default(today())->maxDate(today())`                                               |
| method      | `Filament\Forms\Components\TextInput`  | https://filamentphp.com/docs/5.x/forms/text-input       | required string, max:100                                         | `->required()->maxLength(100)`; free text to avoid inventing payment-method taxonomy             |
| reference   | `Filament\Forms\Components\TextInput`  | https://filamentphp.com/docs/5.x/forms/text-input       | nullable string, max:255                                         | `->maxLength(255)`                                                                               |

Behavior: open modal with a server-generated submission UUID held in a locked Livewire property; `app/Actions/Invoices/RecordInvoicePayment.php` locks invoice, rechecks ownership/sent state and sums committed payments, validates amount against fresh balance, creates Payment with server recorder/invoice IDs and that unique key, and commits. Retrying the same submitted key returns the existing identical payment; reject key reuse for differing data. Concurrent distinct submissions serialize on the invoice so they cannot overpay. Show “Payment recorded”, refresh the parent invoice/aggregates, and dispatch `payments-recorded`; PaymentsRelationManager handles it with `#[Livewire\Attributes\On('payments-recorded')]` to refresh the table. Do not send a receipt email unless requested.

The user sees the payment in the ledger, reduced balance, and Partially paid or Paid status. There is no manual “Mark paid” action or general editable status field.

### Transition summary

| Initial state         | Trigger                          | Persisted outcome / visible outcome                         |
| --------------------- | -------------------------------- | ----------------------------------------------------------- |
| No invoice            | Create form submit               | Owned draft, saved items/total; Draft                       |
| Draft                 | Save form submit                 | Updated draft only; no delivery                             |
| Draft                 | Send invoice                     | Issued snapshot + queued delivery; Queued, editing disabled |
| Queued                | SendInvoice job claim            | sending; Sending                                            |
| Sending               | Transport accepted               | sent + sent_at; Sent                                        |
| Sending               | Proven pre-acceptance failure    | failed; Send failed, retry available                        |
| Sending               | Ambiguous failure/lost attempt   | unknown; Delivery unknown, no blind retry                   |
| Failed                | Retry failed send                | queued, same issued content/number                          |
| Sent / Partially paid | Record payment less than balance | Ledger append; Partially paid                               |
| Sent / Partially paid | Record payment equal to balance  | Ledger append; Paid, payment action unavailable             |
| Any                   | Unauthorized or invalid mutation | No writes, no job/mail dispatch, no success notification    |

## 6. Authorization and enforcement boundaries

Under the unconfirmed single-owner baseline, implement `Filament\Models\Contracts\FilamentUser` on User and `canAccessPanel(Panel $panel): bool` for authenticated provisioned users of the `admin` panel. Do not introduce public signup or assume the skeleton's development access is a production access policy. Docs: https://filamentphp.com/docs/5.x/users/overview.

Policies live in `app/Policies` (`App\Policies`). For CustomerPolicy and ProductPolicy: viewAny/create require an authenticated provisioned user; view/update require record.user_id equal to user.id; delete/deleteAny/restore/restoreAny/forceDelete/forceDeleteAny return false. InvoicePolicy uses the same list/create/view ownership rules; update requires owned draft; send requires owned draft; retrySend requires owned failed delivery; recordPayment requires owned sent delivery and positive balance; all deletion/restoration abilities false. PaymentPolicy view requires ownership through invoice; create requires the same invoice condition as recordPayment; update/delete and bulk destructive abilities false. No privileged cross-owner override is proposed.

Scope each Resource's `getEloquentQuery()` to authenticated `user_id` for record resolution and listing. Scope Select options, filters, existence validation, and domain lookups explicitly too. Laravel raw `exists`/`unique` rules do not inherit Eloquent owner scopes: use `scopedExists(model: Customer::class/Product::class, column: 'id', modifyQueryUsing: ...)` with the same owner predicate. Validate customer/product IDs again inside the mutation transaction. Derive invoice-item/payment/delivery access through their parent invoice; these models have no independent globally visible endpoints. Jobs take persisted IDs and explicitly re-establish the parent context; they must not depend on `auth()->id()` or ambient Filament scopes.

Custom actions use `->authorize(...)` and domain operations call `Gate::authorize()` on freshly loaded records. Filament-hidden actions may refuse to mount without producing a 403, so do not promise that every hidden-button invocation has the same HTTP status. Required invariant: no mutation/notification occurs; direct domain authorization failures are 403. Cross-owner Resource URLs return 404 via scoped resolution. Ownership/state checks precede writes and external effects.

### Verified lifecycle constraints to preserve

- Resolved CreateRecord runs access authorization → beforeValidate → schema getState → data mutation → handleRecordCreation → relationship saves → afterCreate → commit → notification.
- Resolved EditRecord runs authorization → beforeValidate → schema validation → afterValidate/beforeSave callback → relationship saves → mutateFormDataBeforeSave → handleRecordUpdate → afterSave → commit. Therefore `handleRecordUpdate` is too late to protect automatic relationship writes if someone changes this plan to a relationship Repeater.
- This plan intentionally uses an ordinary array Repeater and owns invoice/item writes in the same domain transaction; no automatic line writes occur before the locked draft check. Do not switch to `->relationship('items')` without redesigning/test-covering that ordering.
- Filament page transactions are configurable, not inherently guaranteed. Domain operations explicitly use `DB::transaction`; network delivery is outside it. Do not place correctness-critical work in afterSave notifications or assume `mutateStateForValidation()` also changes persisted state: the resolved schema restores raw state before dehydration. The same money parser must validate and convert actual save inputs.
- Generated resource policies are necessary but not sufficient for concurrent state transitions; fresh locking/version checks still own the write invariant.

References: https://filamentphp.com/docs/5.x/resources/creating-records, https://filamentphp.com/docs/5.x/resources/editing-records, https://filamentphp.com/docs/5.x/resources/overview#authorization, https://filamentphp.com/docs/5.x/forms/overview#field-utility-injection, https://laravel.com/docs/13.x/validation, https://laravel.com/docs/13.x/queues, https://laravel.com/docs/13.x/mail.

## 7. Verification plan and completion criteria

Use the existing PHPUnit runner, `Tests\TestCase`, `Illuminate\Foundation\Testing\RefreshDatabase`, PHPUnit data providers, and `Livewire\Livewire::test()`; do not add Pest solely to copy documentation examples. Set the current panel with `Filament\Facades\Filament::setCurrentPanel('admin')` and authenticate a provisioned user. Installed packages provide `fillForm`, `assertHasFormErrors`, `assertSchemaStateSet`, `callAction`, and `Filament\Actions\Testing\TestAction`. Relation Manager tests pass `ownerRecord` and `pageClass`. Use `Filament\Forms\Components\Repeater::fake()` and restore its returned undo callback in cleanup. Use real Livewire `set('data.items.0.product_id', ...)` updates for reactive tests, not only `fillForm()` final arrays.

Place tests in `tests/Feature/Invoicing` and `tests/Unit/Invoicing`. Use `Illuminate\Support\Facades\Mail::fake()` and `Queue::fake()` appropriately; job-fake assertions do not prove transport outcome handling. A controlled mail transport stub must exercise successful, proven-failed, and ambiguous outcomes. Docs: https://filamentphp.com/docs/5.x/testing/testing-resources, https://filamentphp.com/docs/5.x/testing/testing-schemas, https://filamentphp.com/docs/5.x/testing/testing-actions.

| Risk / plausible wrong implementation                                   | Entry point and discriminating expected outcome                                                                                                                                                                                                                                                                                                                                                                                                           |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Unscoped list or forged related IDs leak another account                | Owner A has customer Alpha/product 19.95/invoice 7405; B has Beta/product 88.30/invoice 8830. A's lists/options/filter results contain exact A identities, never B. B IDs submitted through CreateInvoice, EditInvoice, Send and payment actions cause denial with no invoice/item/payment writes, queued jobs or mail. Exercise direct URLs and post-mount ownership/state changes, not only button visibility.                                          |
| Float arithmetic, invalid precision, or client total trusted            | Through CreateInvoice save 3 × 19.95 plus 2 × 7.10; assert line totals 5985 and 1420, total 7405 in database and 74.05 on reload. Forge total 1 and line totals 1; persisted totals still 7405. Money boundaries 0/0.01, 999999.99/1000000.00, `1.20`/`1.201`, `1e2`, comma input; zero unit price valid, zero-total issuance invalid. Isolate each rule with otherwise valid data.                                                                       |
| Hydration or unchanged Save reprices snapshots                          | Product A 19.95, B 7.10. Select A, override line price to 18.25, save; change catalog A to 21.40; reload and unchanged save must retain 18.25. Then actual reactive A→B→A results in 21.40, save/reload retains 21.40. Clearing product retains description/price. Preview, persisted values, and fresh infolist agree.                                                                                                                                   |
| Repeater order/removal or partial save corrupts invoice                 | Create three asymmetric lines; remove middle item with `TestAction::make('delete')->schemaComponent('items')->arguments(['item' => ...])`, reorder remaining rows, save/reload; only intended two descriptions/order and exact recomputed total remain. Trigger failure while replacing items and assert rollback restores prior lines/total/version. Draft line IDs may change by explicit design.                                                       |
| Hidden edit controls are mistaken for immutability                      | Open two EditInvoice components at the same version; save first; second save must reject stale version and leave first's values intact. Open draft editor, issue in another request, then submit editor; no invoice or line mutation. Customer/product edits after issuance leave customer snapshot, lines and rendered email unchanged.                                                                                                                  |
| Queue acceptance mistaken for sent, or failure locks falsely paid state | Send via ViewInvoice; assert issued snapshot and one queued delivery, number fixed, sent_at null, UI Queued. Execute job under success stub at a later frozen time; only then sent_at and Sent. Confirm failed/unknown behavior and permitted/denied retries. Repeat Send while queued creates no second delivery or number. Simulate lost post-commit dispatch and let dispatcher recover queued intent. Duplicate jobs must result in one atomic claim. |
| Payment status/balance inconsistent or overpayment accepted             | Sent total 7405; record 3000 on one date with bank reference A; assert balance 4405 and Partially paid. Record 4405 on a later date/reference B; assert balance 0, Paid, and both ledger identities/dates. Against balance 4405, 4405 succeeds, 4406 fails, zero/negative fail; other fields valid. Sent-but-overdue with partial payment remains overdue; exact due date today is not overdue.                                                           |
| Duplicate or concurrent payments double-count money                     | Retry same submission UUID/data: one ledger row and one balance reduction. Different payload with same key rejected. Two concurrent payments of 4000 against 7405: one succeeds and the other fails fresh balance validation; final balance 3405, never negative. Run with separate connections against the chosen production database engine; SQLite in-memory tests cannot establish row-lock behavior.                                                 |
| Parent action succeeds but child table is stale                         | Invoke Record payment through ViewInvoice; assert parent paid/balance/status refresh and PaymentsRelationManager shows the new reference after `payments-recorded`. Reload page and compare exact values.                                                                                                                                                                                                                                                 |
| Generic field validation hides unrelated errors                         | PHPUnit providers check required name/email, invalid email, 255/256 lengths, 2000/2001 note/address length, quantity 1/0 and 10000/10001, due date equal to/before issue date, future payment date, and 1/0 and 100/101 line counts with otherwise valid state.                                                                                                                                                                                           |

Run `php artisan test --filter=Invoicing`, the full `php artisan test`, and `vendor/bin/pint --dirty`. Once UI exists, browser-check Customer/Product creation, multi-line draft editing at desktop/mobile widths, failed delivery, partial payment, fully paid view, and actual forbidden edits. Inspect rendered screenshots and keyboard/modal behavior; automated service assertions alone do not verify the UI.

Current verification establishes only that the supplied skeleton restores and its two existing tests pass. No feature tests or UI behavior described here have been implemented or passed. `search-docs` was unavailable; the plan uses Filament 5.x documentation plus resolved installed framework source and scaffold command help. No provider-specific delivery/reconciliation, production database concurrency, or business/legal assumptions are verified; those remain explicit implementation/launch prerequisites.