From a5bf33109e2849689d75fbdf1151c72310c123c6 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Tue, 6 Oct 2026 14:47:49 +0000 Subject: [PATCH] =?UTF-8?q?billing:=20Rechnungsversand=20=E2=80=94=20PDF?= =?UTF-8?q?=20im=20Anhang=20der=20Zahlungsmail,=20optionale=20Rechnungs-E-?= =?UTF-8?q?Mail,=20Stripe-Rechnungs-PDF=20direkt=20aus=20dem=20Kundenberei?= =?UTF-8?q?ch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kundenwunsch 06.10.2026: Ein Kunde kam über die Stripe-Rechnungsseite nicht an seine Rechnung und fragte nach Belegen und einer Rechnungs-E-Mail. - Profil ▸ Rechnungsadresse ▸ Rechnungsversand: Schalter „Rechnungs-PDF an die Konto-E-Mail" (Standard an), optionale „E-Mail für Rechnungen" mit Schalter „Rechnungen als PDF an diese Adresse senden" (Standard an). Neue Spalten an billing_addresses (klein, ohne FULLTEXT). - InvoiceDelivery entscheidet Empfänger und Anhang; gleiche Adresse wie das Konto ergibt eine Mail mit PDF. - Zahlungsbestätigung (STR) und Bestandskunden-Rechnung/-Erinnerung (MAN) folgen der Einstellung; die Fassung an die Rechnungs-E-Mail nennt das Konto, ohne Blindkopie und ohne Login-Button. - StripeInvoicePdf holt das Stripe-PDF einmal und legt es lokal ab (storage/app/private/invoices/stripe); Mailversand wiederholt bei Stripe-Ausfall (5 Versuche). - Rechnungsseite: „PDF öffnen" aus pressekonto, „Beleg bei Stripe" für den Zahlungsbeleg auf der Stripe-Seite. Suite 2.340 grün, einer übersprungen; Larastan und Pint ohne Meldung. Co-Authored-By: Claude Opus 5.5 --- .../StripeInvoicePdfController.php | 38 ++++ app/Listeners/ProcessStripeWebhook.php | 10 +- app/Mail/BookingPaymentConfirmed.php | 39 +++- app/Mail/ManualInvoiceIssued.php | 13 +- app/Models/BillingAddress.php | 14 ++ app/Services/Billing/InvoiceDelivery.php | 44 ++++ .../Billing/ManualInvoiceDunningService.php | 30 ++- app/Services/Billing/StripeInvoicePdf.php | 71 ++++++ ...ce_delivery_to_billing_addresses_table.php | 30 +++ .../billing/manual-invoice-issued.blade.php | 31 ++- .../billing/payment-confirmed.blade.php | 34 ++- .../livewire/customer/invoices.blade.php | 31 ++- .../views/livewire/customer/profile.blade.php | 52 ++++- routes/customer.php | 2 + tests/Feature/Billing/InvoiceDeliveryTest.php | 204 ++++++++++++++++++ .../Billing/ManualInvoiceDunningTest.php | 34 +++ .../Billing/StripeWebhookProcessingTest.php | 44 ++++ tests/Feature/CustomerPortalTest.php | 4 +- 18 files changed, 695 insertions(+), 30 deletions(-) create mode 100644 app/Http/Controllers/StripeInvoicePdfController.php create mode 100644 app/Services/Billing/InvoiceDelivery.php create mode 100644 app/Services/Billing/StripeInvoicePdf.php create mode 100644 database/migrations/2026_10_06_164009_add_invoice_delivery_to_billing_addresses_table.php create mode 100644 tests/Feature/Billing/InvoiceDeliveryTest.php diff --git a/app/Http/Controllers/StripeInvoicePdfController.php b/app/Http/Controllers/StripeInvoicePdfController.php new file mode 100644 index 00000000..29e0b7fa --- /dev/null +++ b/app/Http/Controllers/StripeInvoicePdfController.php @@ -0,0 +1,38 @@ +user_id === $request->user()->id, 404); + abort_unless(filled($invoice->stripe_invoice_id), 404); + + try { + return $pdf->inlineResponse($invoice); + } catch (\Throwable $exception) { + Log::channel('billing')->warning('Stripe-Rechnungs-PDF nicht abrufbar.', [ + 'invoice_id' => $invoice->id, + 'stripe_invoice_id' => $invoice->stripe_invoice_id, + 'error' => $exception->getMessage(), + ]); + + return redirect() + ->route('me.invoices.index') + ->with('invoice-error', __('Das Rechnungs-PDF ist gerade nicht abrufbar. Bitte versuchen Sie es später erneut.')); + } + } +} diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 82ab3cf2..a2f6c499 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -22,6 +22,7 @@ use App\Models\User; use App\Scopes\PortalScope; use App\Services\ActivityLog; use App\Services\Billing\CreditTopupService; +use App\Services\Billing\InvoiceDelivery; use App\Services\Billing\InvoiceNumberGenerator; use App\Services\Conversion\ConversionRecorder; use App\Services\PressRelease\CorrectionService; @@ -294,8 +295,15 @@ class ProcessStripeWebhook // Zahlungsbestätigung an den Kunden — genau eine pro Beleg, da die // Spiegelung über stripe_invoice_id idempotent ist. Deckt alle // Buchungsarten ab (Tarif inkl. Verlängerung, Einzel-PM, Credits). + // PDF-Anhang und Rechnungs-E-Mail nach Profileinstellung. + $delivery = InvoiceDelivery::for($user); + if ($user->email) { - Mail::to($user->email)->queue(new BookingPaymentConfirmed($user, $invoice)); + Mail::to($user->email)->queue(new BookingPaymentConfirmed($user, $invoice, attachPdf: $delivery->attachPdfForAccount)); + } + + if ($delivery->invoiceEmail !== null) { + Mail::to($delivery->invoiceEmail)->queue(new BookingPaymentConfirmed($user, $invoice, attachPdf: true, forInvoiceRecipient: true)); } $this->activityLog->record( diff --git a/app/Mail/BookingPaymentConfirmed.php b/app/Mail/BookingPaymentConfirmed.php index 50b4642e..8baad613 100644 --- a/app/Mail/BookingPaymentConfirmed.php +++ b/app/Mail/BookingPaymentConfirmed.php @@ -4,8 +4,10 @@ namespace App\Mail; use App\Models\Invoice; use App\Models\User; +use App\Services\Billing\StripeInvoicePdf; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Mail\Attachment; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; @@ -18,16 +20,28 @@ use Illuminate\Queue\SerializesModels; * * Transaktionsmail: bewusst ohne Opt-out (analog Kündigungs-/ * Widerrufsbestätigungen, siehe EmailNotificationType-Doku). + * + * Rechnungsversand (06.10.2026): Mit `attachPdf` hängt das Stripe-PDF an; + * `forInvoiceRecipient` ist die Fassung an die Rechnungs-E-Mail aus dem + * Profil (siehe InvoiceDelivery). Das PDF wird erst beim Versand geholt — + * ist Stripe nicht erreichbar, wiederholt die Queue den Versand. */ class BookingPaymentConfirmed extends Mailable implements ShouldQueue { use Queueable, SerializesModels; + public int $tries = 5; + + /** @var list */ + public array $backoff = [60, 300, 900, 3600]; + public string $invoicesUrl; public function __construct( public readonly User $user, public readonly Invoice $invoice, + public readonly bool $attachPdf = false, + public readonly bool $forInvoiceRecipient = false, ) { $this->invoicesUrl = route('me.invoices.index'); } @@ -36,10 +50,14 @@ class BookingPaymentConfirmed extends Mailable implements ShouldQueue { // Blindkopie an das Rechts-/Service-Postfach: interner Beleg über // jede versendete Zahlungsbestätigung. - $bcc = (string) config('legal.contact_email'); + // Die Fassung an die Rechnungs-E-Mail ist ein Duplikat derselben + // Rechnung — eine Blindkopie davon braucht das Postfach nicht. + $bcc = $this->forInvoiceRecipient ? '' : (string) config('legal.contact_email'); return new Envelope( - subject: 'Zahlungsbestätigung für Ihre Buchung — Rechnung '.$this->invoice->number, + subject: $this->forInvoiceRecipient + ? 'Rechnung '.$this->invoice->number.' — pressekonto' + : 'Zahlungsbestätigung für Ihre Buchung — Rechnung '.$this->invoice->number, bcc: $bcc !== '' ? [$bcc] : [], ); } @@ -48,4 +66,21 @@ class BookingPaymentConfirmed extends Mailable implements ShouldQueue { return new Content(view: 'emails.billing.payment-confirmed'); } + + /** + * @return array + */ + public function attachments(): array + { + if (! $this->attachPdf || blank($this->invoice->stripe_invoice_id)) { + return []; + } + + $pdf = app(StripeInvoicePdf::class); + + return [ + Attachment::fromData(fn (): string => $pdf->contents($this->invoice), $pdf->filename($this->invoice)) + ->withMime('application/pdf'), + ]; + } } diff --git a/app/Mail/ManualInvoiceIssued.php b/app/Mail/ManualInvoiceIssued.php index 8a421275..d78319de 100644 --- a/app/Mail/ManualInvoiceIssued.php +++ b/app/Mail/ManualInvoiceIssued.php @@ -21,6 +21,10 @@ use Illuminate\Queue\SerializesModels; * * Transaktionsmail: bewusst ohne Opt-out. Die Bounce-Sperrliste greift * über `GateOutgoingMail`; der Service prüft sie zusätzlich vorab. + * + * Rechnungsversand (06.10.2026): `attachPdf` folgt der Profileinstellung + * (Standard: an); `forInvoiceRecipient` ist die Fassung an die + * Rechnungs-E-Mail (siehe InvoiceDelivery), immer mit PDF. */ class ManualInvoiceIssued extends Mailable implements ShouldQueue { @@ -42,6 +46,8 @@ class ManualInvoiceIssued extends Mailable implements ShouldQueue public readonly User $user, public readonly Invoice $invoice, public readonly bool $isReminder = false, + public readonly bool $attachPdf = true, + public readonly bool $forInvoiceRecipient = false, ) { $renderer = app(ManualInvoicePdfRenderer::class); @@ -54,7 +60,8 @@ class ManualInvoiceIssued extends Mailable implements ShouldQueue public function envelope(): Envelope { - $bcc = (string) config('legal.contact_email'); + // Die Fassung an die Rechnungs-E-Mail ist ein Duplikat — ohne Blindkopie. + $bcc = $this->forInvoiceRecipient ? '' : (string) config('legal.contact_email'); $subject = $this->isReminder ? 'Zahlungserinnerung — Rechnung '.$this->invoice->number @@ -76,6 +83,10 @@ class ManualInvoiceIssued extends Mailable implements ShouldQueue */ public function attachments(): array { + if (! $this->attachPdf) { + return []; + } + $renderer = app(ManualInvoicePdfRenderer::class); return [ diff --git a/app/Models/BillingAddress.php b/app/Models/BillingAddress.php index 3c7d24d1..b356b844 100644 --- a/app/Models/BillingAddress.php +++ b/app/Models/BillingAddress.php @@ -24,8 +24,22 @@ class BillingAddress extends Model 'city', 'country_code', 'vat_id', + 'invoice_email', + 'invoice_pdf_to_account', + 'invoice_pdf_to_invoice_email', ]; + /** + * @return array + */ + protected function casts(): array + { + return [ + 'invoice_pdf_to_account' => 'boolean', + 'invoice_pdf_to_invoice_email' => 'boolean', + ]; + } + /** * @return BelongsTo */ diff --git a/app/Services/Billing/InvoiceDelivery.php b/app/Services/Billing/InvoiceDelivery.php new file mode 100644 index 00000000..541bd721 --- /dev/null +++ b/app/Services/Billing/InvoiceDelivery.php @@ -0,0 +1,44 @@ +billingAddress; + + $attachPdfForAccount = $billingAddress->invoice_pdf_to_account ?? true; + + $invoiceEmail = trim((string) $billingAddress?->invoice_email); + + if ($invoiceEmail === '' || ! $billingAddress?->invoice_pdf_to_invoice_email) { + return new self($attachPdfForAccount, null); + } + + if (strcasecmp($invoiceEmail, trim((string) $user->email)) === 0) { + return new self(true, null); + } + + return new self($attachPdfForAccount, $invoiceEmail); + } +} diff --git a/app/Services/Billing/ManualInvoiceDunningService.php b/app/Services/Billing/ManualInvoiceDunningService.php index 30b23971..e533c94e 100644 --- a/app/Services/Billing/ManualInvoiceDunningService.php +++ b/app/Services/Billing/ManualInvoiceDunningService.php @@ -60,7 +60,7 @@ class ManualInvoiceDunningService 'due_date' => $dueDate, ]); - Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice)); + $this->queueInvoiceMails($invoice); $this->activityLog->record( ActivityType::ManualInvoice, @@ -103,7 +103,7 @@ class ManualInvoiceDunningService 'due_date' => $dueDate, ]); - Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice, isReminder: true)); + $this->queueInvoiceMails($invoice, isReminder: true); $this->activityLog->record( ActivityType::ManualInvoice, @@ -279,6 +279,32 @@ class ManualInvoiceDunningService }); } + /** + * Rechnungsmail an die Konto-Adresse (PDF nach Profileinstellung) und, + * falls hinterlegt und aktiviert, an die Rechnungs-E-Mail (mit PDF). + */ + private function queueInvoiceMails(Invoice $invoice, bool $isReminder = false): void + { + $delivery = InvoiceDelivery::for($invoice->user); + + Mail::to($invoice->user)->queue(new ManualInvoiceIssued( + $invoice->user, + $invoice, + isReminder: $isReminder, + attachPdf: $delivery->attachPdfForAccount, + )); + + if ($delivery->invoiceEmail !== null) { + Mail::to($delivery->invoiceEmail)->queue(new ManualInvoiceIssued( + $invoice->user, + $invoice, + isReminder: $isReminder, + attachPdf: true, + forInvoiceRecipient: true, + )); + } + } + private function assertOpenManual(Invoice $invoice): void { if (! $invoice->isManual()) { diff --git a/app/Services/Billing/StripeInvoicePdf.php b/app/Services/Billing/StripeInvoicePdf.php new file mode 100644 index 00000000..f3689d34 --- /dev/null +++ b/app/Services/Billing/StripeInvoicePdf.php @@ -0,0 +1,71 @@ +stripe_invoice_id)) { + throw new RuntimeException(sprintf('Rechnung %s hat keine Stripe-Rechnung.', $invoice->number)); + } + + $disk = Storage::disk('local'); + $path = $this->path($invoice); + + if ($disk->exists($path)) { + return (string) $disk->get($path); + } + + $pdfUrl = Cashier::stripe()->invoices->retrieve($invoice->stripe_invoice_id)->invoice_pdf; + + if (blank($pdfUrl)) { + throw new RuntimeException(sprintf('Stripe liefert für Rechnung %s kein PDF.', $invoice->number)); + } + + $pdf = Http::timeout(30)->get($pdfUrl)->throw()->body(); + + if (! str_starts_with($pdf, '%PDF')) { + throw new RuntimeException(sprintf('Stripe-PDF zu Rechnung %s ist kein PDF.', $invoice->number)); + } + + if (! $disk->put($path, $pdf)) { + report(new RuntimeException(sprintf('Stripe-PDF zu Rechnung %s konnte nicht abgelegt werden.', $invoice->number))); + } + + return $pdf; + } + + public function filename(Invoice $invoice): string + { + return 'Rechnung-'.$invoice->number.'.pdf'; + } + + public function inlineResponse(Invoice $invoice): Response + { + return response($this->contents($invoice), Response::HTTP_OK, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$this->filename($invoice).'"', + 'Cache-Control' => 'private, max-age=0, must-revalidate', + ]); + } + + private function path(Invoice $invoice): string + { + return 'invoices/stripe/'.$invoice->number.'.pdf'; + } +} diff --git a/database/migrations/2026_10_06_164009_add_invoice_delivery_to_billing_addresses_table.php b/database/migrations/2026_10_06_164009_add_invoice_delivery_to_billing_addresses_table.php new file mode 100644 index 00000000..d49473e7 --- /dev/null +++ b/database/migrations/2026_10_06_164009_add_invoice_delivery_to_billing_addresses_table.php @@ -0,0 +1,30 @@ +string('invoice_email', 255)->nullable()->after('vat_id'); + $table->boolean('invoice_pdf_to_account')->default(true)->after('invoice_email'); + $table->boolean('invoice_pdf_to_invoice_email')->default(true)->after('invoice_pdf_to_account'); + }); + } + + public function down(): void + { + Schema::table('billing_addresses', function (Blueprint $table): void { + $table->dropColumn(['invoice_email', 'invoice_pdf_to_account', 'invoice_pdf_to_invoice_email']); + }); + } +}; diff --git a/resources/views/emails/billing/manual-invoice-issued.blade.php b/resources/views/emails/billing/manual-invoice-issued.blade.php index 64459e5c..1b734d26 100644 --- a/resources/views/emails/billing/manual-invoice-issued.blade.php +++ b/resources/views/emails/billing/manual-invoice-issued.blade.php @@ -5,22 +5,39 @@ $title = $isReminder ? 'Zahlungserinnerung zu Ihrer Rechnung' : 'Ihre Rechnung für den Bestandsschutz'; $preheader = $isReminder ? 'Für die Rechnung '.$invoice->number.' liegt uns noch kein Zahlungseingang vor.' - : 'Rechnung '.$invoice->number.' im Anhang — bitte bis zum '.$dueDate.' überweisen.'; + : 'Rechnung '.$invoice->number.($attachPdf ? ' im Anhang' : ' im Konto').' — bitte bis zum '.$dueDate.' überweisen.'; @endphp -

Guten Tag {{ $user->name }},

+ @if ($forInvoiceRecipient) +

Guten Tag,

+ +

+ diese Rechnung betrifft das Konto von {{ $user->name }} ({{ $user->email }}) auf + pressekonto. Diese Adresse ist dort als Empfänger für Rechnungen hinterlegt. +

+ @else +

Guten Tag {{ $user->name }},

+ @endif @if ($isReminder)

zu der unten aufgeführten Rechnung konnten wir bisher keinen Zahlungseingang - feststellen. Vielleicht ist sie in der Ablage untergegangen — die Rechnung liegt - dieser E-Mail noch einmal als PDF bei. + feststellen. Vielleicht ist sie in der Ablage untergegangen — + @if ($attachPdf) + die Rechnung liegt dieser E-Mail noch einmal als PDF bei. + @else + die Rechnung finden Sie in Ihrem Konto unter Rechnungen. + @endif

@else

als Bestandskunde nutzen Sie Ihre bisherige Vereinbarung auf pressekonto weiter zu den gewohnten Konditionen (Bestandsschutz). Für den neuen Leistungszeitraum - erhalten Sie anbei Ihre Rechnung als PDF. + @if ($attachPdf) + erhalten Sie anbei Ihre Rechnung als PDF. + @else + finden Sie Ihre Rechnung in Ihrem Konto unter Rechnungen. + @endif

@if ($invoice->replaces_legacy_invoice_number)

@@ -79,7 +96,9 @@

- Rechnung im Konto ansehen + @unless ($forInvoiceRecipient) + Rechnung im Konto ansehen + @endunless

diff --git a/resources/views/emails/billing/payment-confirmed.blade.php b/resources/views/emails/billing/payment-confirmed.blade.php index 58e366bd..92b3fcf2 100644 --- a/resources/views/emails/billing/payment-confirmed.blade.php +++ b/resources/views/emails/billing/payment-confirmed.blade.php @@ -3,12 +3,22 @@ .' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency); @endphp -

Hallo {{ $user->name }},

+ @if ($forInvoiceRecipient) +

Guten Tag,

-

- vielen Dank für Ihre Buchung. Wir haben Ihre Zahlung erhalten und - die zugehörige Rechnung in Ihrem Konto hinterlegt: -

+

+ im Auftrag von {{ $user->name }} ({{ $user->email }}) erhalten Sie + die Rechnung zu einer bezahlten Buchung auf pressekonto. Diese + Adresse ist im Konto als Empfänger für Rechnungen hinterlegt. +

+ @else +

Hallo {{ $user->name }},

+ +

+ vielen Dank für Ihre Buchung. Wir haben Ihre Zahlung erhalten und + die zugehörige Rechnung in Ihrem Konto hinterlegt: +

+ @endif
Rechnungsnummer
@@ -25,10 +35,20 @@
{{ $formatCents($invoice->total_cents) }}
- Rechnung im Konto ansehen + @if ($attachPdf) +

Die Rechnung finden Sie als PDF im Anhang dieser E-Mail.

+ @endif + + @unless ($forInvoiceRecipient) + Rechnung im Konto ansehen + @endunless
-

Alle Rechnungen und Belege finden Sie jederzeit in Ihrem Konto unter Rechnungen.

+ @if ($forInvoiceRecipient) +

Den Versand an diese Adresse kann der Kontoinhaber im Profil unter Rechnungsadresse ändern oder abstellen.

+ @else +

Alle Rechnungen und Belege finden Sie jederzeit in Ihrem Konto unter Rechnungen. Ob das PDF anhängt und ob Rechnungen zusätzlich an eine eigene Rechnungs-E-Mail gehen, legen Sie im Profil unter Rechnungsadresse fest.

+ @endif

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

diff --git a/resources/views/livewire/customer/invoices.blade.php b/resources/views/livewire/customer/invoices.blade.php index 6c19ba37..67a6c71a 100644 --- a/resources/views/livewire/customer/invoices.blade.php +++ b/resources/views/livewire/customer/invoices.blade.php @@ -169,15 +169,28 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo @if ($invoice->stripe_invoice_id) - - {{ __('Bei Stripe öffnen') }} - + {{-- PDF kommt aus pressekonto; die Stripe-Seite bleibt für den Zahlungsbeleg. --}} +
+ + {{ __('PDF öffnen') }} + + + {{ __('Beleg bei Stripe') }} + +
@else billingPostalCode = (string) ($billingAddress?->postal_code ?? ''); $this->billingCity = (string) ($billingAddress?->city ?? ''); $this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE'); + $this->invoiceEmail = (string) ($billingAddress?->invoice_email ?? ''); + $this->invoicePdfToAccount = (bool) ($billingAddress?->invoice_pdf_to_account ?? true); + $this->invoicePdfToInvoiceEmail = (bool) ($billingAddress?->invoice_pdf_to_invoice_email ?? true); // Bestandsdaten vor der Feld-Trennung: `name` war eine freie // Empfängerzeile — einmalig in Vor-/Nachname aufteilen. @@ -185,6 +198,9 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp 'billingPostalCode' => ['nullable', 'string', 'max:20'], 'billingCity' => ['nullable', 'string', 'max:120'], 'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))], + 'invoiceEmail' => ['nullable', 'email', 'max:255'], + 'invoicePdfToAccount' => ['boolean'], + 'invoicePdfToInvoiceEmail' => ['boolean'], ]; // Sobald irgendein Rechnungsfeld gefüllt ist, werden die @@ -205,6 +221,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp 'billingPostalCode' => __('PLZ'), 'billingCity' => __('Ort'), 'billingCountryCode' => __('Land'), + 'invoiceEmail' => __('E-Mail für Rechnungen'), ]); // USt-ID: hartes Format-Gate; die Online-Bestätigung (eVatR) bleibt @@ -266,6 +283,9 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp // pro Rechnung eingefroren und bestimmt die Steuer // (EU-Befreiung nur mit gültiger USt-ID). 'vat_id' => $validated['taxIdNumber'] ?: null, + 'invoice_email' => $validated['invoiceEmail'] ?: null, + 'invoice_pdf_to_account' => $validated['invoicePdfToAccount'], + 'invoice_pdf_to_invoice_email' => $validated['invoicePdfToInvoiceEmail'], ], ); } @@ -307,7 +327,8 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp || filled($this->billingAddress1) || filled($this->billingAddress2) || filled($this->billingPostalCode) - || filled($this->billingCity); + || filled($this->billingCity) + || filled($this->invoiceEmail); } public function billingIsComplete(): bool @@ -525,6 +546,35 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp @endif @endif
+ +
+
+ {{ __('Rechnungsversand') }} +
+
+ + + @if (filled($invoiceEmail)) + + @endif +
+
diff --git a/routes/customer.php b/routes/customer.php index c5a28605..5dbc064f 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -5,6 +5,7 @@ use App\Http\Controllers\EmailUnsubscribeController; use App\Http\Controllers\InvoicePdfController; use App\Http\Controllers\LegacyInvoicePdfController; use App\Http\Controllers\ProofPdfController; +use App\Http\Controllers\StripeInvoicePdfController; use App\Http\Controllers\StripeInvoiceRedirectController; use App\Http\Controllers\Web\PressReleaseReportController; use App\Http\Middleware\EnsureUserIsCustomer; @@ -67,6 +68,7 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin Route::livewire('invoices', 'customer.invoices')->name('invoices.index'); Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf'); Route::get('invoices/{invoice}/stripe', StripeInvoiceRedirectController::class)->name('invoices.stripe'); + Route::get('invoices/{invoice}/stripe-pdf', StripeInvoicePdfController::class)->name('invoices.stripe-pdf'); Route::get('invoices/{invoice}/pdf', InvoicePdfController::class)->name('invoices.manual-pdf'); Route::livewire('tokens', 'customer.tokens')->name('tokens.index'); diff --git a/tests/Feature/Billing/InvoiceDeliveryTest.php b/tests/Feature/Billing/InvoiceDeliveryTest.php new file mode 100644 index 00000000..f1e764d5 --- /dev/null +++ b/tests/Feature/Billing/InvoiceDeliveryTest.php @@ -0,0 +1,204 @@ +create([ + 'user_id' => $user->id, + 'user_payment_id' => null, + 'number' => 'STR-00042', + 'status' => InvoiceStatus::Paid->value, + 'stripe_invoice_id' => 'in_test_42', + ...$overrides, + ]); +} + +test('without billing settings the account gets the pdf and no second address is used', function () { + $user = User::factory()->create(); + + $delivery = InvoiceDelivery::for($user); + + expect($delivery->attachPdfForAccount)->toBeTrue(); + expect($delivery->invoiceEmail)->toBeNull(); +}); + +test('an enabled invoice email is used and the account pdf follows its own switch', function () { + $user = User::factory()->create(); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'invoice_email' => 'buchhaltung@example.com', + 'invoice_pdf_to_account' => false, + 'invoice_pdf_to_invoice_email' => true, + ]); + + $delivery = InvoiceDelivery::for($user); + + expect($delivery->attachPdfForAccount)->toBeFalse(); + expect($delivery->invoiceEmail)->toBe('buchhaltung@example.com'); +}); + +test('a disabled invoice email is not used', function () { + $user = User::factory()->create(); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'invoice_email' => 'buchhaltung@example.com', + 'invoice_pdf_to_invoice_email' => false, + ]); + + expect(InvoiceDelivery::for($user)->invoiceEmail)->toBeNull(); +}); + +test('an invoice email equal to the account email collapses into one mail with pdf', function () { + $user = User::factory()->create(['email' => 'max@example.com']); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'invoice_email' => 'MAX@example.com', + 'invoice_pdf_to_account' => false, + 'invoice_pdf_to_invoice_email' => true, + ]); + + $delivery = InvoiceDelivery::for($user); + + expect($delivery->attachPdfForAccount)->toBeTrue(); + expect($delivery->invoiceEmail)->toBeNull(); +}); + +test('the payment confirmation attaches the stripe pdf only when asked to', function () { + $user = User::factory()->create(); + $invoice = deliveryInvoice($user); + + $this->mock(StripeInvoicePdf::class, function ($mock) { + $mock->shouldReceive('filename')->andReturn('Rechnung-STR-00042.pdf'); + $mock->shouldReceive('contents')->andReturn('%PDF-1.4 test'); + }); + + $withPdf = new BookingPaymentConfirmed($user, $invoice, attachPdf: true); + $withPdf->assertHasAttachedData('%PDF-1.4 test', 'Rechnung-STR-00042.pdf', ['mime' => 'application/pdf']); + $withPdf->assertSeeInHtml('als PDF im Anhang'); + + $withoutPdf = new BookingPaymentConfirmed($user, $invoice); + expect($withoutPdf->attachments())->toBe([]); + $withoutPdf->assertDontSeeInHtml('als PDF im Anhang'); +}); + +test('the copy for the invoice email names the account and skips the bcc and login button', function () { + $user = User::factory()->create(['name' => 'Max Muster', 'email' => 'max@example.com']); + $invoice = deliveryInvoice($user); + + $this->mock(StripeInvoicePdf::class, function ($mock) { + $mock->shouldReceive('filename')->andReturn('Rechnung-STR-00042.pdf'); + $mock->shouldReceive('contents')->andReturn('%PDF-1.4 test'); + }); + + $mail = new BookingPaymentConfirmed($user, $invoice, attachPdf: true, forInvoiceRecipient: true); + + $mail->assertHasSubject('Rechnung STR-00042 — pressekonto'); + $mail->assertSeeInHtml('im Auftrag von Max Muster (max@example.com)'); + $mail->assertDontSeeInHtml('Rechnung im Konto ansehen'); + expect($mail->hasBcc((string) config('legal.contact_email')))->toBeFalse(); +}); + +test('a stored stripe pdf is served without asking stripe again', function () { + Storage::fake('local'); + Storage::disk('local')->put('invoices/stripe/STR-00042.pdf', '%PDF-1.4 cached'); + + $invoice = deliveryInvoice(User::factory()->create()); + + expect(app(StripeInvoicePdf::class)->contents($invoice))->toBe('%PDF-1.4 cached'); +}); + +test('an invoice without stripe id has no stripe pdf', function () { + $invoice = deliveryInvoice(User::factory()->create(), ['stripe_invoice_id' => null, 'number' => 'MAN-00001']); + + expect(fn () => app(StripeInvoicePdf::class)->contents($invoice))->toThrow(RuntimeException::class); +}); + +test('the owner opens the stripe invoice pdf inline, others get a 404', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + Storage::fake('local'); + Storage::disk('local')->put('invoices/stripe/STR-00042.pdf', '%PDF-1.4 cached'); + + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $intruder = User::factory()->create(['is_active' => true]); + $intruder->assignRole('customer'); + $invoice = deliveryInvoice($customer); + + $this->actingAs($customer) + ->get(route('me.invoices.stripe-pdf', $invoice)) + ->assertSuccessful() + ->assertHeader('Content-Type', 'application/pdf') + ->assertHeader('Content-Disposition', 'inline; filename="Rechnung-STR-00042.pdf"'); + + $this->actingAs($intruder) + ->get(route('me.invoices.stripe-pdf', $invoice)) + ->assertNotFound(); +}); + +test('an unreachable stripe pdf sends the customer back with a notice', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + $invoice = deliveryInvoice($customer); + + $this->mock(StripeInvoicePdf::class, function ($mock) { + $mock->shouldReceive('inlineResponse')->andThrow(new RuntimeException('Stripe down')); + }); + + $this->actingAs($customer) + ->get(route('me.invoices.stripe-pdf', $invoice)) + ->assertRedirect(route('me.invoices.index')) + ->assertSessionHas('invoice-error'); +}); + +test('the profile stores the invoice email and both pdf switches with the billing address', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + + $this->actingAs($customer); + + Livewire::test('customer.profile') + ->set('billingLastName', 'Mustermann') + ->set('billingAddress1', 'Musterstrasse 1') + ->set('billingPostalCode', '10115') + ->set('billingCity', 'Berlin') + ->set('billingCountryCode', 'DE') + ->set('invoiceEmail', 'buchhaltung@example.com') + ->assertSee('Rechnungen als PDF an diese Adresse senden') + ->set('invoicePdfToAccount', false) + ->set('invoicePdfToInvoiceEmail', true) + ->call('saveProfile') + ->assertHasNoErrors(); + + $billing = $customer->fresh()->billingAddress; + + expect($billing->invoice_email)->toBe('buchhaltung@example.com'); + expect($billing->invoice_pdf_to_account)->toBeFalse(); + expect($billing->invoice_pdf_to_invoice_email)->toBeTrue(); +}); + +test('the invoice email must be valid and needs a complete billing address', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + + $this->actingAs($customer); + + Livewire::test('customer.profile') + ->set('invoiceEmail', 'keine-adresse') + ->call('saveProfile') + ->assertHasErrors(['invoiceEmail', 'billingLastName', 'billingAddress1']); +}); diff --git a/tests/Feature/Billing/ManualInvoiceDunningTest.php b/tests/Feature/Billing/ManualInvoiceDunningTest.php index 4b80a58c..9cbe46d3 100644 --- a/tests/Feature/Billing/ManualInvoiceDunningTest.php +++ b/tests/Feature/Billing/ManualInvoiceDunningTest.php @@ -7,6 +7,7 @@ use App\Exceptions\ManualInvoiceActionException; use App\Mail\GrandfatheringTerminated; use App\Mail\ManualInvoiceIssued; use App\Models\ActivityEvent; +use App\Models\BillingAddress; use App\Models\Invoice; use App\Models\InvoiceBillingAddress; use App\Models\LegacyInvoice; @@ -98,6 +99,39 @@ test('sending an invoice mails the pdf, sets the due date from today and records expect($event->actor_label)->toBe('Admin Alma'); }); +test('an enabled invoice email gets its own copy with pdf, the account mail follows its switch', function () { + [$invoice, , $customer] = dunningFixture(); + BillingAddress::factory()->create([ + 'user_id' => $customer->id, + 'invoice_email' => 'buchhaltung@example.com', + 'invoice_pdf_to_account' => false, + 'invoice_pdf_to_invoice_email' => true, + ]); + + app(ManualInvoiceDunningService::class)->send($invoice, dunningAdmin()); + + Mail::assertQueuedCount(2); + Mail::assertQueued(ManualInvoiceIssued::class, function (ManualInvoiceIssued $mail) use ($customer): bool { + if ($mail->forInvoiceRecipient) { + return false; + } + + $mail->assertSeeInHtml('in Ihrem Konto unter Rechnungen'); + + return $mail->hasTo($customer->email) && ! $mail->attachPdf && $mail->attachments() === []; + }); + Mail::assertQueued(ManualInvoiceIssued::class, function (ManualInvoiceIssued $mail): bool { + if (! $mail->forInvoiceRecipient) { + return false; + } + + $mail->assertSeeInHtml('Diese Adresse ist dort als Empfänger für Rechnungen hinterlegt'); + $mail->assertHasAttachedData(app(ManualInvoicePdfRenderer::class)->render($mail->invoice), 'Rechnung-MAN-00010.pdf', ['mime' => 'application/pdf']); + + return $mail->hasTo('buchhaltung@example.com') && $mail->forInvoiceRecipient && ! $mail->hasBcc((string) config('legal.contact_email')); + }); +}); + test('a sent invoice cannot be sent twice but can be reminded', function () { [$invoice] = dunningFixture(['sent_at' => now()->subDays(20), 'due_date' => '2026-09-10']); $service = app(ManualInvoiceDunningService::class); diff --git a/tests/Feature/Billing/StripeWebhookProcessingTest.php b/tests/Feature/Billing/StripeWebhookProcessingTest.php index f0eae733..1fb54f26 100644 --- a/tests/Feature/Billing/StripeWebhookProcessingTest.php +++ b/tests/Feature/Billing/StripeWebhookProcessingTest.php @@ -10,11 +10,21 @@ use App\Models\BillingAddress; use App\Models\Invoice; use App\Models\SinglePurchase; use App\Models\User; +use App\Services\Billing\StripeInvoicePdf; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; use Laravel\Cashier\Events\WebhookReceived; +// Mail-Assertions bereiten die Mail samt Anhängen vor — das Stripe-PDF +// käme sonst live von Stripe. +beforeEach(function (): void { + $this->mock(StripeInvoicePdf::class, function ($mock): void { + $mock->shouldReceive('filename')->andReturn('Rechnung.pdf'); + $mock->shouldReceive('contents')->andReturn('%PDF-1.4 test'); + }); +}); + function stripeInvoicePayload(User $user, array $overrides = []): array { return [ @@ -184,6 +194,40 @@ test('a mirrored invoice queues a payment confirmation mail to the customer', fu }); }); +test('the account confirmation carries the pdf by default and goes to the invoice email when enabled', function () { + Mail::fake(); + + $user = User::factory()->create(['stripe_id' => 'cus_test_1', 'email' => 'max@example.com']); + BillingAddress::factory()->create([ + 'user_id' => $user->id, + 'invoice_email' => 'buchhaltung@example.com', + 'invoice_pdf_to_account' => true, + 'invoice_pdf_to_invoice_email' => true, + ]); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); + + Mail::assertQueuedCount(2); + Mail::assertQueued(BookingPaymentConfirmed::class, fn (BookingPaymentConfirmed $mail): bool => $mail->hasTo('max@example.com') + && $mail->attachPdf + && ! $mail->forInvoiceRecipient); + Mail::assertQueued(BookingPaymentConfirmed::class, fn (BookingPaymentConfirmed $mail): bool => $mail->hasTo('buchhaltung@example.com') + && $mail->attachPdf + && $mail->forInvoiceRecipient); +}); + +test('a customer who switched the account pdf off gets the confirmation without attachment', function () { + Mail::fake(); + + $user = User::factory()->create(['stripe_id' => 'cus_test_1']); + BillingAddress::factory()->create(['user_id' => $user->id, 'invoice_pdf_to_account' => false]); + + app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user))); + + Mail::assertQueuedCount(1); + Mail::assertQueued(BookingPaymentConfirmed::class, fn (BookingPaymentConfirmed $mail): bool => $mail->hasTo($user->email) && ! $mail->attachPdf); +}); + test('a replayed invoice webhook does not queue a second confirmation mail', function () { Mail::fake(); diff --git a/tests/Feature/CustomerPortalTest.php b/tests/Feature/CustomerPortalTest.php index 2f9542ce..29ed41c0 100644 --- a/tests/Feature/CustomerPortalTest.php +++ b/tests/Feature/CustomerPortalTest.php @@ -219,7 +219,9 @@ test('current STR and MAN invoices are listed with a stripe link where available ->assertSee('STR-00042') ->assertSee('MAN-00007') ->assertDontSee('STR-99999') - ->assertSee('Bei Stripe öffnen') + ->assertSee('PDF öffnen') + ->assertSee(route('me.invoices.stripe-pdf', $strInvoice)) + ->assertSee('Beleg bei Stripe') ->assertSee(route('me.invoices.stripe', $strInvoice)); });