Skip to content

[Invoices]: An invoice that has a creditNote cannot be deleted #44

Description

@nielsdrost7

SMART Plan — [Invoices] Guard: prevent deletion of a parent invoice that has a linked credit note

Specific

An invoice cannot be deleted when it has one or more credit notes linked via creditinvoice_parent_id. The DeleteAction on both the InvoicesTable row and the EditInvoice header must be hidden when $invoice->creditNotes()->exists() is true. The service layer must also reject deletion so a direct call cannot bypass the UI check.

Root cause

No guard exists. Deleting a parent invoice leaves its credit note(s) with a dangling creditinvoice_parent_id, breaking the credit note's history and potentially causing errors on the credit note detail page.

Measurable

  • The Delete action is hidden on the invoice row when the invoice has one or more credit notes.
  • Calling InvoiceService::deleteInvoice(Invoice) throws InvoiceHasCreditNotesException when a credit note exists.
  • Deleting an invoice with no credit notes succeeds normally.
  • PHPUnit covers all three scenarios.

Achievable

  1. Confirm or add a creditNotes() HasMany relationship on Modules/Invoices/Models/Invoice.php: return $this->hasMany(Invoice::class, 'creditinvoice_parent_id');
  2. Create Modules/Invoices/Exceptions/InvoiceHasCreditNotesException.php.
  3. Add guard in InvoiceService::deleteInvoice(): throw the exception when $invoice->creditNotes()->withoutGlobalScopes()->exists().
  4. Add ->hidden(fn (Invoice $record) => $record->creditNotes()->withoutGlobalScopes()->exists()) to the DeleteAction in InvoicesTable and EditInvoice.
  5. Add PHPUnit tests.

Relevant

Credit notes are financial documents that reverse part or all of an invoice. Deleting the parent while credit notes exist corrupts the accounting trail.

Time-bound

Sprint 5. Related: #171 (create credit notes), #43 (duplicate-number guard for credit notes).


Arrange · Act · Assert

// Modules/Invoices/Tests/Feature/InvoicesTest.php

#[Test]
public function it_throws_when_deleting_an_invoice_that_has_a_credit_note(): void
{
    /* Arrange */
    $parent = Invoice::factory()->for($this->company)->create([
        'invoice_number' => 'INV-2025-001',
        'invoice_sign'   => '1',
    ]);

    Invoice::factory()->for($this->company)->create([
        'invoice_number'          => 'INV-2025-001',
        'invoice_sign'            => '-1',
        'creditinvoice_parent_id' => $parent->id,
    ]);

    /* Act & Assert */
    $this->expectException(\Modules\Invoices\Exceptions\InvoiceHasCreditNotesException::class);

    app(\Modules\Invoices\Services\InvoiceService::class)->deleteInvoice($parent);
}

#[Test]
public function it_allows_deletion_of_an_invoice_without_credit_notes(): void
{
    /* Arrange */
    $invoice = Invoice::factory()->for($this->company)->create();

    /* Act */
    app(\Modules\Invoices\Services\InvoiceService::class)->deleteInvoice($invoice);

    /* Assert */
    $this->assertSoftDeleted('invoices', ['id' => $invoice->id]);
}

#[Test]
public function it_hides_delete_action_on_the_invoice_list_when_credit_note_exists(): void
{
    /* Arrange */
    $parent = Invoice::factory()->for($this->company)->create(['invoice_sign' => '1']);
    Invoice::factory()->for($this->company)->create([
        'invoice_sign'            => '-1',
        'creditinvoice_parent_id' => $parent->id,
    ]);

    /* Act */
    $component = Livewire::actingAs($this->user)
        ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]);

    /* Assert */
    $component->assertSuccessful();
    $component->assertTableActionHidden('delete', $parent);
}

Metadata

Metadata

Assignees

Labels

refinedRefined with SMART plan / test suggestions

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions