diff --git a/app/Enums/VatIdCheckStatus.php b/app/Enums/VatIdCheckStatus.php new file mode 100644 index 0000000..a1a80ae --- /dev/null +++ b/app/Enums/VatIdCheckStatus.php @@ -0,0 +1,19 @@ +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.')); + } } diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 18a579a..5200c7f 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -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, diff --git a/app/Models/BillingAddress.php b/app/Models/BillingAddress.php index d6bb159..5d16f2a 100644 --- a/app/Models/BillingAddress.php +++ b/app/Models/BillingAddress.php @@ -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); + } } diff --git a/app/Models/InvoiceBillingAddress.php b/app/Models/InvoiceBillingAddress.php index 2765976..1680fe8 100644 --- a/app/Models/InvoiceBillingAddress.php +++ b/app/Models/InvoiceBillingAddress.php @@ -13,6 +13,7 @@ class InvoiceBillingAddress extends Model protected $fillable = [ 'salutation_key', 'title', + 'company', 'name', 'address1', 'address2', diff --git a/app/Models/User.php b/app/Models/User.php index b38ff79..7784a95 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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. diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index 83980a0..8b11b23 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -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, diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index 188e516..c25b853 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -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], diff --git a/app/Services/Billing/VatIdValidationService.php b/app/Services/Billing/VatIdValidationService.php new file mode 100644 index 0000000..2ff293d --- /dev/null +++ b/app/Services/Billing/VatIdValidationService.php @@ -0,0 +1,148 @@ +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]; + } +} diff --git a/app/Services/Billing/VatResolver.php b/app/Services/Billing/VatResolver.php index 17b447e..197c471 100644 --- a/app/Services/Billing/VatResolver.php +++ b/app/Services/Billing/VatResolver.php @@ -49,8 +49,9 @@ class VatResolver /** * Formale Plausibilität: beginnt mit dem Ländercode der Adresse und * trägt danach 2–13 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) ?? ''); diff --git a/config/billing.php b/config/billing.php index 7cfdf37..793adfa 100644 --- a/config/billing.php +++ b/config/billing.php @@ -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' => [ diff --git a/database/migrations/2026_06_12_142356_extend_billing_addresses_with_person_and_company.php b/database/migrations/2026_06_12_142356_extend_billing_addresses_with_person_and_company.php new file mode 100644 index 0000000..a467f72 --- /dev/null +++ b/database/migrations/2026_06_12_142356_extend_billing_addresses_with_person_and_company.php @@ -0,0 +1,40 @@ +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'); + }); + } +}; diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index ea61753..ee3d93a 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -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 diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md index e913e51..ae8bbfd 100644 --- a/docs/user-admin/Billing-und-Rechnungskreise.md +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -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` diff --git a/docs/user-admin/User-Panel-Restarbeiten.md b/docs/user-admin/User-Panel-Restarbeiten.md index a44f22e..2890541 100644 --- a/docs/user-admin/User-Panel-Restarbeiten.md +++ b/docs/user-admin/User-Panel-Restarbeiten.md @@ -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. \ No newline at end of file +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. | \ No newline at end of file diff --git a/resources/views/livewire/customer/press-kits/create.blade.php b/resources/views/livewire/customer/press-kits/create.blade.php index d2ad031..8123ef2 100644 --- a/resources/views/livewire/customer/press-kits/create.blade.php +++ b/resources/views/livewire/customer/press-kits/create.blade.php @@ -201,7 +201,7 @@ new #[Layout('components.layouts.app'), Title('Neue Firma anlegen')] class exten - + diff --git a/resources/views/livewire/customer/press-kits/index.blade.php b/resources/views/livewire/customer/press-kits/index.blade.php index b3cddc8..6a75815 100644 --- a/resources/views/livewire/customer/press-kits/index.blade.php +++ b/resources/views/livewire/customer/press-kits/index.blade.php @@ -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', diff --git a/resources/views/livewire/customer/press-kits/show.blade.php b/resources/views/livewire/customer/press-kits/show.blade.php index f219593..d6f24be 100644 --- a/resources/views/livewire/customer/press-kits/show.blade.php +++ b/resources/views/livewire/customer/press-kits/show.blade.php @@ -425,7 +425,7 @@ new #[Layout('components.layouts.app'), Title('Firma')] class extends Component - + diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index 993f679..f95ed37 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -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 + @if (! $hasCompanies) + {{-- ============== KEINE FIRMA: MELDUNG STATT FORMULAR ============== --}} +
+
+
+ +
+
+

+ {{ __('Ohne Firma kann keine Pressemitteilung angelegt werden.') }} +

+

+ {{ __('Jede Pressemitteilung erscheint im Namen einer Firma. Bitte legen Sie zuerst eine Firma an — danach können Sie hier direkt loslegen.') }} +

+
+
+ + {{ __('Firma anlegen') }} + + + {{ __('Zur PM-Übersicht') }} + +
+
+
+ @else {{-- ============== 2-COLUMN GRID ============== --}}
@@ -952,7 +991,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex — {{ __('Boilerplate aus Firma') }} - @@ -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
diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index 046ea93..457a7ca 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -901,7 +901,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl — {{ __('Boilerplate aus Firma') }} - diff --git a/resources/views/livewire/customer/profile.blade.php b/resources/views/livewire/customer/profile.blade.php index 71a30be..8db666f 100644 --- a/resources/views/livewire/customer/profile.blade.php +++ b/resources/views/livewire/customer/profile.blade.php @@ -1,8 +1,9 @@ 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 ============== --}}