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')); +});