billing: Rechnungsversand — PDF im Anhang der Zahlungsmail, optionale Rechnungs-E-Mail, Stripe-Rechnungs-PDF direkt aus dem Kundenbereich
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-06 14:47:49 +00:00
parent debf094cae
commit a5bf33109e
18 changed files with 695 additions and 30 deletions

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Controllers;
use App\Models\Invoice;
use App\Services\Billing\StripeInvoicePdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF einer STR-Rechnung direkt aus pressekonto (06.10.2026). Bis dahin gab
* es nur die Weiterleitung auf die bei Stripe gehostete Rechnungsseite —
* ein Kunde kam darüber nicht an seine Rechnung („blockiert"). Das PDF
* kommt einmal von Stripe und liegt danach lokal (StripeInvoicePdf).
*/
class StripeInvoicePdfController extends Controller
{
public function __invoke(Request $request, Invoice $invoice, StripeInvoicePdf $pdf): Response
{
abort_unless($invoice->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.'));
}
}
}

View file

@ -22,6 +22,7 @@ use App\Models\User;
use App\Scopes\PortalScope; use App\Scopes\PortalScope;
use App\Services\ActivityLog; use App\Services\ActivityLog;
use App\Services\Billing\CreditTopupService; use App\Services\Billing\CreditTopupService;
use App\Services\Billing\InvoiceDelivery;
use App\Services\Billing\InvoiceNumberGenerator; use App\Services\Billing\InvoiceNumberGenerator;
use App\Services\Conversion\ConversionRecorder; use App\Services\Conversion\ConversionRecorder;
use App\Services\PressRelease\CorrectionService; use App\Services\PressRelease\CorrectionService;
@ -294,8 +295,15 @@ class ProcessStripeWebhook
// Zahlungsbestätigung an den Kunden — genau eine pro Beleg, da die // Zahlungsbestätigung an den Kunden — genau eine pro Beleg, da die
// Spiegelung über stripe_invoice_id idempotent ist. Deckt alle // Spiegelung über stripe_invoice_id idempotent ist. Deckt alle
// Buchungsarten ab (Tarif inkl. Verlängerung, Einzel-PM, Credits). // Buchungsarten ab (Tarif inkl. Verlängerung, Einzel-PM, Credits).
// PDF-Anhang und Rechnungs-E-Mail nach Profileinstellung.
$delivery = InvoiceDelivery::for($user);
if ($user->email) { 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( $this->activityLog->record(

View file

@ -4,8 +4,10 @@ namespace App\Mail;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\User; use App\Models\User;
use App\Services\Billing\StripeInvoicePdf;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope; use Illuminate\Mail\Mailables\Envelope;
@ -18,16 +20,28 @@ use Illuminate\Queue\SerializesModels;
* *
* Transaktionsmail: bewusst ohne Opt-out (analog Kündigungs-/ * Transaktionsmail: bewusst ohne Opt-out (analog Kündigungs-/
* Widerrufsbestätigungen, siehe EmailNotificationType-Doku). * 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 class BookingPaymentConfirmed extends Mailable implements ShouldQueue
{ {
use Queueable, SerializesModels; use Queueable, SerializesModels;
public int $tries = 5;
/** @var list<int> */
public array $backoff = [60, 300, 900, 3600];
public string $invoicesUrl; public string $invoicesUrl;
public function __construct( public function __construct(
public readonly User $user, public readonly User $user,
public readonly Invoice $invoice, public readonly Invoice $invoice,
public readonly bool $attachPdf = false,
public readonly bool $forInvoiceRecipient = false,
) { ) {
$this->invoicesUrl = route('me.invoices.index'); $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 // Blindkopie an das Rechts-/Service-Postfach: interner Beleg über
// jede versendete Zahlungsbestätigung. // 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( 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] : [], bcc: $bcc !== '' ? [$bcc] : [],
); );
} }
@ -48,4 +66,21 @@ class BookingPaymentConfirmed extends Mailable implements ShouldQueue
{ {
return new Content(view: 'emails.billing.payment-confirmed'); return new Content(view: 'emails.billing.payment-confirmed');
} }
/**
* @return array<int, Attachment>
*/
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'),
];
}
} }

View file

@ -21,6 +21,10 @@ use Illuminate\Queue\SerializesModels;
* *
* Transaktionsmail: bewusst ohne Opt-out. Die Bounce-Sperrliste greift * Transaktionsmail: bewusst ohne Opt-out. Die Bounce-Sperrliste greift
* über `GateOutgoingMail`; der Service prüft sie zusätzlich vorab. * ü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 class ManualInvoiceIssued extends Mailable implements ShouldQueue
{ {
@ -42,6 +46,8 @@ class ManualInvoiceIssued extends Mailable implements ShouldQueue
public readonly User $user, public readonly User $user,
public readonly Invoice $invoice, public readonly Invoice $invoice,
public readonly bool $isReminder = false, public readonly bool $isReminder = false,
public readonly bool $attachPdf = true,
public readonly bool $forInvoiceRecipient = false,
) { ) {
$renderer = app(ManualInvoicePdfRenderer::class); $renderer = app(ManualInvoicePdfRenderer::class);
@ -54,7 +60,8 @@ class ManualInvoiceIssued extends Mailable implements ShouldQueue
public function envelope(): Envelope 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 $subject = $this->isReminder
? 'Zahlungserinnerung — Rechnung '.$this->invoice->number ? 'Zahlungserinnerung — Rechnung '.$this->invoice->number
@ -76,6 +83,10 @@ class ManualInvoiceIssued extends Mailable implements ShouldQueue
*/ */
public function attachments(): array public function attachments(): array
{ {
if (! $this->attachPdf) {
return [];
}
$renderer = app(ManualInvoicePdfRenderer::class); $renderer = app(ManualInvoicePdfRenderer::class);
return [ return [

View file

@ -24,8 +24,22 @@ class BillingAddress extends Model
'city', 'city',
'country_code', 'country_code',
'vat_id', 'vat_id',
'invoice_email',
'invoice_pdf_to_account',
'invoice_pdf_to_invoice_email',
]; ];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'invoice_pdf_to_account' => 'boolean',
'invoice_pdf_to_invoice_email' => 'boolean',
];
}
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, $this>
*/ */

View file

@ -0,0 +1,44 @@
<?php
namespace App\Services\Billing;
use App\Models\User;
/**
* Wer eine Rechnungsmail bekommt und ob das PDF anhängt (Kundenwunsch
* 06.10.2026, Einstellung an der Rechnungsadresse im Profil).
*
* - Die Konto-Adresse bekommt die Rechnungsmail immer; das PDF hängt an,
* solange der Kunde es nicht abgewählt hat (Standard: an).
* - Eine optionale Rechnungs-E-Mail bekommt eine eigene Mail mit PDF,
* wenn der Versand dorthin aktiviert ist (Standard: an, sobald eine
* Adresse eingetragen ist).
* - Ist die Rechnungs-E-Mail dieselbe wie die Konto-Adresse, geht keine
* zweite Mail raus; die Konto-Mail trägt dann das PDF.
*/
final class InvoiceDelivery
{
public function __construct(
public readonly bool $attachPdfForAccount,
public readonly ?string $invoiceEmail,
) {}
public static function for(User $user): self
{
$billingAddress = $user->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);
}
}

View file

@ -60,7 +60,7 @@ class ManualInvoiceDunningService
'due_date' => $dueDate, 'due_date' => $dueDate,
]); ]);
Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice)); $this->queueInvoiceMails($invoice);
$this->activityLog->record( $this->activityLog->record(
ActivityType::ManualInvoice, ActivityType::ManualInvoice,
@ -103,7 +103,7 @@ class ManualInvoiceDunningService
'due_date' => $dueDate, 'due_date' => $dueDate,
]); ]);
Mail::to($invoice->user)->queue(new ManualInvoiceIssued($invoice->user, $invoice, isReminder: true)); $this->queueInvoiceMails($invoice, isReminder: true);
$this->activityLog->record( $this->activityLog->record(
ActivityType::ManualInvoice, 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 private function assertOpenManual(Invoice $invoice): void
{ {
if (! $invoice->isManual()) { if (! $invoice->isManual()) {

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Billing;
use App\Models\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Laravel\Cashier\Cashier;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
/**
* PDF einer STR-Rechnung, wie Stripe es erzeugt. Beim ersten Abruf von
* Stripe geladen und lokal abgelegt — eine bezahlte Rechnung ändert sich
* nicht mehr, und Mailanhang wie Download hängen danach nicht mehr an der
* Erreichbarkeit von Stripe oder an ablaufenden Links der gehosteten
* Rechnungsseite.
*/
class StripeInvoicePdf
{
public function contents(Invoice $invoice): string
{
if (blank($invoice->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';
}
}

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Rechnungsversand (Kundenwunsch 06.10.2026): optionale Rechnungs-E-Mail
* neben der Konto-Adresse und je Empfänger, ob das Rechnungs-PDF anhängt.
* Ohne Eintrag gilt: Konto-Adresse bekommt die Rechnung mit PDF.
* `billing_addresses` ist klein und ohne FULLTEXT — ALTER ist sofort.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('billing_addresses', function (Blueprint $table): void {
$table->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']);
});
}
};

View file

@ -5,22 +5,39 @@
$title = $isReminder ? 'Zahlungserinnerung zu Ihrer Rechnung' : 'Ihre Rechnung für den Bestandsschutz'; $title = $isReminder ? 'Zahlungserinnerung zu Ihrer Rechnung' : 'Ihre Rechnung für den Bestandsschutz';
$preheader = $isReminder $preheader = $isReminder
? 'Für die Rechnung '.$invoice->number.' liegt uns noch kein Zahlungseingang vor.' ? '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 @endphp
<x-emails.layout :title="$title" :preheader="$preheader"> <x-emails.layout :title="$title" :preheader="$preheader">
<p>Guten Tag {{ $user->name }},</p> @if ($forInvoiceRecipient)
<p>Guten Tag,</p>
<p>
diese Rechnung betrifft das Konto von {{ $user->name }} ({{ $user->email }}) auf
pressekonto. Diese Adresse ist dort als Empfänger für Rechnungen hinterlegt.
</p>
@else
<p>Guten Tag {{ $user->name }},</p>
@endif
@if ($isReminder) @if ($isReminder)
<p> <p>
zu der unten aufgeführten Rechnung konnten wir bisher keinen Zahlungseingang zu der unten aufgeführten Rechnung konnten wir bisher keinen Zahlungseingang
feststellen. Vielleicht ist sie in der Ablage untergegangen — die Rechnung liegt feststellen. Vielleicht ist sie in der Ablage untergegangen —
dieser E-Mail noch einmal als PDF bei. @if ($attachPdf)
die Rechnung liegt dieser E-Mail noch einmal als PDF bei.
@else
die Rechnung finden Sie in Ihrem Konto unter Rechnungen.
@endif
</p> </p>
@else @else
<p> <p>
als Bestandskunde nutzen Sie Ihre bisherige Vereinbarung auf pressekonto weiter als Bestandskunde nutzen Sie Ihre bisherige Vereinbarung auf pressekonto weiter
zu den gewohnten Konditionen (Bestandsschutz). Für den neuen Leistungszeitraum 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
</p> </p>
@if ($invoice->replaces_legacy_invoice_number) @if ($invoice->replaces_legacy_invoice_number)
<p> <p>
@ -79,7 +96,9 @@
</p> </p>
</div> </div>
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button> @unless ($forInvoiceRecipient)
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button>
@endunless
<div class="note"> <div class="note">
<p> <p>

View file

@ -3,12 +3,22 @@
.' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency); .' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency);
@endphp @endphp
<x-emails.layout title="Zahlungsbestätigung für Ihre Buchung" preheader="Wir haben Ihre Zahlung erhalten — vielen Dank für Ihre Buchung."> <x-emails.layout title="Zahlungsbestätigung für Ihre Buchung" preheader="Wir haben Ihre Zahlung erhalten — vielen Dank für Ihre Buchung.">
<p>Hallo {{ $user->name }},</p> @if ($forInvoiceRecipient)
<p>Guten Tag,</p>
<p> <p>
vielen Dank für Ihre Buchung. Wir haben Ihre Zahlung erhalten und im Auftrag von {{ $user->name }} ({{ $user->email }}) erhalten Sie
die zugehörige Rechnung in Ihrem Konto hinterlegt: die Rechnung zu einer bezahlten Buchung auf pressekonto. Diese
</p> Adresse ist im Konto als Empfänger für Rechnungen hinterlegt.
</p>
@else
<p>Hallo {{ $user->name }},</p>
<p>
vielen Dank für Ihre Buchung. Wir haben Ihre Zahlung erhalten und
die zugehörige Rechnung in Ihrem Konto hinterlegt:
</p>
@endif
<dl class="meta"> <dl class="meta">
<dt>Rechnungsnummer</dt> <dt>Rechnungsnummer</dt>
@ -25,10 +35,20 @@
<dd>{{ $formatCents($invoice->total_cents) }}</dd> <dd>{{ $formatCents($invoice->total_cents) }}</dd>
</dl> </dl>
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button> @if ($attachPdf)
<p>Die Rechnung finden Sie als PDF im Anhang dieser E-Mail.</p>
@endif
@unless ($forInvoiceRecipient)
<x-emails.button :url="$invoicesUrl">Rechnung im Konto ansehen</x-emails.button>
@endunless
<div class="note"> <div class="note">
<p>Alle Rechnungen und Belege finden Sie jederzeit in Ihrem Konto unter Rechnungen.</p> @if ($forInvoiceRecipient)
<p>Den Versand an diese Adresse kann der Kontoinhaber im Profil unter Rechnungsadresse ändern oder abstellen.</p>
@else
<p>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.</p>
@endif
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p> <p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div> </div>
</x-emails.layout> </x-emails.layout>

View file

@ -169,15 +169,28 @@ new #[Layout('components.layouts.app'), Title('Rechnungen')] class extends Compo
</flux:table.cell> </flux:table.cell>
<flux:table.cell> <flux:table.cell>
@if ($invoice->stripe_invoice_id) @if ($invoice->stripe_invoice_id)
<flux:button {{-- PDF kommt aus pressekonto; die Stripe-Seite bleibt für den Zahlungsbeleg. --}}
size="sm" <div class="flex items-center gap-2">
variant="filled" <flux:button
icon="arrow-top-right-on-square" size="sm"
:href="route('me.invoices.stripe', $invoice)" variant="filled"
target="_blank" icon="document-text"
> :href="route('me.invoices.stripe-pdf', $invoice)"
{{ __('Bei Stripe öffnen') }} target="_blank"
</flux:button> >
{{ __('PDF öffnen') }}
</flux:button>
<flux:button
size="sm"
variant="ghost"
icon="arrow-top-right-on-square"
:href="route('me.invoices.stripe', $invoice)"
target="_blank"
:tooltip="__('Rechnung und Zahlungsbeleg auf der Stripe-Seite')"
>
{{ __('Beleg bei Stripe') }}
</flux:button>
</div>
@else @else
<flux:button <flux:button
size="sm" size="sm"

View file

@ -57,6 +57,16 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public string $billingCountryCode = 'DE'; public string $billingCountryCode = 'DE';
/**
* Rechnungsversand (06.10.2026): optionale Rechnungs-E-Mail und je
* Empfänger, ob das Rechnungs-PDF anhängt (siehe InvoiceDelivery).
*/
public string $invoiceEmail = '';
public bool $invoicePdfToAccount = true;
public bool $invoicePdfToInvoiceEmail = true;
public ?string $vatCheckStatus = null; public ?string $vatCheckStatus = null;
public ?string $vatCheckMessage = null; public ?string $vatCheckMessage = null;
@ -101,6 +111,9 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? ''); $this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
$this->billingCity = (string) ($billingAddress?->city ?? ''); $this->billingCity = (string) ($billingAddress?->city ?? '');
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE'); $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 // Bestandsdaten vor der Feld-Trennung: `name` war eine freie
// Empfängerzeile — einmalig in Vor-/Nachname aufteilen. // 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'], 'billingPostalCode' => ['nullable', 'string', 'max:20'],
'billingCity' => ['nullable', 'string', 'max:120'], 'billingCity' => ['nullable', 'string', 'max:120'],
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))], '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 // 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'), 'billingPostalCode' => __('PLZ'),
'billingCity' => __('Ort'), 'billingCity' => __('Ort'),
'billingCountryCode' => __('Land'), 'billingCountryCode' => __('Land'),
'invoiceEmail' => __('E-Mail für Rechnungen'),
]); ]);
// USt-ID: hartes Format-Gate; die Online-Bestätigung (eVatR) bleibt // 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 // pro Rechnung eingefroren und bestimmt die Steuer
// (EU-Befreiung nur mit gültiger USt-ID). // (EU-Befreiung nur mit gültiger USt-ID).
'vat_id' => $validated['taxIdNumber'] ?: null, '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->billingAddress1)
|| filled($this->billingAddress2) || filled($this->billingAddress2)
|| filled($this->billingPostalCode) || filled($this->billingPostalCode)
|| filled($this->billingCity); || filled($this->billingCity)
|| filled($this->invoiceEmail);
} }
public function billingIsComplete(): bool public function billingIsComplete(): bool
@ -525,6 +546,35 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
@endif @endif
@endif @endif
</div> </div>
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Rechnungsversand') }}
</div>
<div class="space-y-4">
<flux:switch
wire:model="invoicePdfToAccount"
align="right"
:label="__('Rechnungs-PDF an die Konto-E-Mail')"
:description="__('Die Zahlungsbestätigung geht immer an :email. Eingeschaltet hängt die Rechnung als PDF an.', ['email' => $user->email])"
/>
<flux:input
wire:model.live.blur="invoiceEmail"
type="email"
:label="__('E-Mail für Rechnungen (optional)')"
placeholder="buchhaltung@firma.de"
:description="__('Zum Beispiel die Buchhaltung. Diese Adresse bekommt nur Rechnungen, keine anderen Nachrichten.')"
/>
@if (filled($invoiceEmail))
<flux:switch
wire:model="invoicePdfToInvoiceEmail"
align="right"
:label="__('Rechnungen als PDF an diese Adresse senden')"
:description="__('Jede neue Rechnung geht zusätzlich mit PDF an :email.', ['email' => $invoiceEmail])"
/>
@endif
</div>
</div>
</div> </div>
</div> </div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end"> <div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">

View file

@ -5,6 +5,7 @@ use App\Http\Controllers\EmailUnsubscribeController;
use App\Http\Controllers\InvoicePdfController; use App\Http\Controllers\InvoicePdfController;
use App\Http\Controllers\LegacyInvoicePdfController; use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Controllers\ProofPdfController; use App\Http\Controllers\ProofPdfController;
use App\Http\Controllers\StripeInvoicePdfController;
use App\Http\Controllers\StripeInvoiceRedirectController; use App\Http\Controllers\StripeInvoiceRedirectController;
use App\Http\Controllers\Web\PressReleaseReportController; use App\Http\Controllers\Web\PressReleaseReportController;
use App\Http\Middleware\EnsureUserIsCustomer; 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::livewire('invoices', 'customer.invoices')->name('invoices.index');
Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf'); 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', 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::get('invoices/{invoice}/pdf', InvoicePdfController::class)->name('invoices.manual-pdf');
Route::livewire('tokens', 'customer.tokens')->name('tokens.index'); Route::livewire('tokens', 'customer.tokens')->name('tokens.index');

View file

@ -0,0 +1,204 @@
<?php
use App\Enums\InvoiceStatus;
use App\Mail\BookingPaymentConfirmed;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\User;
use App\Services\Billing\InvoiceDelivery;
use App\Services\Billing\StripeInvoicePdf;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
function deliveryInvoice(User $user, array $overrides = []): Invoice
{
return Invoice::factory()->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']);
});

View file

@ -7,6 +7,7 @@ use App\Exceptions\ManualInvoiceActionException;
use App\Mail\GrandfatheringTerminated; use App\Mail\GrandfatheringTerminated;
use App\Mail\ManualInvoiceIssued; use App\Mail\ManualInvoiceIssued;
use App\Models\ActivityEvent; use App\Models\ActivityEvent;
use App\Models\BillingAddress;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\InvoiceBillingAddress; use App\Models\InvoiceBillingAddress;
use App\Models\LegacyInvoice; 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'); 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 () { 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']); [$invoice] = dunningFixture(['sent_at' => now()->subDays(20), 'due_date' => '2026-09-10']);
$service = app(ManualInvoiceDunningService::class); $service = app(ManualInvoiceDunningService::class);

View file

@ -10,11 +10,21 @@ use App\Models\BillingAddress;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\SinglePurchase; use App\Models\SinglePurchase;
use App\Models\User; use App\Models\User;
use App\Services\Billing\StripeInvoicePdf;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
use Laravel\Cashier\Events\WebhookReceived; 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 function stripeInvoicePayload(User $user, array $overrides = []): array
{ {
return [ 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 () { test('a replayed invoice webhook does not queue a second confirmation mail', function () {
Mail::fake(); Mail::fake();

View file

@ -219,7 +219,9 @@ test('current STR and MAN invoices are listed with a stripe link where available
->assertSee('STR-00042') ->assertSee('STR-00042')
->assertSee('MAN-00007') ->assertSee('MAN-00007')
->assertDontSee('STR-99999') ->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)); ->assertSee(route('me.invoices.stripe', $strInvoice));
}); });