Skip to content

[Invoices]: Recurring Invoices — filter by recurring status #143

Open

Description

@naui95

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 RecurringInvoice record (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') to InvoicesTable::configure() in Modules/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.recurring to Modules/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]);

Activity

  1. added
    enhancementNew feature or request
    and removed
    enhancementNew feature or request
    on Nov 27, 2025
  2. changed the title [-][RecurringInvoices] Invoices View - recurring invoices filter[/-] [+][Invoices]: Recurring Invoices — filter by recurring status[/+] on Jun 13, 2026
  3. added this to the v2.0.0-alpha milestone on Jun 13, 2026
  4. nielsdrost7 commented on Jul 16, 2026

    @nielsdrost7
    Collaborator

    SMART Plan — Recurring Invoices filter in the Invoices list

    Context/Epic: Invoices

    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 NULL exists — it doesn't. The invoices table (Modules/Invoices/Database/Migrations/2010_01_01_000023_create_invoices_table.php) has no recurring_invoice_id column at all. The real relationship today is the other way around: recurring_invoices.invoice_id points at a single invoice (its template), and Invoice has no relation back to RecurringInvoice. On top of that, Modules\Invoices\Services\RecurringInvoiceService is 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

    • invoices gains a nullable recurring_invoice_id FK column (verified absent from the current migration).
      • PHPUnit: 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(): BelongsTo relation is added (doesn't exist today).
      • PHPUnit: 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 only" shows only invoices with a non-null recurring_invoice_id.
      • Frontend/Livewire: As a company admin I want a "Recurring" filter on the invoice list so I can review recurring billing without scrolling past one-off invoices.
        #[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]);
        }
    • "Non-recurring only" shows only invoices with a null recurring_invoice_id.
      • PHPUnit: 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]);
        }
    • The default (no filter applied) shows all invoices, recurring and one-off alike.
      • PHPUnit: 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]);
        }
    • The filter's selected value persists in the URL query string across reloads (Filament's built-in table filter state).
      • PHPUnit: 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

    1. Migration add_recurring_invoice_id_to_invoices_table — nullable, indexed FK on invoices referencing recurring_invoices.id, onDelete('set null') so invoice history survives if the profile is later deleted.
    2. Add recurringInvoice(): BelongsTo to Modules/Invoices/Models/Invoice.php.
    3. Add SelectFilter::make('recurring') to InvoicesTable::configure() per the issue's own implementation notes (->when($value === 'yes', ...) / ->when($value === 'no', ...)), reusing the existing ip.recurring translation key (resources/lang/en/ip.php:339 — already present, no i18n work needed).
    4. Coordinate with [Invoices]: Epic Recurring-Invoices #52 (recurring invoice generation): once RecurringInvoiceService::generateDueInvoices() exists, it must set recurring_invoice_id on every invoice it creates, or this filter will always show zero recurring invoices in production.
    5. Tests live in Modules/Invoices/Tests/Feature/InvoicesTest.php or a new InvoiceRecurringFilterTest.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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    refinedRefined with SMART plan / test suggestionsv1-feature

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions