Repository navigation
Open
Labels
refinedRefined with SMART plan / test suggestionsRefined with SMART plan / test suggestionsv1-feature
Milestone
Description
Story: Recurring Invoices Filter in the Invoices List
As a company admin, I want to filter the invoices list to show only recurring invoices (or exclude them), so that I can quickly review my recurring billing schedule without scrolling through one-off invoices.
Background
InvoicePlane v1 had a dedicated recurring-invoices tab/filter on the invoices index. v2 currently shows all invoices in one flat list with no way to isolate recurring ones.
Acceptance Criteria
- A "Recurring" filter appears in the Filament table filter bar on the company-panel Invoices list.
- Filter options: All (default) / Recurring only / Non-recurring only.
- "Recurring only" shows invoices linked to a
RecurringInvoicerecord (i.e.invoices.recurring_invoice_id IS NOT NULL). - Filter persists across page reloads via Filament's built-in URL state.
- Filter is additive — can be combined with status and date-range filters.
- Filter label is translatable via
trans('ip.recurring').
Implementation Notes
- Add
SelectFilter::make('recurring')toInvoicesTable::configure()inModules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php. - Query modifier:
->when($value === 'yes', fn ($q) => $q->whereNotNull('recurring_invoice_id'))/->when($value === 'no', fn ($q) => $q->whereNull('recurring_invoice_id')). - Add translation key
ip.recurringtoModules/Core/Resources/lang/en/ip.php.
Test Scenarios (PHPUnit + Livewire)
// Filter shows only recurring invoices
/* Arrange */ $recurring = Invoice::factory()->for($this->company)
->create(['recurring_invoice_id' => RecurringInvoice::factory()->for($this->company)->create()]);
$oneOff = Invoice::factory()->for($this->company)->create();
/* Act */ $this->testLivewire(ListInvoices::class)
->filterTable('recurring', 'yes')
/* Assert */ ->assertCanSeeTableRecords([$recurring])
->assertCanNotSeeTableRecords([$oneOff]);// Filter shows only non-recurring invoices
/* Arrange */ // (same records from above)
/* Act */ $this->testLivewire(ListInvoices::class)
->filterTable('recurring', 'no')
/* Assert */ ->assertCanNotSeeTableRecords([$recurring])
->assertCanSeeTableRecords([$oneOff]);// Default (no filter) shows all invoices
/* Arrange */ // (same records)
/* Act */ $this->testLivewire(ListInvoices::class)
/* Assert */ ->assertCanSeeTableRecords([$recurring, $oneOff]);Metadata
Metadata
Assignees
Labels
refinedRefined with SMART plan / test suggestionsRefined with SMART plan / test suggestionsv1-feature
Activity
[-][RecurringInvoices] Invoices View - recurring invoices filter[/-][+][Invoices]: Recurring Invoices — filter by recurring status[/+]nielsdrost7 commentedon Jul 16, 2026
SMART Plan — Recurring Invoices filter in the Invoices list
Specific
Add a "Recurring" filter to the company-panel invoice list so admins can isolate recurring-generated invoices from one-off ones. Grounding gap versus the issue's own implementation notes: the issue assumes
invoices.recurring_invoice_id IS NOT NULLexists — it doesn't. Theinvoicestable (Modules/Invoices/Database/Migrations/2010_01_01_000023_create_invoices_table.php) has norecurring_invoice_idcolumn at all. The real relationship today is the other way around:recurring_invoices.invoice_idpoints at a single invoice (its template), andInvoicehas no relation back toRecurringInvoice. On top of that,Modules\Invoices\Services\RecurringInvoiceServiceis an empty stub — nothing in v2 currently generates additional invoices from a recurring profile (tracked separately as #52), so as of today there would be zero invoices in any company's data for a "recurring only" filter to ever match. This plan adds the filter and the missing link column, since #52's eventual generation logic will need somewhere to record it anyway.Measurable
invoicesgains a nullablerecurring_invoice_idFK column (verified absent from the current migration).it_stores_a_nullable_recurring_invoice_id_on_an_invoice()#[Test] public function it_stores_a_nullable_recurring_invoice_id_on_an_invoice(): void { /* Arrange */ $recurring = RecurringInvoice::factory()->for($this->company)->create(); /* Act */ $invoice = Invoice::factory()->for($this->company)->create(['recurring_invoice_id' => $recurring->id]); /* Assert */ $this->assertSame($recurring->id, $invoice->fresh()->recurring_invoice_id); }Invoice::recurringInvoice(): BelongsTorelation is added (doesn't exist today).it_exposes_the_recurring_invoice_it_was_generated_from()#[Test] public function it_exposes_the_recurring_invoice_it_was_generated_from(): void { /* Arrange */ $recurring = RecurringInvoice::factory()->for($this->company)->create(); $invoice = Invoice::factory()->for($this->company)->create(['recurring_invoice_id' => $recurring->id]); /* Act */ $loaded = $invoice->fresh()->recurringInvoice; /* Assert */ $this->assertSame($recurring->id, $loaded->id); }recurring_invoice_id.#[Test] public function it_filters_the_invoice_list_to_only_recurring_invoices(): void { /* Arrange */ $recurring = Invoice::factory()->for($this->company)->create([ 'recurring_invoice_id' => RecurringInvoice::factory()->for($this->company)->create()->id, ]); $oneOff = Invoice::factory()->for($this->company)->create(); /* Act */ $component = $this->testLivewire(ListInvoices::class)->filterTable('recurring', 'yes'); /* Assert */ $component->assertCanSeeTableRecords([$recurring])->assertCanNotSeeTableRecords([$oneOff]); }recurring_invoice_id.it_filters_the_invoice_list_to_only_non_recurring_invoices()#[Test] public function it_filters_the_invoice_list_to_only_non_recurring_invoices(): void { /* Arrange */ $recurring = Invoice::factory()->for($this->company)->create([ 'recurring_invoice_id' => RecurringInvoice::factory()->for($this->company)->create()->id, ]); $oneOff = Invoice::factory()->for($this->company)->create(); /* Act */ $component = $this->testLivewire(ListInvoices::class)->filterTable('recurring', 'no'); /* Assert */ $component->assertCanSeeTableRecords([$oneOff])->assertCanNotSeeTableRecords([$recurring]); }it_shows_all_invoices_when_no_recurring_filter_is_applied()#[Test] public function it_shows_all_invoices_when_no_recurring_filter_is_applied(): void { /* Arrange */ $recurring = Invoice::factory()->for($this->company)->create([ 'recurring_invoice_id' => RecurringInvoice::factory()->for($this->company)->create()->id, ]); $oneOff = Invoice::factory()->for($this->company)->create(); /* Act */ $component = $this->testLivewire(ListInvoices::class); /* Assert */ $component->assertCanSeeTableRecords([$recurring, $oneOff]); }it_persists_the_recurring_filter_value_in_the_url_query_string()#[Test] public function it_persists_the_recurring_filter_value_in_the_url_query_string(): void { /* Arrange */ Invoice::factory()->for($this->company)->create(); /* Act */ $component = $this->testLivewire(ListInvoices::class)->filterTable('recurring', 'yes'); /* Assert */ $component->assertSet('tableFilters.recurring.value', 'yes'); }Achievable
add_recurring_invoice_id_to_invoices_table— nullable, indexed FK oninvoicesreferencingrecurring_invoices.id,onDelete('set null')so invoice history survives if the profile is later deleted.recurringInvoice(): BelongsTotoModules/Invoices/Models/Invoice.php.SelectFilter::make('recurring')toInvoicesTable::configure()per the issue's own implementation notes (->when($value === 'yes', ...)/->when($value === 'no', ...)), reusing the existingip.recurringtranslation key (resources/lang/en/ip.php:339— already present, no i18n work needed).RecurringInvoiceService::generateDueInvoices()exists, it must setrecurring_invoice_idon every invoice it creates, or this filter will always show zero recurring invoices in production.Modules/Invoices/Tests/Feature/InvoicesTest.phpor a newInvoiceRecurringFilterTest.php.Relevant
InvoicePlane v1 had a dedicated recurring tab; v2's flat invoice list currently has no way to isolate recurring billing, and the filter becomes immediately useful the moment #52 starts generating real invoices from profiles.
Time-bound
Sprint 7 — sequence directly after #52 lands (the filter itself is a half-day change, but has nothing to filter until then).