Skip to content

[Invoices]: Sent-PDF archive — persist every generated invoice PDF, with archive browser and download #662

Description

@nielsdrost7

In v1, every invoice PDF generation writes a dated, immutable copy to uploads/archive/ (application/helpers/mpdf_helper.php:144-217: <Y-m-d>_<trans('invoice')>_<number>.pdf), and users can browse (application/modules/invoices/controllers/Invoices.php:78-91 + Mdl_invoices::get_archives()), filter, and securely download (Invoices.php:93-118) archived copies. This satisfies legal retention: the archive proves exactly what was sent to the client, even after the invoice record changes. v2 has no trace of this — PDFs are streamed and discarded.

Specific

On every invoice PDF generation (download action, email attach per #526, and future guest access), persist the rendered bytes to a company-scoped archive disk path (e.g. storage/app/archive/{company_id}/{Y-m-d}_{invoice_number}.pdf, or an Upload-model-backed store consistent with Modules/Core uploads). Add a Filament Invoice Archive page (Company panel) listing archived PDFs (date, invoice number, filename) with search by invoice number and a per-row download action. Repeat generations on the same day overwrite that day's copy; different days accumulate history (v1 behaviour).

Measurable

  • Generating an invoice PDF creates exactly one archive file per invoice per day, named <Y-m-d>_<invoice_number>.pdf, scoped to the invoice's company.
  • The archive page lists entries newest-first and filters by invoice number.
  • Download returns the archived bytes (not a re-render) with Content-Type: application/pdf.
  • Users of company A can never list or download company B's archives (path traversal and cross-tenant access rejected).

Achievable

  1. Extend the PDF path (InvoiceService::generatePdf() / PdfGenerationService::invoicePdf() after PR [IP-130]: Report Builder — bands + bricks (IP-130, Phases 1-4 + Browsershot driver) #608) to write the output via Storage::disk(...) before streaming.
  2. New Filament page Modules/Invoices/Filament/Company/Pages/InvoiceArchive.php backed by a disk listing (or an invoice_archives table if metadata querying is preferred).
  3. Download via Storage::download() with basename validation — mirror v1's validate_file_access() hardening.
  4. Gate with a new VIEW_INVOICE_ARCHIVE permission (PermissionsSeeder).

Relevant

Retention of sent invoices is a legal requirement in several jurisdictions and a v1 feature users rely on; without it, editing an invoice silently rewrites history.

Time-Bound

After PDF list-action wiring (#200) and alongside email attach (#526), which should archive at send time exactly as v1 does.

Arrange

Company + invoice (Invoice::factory()->for($this->company)->create(['invoice_number' => 'INV-2026-001'])), fake storage disk, Carbon frozen (base TestCase freezes 2026-01-01).

Act

Call the invoice PDF generation service method; then load the archive page component and call its download action for the created entry.

Assert

  • storage contains archive/{company_id}/2026-01-01_INV-2026-001.pdf after generation.
  • Generating twice on the same day leaves exactly one file for that invoice/date.
  • The archive page lists the file and downloads it byte-identical to what was generated.
  • A user from another company cannot see or fetch the entry (404/403).
  • A crafted filename (../../etc/passwd) is rejected.

Metadata

Metadata

Assignees

No one assigned

    Labels

    refinedRefined with SMART plan / test suggestions

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions