User-Panel-Restarbeiten: PM-Guard, Profil-Rework, USt-ID-Prüfung, Buchungspflicht-Adresse

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 14:36:18 +00:00
parent 036a53499f
commit afcca34f91
25 changed files with 905 additions and 140 deletions

View file

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
/**
* Ergebnis der USt-ID-Prüfung (Format + optionale eVatR-Online-Abfrage).
*/
enum VatIdCheckStatus: string
{
case Valid = 'valid';
case Invalid = 'invalid';
case FormatInvalid = 'format_invalid';
case Unverified = 'unverified';
public function isUsable(): bool
{
return $this !== self::Invalid && $this !== self::FormatInvalid;
}
}

View file

@ -28,6 +28,10 @@ class CheckoutController extends Controller
$plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail();
$user = $request->user();
if (! $user->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if ($user->subscribed()) {
return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.'));
}
@ -45,6 +49,10 @@ class CheckoutController extends Controller
public function singlePm(Request $request): Checkout|RedirectResponse
{
if (! $request->user()->hasCompleteBillingAddress()) {
return $this->backToProfile();
}
if (! config('billing.single_pm_stripe_price_id')) {
return $this->backToBookings(__('Die Einzel-Pressemitteilung ist noch nicht buchbar. Bitte versuchen Sie es später erneut.'));
}
@ -81,4 +89,16 @@ class CheckoutController extends Controller
->route('me.bookings.index')
->with('checkout-notice', $notice);
}
/**
* Buchungs-Voraussetzung (12.06.2026): ohne vollständige
* Rechnungsadresse kein Checkout der Hinweis erscheint direkt auf
* der Profil-Seite über dem Rechnungsadress-Formular.
*/
private function backToProfile(): RedirectResponse
{
return redirect()
->route('me.profile')
->with('checkout-notice', __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung und wird an Stripe übergeben.'));
}
}

View file

@ -106,6 +106,7 @@ class ProcessStripeWebhook
$local = $user->billingAddress;
return InvoiceBillingAddress::query()->create([
'company' => $local?->company,
'name' => $stripeInvoice['customer_name'] ?? $local?->name ?? $user->name,
'address1' => $stripeAddress['line1'] ?? $local?->address1 ?? '',
'address2' => $stripeAddress['line2'] ?? $local?->address2,

View file

@ -14,6 +14,9 @@ class BillingAddress extends Model
'user_id',
'salutation_key',
'title',
'company',
'first_name',
'last_name',
'name',
'address1',
'address2',
@ -27,4 +30,17 @@ class BillingAddress extends Model
{
return $this->belongsTo(User::class);
}
/**
* Ist die Adresse vollständig genug für eine Rechnung bzw. Buchung?
* Maßgeblich: Empfänger (Name), Straße, PLZ, Ort und Land.
*/
public function isComplete(): bool
{
return filled($this->name)
&& filled($this->address1)
&& filled($this->postal_code)
&& filled($this->city)
&& filled($this->country_code);
}
}

View file

@ -13,6 +13,7 @@ class InvoiceBillingAddress extends Model
protected $fillable = [
'salutation_key',
'title',
'company',
'name',
'address1',
'address2',

View file

@ -105,6 +105,15 @@ class User extends Authenticatable
];
}
/**
* Buchungs-Voraussetzung (Entscheidung 12.06.2026): Tarif- und
* Einzel-PM-Checkouts erfordern eine vollständige Rechnungsadresse.
*/
public function hasCompleteBillingAddress(): bool
{
return (bool) $this->billingAddress?->isComplete();
}
/**
* Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans`
* gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo.

View file

@ -104,6 +104,7 @@ class ManualInvoiceService
$addressSnapshot = InvoiceBillingAddress::query()->create([
'salutation_key' => $billingAddress->salutation_key,
'title' => $billingAddress->title,
'company' => $billingAddress->company,
'name' => $billingAddress->name,
'address1' => $billingAddress->address1,
'address2' => $billingAddress->address2,

View file

@ -5,6 +5,7 @@ namespace App\Services\Billing;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Checkout;
/**
@ -27,11 +28,51 @@ class StripeCheckoutService
? $plan->stripe_price_id_yearly
: $plan->stripe_price_id_monthly;
$this->syncTaxIdFromBillingAddress($user);
return $user
->newSubscription('default', $priceId)
->checkout($this->sessionOptions());
}
/**
* Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer
* übergeben (User-Panel-Restarbeiten, 12.06.2026) Stripe Tax
* berücksichtigt sie dann ohne erneute Eingabe im Checkout. Fehler
* (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht:
* Stripe validiert die im Checkout erfasste ID ohnehin selbst.
*/
private function syncTaxIdFromBillingAddress(User $user): void
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
if ($vatId === '') {
return;
}
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
if ($prefixCountry !== 'DE' && ! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
return;
}
try {
$user->createOrGetStripeCustomer();
$alreadySet = collect($user->taxIds())
->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId);
if (! $alreadySet) {
$user->createTaxId('eu_vat', $vatId);
}
} catch (\Throwable $exception) {
Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
}
}
/**
* Gemeinsame Session-Optionen: Stripe Tax braucht eine gültige
* Kundenadresse die im Checkout erfasste Rechnungsadresse (und der
@ -69,6 +110,8 @@ class StripeCheckoutService
*/
public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout
{
$this->syncTaxIdFromBillingAddress($user);
return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [
...$this->sessionOptions(),
'metadata' => ['single_purchase_id' => (string) $purchase->id],

View file

@ -0,0 +1,148 @@
<?php
namespace App\Services\Billing;
use App\Enums\VatIdCheckStatus;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Prüft USt-IDs in zwei Stufen (User-Panel-Restarbeiten, 12.06.2026):
*
* 1. **Formatprüfung** (immer): EU-Formatrahmen über den VatResolver,
* inkl. Abgleich mit dem Land der Rechnungsadresse.
* 2. **eVatR-Online-Abfrage** (wenn möglich): REST-API des BZSt
* (`POST /app/v1/abfrage`, Status `evatr-0000` = gültig). Die Abfrage
* setzt die eigene deutsche USt-ID des Betreibers voraus
* (`billing.own_vat_id`) und kann nur ausländische EU-IDs bestätigen
* deutsche IDs und Ausfälle der API führen zu `Unverified`, nie zu
* einem harten Fehler. Stripe validiert die im Checkout erfasste
* USt-ID zusätzlich selbst; diese Prüfung verbessert die Eingabe-UX
* und sichert den manuellen MAN-Rechnungskreis ab.
*/
class VatIdValidationService
{
private const EVATR_ENDPOINT = 'https://api.evatr.vies.bzst.de/app/v1/abfrage';
private const STATUS_VALID = 'evatr-0000';
public function __construct(private readonly VatResolver $vatResolver) {}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
public function check(string $vatId, ?string $billingCountryCode = null): array
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', $vatId));
if ($vatId === '') {
return $this->result(VatIdCheckStatus::Unverified, __('Keine USt-ID angegeben.'));
}
$prefix = substr($vatId, 0, 2);
$prefixCountry = $prefix === 'EL' ? 'GR' : $prefix;
if (! $this->vatResolver->isPlausibleVatId($vatId, $prefixCountry)) {
return $this->result(
VatIdCheckStatus::FormatInvalid,
__('Das Format der USt-ID ist ungültig (erwartet: Ländercode + Kennziffer, z. B. DE123456789).'),
);
}
$billingCountryCode = strtoupper((string) $billingCountryCode);
if ($billingCountryCode !== '' && ! $this->vatResolver->isPlausibleVatId($vatId, $billingCountryCode)) {
return $this->result(
VatIdCheckStatus::FormatInvalid,
__('Die USt-ID passt nicht zum Land der Rechnungsadresse (:country).', ['country' => $billingCountryCode]),
);
}
if ($prefix === 'DE') {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Deutsche USt-IDs können über eVatR nicht online bestätigt werden.'),
);
}
if (! in_array($prefixCountry, (array) config('billing.eu_country_codes', []), true)) {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Online-Bestätigung ist nur für EU-USt-IDs möglich.'),
);
}
$ownVatId = (string) config('billing.own_vat_id');
if ($ownVatId === '') {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Online-Prüfung ist nicht konfiguriert (BILLING_OWN_VAT_ID).'),
);
}
return $this->confirmViaEvatr($vatId, $ownVatId);
}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
private function confirmViaEvatr(string $vatId, string $ownVatId): array
{
$cacheKey = 'evatr-check:'.$vatId;
/** @var array{status: VatIdCheckStatus, message: string}|null $cached */
$cached = Cache::get($cacheKey);
if ($cached !== null) {
return $cached;
}
try {
$response = Http::timeout(8)
->acceptJson()
->post(self::EVATR_ENDPOINT, [
'anfragendeUstid' => $ownVatId,
'angefragteUstid' => $vatId,
]);
} catch (\Throwable $exception) {
Log::warning('eVatR-Abfrage fehlgeschlagen.', ['vat_id' => $vatId, 'error' => $exception->getMessage()]);
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht erreichbar.'),
);
}
if (! $response->successful()) {
return $this->result(
VatIdCheckStatus::Unverified,
__('Format plausibel. Die Online-Prüfung (eVatR) ist derzeit nicht verfügbar.'),
);
}
$status = (string) $response->json('status');
$result = $status === self::STATUS_VALID
? $this->result(VatIdCheckStatus::Valid, __('USt-ID per eVatR (BZSt) bestätigt — gültig.'))
: $this->result(
VatIdCheckStatus::Invalid,
__('Die USt-ID wurde von eVatR (BZSt) nicht bestätigt (Status :status).', ['status' => $status ?: 'unbekannt']),
);
// Nur definitive Ergebnisse cachen — Ausfälle der API sollen beim
// nächsten Versuch erneut geprüft werden.
Cache::put($cacheKey, $result, now()->addHours(6));
return $result;
}
/**
* @return array{status: VatIdCheckStatus, message: string}
*/
private function result(VatIdCheckStatus $status, string $message): array
{
return ['status' => $status, 'message' => $message];
}
}

View file

@ -49,8 +49,9 @@ class VatResolver
/**
* Formale Plausibilität: beginnt mit dem Ländercode der Adresse und
* trägt danach 213 alphanumerische Zeichen (EU-Formatrahmen).
* Public, damit der VatIdValidationService dieselbe Definition nutzt.
*/
private function isPlausibleVatId(?string $vatId, string $countryCode): bool
public function isPlausibleVatId(?string $vatId, string $countryCode): bool
{
$vatId = strtoupper(preg_replace('/\s+/', '', (string) $vatId) ?? '');

View file

@ -63,6 +63,11 @@ return [
'vat_rate' => env('BILLING_VAT_RATE', 0.19),
// Eigene deutsche USt-ID des Betreibers — Pflichtangabe für die
// eVatR-Online-Bestätigung ausländischer EU-USt-IDs (BZSt-REST-API).
// Ohne sie bleibt die USt-ID-Prüfung eine reine Formatprüfung.
'own_vat_id' => env('BILLING_OWN_VAT_ID'),
// EU-Mitgliedstaaten (ISO 3166-1 alpha-2), Stand 2026 — ohne DE,
// das im VatResolver als Inland behandelt wird.
'eu_country_codes' => [

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Rechnungsadresse vervollständigen (User-Panel-Restarbeiten, 12.06.2026):
* Anrede/Titel existierten bereits, es fehlten Firmenname sowie getrennte
* Vor-/Nachnamen. `name` bleibt als zusammengesetzte Empfängerzeile für
* die Rechnungs-Snapshots erhalten und wird beim Speichern aus
* Vor-/Nachname gefüllt. Die Snapshot-Tabelle bekommt `company` ebenfalls,
* damit der Firmenname pro Rechnung eingefroren wird.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('billing_addresses', function (Blueprint $table): void {
$table->string('company', 255)->nullable()->after('title');
$table->string('first_name', 80)->nullable()->after('company');
$table->string('last_name', 80)->nullable()->after('first_name');
});
Schema::table('invoice_billing_addresses', function (Blueprint $table): void {
$table->string('company', 255)->nullable()->after('title');
});
}
public function down(): void
{
Schema::table('billing_addresses', function (Blueprint $table): void {
$table->dropColumn(['company', 'first_name', 'last_name']);
});
Schema::table('invoice_billing_addresses', function (Blueprint $table): void {
$table->dropColumn('company');
});
}
};

View file

@ -5,6 +5,44 @@
---
## 2026-06-12 · User-Panel-Restarbeiten (Kevins Liste) ✅
- **Was**: Alle Punkte aus `docs/user-admin/User-Panel-Restarbeiten.md`
(Status-Tabelle dort): (1) PM-Anlage ohne Firma zeigt eine Meldung mit
„Firma anlegen"-Button statt des leeren Editors. (2) Profil-Seite neu
gegliedert (Persönliche Daten / Konto / Rechnungsadresse / Einstellungen);
Rechnungsadresse vervollständigt um Anrede, Vorname, Nachname,
Firmenname (Migration `billing_addresses` + Snapshot-Spalte `company`
in `invoice_billing_addresses`, von MAN-Lauf und STR-Spiegelung
durchgereicht); „Persönliche Daten übernehmen"-Button gegen die
Doppel-Eingabe. (3) Doppelte Validierungsmeldung behoben: fehlende
Pflichtfelder melden einzeln unter dem Feld. (4) USt-ID-Validierung:
Formatprüfung sofort (hartes Speicher-Gate) + eVatR-Online-Bestätigung
(BZSt-REST-API, neuer `VatIdValidationService`, ENV `BILLING_OWN_VAT_ID`,
6h-Cache nur für definitive Ergebnisse, Ausfälle degradieren sanft).
(5) Rechnungsadresse ist Pflicht für jeden Checkout (Redirect aufs
Profil mit Hinweis); USt-ID wird als Stripe-Tax-ID übergeben.
(6) Firmenübersicht zeigt Logos auch für Legacy-Firmen (zentrale
logoUrl-Auflösung statt verkürzter Fast-Variante); „Letzte PM" mit
Jahreszahl. (7) Checkboxen → Flux-Switches (Boilerplate-Override,
Footer-Code); Token-Abilities bleiben Checkbox-Gruppe. Befund zu den
Profil-Schaltern: `show_stats`/`disable_footer_code` werden noch
nirgends ausgewertet (greifen mit Web-Relaunch) — steht jetzt in den
Beschreibungen.
- **Dateien**: `customer/profile.blade.php` (Neufassung),
`customer/press-releases/create.blade.php` (Guard),
`customer/press-kits/index.blade.php` (Logo/Datum),
`app/Services/Billing/VatIdValidationService.php` (neu) +
`VatIdCheckStatus`-Enum, `VatResolver` (isPlausibleVatId public),
`StripeCheckoutService` (Tax-ID-Sync), `CheckoutController` (Guard),
`User::hasCompleteBillingAddress()`, `BillingAddress::isComplete()`,
Migration, `config/billing.php` (`own_vat_id`).
- **Build/Test**: Suite 546 passed / 4 skipped, Pint clean; 14 neue Tests
(VatId-Service, PM-Guard, Profil-Validierung, Checkout-Guard).
- **Offene Fragen**: `BILLING_OWN_VAT_ID` in .env setzen (eigene USt-ID),
sonst bleibt die Online-Prüfung aus; Entscheidung zu den beiden
Profil-Schaltern (behalten vs. bis Relaunch ausblenden).
## 2026-06-12 · Responsive-Härtung (Block 3, Punkt 1) ✅
- **Was**: Systemische Responsive-Fehler behoben (Screenshots in

View file

@ -69,6 +69,13 @@ Stub-Spalte `users.press_release_quota` ist entfernt.
| `me.checkout.single-pm` (`/admin/me/checkout/einzel-pm`) | Stripe-Checkout Einzel-PM (legt `single_purchases`-Eintrag `pending` an; Webhook setzt `paid`) |
| `me.checkout.billing-portal` (`/admin/me/checkout/abo-verwalten`) | Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung) |
**Buchungs-Voraussetzung** (12.06.2026): Jeder Checkout erfordert eine
vollständige Rechnungsadresse (`User::hasCompleteBillingAddress()`,
Pflicht: Nachname, Straße, PLZ, Ort, Land) — sonst Redirect aufs Profil
mit Hinweis. Lokale Adresse (`User::stripeAddress()`) und USt-ID
(`StripeCheckoutService::syncTaxIdFromBillingAddress`, Typ `eu_vat`)
werden an den Stripe-Customer übergeben.
Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`).
Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()`
im AppServiceProvider, Netto-Preise mit `tax_behavior: exclusive`) — nach
@ -159,6 +166,7 @@ Stripe/Cashier:
| `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) |
| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints (gesetzt; Endpoint `https://pressekonto.com/stripe/webhook` im Dashboard registriert, 12.06.2026) |
| `STRIPE_PRICE_SINGLE_PM` | Stripe-Price-ID der Einzel-PM (legt `billing:sync-stripe-plans` an; ohne sie ist der Einzel-PM-Checkout deaktiviert) |
| `BILLING_OWN_VAT_ID` | Eigene deutsche USt-ID des Betreibers — schaltet die eVatR-Online-Bestätigung ausländischer EU-USt-IDs frei (BZSt-REST-API, `VatIdValidationService`); ohne sie bleibt es bei der Formatprüfung |
| `CASHIER_CURRENCY` / `CASHIER_CURRENCY_LOCALE` | `eur` / `de_DE` (gesetzt) |
**Benötigte Webhook-Events** am Stripe-Endpoint: `invoice.payment_succeeded`

View file

@ -1,7 +1,34 @@
1. Pressemitteilungen anlegen ohne Firma hier würde ich das Create Formular nicht aufrufen lassen, wenn keine Firma existiert, sondern es muss eine Meldung erscheinen, dass ohne eine Firma keine Pressemitteilung angelegt werden kann.. Hintergrund öffnet man. Eine neue Pressemitteilung kann man keine Firma angeben und kann auch nicht speichern man muss wieder zurückspringen eine Firma anlegen und dann kann man erst entsprechend ein Presse Beitrag erstellen.
2. https://pressekonto.test/admin/me/profile die Darstellung ist noch nicht wirklich sauber hier kann noch etwas mehr Struktur rein. Grundsätzlich würde ich ab sofort Rechnungsadresse als Pflicht machen, wenn ein Paket gebucht wird und diese Rechnungsdaten gleich mit übergeben. Profil Einstellungen. Hier gibt es auch schon so etwas wie Anrede Vorname Telefon Risse etc. dieses doppelt sich mit der Rechnungsadresse zumindest in Teilen, denn die Rechnungsadresse ist technisch eigentlich gar nicht richtig vollständig hier viel zum Beispiel Firmenname Vorname Nachname Anrede.
Profil
1. https://pressekonto.test/admin/me/profile die Darstellung ist noch nicht wirklich sauber hier kann noch etwas mehr Struktur rein. Grundsätzlich würde ich ab sofort Rechnungsadresse als Pflicht machen, wenn ein Paket gebucht wird und diese Rechnungsdaten gleich mit übergeben. Profil Einstellungen. Hier gibt es auch schon so etwas wie Anrede Vorname Telefon Risse etc. dieses doppelt sich mit der Rechnungsadresse zumindest in Teilen, denn die Rechnungsadresse ist technisch eigentlich gar nicht richtig vollständig hier viel zum Beispiel Firmenname Vorname Nachname Anrede.
3. Validierung der Rechnungsadresse gebe ich etwas ein und drücke ich auf. Absenden bekomme ich die Fehlermeldung zweimal einmal direkt unter dem Feld und einmal unten.
2. Validierung der Rechnungsadresse gebe ich etwas ein und drücke ich auf. Absenden bekomme ich die Fehlermeldung zweimal einmal direkt unter dem Feld und einmal unten.
4. Umsatzsteuer ID. Ich würde hier gerne schon mal eine Validierung der Umsatzsteuer ID haben, ob diese gültig ist oder macht das an dieser Stelle gar keinen Sinn, weil Strip das komplett übernimmt? Ich glaube es wär aber gut, wenn Firmen aus EU Land hier schon eine Steuernummer hinterlegen können und diese zumindest schon einmal während der Eingabe validiert wird, ob diese richtig ist. Diese kann dann ja Stripe übergeben werden.
3. Umsatzsteuer ID. Ich würde hier gerne schon mal eine Validierung der Umsatzsteuer ID haben, ob diese gültig ist oder macht das an dieser Stelle gar keinen Sinn, weil Strip das komplett übernimmt? Ich glaube es wär aber gut, wenn Firmen aus EU Land hier schon eine Steuernummer hinterlegen können und diese zumindest schon einmal während der Eingabe validiert wird, ob diese richtig ist. Diese kann dann ja Stripe übergeben werden. https://api.evatr.vies.bzst.de/app/v1/abfrage
4. Unten gibt es noch Einstellungen Statistiken anzeigen und Footer code deaktivieren haben die aktuell eine Funktion?
Firmen:
Wenn ich auf die Firmenübersicht gehe und eine Firma ein Logo hat, soll das auch in der Übersicht erscheinen und angezeigt werden. Jetzt ist da dann immer der Kürzel der Firma
Bei der Firma ist zusätzlich angegeben, letzte PM ihr fehlt Jahreszahl
Einheitlichkeit.
Statt Checkboxen, bitte die Switcher benutzen von Flux UI.
---
## Status (Claude, 12.06.2026)
| # | Punkt | Status |
|---|---|---|
| PM 1 | PM-Anlage ohne Firma | ✅ Das Formular erscheint nicht mehr; stattdessen Meldung „Ohne Firma kann keine Pressemitteilung angelegt werden" mit Button „Firma anlegen". |
| Profil 1 | Struktur + Rechnungsadresse vollständig | ✅ Seite neu gegliedert (Persönliche Daten / Konto & Sicherheit / Rechnungsadresse / Einstellungen). Rechnungsadresse hat jetzt Anrede, Vorname, Nachname, Firmenname (optional) + Anschrift, mit Unterabschnitten Empfänger/Anschrift/Steuern und Button „Persönliche Daten übernehmen" gegen die Doppel-Eingabe. Rechnungsadresse ist ab sofort **Pflicht für jede Buchung** (Checkout leitet sonst mit Hinweis aufs Profil); Adresse und USt-ID werden an Stripe übergeben. |
| Profil 2 | Doppelte Fehlermeldung | ✅ Statt einer generischen Sammelmeldung + Feldmeldung werden fehlende Pflichtfelder jetzt einzeln und genau einmal unter dem jeweiligen Feld gemeldet. |
| Profil 3 | USt-ID-Validierung | ✅ Zweistufig: Formatprüfung sofort bei der Eingabe (hartes Gate beim Speichern), Online-Bestätigung über die BZSt-eVatR-REST-API (`api.evatr.vies.bzst.de`) live während der Eingabe. **Wichtig:** eVatR kann nur ausländische EU-IDs bestätigen und braucht unsere eigene deutsche USt-ID → ENV `BILLING_OWN_VAT_ID` setzen. Deutsche IDs werden nur formatgeprüft. Antwort auf die Frage: Stripe validiert die im Checkout erfasste ID zusätzlich selbst — unsere Prüfung verbessert die Eingabe-UX und sichert den manuellen MAN-Rechnungskreis ab, ersetzt Stripe also nicht, ergänzt es. Die lokal gepflegte USt-ID wird beim Checkout als Stripe-Tax-ID übergeben. |
| Profil 4 | Schalter „Statistiken anzeigen" / „Footer-Code deaktivieren" | ⚠️ Befund: Beide Flags werden gespeichert (auch aus dem Legacy-Import), aber **noch nirgends ausgewertet** — die konsumierenden Features (öffentliche PM-Seiten mit Footer-Codes/Statistiken) kommen mit dem Web-Relaunch. Die Beschreibungen weisen jetzt darauf hin („Greift mit dem Relaunch der Portal-Seiten"). Entscheidung offen: behalten (empfohlen, Daten sind da) oder bis zum Relaunch ausblenden. |
| Firmen 1 | Logo in der Übersicht | ✅ Die Übersicht nutzt jetzt dieselbe Logo-Auflösung wie die Detailseite (inkl. migrierter Legacy-Pfade) — vorher fielen alle Legacy-Firmen auf die Initialen zurück. |
| Firmen 2 | „Letzte PM" ohne Jahr | ✅ Kartenansicht zeigt jetzt `d.m.Y`. |
| Einheitlichkeit | Checkboxen → Flux-Switches | ✅ Umgestellt (Boilerplate-Override in PM-Create/-Edit, Footer-Code in Firma-Anlage/-Detail). Ausnahme: die Mehrfachauswahl der API-Token-Berechtigungen bleibt eine Checkbox-Gruppe — dort ist die Checkbox die semantisch richtige Flux-Komponente. |

View file

@ -201,7 +201,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten
</flux:field>
<flux:field class="sm:col-span-2">
<flux:checkbox wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
<flux:switch wire:model="disableFooterCode" :label="__('Footer-Code deaktivieren (z. B. wenn die Firma keine Quellenangabe haben möchte)')" />
</flux:field>
</div>
</article>

View file

@ -6,7 +6,6 @@ use App\Services\Customer\CustomerCompanyContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
@ -313,29 +312,13 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
public function fastLogoUrl(Company $company): ?string
{
if (blank($company->logo_path)) {
return null;
}
$logoPath = trim((string) $company->logo_path);
if (Str::startsWith($logoPath, ['http://', 'https://']) && blank($company->legacy_portal)) {
return $logoPath;
}
if (Str::startsWith($logoPath, '/storage/')) {
return asset($logoPath);
}
if (filled($company->legacy_portal)) {
return null;
}
if (! Str::startsWith($logoPath, ['http://', 'https://'])) {
return asset('storage/'.ltrim($logoPath, '/'));
}
return null;
// Delegiert an die zentrale Auflösung inkl. der migrierten
// Legacy-Pfade (company-logos/{portal}/{id}/…) — die frühere
// „schnelle" Variante übersprang Legacy-Firmen komplett, wodurch
// in der Übersicht trotz vorhandenem Logo nur die Initialen
// erschienen. Die Existenz-Checks laufen auf dem lokalen Disk
// und sind für 50 Karten pro Seite unkritisch.
return $company->logoUrl();
}
public function with(): array
@ -389,7 +372,7 @@ new #[Layout('components.layouts.app'), Title('Meine Firmen')] class extends Com
$company->setAttribute('panel_meta_line', $this->metaLine($company));
$company->setAttribute(
'panel_last_press_release_short',
$lastPublishedAt?->format('d.m.') ?? '—'
$lastPublishedAt?->format('d.m.Y') ?? '—'
);
$company->setAttribute(
'panel_last_press_release_date',

View file

@ -425,7 +425,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component
<flux:error name="companyCountryCode" />
</flux:field>
<flux:field>
<flux:checkbox wire:model="companyDisableFooterCode" :label="__('Footer-Code deaktivieren')" />
<flux:switch wire:model="companyDisableFooterCode" :label="__('Footer-Code deaktivieren')" />
</flux:field>
</div>

View file

@ -65,10 +65,22 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
public string $placeholderVariant = '';
public bool $hasCompanies = true;
public function mount(): void
{
$user = auth()->user();
$context = app(CustomerCompanyContext::class);
// Ohne Firma kein PM-Formular: statt eines leeren Editors, in dem
// weder Firma wählbar noch Speichern möglich ist, erscheint eine
// Meldung mit dem direkten Weg zur Firmen-Anlage.
$this->hasCompanies = $context->companyCountFor($user) > 0;
if (! $this->hasCompanies) {
return;
}
$firstCompany = $context->selectedCompany($user) ?? $context->latestCompaniesFor($user, 1)->first();
if ($firstCompany) {
@ -711,6 +723,33 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
</div>
</header>
@if (! $hasCompanies)
{{-- ============== KEINE FIRMA: MELDUNG STATT FORMULAR ============== --}}
<article class="panel">
<div class="p-8 flex flex-col items-center text-center gap-4">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.building-office class="size-6" />
</div>
<div class="space-y-1 max-w-[520px]">
<h2 class="text-[16px] font-semibold text-[color:var(--color-ink)] m-0">
{{ __('Ohne Firma kann keine Pressemitteilung angelegt werden.') }}
</h2>
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
{{ __('Jede Pressemitteilung erscheint im Namen einer Firma. Bitte legen Sie zuerst eine Firma an — danach können Sie hier direkt loslegen.') }}
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-2 pt-1">
<flux:button variant="primary" icon="plus" href="{{ route('me.press-kits.create') }}" wire:navigate>
{{ __('Firma anlegen') }}
</flux:button>
<flux:button variant="filled" href="{{ route('me.press-releases.index') }}" wire:navigate>
{{ __('Zur PM-Übersicht') }}
</flux:button>
</div>
</div>
</article>
@else
{{-- ============== 2-COLUMN GRID ============== --}}
<div class="grid gap-6 pr-editor-layout">
@ -952,7 +991,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
{{ __('Boilerplate aus Firma') }}
</span>
</span>
<flux:checkbox
<flux:switch
wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')"
/>
@ -1334,4 +1373,5 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
:confirm-label="__('Zur Prüfung senden')"
:quota-total="$quotaTotal"
:quota-remaining="$quotaRemaining" />
@endif
</div>

View file

@ -901,7 +901,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
{{ __('Boilerplate aus Firma') }}
</span>
</span>
<flux:checkbox
<flux:switch
wire:model.live="useBoilerplateOverride"
:label="__('Für diese PM überschreiben')"
/>

View file

@ -1,8 +1,9 @@
<?php
use App\Enums\VatIdCheckStatus;
use App\Models\User;
use App\Services\Billing\VatIdValidationService;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
@ -35,7 +36,13 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public string $taxIdNumber = '';
public string $billingName = '';
public string $billingSalutationKey = 'none';
public string $billingCompany = '';
public string $billingFirstName = '';
public string $billingLastName = '';
public string $billingAddress1 = '';
@ -47,6 +54,10 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public string $billingCountryCode = 'DE';
public ?string $vatCheckStatus = null;
public ?string $vatCheckMessage = null;
public function mount(): void
{
$user = auth()->user();
@ -68,17 +79,72 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
$this->taxIdNumber = (string) ($profile?->tax_id_number ?? '');
$billingAddress = $user->billingAddress;
$this->billingName = (string) ($billingAddress?->name ?? '');
$this->billingSalutationKey = (string) ($billingAddress?->salutation_key ?? 'none');
$this->billingCompany = (string) ($billingAddress?->company ?? '');
$this->billingFirstName = (string) ($billingAddress?->first_name ?? '');
$this->billingLastName = (string) ($billingAddress?->last_name ?? '');
$this->billingAddress1 = (string) ($billingAddress?->address1 ?? '');
$this->billingAddress2 = (string) ($billingAddress?->address2 ?? '');
$this->billingPostalCode = (string) ($billingAddress?->postal_code ?? '');
$this->billingCity = (string) ($billingAddress?->city ?? '');
$this->billingCountryCode = (string) ($billingAddress?->country_code ?? 'DE');
// Bestandsdaten vor der Feld-Trennung: `name` war eine freie
// Empfängerzeile — einmalig in Vor-/Nachname aufteilen.
if (blank($this->billingFirstName) && blank($this->billingLastName) && filled($billingAddress?->name)) {
$parts = preg_split('/\s+/u', trim((string) $billingAddress->name)) ?: [];
$this->billingLastName = (string) array_pop($parts);
$this->billingFirstName = implode(' ', $parts);
}
if (filled($this->taxIdNumber)) {
$this->refreshVatCheck();
}
}
/**
* Persönliche Daten als Rechnungsempfänger übernehmen löst die von
* Kevin angemerkte Doppel-Eingabe auf, ohne die Datensätze zu koppeln.
*/
public function copyProfileToBilling(): void
{
$this->billingSalutationKey = $this->salutationKey;
$this->billingFirstName = $this->firstName;
$this->billingLastName = $this->lastName;
if (filled($this->countryCode)) {
$this->billingCountryCode = $this->countryCode;
}
}
public function updatedTaxIdNumber(): void
{
$this->refreshVatCheck();
}
public function updatedBillingCountryCode(): void
{
$this->refreshVatCheck();
}
private function refreshVatCheck(): void
{
if (blank($this->taxIdNumber)) {
$this->vatCheckStatus = null;
$this->vatCheckMessage = null;
return;
}
$result = app(VatIdValidationService::class)->check($this->taxIdNumber, $this->billingCountryCode);
$this->vatCheckStatus = $result['status']->value;
$this->vatCheckMessage = $result['message'];
}
public function saveProfile(): void
{
$validated = $this->validate([
$rules = [
'name' => ['required', 'string', 'max:120'],
'language' => ['required', Rule::in(['de', 'en'])],
'salutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
@ -90,18 +156,47 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
'countryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
'backlinkUrl' => ['nullable', 'url', 'max:255'],
'taxIdNumber' => ['nullable', 'string', 'max:255'],
'billingName' => ['nullable', 'string', 'max:255'],
'billingSalutationKey' => ['required', Rule::in(array_keys((array) config('salutations.items', [])))],
'billingCompany' => ['nullable', 'string', 'max:255'],
'billingFirstName' => ['nullable', 'string', 'max:80'],
'billingLastName' => ['nullable', 'string', 'max:80'],
'billingAddress1' => ['nullable', 'string', 'max:255'],
'billingAddress2' => ['nullable', 'string', 'max:255'],
'billingPostalCode' => ['nullable', 'string', 'max:20'],
'billingCity' => ['nullable', 'string', 'max:120'],
'billingCountryCode' => ['nullable', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))],
];
// Sobald irgendein Rechnungsfeld gefüllt ist, werden die
// Pflichtfelder einzeln eingefordert — die Meldung erscheint genau
// einmal unter dem jeweils fehlenden Feld (vorher: eine generische
// Sammelmeldung zusätzlich zur Feldmeldung).
if ($this->billingHasInput()) {
$rules['billingLastName'] = ['required', 'string', 'max:80'];
$rules['billingAddress1'] = ['required', 'string', 'max:255'];
$rules['billingPostalCode'] = ['required', 'string', 'max:20'];
$rules['billingCity'] = ['required', 'string', 'max:120'];
$rules['billingCountryCode'] = ['required', 'string', 'size:2', Rule::in(array_keys((array) config('countries.items', [])))];
}
$validated = $this->validate($rules, attributes: [
'billingLastName' => __('Nachname (Rechnung)'),
'billingAddress1' => __('Straße und Hausnummer'),
'billingPostalCode' => __('PLZ'),
'billingCity' => __('Ort'),
'billingCountryCode' => __('Land'),
]);
if ($this->billingHasInput() && ! $this->billingIsComplete()) {
throw ValidationException::withMessages([
'billingName' => __('Bitte füllen Sie für eine Rechnungsadresse Name, Adresse, PLZ, Ort und Land aus.'),
]);
// USt-ID: hartes Format-Gate; die Online-Bestätigung (eVatR) bleibt
// ein Hinweis und blockiert das Speichern nicht.
if (filled($validated['taxIdNumber'])) {
$this->refreshVatCheck();
if ($this->vatCheckStatus === VatIdCheckStatus::FormatInvalid->value) {
$this->addError('taxIdNumber', (string) $this->vatCheckMessage);
return;
}
}
/** @var User $user */
@ -135,9 +230,12 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
$user->billingAddress()->updateOrCreate(
['user_id' => $user->id],
[
'salutation_key' => $validated['salutationKey'] !== 'none' ? $validated['salutationKey'] : null,
'title' => $validated['title'] ?: null,
'name' => $validated['billingName'],
'salutation_key' => $validated['billingSalutationKey'] !== 'none' ? $validated['billingSalutationKey'] : null,
'company' => $validated['billingCompany'] ?: null,
'first_name' => $validated['billingFirstName'] ?: null,
'last_name' => $validated['billingLastName'] ?: null,
// Zusammengesetzte Empfängerzeile für Rechnungs-Snapshots.
'name' => trim($validated['billingFirstName'].' '.$validated['billingLastName']),
'address1' => $validated['billingAddress1'],
'address2' => $validated['billingAddress2'] ?: null,
'postal_code' => $validated['billingPostalCode'],
@ -170,7 +268,9 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public function billingHasInput(): bool
{
return filled($this->billingName)
return filled($this->billingCompany)
|| filled($this->billingFirstName)
|| filled($this->billingLastName)
|| filled($this->billingAddress1)
|| filled($this->billingAddress2)
|| filled($this->billingPostalCode)
@ -179,7 +279,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public function billingIsComplete(): bool
{
return filled($this->billingName)
return filled($this->billingLastName)
&& filled($this->billingAddress1)
&& filled($this->billingPostalCode)
&& filled($this->billingCity)
@ -191,7 +291,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<div class="flex items-center gap-3 mb-3 flex-wrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Profil') }}</span>
</div>
@ -199,7 +299,7 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
{{ __('Mein Profil') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[680px] text-[color:var(--color-ink-2)]">
{{ __('Hier verwalten Sie Ihre Rechnungsadresse und persönlichen Profileinstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
{{ __('Persönliche Daten, Rechnungsadresse und Konto-Einstellungen. Firmendaten liegen separat in der Firmenverwaltung.') }}
</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
@ -217,94 +317,44 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
</div>
@endif
@if (session('checkout-notice'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">{{ session('checkout-notice') }}</div>
</div>
@endif
<form wire:submit="saveProfile" class="space-y-6">
<article class="panel" id="rechnungsadresse">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
@if ($billingComplete)
<span class="badge ok dot">{{ __('vollständig') }}</span>
@else
<span class="badge warn dot">{{ __('unvollständig') }}</span>
@endif
</div>
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="space-y-3">
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
{{ __('Diese Adresse ist die maßgebliche Grundlage für Rechnungen und künftige Buchungen.') }}
</p>
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
{{ __('Pflichtangaben sind Rechnungsname, Adresse, PLZ, Ort und Land. Die USt-ID ist optional.') }}
</p>
@if (! $billingComplete)
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
{{ __('Bitte ergänzen Sie die Rechnungsadresse, damit neue Buchungen sauber abgerechnet werden können.') }}
</div>
</div>
@else
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
<div class="flex-1">
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
</div>
</div>
@endif
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="billingName" :label="__('Rechnungsname')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress1" :label="__('Adresse Zeile 1')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress2" :label="__('Adresse Zeile 2')" class="sm:col-span-2" />
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
<flux:input wire:model="billingCity" :label="__('Ort')" />
<flux:select wire:model="billingCountryCode" :label="__('Land')">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
<flux:input wire:model="taxIdNumber" :label="__('USt-ID')" />
<flux:error name="billingName" class="sm:col-span-2" />
</div>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Rechnungsadresse speichern') }}
</flux:button>
</div>
</article>
{{-- ============== 1) PERSÖNLICHE DATEN + KONTO ============== --}}
<div class="grid gap-6 lg:grid-cols-2">
<article class="panel" id="profil">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Profileinstellungen') }}</span>
<span class="section-eyebrow">{{ __('Persönliche Daten') }}</span>
</div>
<div class="p-5 grid gap-4 sm:grid-cols-2">
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
<flux:select wire:model="salutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
<flux:input wire:model="firstName" :label="__('Vorname')" />
<flux:input wire:model="lastName" :label="__('Nachname')" />
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
<flux:textarea wire:model="address" :label="__('Adresse')" class="sm:col-span-2" />
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Profil speichern') }}
</flux:button>
<div class="p-5 space-y-4">
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
{{ __('Ihre Kontaktdaten für Ansprache und Rückfragen — unabhängig von der Rechnungsadresse unten.') }}
</p>
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="name" :label="__('Anzeigename')" required class="sm:col-span-2" />
<flux:select wire:model="salutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="title" :label="__('Titel')" placeholder="Dr." />
<flux:input wire:model="firstName" :label="__('Vorname')" />
<flux:input wire:model="lastName" :label="__('Nachname')" />
<flux:input wire:model="phone" :label="__('Telefon')" />
<flux:input wire:model="backlinkUrl" :label="__('Backlink-URL')" placeholder="https://..." />
<flux:textarea wire:model="address" :label="__('Kontaktadresse')" class="sm:col-span-2" />
<flux:select wire:model="countryCode" :label="__('Land')" class="sm:col-span-2">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
</div>
</div>
</article>
@ -327,6 +377,121 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
</article>
</div>
{{-- ============== 2) RECHNUNGSADRESSE ============== --}}
<article class="panel" id="rechnungsadresse">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Rechnungsadresse') }}</span>
@if ($billingComplete)
<span class="badge ok dot">{{ __('vollständig') }}</span>
@else
<span class="badge warn dot">{{ __('unvollständig') }}</span>
@endif
</div>
<div class="p-5 grid gap-6 lg:grid-cols-[0.8fr_1.2fr]">
<div class="space-y-3">
<p class="text-[13px] leading-[1.55] text-[color:var(--color-ink-2)] m-0">
{{ __('Grundlage für Rechnungen und Pflicht für jede Tarif- oder Einzel-PM-Buchung. Die Daten werden bei der Buchung an Stripe übergeben.') }}
</p>
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0">
{{ __('Pflichtangaben: Nachname, Straße, PLZ, Ort und Land. Firmenname und USt-ID sind optional.') }}
</p>
@if (! $billingComplete)
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
<div class="flex-1">
{{ __('Ohne vollständige Rechnungsadresse können keine Tarife oder Einzel-PMs gebucht werden.') }}
</div>
</div>
@else
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5" />
<div class="flex-1">
{{ __('Ihre Rechnungsadresse ist vollständig hinterlegt.') }}
</div>
</div>
@endif
<flux:button size="sm" variant="filled" icon="arrow-down-on-square" wire:click="copyProfileToBilling">
{{ __('Persönliche Daten übernehmen') }}
</flux:button>
</div>
<div class="space-y-5">
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Rechnungsempfänger') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:select wire:model="billingSalutationKey" :label="__('Anrede')">
@foreach ($salutations as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</flux:select>
<flux:input wire:model="billingCompany" :label="__('Firmenname (optional)')" />
<flux:input wire:model="billingFirstName" :label="__('Vorname')" />
<flux:input wire:model="billingLastName" :label="__('Nachname')" />
</div>
</div>
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Anschrift') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<flux:input wire:model="billingAddress1" :label="__('Straße und Hausnummer')" class="sm:col-span-2" />
<flux:input wire:model="billingAddress2" :label="__('Adresszusatz (optional)')" class="sm:col-span-2" />
<flux:input wire:model="billingPostalCode" :label="__('PLZ')" />
<flux:input wire:model="billingCity" :label="__('Ort')" />
<flux:select wire:model.live="billingCountryCode" :label="__('Land')" class="sm:col-span-2">
@foreach ($countries as $code => $name)
<option value="{{ $code }}">{{ $name }}</option>
@endforeach
</flux:select>
</div>
</div>
<div>
<div class="text-[11px] font-semibold tracking-[0.14em] uppercase text-[color:var(--color-ink-3)] mb-3">
{{ __('Steuern') }}
</div>
<flux:input
wire:model.live.debounce.600ms="taxIdNumber"
:label="__('USt-ID (optional)')"
placeholder="DE123456789"
:description="__('Für EU-Firmen außerhalb Deutschlands entfällt mit gültiger USt-ID die deutsche Umsatzsteuer (Reverse Charge).')"
/>
@if ($vatCheckMessage)
@if ($vatCheckStatus === 'valid')
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[14px] flex-shrink-0 mt-0.5" />
<span>{{ $vatCheckMessage }}</span>
</div>
@elseif (in_array($vatCheckStatus, ['invalid', 'format_invalid'], true))
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-err,#b91c1c)]">
<flux:icon.x-circle class="size-[14px] flex-shrink-0 mt-0.5" />
<span>{{ $vatCheckMessage }}</span>
</div>
@else
<div class="mt-2 text-[12px] flex items-start gap-1.5 text-[color:var(--color-ink-3)]">
<flux:icon.information-circle class="size-[14px] flex-shrink-0 mt-0.5" />
<span>{{ $vatCheckMessage }}</span>
</div>
@endif
@endif
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Speichern') }}
</flux:button>
</div>
</article>
{{-- ============== 3) EINSTELLUNGEN ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>
@ -336,18 +501,18 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
wire:model="showStats"
align="right"
:label="__('Statistiken anzeigen')"
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen.')"
:description="__('Statistiken und Kennzahlen in Ihren Pressemitteilungen anzeigen. Greift mit dem Relaunch der Portal-Seiten.')"
/>
<flux:switch
wire:model="disableFooterCode"
align="right"
:label="__('Footer-Code deaktivieren')"
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren.')"
:description="__('Automatische Footer-Codes in Pressemitteilungen für dieses Profil deaktivieren. Greift mit dem Relaunch der Portal-Seiten.')"
/>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Einstellungen speichern') }}
{{ __('Speichern') }}
</flux:button>
</div>
</article>

View file

@ -2,6 +2,7 @@
use App\Enums\SinglePurchaseStatus;
use App\Enums\SinglePurchaseType;
use App\Models\BillingAddress;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
@ -21,6 +22,9 @@ function checkoutTestCustomer(): User
$user = User::factory()->create();
$user->assignRole('customer');
// Buchungs-Voraussetzung (12.06.2026): vollständige Rechnungsadresse.
BillingAddress::factory()->create(['user_id' => $user->id]);
return $user;
}
@ -55,6 +59,26 @@ test('an unknown plan or invalid interval responds 404', function () {
$this->get("/admin/me/checkout/abo/{$plan->slug}/weekly")->assertNotFound();
});
test('a checkout without a complete billing address redirects to the profile', function () {
/** @var TestCase $this */
$user = User::factory()->create();
$user->assignRole('customer');
$plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_addr']);
config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm');
$this->actingAs($user)
->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly']))
->assertRedirect(route('me.profile'))
->assertSessionHas('checkout-notice');
$this->actingAs($user)
->get(route('me.checkout.single-pm'))
->assertRedirect(route('me.profile'))
->assertSessionHas('checkout-notice');
expect(SinglePurchase::count())->toBe(0);
});
test('a plan without a synced stripe price redirects back with a notice', function () {
/** @var TestCase $this */
$plan = Plan::factory()->create(['stripe_price_id_monthly' => null]);

View file

@ -0,0 +1,94 @@
<?php
use App\Enums\VatIdCheckStatus;
use App\Services\Billing\VatIdValidationService;
use Illuminate\Support\Facades\Http;
function vatIdService(): VatIdValidationService
{
return app(VatIdValidationService::class);
}
test('a malformed vat id fails the format check', function () {
$result = vatIdService()->check('123-nicht-gueltig');
expect($result['status'])->toBe(VatIdCheckStatus::FormatInvalid);
});
test('a vat id must match the billing country', function () {
$result = vatIdService()->check('ATU12345678', 'DE');
expect($result['status'])->toBe(VatIdCheckStatus::FormatInvalid)
->and($result['message'])->toContain('passt nicht zum Land');
});
test('greek vat ids use the EL prefix and pass for country GR', function () {
$result = vatIdService()->check('EL123456789', 'GR');
expect($result['status'])->not->toBe(VatIdCheckStatus::FormatInvalid);
});
test('german vat ids are format checked but not confirmed online', function () {
config()->set('billing.own_vat_id', 'DE999999999');
Http::fake();
$result = vatIdService()->check('DE123456789', 'DE');
expect($result['status'])->toBe(VatIdCheckStatus::Unverified);
Http::assertNothingSent();
});
test('without an own vat id the check stays a format check', function () {
config()->set('billing.own_vat_id', null);
Http::fake();
$result = vatIdService()->check('ATU12345678', 'AT');
expect($result['status'])->toBe(VatIdCheckStatus::Unverified)
->and($result['message'])->toContain('nicht konfiguriert');
Http::assertNothingSent();
});
test('a foreign eu vat id is confirmed via the evatr api', function () {
config()->set('billing.own_vat_id', 'DE999999999');
Http::fake([
'api.evatr.vies.bzst.de/*' => Http::response(['status' => 'evatr-0000']),
]);
$result = vatIdService()->check('ATU12345678', 'AT');
expect($result['status'])->toBe(VatIdCheckStatus::Valid);
Http::assertSent(function ($request): bool {
return $request['anfragendeUstid'] === 'DE999999999'
&& $request['angefragteUstid'] === 'ATU12345678';
});
});
test('a rejected evatr status marks the vat id as invalid', function () {
config()->set('billing.own_vat_id', 'DE999999999');
Http::fake([
'api.evatr.vies.bzst.de/*' => Http::response(['status' => 'evatr-1001']),
]);
$result = vatIdService()->check('FRXX999999999', 'FR');
expect($result['status'])->toBe(VatIdCheckStatus::Invalid)
->and($result['message'])->toContain('evatr-1001');
});
test('an unreachable evatr api degrades to unverified', function () {
config()->set('billing.own_vat_id', 'DE999999999');
Http::fake([
'api.evatr.vies.bzst.de/*' => Http::response(null, 503),
]);
$result = vatIdService()->check('NL123456789B01', 'NL');
expect($result['status'])->toBe(VatIdCheckStatus::Unverified);
});

View file

@ -32,12 +32,14 @@ test('customer can update own profile fields', function () {
->set('language', 'en')
->set('address', 'Musterfirma GmbH, Musterstrasse 1, 10115 Berlin')
->set('countryCode', 'AT')
->set('billingName', 'Musterfirma GmbH')
->set('billingCompany', 'Musterfirma GmbH')
->set('billingFirstName', 'Max')
->set('billingLastName', 'Mustermann')
->set('billingAddress1', 'Musterstrasse 1')
->set('billingPostalCode', '10115')
->set('billingCity', 'Berlin')
->set('billingCountryCode', 'DE')
->set('taxIdNumber', 'ATU12345678')
->set('taxIdNumber', 'DE123456789')
->set('showStats', true)
->call('saveProfile')
->assertHasNoErrors();
@ -50,13 +52,59 @@ test('customer can update own profile fields', function () {
expect($customer->profile?->last_name)->toBe('Mustermann');
expect($customer->profile?->address)->toBe('Musterfirma GmbH, Musterstrasse 1, 10115 Berlin');
expect($customer->profile?->country_code)->toBe('AT');
expect($customer->profile?->tax_id_number)->toBe('ATU12345678');
expect($customer->profile?->tax_id_number)->toBe('DE123456789');
expect($customer->profile?->show_stats)->toBeTrue();
expect($customer->billingAddress?->name)->toBe('Musterfirma GmbH');
expect($customer->billingAddress?->company)->toBe('Musterfirma GmbH');
expect($customer->billingAddress?->first_name)->toBe('Max');
expect($customer->billingAddress?->last_name)->toBe('Mustermann');
expect($customer->billingAddress?->name)->toBe('Max Mustermann');
expect($customer->billingAddress?->address1)->toBe('Musterstrasse 1');
expect($customer->billingAddress?->postal_code)->toBe('10115');
expect($customer->billingAddress?->city)->toBe('Berlin');
expect($customer->billingAddress?->country_code)->toBe('DE');
expect($customer->billingAddress?->vat_id)->toBe('DE123456789');
});
test('an incomplete billing address reports each missing field exactly once', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('billingCompany', 'Nur Firma GmbH')
->call('saveProfile')
->assertHasErrors(['billingLastName', 'billingAddress1', 'billingPostalCode', 'billingCity']);
});
test('a malformed vat id blocks saving with a field error', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('taxIdNumber', '123-nicht-gueltig')
->call('saveProfile')
->assertHasErrors(['taxIdNumber']);
});
test('the profile data can be copied into the billing address', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
LivewireVolt::test('customer.profile')
->set('salutationKey', 'mr')
->set('firstName', 'Kopier')
->set('lastName', 'Kunde')
->set('countryCode', 'AT')
->call('copyProfileToBilling')
->assertSet('billingSalutationKey', 'mr')
->assertSet('billingFirstName', 'Kopier')
->assertSet('billingLastName', 'Kunde')
->assertSet('billingCountryCode', 'AT');
});
test('customer profile keeps company management out of the profile page', function () {
@ -72,7 +120,7 @@ test('customer profile keeps company management out of the profile page', functi
LivewireVolt::test('customer.profile')
->assertSee('Rechnungsadresse')
->assertSee('Profileinstellungen')
->assertSee('Persönliche Daten')
->assertSee('Firmen verwalten')
->assertDontSee('Zugeordnete Firmen')
->assertDontSee('Nicht im Profil geladene Firma');

View file

@ -0,0 +1,34 @@
<?php
use App\Models\Company;
use App\Models\User;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
test('the pm create page shows a notice instead of the form without a company', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->assertSet('hasCompanies', false)
->assertSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
->assertSee('Firma anlegen')
->assertDontSee('Zur Prüfung senden');
});
test('the pm create page shows the editor when a company exists', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$company = Company::factory()->presseecho()->create();
$customer->companies()->attach($company->id, ['role' => 'owner']);
$this->actingAs($customer);
LivewireVolt::test('customer.press-releases.create')
->assertSet('hasCompanies', true)
->assertSet('companyId', $company->id)
->assertDontSee('Ohne Firma kann keine Pressemitteilung angelegt werden.')
->assertSee('Zur Prüfung senden');
});