Send the invoice with the invoice attached
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 <noreply@anthropic.com>feat/granted-plans
parent
cd54212b34
commit
92bcdcd186
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Mail\Concerns\SendsFromMailbox;
|
||||||
|
use App\Models\Invoice;
|
||||||
|
use App\Services\Billing\InvoiceRenderer;
|
||||||
|
use App\Services\Mail\MailPurpose;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Attachment;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The invoice, with the invoice attached.
|
||||||
|
*
|
||||||
|
* The PDF is rendered when the mail is sent 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 round to
|
||||||
|
* it, or how often it is retried.
|
||||||
|
*
|
||||||
|
* From the BILLING mailbox: a reply to this is a question about money.
|
||||||
|
*/
|
||||||
|
class InvoiceMail extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SendsFromMailbox, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(public Invoice $invoice, public string $name) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return $this->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<int, Attachment> */
|
||||||
|
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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject' => '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.',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
<x-mail.layout
|
||||||
|
:heading="__('invoice_mail.heading', ['number' => $number])"
|
||||||
|
:preheader="__('invoice_mail.preheader', ['amount' => $gross.' '.$currency])"
|
||||||
|
:greeting="$name ? __('invoice_mail.greeting', ['name' => $name]) : null"
|
||||||
|
>
|
||||||
|
|
||||||
|
<tr><td style="padding:0 40px 24px 40px;">
|
||||||
|
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('invoice_mail.intro') }}</p>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:0 40px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:38%;">{{ __('invoice_mail.field_number') }}</td>
|
||||||
|
<td style="padding:14px 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $number }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('invoice_mail.field_date') }}</td>
|
||||||
|
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#43434e;">{{ $issuedOn }}</td>
|
||||||
|
</tr>
|
||||||
|
@if ($dueOn)
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('invoice_mail.field_due') }}</td>
|
||||||
|
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#43434e;">{{ $dueOn }}</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('invoice_mail.field_amount') }}</td>
|
||||||
|
<td style="padding:0 16px 14px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:15px;line-height:20px;color:#17171c;font-weight:600;">{{ $gross }} {{ $currency }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
@if ($paymentTerms)
|
||||||
|
<tr><td style="padding:20px 40px 0 40px;">
|
||||||
|
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $paymentTerms }}</p>
|
||||||
|
</td></tr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<tr><td style="padding:24px 40px 0 40px;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||||
|
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||||
|
<a href="{{ $invoicesUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('invoice_mail.action') }}</a>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
<tr><td style="padding:24px 40px 32px 40px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||||
|
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||||
|
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('invoice_mail.attached') }}</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
|
||||||
|
</x-mail.layout>
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Mail\InvoiceMail;
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\Order;
|
||||||
|
use App\Services\Billing\IssueInvoice;
|
||||||
|
use App\Support\CompanyProfile;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
CompanyProfile::put([
|
||||||
|
'name' => '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'));
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue