billing: Rechnungsversand — PDF im Anhang der Zahlungsmail, optionale Rechnungs-E-Mail, Stripe-Rechnungs-PDF direkt aus dem Kundenbereich

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 da9411bdab
commit 6d03d8de39
18 changed files with 695 additions and 30 deletions

View file

@ -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
<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)
<p>
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
</p>
@else
<p>
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
</p>
@if ($invoice->replaces_legacy_invoice_number)
<p>
@ -79,7 +96,9 @@
</p>
</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">
<p>

View file

@ -3,12 +3,22 @@
.' '.($invoice->currency === 'EUR' ? '€' : $invoice->currency);
@endphp
<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>
vielen Dank für Ihre Buchung. Wir haben Ihre Zahlung erhalten und
die zugehörige Rechnung in Ihrem Konto hinterlegt:
</p>
<p>
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.
</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">
<dt>Rechnungsnummer</dt>
@ -25,10 +35,20 @@
<dd>{{ $formatCents($invoice->total_cents) }}</dd>
</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">
<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>
</div>
</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>
@if ($invoice->stripe_invoice_id)
<flux:button
size="sm"
variant="filled"
icon="arrow-top-right-on-square"
:href="route('me.invoices.stripe', $invoice)"
target="_blank"
>
{{ __('Bei Stripe öffnen') }}
</flux:button>
{{-- PDF kommt aus pressekonto; die Stripe-Seite bleibt für den Zahlungsbeleg. --}}
<div class="flex items-center gap-2">
<flux:button
size="sm"
variant="filled"
icon="document-text"
:href="route('me.invoices.stripe-pdf', $invoice)"
target="_blank"
>
{{ __('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
<flux:button
size="sm"

View file

@ -57,6 +57,16 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
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 $vatCheckMessage = null;
@ -101,6 +111,9 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
$this->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
</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 class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">