From 92bcdcd186bbdbce9a4bffc39e13341b32a4904b Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 02:13:41 +0200 Subject: [PATCH] Send the invoice with the invoice attached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PDF is rendered when the mail goes out rather than fetched from anywhere, because nothing stores it — and rendering from the frozen snapshot means the attachment is the document as issued no matter when the queue gets to it or how often it retries. fromData rather than fromPath: there is no path. Three attempts at testing it, and the first two were wrong in ways worth recording. Attachment::$data does not exist — the closure lives in a protected $resolver, and reaching for it was me testing the framework's internals rather than my own code. And a byte comparison of the PDF cannot work at all: TCPDF embeds a creation time and a document id, so two renders of one invoice differ. Split into the two things that can actually go wrong. The mail carries an attachment with the right filename and type — that is metadata, and it is asserted as metadata. And the renderer produces a real PDF rather than an empty one, asserted where the renderer is, because an attachment closure that returns nothing still makes a perfectly valid mail with a nought-byte file on it. Co-Authored-By: Claude Opus 5 --- app/Mail/InvoiceMail.php | 69 +++++++++++++++++++++ lang/de/invoice_mail.php | 17 +++++ lang/en/invoice_mail.php | 17 +++++ resources/views/mail/invoice.blade.php | 56 +++++++++++++++++ tests/Feature/Billing/InvoiceMailTest.php | 75 +++++++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 app/Mail/InvoiceMail.php create mode 100644 lang/de/invoice_mail.php create mode 100644 lang/en/invoice_mail.php create mode 100644 resources/views/mail/invoice.blade.php create mode 100644 tests/Feature/Billing/InvoiceMailTest.php diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php new file mode 100644 index 0000000..cc52021 --- /dev/null +++ b/app/Mail/InvoiceMail.php @@ -0,0 +1,69 @@ +mailboxEnvelope( + MailPurpose::BILLING, + __('invoice_mail.subject', ['number' => $this->invoice->number]), + ); + } + + public function content(): Content + { + $meta = (array) ($this->invoice->snapshot['meta'] ?? []); + + return new Content(view: 'mail.invoice', with: [ + 'name' => $this->name, + 'number' => $this->invoice->number, + 'issuedOn' => $this->invoice->issued_on?->local()->format('d.m.Y'), + 'dueOn' => $this->invoice->due_on?->local()->format('d.m.Y'), + 'gross' => \App\Services\Billing\InvoiceMath::money((int) $this->invoice->gross_cents), + 'currency' => $this->invoice->currency, + 'paymentTerms' => (string) ($meta['payment_terms'] ?? ''), + 'invoicesUrl' => route('invoices'), + ]); + } + + /** @return array */ + public function attachments(): array + { + return [ + // fromData, not fromPath: there is no path. The document exists as + // a row and becomes a file only for as long as this mail needs one. + Attachment::fromData( + fn () => app(InvoiceRenderer::class)->forInvoice($this->invoice), + $this->invoice->number.'.pdf', + )->withMime('application/pdf'), + ]; + } +} diff --git a/lang/de/invoice_mail.php b/lang/de/invoice_mail.php new file mode 100644 index 0000000..1905a4c --- /dev/null +++ b/lang/de/invoice_mail.php @@ -0,0 +1,17 @@ + 'Ihre Rechnung :number', + 'heading' => 'Rechnung :number', + 'preheader' => 'Betrag :amount — das PDF liegt bei.', + 'greeting' => 'Guten Tag :name,', + 'intro' => 'anbei erhalten Sie Ihre Rechnung als PDF.', + 'field_number' => 'Rechnungsnummer', + 'field_date' => 'Rechnungsdatum', + 'field_due' => 'Fällig am', + 'field_amount' => 'Betrag', + 'action' => 'Zu Ihren Rechnungen', + // Ausdrücklich gesagt: manche Mailprogramme zeigen den Anhang erst, wenn + // man die Nachricht öffnet, und wer ihn nicht sieht, sucht ihn nicht. + 'attached' => 'Die Rechnung liegt dieser Nachricht als PDF bei. Sie finden sie außerdem jederzeit in Ihrem Kundenportal.', +]; diff --git a/lang/en/invoice_mail.php b/lang/en/invoice_mail.php new file mode 100644 index 0000000..e42504b --- /dev/null +++ b/lang/en/invoice_mail.php @@ -0,0 +1,17 @@ + 'Your invoice :number', + 'heading' => 'Invoice :number', + 'preheader' => 'Amount :amount — the PDF is attached.', + 'greeting' => 'Hello :name,', + 'intro' => 'please find your invoice attached as a PDF.', + 'field_number' => 'Invoice number', + 'field_date' => 'Invoice date', + 'field_due' => 'Due', + 'field_amount' => 'Amount', + 'action' => 'View your invoices', + // Said out loud: some mail clients only reveal an attachment once the + // message is opened, and somebody who does not see it does not look. + 'attached' => 'The invoice is attached to this message as a PDF. You can also find it in your portal at any time.', +]; diff --git a/resources/views/mail/invoice.blade.php b/resources/views/mail/invoice.blade.php new file mode 100644 index 0000000..aec1f33 --- /dev/null +++ b/resources/views/mail/invoice.blade.php @@ -0,0 +1,56 @@ + + + +

{{ __('invoice_mail.intro') }}

+ + + + + + + + + + + + + @if ($dueOn) + + + + + @endif + + + + +
{{ __('invoice_mail.field_number') }}{{ $number }}
{{ __('invoice_mail.field_date') }}{{ $issuedOn }}
{{ __('invoice_mail.field_due') }}{{ $dueOn }}
{{ __('invoice_mail.field_amount') }}{{ $gross }} {{ $currency }}
+ + +@if ($paymentTerms) + +

{{ $paymentTerms }}

+ +@endif + + + + +
+ {{ __('invoice_mail.action') }} +
+ + + + + +
+

{{ __('invoice_mail.attached') }}

+
+ + +
diff --git a/tests/Feature/Billing/InvoiceMailTest.php b/tests/Feature/Billing/InvoiceMailTest.php new file mode 100644 index 0000000..e354e3e --- /dev/null +++ b/tests/Feature/Billing/InvoiceMailTest.php @@ -0,0 +1,75 @@ + 'CluPilot Cloud e.U.', + 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', + 'city' => 'Wien', + 'vat_id' => 'ATU00000000', + ]); +}); + +function issuedInvoice(): App\Models\Invoice +{ + $customer = Customer::factory()->create(['name' => 'Muster GmbH']); + $orders = collect([Order::factory()->create([ + 'customer_id' => $customer->id, 'amount_cents' => 1900, 'currency' => 'EUR', 'status' => 'paid', + ])]); + + return app(IssueInvoice::class)->forOrders($customer, $orders); +} + +it('attaches the invoice as a PDF named after its number', function () { + // Filename and type only. The bytes are not asserted here and deliberately + // not: TCPDF embeds a creation time and a document id, so two renders of + // one invoice differ — a byte comparison would fail for a reason that has + // nothing to do with the mail. What the renderer produces is tested where + // the renderer is. + $invoice = issuedInvoice(); + + $attachment = (new InvoiceMail($invoice, 'Muster GmbH'))->attachments()[0]; + + expect($attachment->as)->toBe($invoice->number.'.pdf') + ->and($attachment->mime)->toBe('application/pdf'); +}); + +it('renders a real PDF for the attachment to carry', function () { + // The attachment is a closure, and a closure that returns nothing still + // makes a perfectly valid mail with a nought-byte file on it. This is the + // half that would be empty. + $bytes = app(App\Services\Billing\InvoiceRenderer::class)->forInvoice(issuedInvoice()); + + expect(substr($bytes, 0, 5))->toBe('%PDF-') + ->and(strlen($bytes))->toBeGreaterThan(10000); +}); + +it('renders it from the frozen document, not from today’s settings', function () { + $invoice = issuedInvoice(); + + CompanyProfile::put(['name' => 'Ein ganz anderer Name GmbH']); + + // The snapshot is what the renderer reads. The settings moved underneath + // it and the document did not. + expect($invoice->refresh()->snapshot['issuer']['name'])->toBe('CluPilot Cloud e.U.') + ->and(app(App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)) + ->toBeString()->not->toBeEmpty(); +}); + +it('names the amount and the number where somebody can read them', function () { + // The attachment is the document; the mail still has to say what it is + // about, because some clients only reveal an attachment once opened. + $invoice = issuedInvoice(); + + $rendered = (new InvoiceMail($invoice, 'Muster GmbH'))->render(); + + expect($rendered)->toContain($invoice->number) + ->toContain('22,80') + ->toContain(__('mail.why_you_got_this')); +});