From 23ac8bc7f1bcd8d54db9523ca2633e96abc0f3a6 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 12 Jun 2026 12:39:39 +0000 Subject: [PATCH] Phase 9F: Tarif-Seite mit Stripe-Checkout und Billing Portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Buchungs-Seite zeigt das echte 4-Tier-Raster aus plans (Monat/Jahr- Toggle, Jahrespreis als "2 Monate gratis") mit Checkout-Buttons, Einzel-PM als separaten No-Abo-Block und Enterprise-Hinweis; Credit-Konzept-Mock entfernt (Credits folgen mit 9I bzw. Phase 2) - Aktueller-Tarif-Panel real: Abo (Preis, Kontingent, Kündigungsstatus), Bestandstarif (unbegrenzt, nächste MAN-Rechnung), offene Einzelkäufe; Kontingent-Kachel zeigt "Unbegrenzt" bei Bestandsschutz - "Abo verwalten" über das Stripe Billing Portal (me.checkout.billing-portal; Zahlungsmethode, Rechnungen, Kündigung) - Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung, offene/eingelöste Einzelkäufe mit PM-Verknüpfung) - Tests: BookingsPageTest (9 Tests), PanelConsolidationTest angepasst; Suite 519 passed / 4 skipped - Doku: PHASE-9-Plan 9F ✅, Billing-Doku (Routen, Stripe Tax aktiviert), STATUS-ABGLEICH, Checkliste, PROGRESS Co-Authored-By: Claude Fable 5 --- app/Http/Controllers/CheckoutController.php | 15 + .../Billing/StripeCheckoutService.php | 9 + dev/frontend/hub-flux/PROGRESS.md | 22 + docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md | 22 +- docs/STATUS-ABGLEICH-USER-PANEL.md | 4 +- .../user-admin/Billing-und-Rechnungskreise.md | 19 +- docs/user-admin/checkliste-user-backend.md | 1 + .../livewire/customer/bookings.blade.php | 624 ++++++++++-------- routes/customer.php | 2 + tests/Feature/Billing/BookingsPageTest.php | 156 +++++ tests/Feature/PanelConsolidationTest.php | 23 +- 11 files changed, 581 insertions(+), 316 deletions(-) create mode 100644 tests/Feature/Billing/BookingsPageTest.php diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 440535c..b2460a2 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -60,6 +60,21 @@ class CheckoutController extends Controller return $this->checkout->forSinglePurchase($request->user(), $purchase); } + /** + * Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode, + * Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll. + */ + public function billingPortal(Request $request): RedirectResponse + { + $user = $request->user(); + + if (! $user->hasStripeId() || ! $user->subscribed()) { + return $this->backToBookings(__('Es besteht kein aktives Abo, das verwaltet werden könnte.')); + } + + return redirect()->away($this->checkout->billingPortalUrl($user)); + } + private function backToBookings(string $notice): RedirectResponse { return redirect() diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php index f83ad31..693f9ad 100644 --- a/app/Services/Billing/StripeCheckoutService.php +++ b/app/Services/Billing/StripeCheckoutService.php @@ -35,6 +35,15 @@ class StripeCheckoutService ]); } + /** + * URL zum Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung). + * Rücksprung auf die Buchungs-Seite. + */ + public function billingPortalUrl(User $user): string + { + return $user->billingPortalUrl(route('me.bookings.index')); + } + /** * Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den * Session-Metadaten schließt den Kreis: `checkout.session.completed` diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index bd30481..e433d92 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -5,6 +5,28 @@ --- +## 2026-06-12 · Phase 9F · Tarif-Seite + Checkout-UI ✅ + +- **Was**: „Buchungen & Add-ons" vom Credit-Konzept-Mock auf echte Daten + umgestellt: 4-Tier-Raster aus `plans` (Alpine Monat/Jahr-Toggle, + „2 Monate gratis"), Checkout-Buttons auf die 9E-Routen, Einzel-PM als + separater No-Abo-Block, Aktueller-Tarif-Panel (Abo / Bestandstarif + unbegrenzt / offene Einzelkäufe / leer) mit Kontingent-Kachel, + „Abo verwalten" → Stripe Billing Portal (neue Route + `me.checkout.billing-portal`), aktive Buchungen + Verlauf real. + Credit-Pakete/Marktplatz/Platzierungen entfernt (→ 9I bzw. Phase 2). + Stripe Tax im Dashboard aktiviert („SaaS – business use", exklusiv). +- **Dateien**: `resources/views/livewire/customer/bookings.blade.php` + (Neufassung), `app/Http/Controllers/CheckoutController.php` + + `app/Services/Billing/StripeCheckoutService.php` (Billing Portal), + `routes/customer.php`. +- **Build/Test**: Suite 519 passed / 4 skipped, Pint clean; 9 neue Tests + in `BookingsPageTest`, `PanelConsolidationTest` auf neue Seite angepasst. +- **Offene Fragen**: Stripe Tax + Produkt-Sync vor Relaunch im Live-Mode + wiederholen. +- **Nächster Schritt**: 9G Tageslimit (`plans.daily_limit` beim + Veröffentlichen), dann 9H Einzel-PM-Abo-Brücke, 9I Launch-Credits. + ## 2026-06-12 · Phase 9E · Stripe-Anbindung komplett ✅ - **Was**: Produkt-Sync nach Stripe (Tarife + Einzel-PM, Netto-Preise, diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index 75a766c..17fcfe0 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -42,7 +42,7 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken: | — | **Review-Stopp mit User** | | | | **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) | | **9E** ✅ | Stripe-Anbindung: Produkt-Sync (Tarife + Einzel-PM), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung, Endpoint registriert), Checkout-Flows (Backend), Slot-Logik auf Plan-Kontingent (Grandfathered = unbegrenzt), Stripe Tax | L | mittel | -| **9F** | Tarif-Seite + Checkout-UI (Raster, Einzel-PM-Block, „2 Monate gratis", Enterprise-Hinweis) | M | gering | +| **9F** ✅ | Tarif-Seite + Checkout-UI: Buchungs-Seite mit echtem 4-Tier-Raster (Monat/Jahr-Toggle, „2 Monate gratis"), Einzel-PM-Block, Bestandstarif-Anzeige, „Abo verwalten" (Stripe Billing Portal), Enterprise-Hinweis | M | gering | | **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering | | **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel | | **9I** | Launch-Credits: Extra-PM, Boost (nur Grün), Veröffentlichungsnachweis-PDF | L | mittel | @@ -201,12 +201,22 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand): - Offen → §7 der Billing-Doku: Stripe Tax im Dashboard aktivieren, Live-Mode-Sync vor Relaunch. -### 9F · Tarif-Seite + Checkout-UI +### 9F · Tarif-Seite + Checkout-UI ✅ (12.06.2026) -- Raster mit 4 Tiers; Einzel-PM als separater No-Abo-Block (nicht als - billigste Spalte); Enterprise als dezenter Sales-Hinweis unter der Tabelle. -- Jahrespreis kommuniziert als „2 Monate gratis". -- Einstieg aus dem Submit-Gate-Hinweis (9C) und aus „Buchungen & Add-ons". +- ✅ „Buchungen & Add-ons" zeigt das echte 4-Tier-Raster aus `plans` + (Monat/Jahr-Toggle, Jahrespreis als „2 Monate gratis") mit + Checkout-Buttons auf `me.checkout.subscription`; Einzel-PM als + separater No-Abo-Block (`me.checkout.single-pm`); Enterprise als + dezenter Hinweis unter dem Raster. Der Credit-Konzept-Mock ist + abgelöst (Credits → 9I bzw. Phase 2). +- ✅ Aktueller Tarif real: Abo (Preis, Kontingent, Kündigungsstatus), + Bestandstarif (unbegrenzt, nächste MAN-Rechnung) oder offene + Einzelkäufe; Kontingent-Kachel (`Unbegrenzt` bei Bestandsschutz). +- ✅ „Abo verwalten" → Stripe Billing Portal (`me.checkout.billing-portal`: + Zahlungsmethode, Rechnungen, Kündigung). +- ✅ Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung, + offene/eingelöste Einzelkäufe mit PM-Verknüpfung). +- Einstieg aus dem Submit-Gate-Hinweis (9C) führt bereits hierher. ### 9G · Tageslimit diff --git a/docs/STATUS-ABGLEICH-USER-PANEL.md b/docs/STATUS-ABGLEICH-USER-PANEL.md index e3eab0d..3b2463b 100644 --- a/docs/STATUS-ABGLEICH-USER-PANEL.md +++ b/docs/STATUS-ABGLEICH-USER-PANEL.md @@ -128,8 +128,8 @@ Zentrale Billing-Referenz: [`user-admin/Billing-und-Rechnungskreise.md`](./user- | Rechnungen mit Legacy-Archiv | umgesetzt | ✅ | | Hybride Rechnungskreise STR-/MAN- (Decision 12.06.) | umgesetzt (Phase 9D) — Nummern-Generator, MAN-Fälligkeitslauf, Grandfather-Migration, USt-Logik (`VatResolver`) | ✅ | | Tarif-Datenmodell + Cashier | umgesetzt (Phase 9D) — `plans`, `single_purchases`, `User` ist Billable | ✅ | -| Stripe-Checkout/Webhooks + STR-Spiegelung | umgesetzt (Phase 9E) — Produkt-Sync, Webhook-Verarbeitung, Checkout-Backend, Plan-Kontingent | ✅ (UI → 9F) | -| Buchungen & Add-ons (UI) | nur Stub | 📝 (mit 9F Tarif-Seite) | +| Stripe-Checkout/Webhooks + STR-Spiegelung | umgesetzt (Phase 9E) — Produkt-Sync, Webhook-Verarbeitung, Checkout-Backend, Plan-Kontingent | ✅ | +| Buchungen & Add-ons (UI) | umgesetzt (Phase 9F) — Tarif-Raster, Einzel-PM-Block, Bestandstarife, Billing Portal | ✅ | | Zahlungsmethoden firmenscharf | **fehlt** | 📝 (Phase 2) | --- diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md index 561413a..9245e09 100644 --- a/docs/user-admin/Billing-und-Rechnungskreise.md +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -1,8 +1,8 @@ # Billing & Rechnungskreise (hybrides Modell) Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie -Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows und Plan-Kontingent -(Phase 9E) umgesetzt. Es fehlt die Checkout-UI (Phase 9F). +Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows, Plan-Kontingent +(Phase 9E) und Tarif-Seite/Checkout-UI (Phase 9F) umgesetzt. Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem: Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration. @@ -61,12 +61,13 @@ monatlicher Reset), danach wird der älteste bezahlte Einmalkauf eingelöst (`single_purchases.status → consumed`, verknüpft mit der PM). Die frühere Stub-Spalte `users.press_release_quota` ist entfernt. -**Checkout-Einstiege** (Phase 9E; UI-Anbindung folgt in 9F): +**Checkout-Einstiege** (Phase 9E/9F — verdrahtet auf der Buchungs-Seite): | Route | Zweck | |---|---| | `me.checkout.subscription` (`/admin/me/checkout/abo/{slug}/{monthly\|yearly}`) | Stripe-Checkout für ein Tarif-Abo | | `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) | Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`). Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()` @@ -180,12 +181,12 @@ CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`. Routen siehe Abschnitt 2), Slot-Logik auf Plan-Kontingent umgestellt (Grandfathered = unbegrenzt, Entscheidung 12.06.2026), Stub-Spalte entfernt, Stripe Tax aktiviert (`Cashier::calculateTaxes()`). -1. **Phase 9F**: Tarif-Seite/Buchungs-UI an die Checkout-Routen anbinden - (die Buchungs-Seite ist noch Konzept-Mock mit deaktivierten Buttons); - echte Tarif-/Buchungsdaten statt Platzhalter anzeigen. -2. **Stripe Tax im Dashboard aktivieren** (Ursprungsadresse/Registrierung - hinterlegen) — ohne das schlägt der Checkout mit automatischer Steuer - fehl. Im Test-Mode prüfen, dann im Live-Mode wiederholen; dort auch +1. **Phase 9F erledigt** (12.06.2026): Die Buchungs-Seite zeigt das echte + Tarif-Raster (Monat/Jahr-Toggle), den Einzel-PM-Block, Bestandstarife + und „Abo verwalten" (Stripe Billing Portal, `me.checkout.billing-portal`). +2. **Stripe Tax**: im Dashboard aktiviert (12.06.2026, Produkt-Steuercode + „SaaS – business use", Steuer nicht im Preis enthalten — passt zu den + Netto-Preisen). Vor Relaunch im **Live-Mode** wiederholen; dort auch `billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs). 3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft die im Checkout erfasste USt-ID asynchron selbst). diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md index d6bff00..51b05dc 100644 --- a/docs/user-admin/checkliste-user-backend.md +++ b/docs/user-admin/checkliste-user-backend.md @@ -129,6 +129,7 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic - [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich). - [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung. - [x] Stripe-Checkout + Webhooks (Phase 9E, 12.06.): Produkt-Sync nach Stripe (Tarife + Einzel-PM, netto, Stripe Tax), STR-Rechnungsspiegelung + Einmalkauf-Erfuellung per Webhook (Endpoint registriert), Checkout-Flows als Backend (`me.checkout.subscription`/`me.checkout.single-pm`), Slot-Logik auf Plan-Kontingent umgestellt (Grandfathered = unbegrenzt, Bestandsschutz), Quota-Stub-Spalte entfernt. UI-Anbindung folgt in 9F. Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`. +- [x] Tarif-Seite + Checkout-UI (Phase 9F, 12.06.): Buchungs-Seite mit echtem 4-Tier-Raster (Monat/Jahr-Toggle, "2 Monate gratis"), Einzel-PM-Block, Bestandstarif-Anzeige (unbegrenzt), "Abo verwalten" via Stripe Billing Portal; Credit-Mock abgeloest (Credits → 9I/Phase 2). - [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs. - [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €. - [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen). diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index fde55b5..b7fc5b5 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -1,103 +1,74 @@ user(); + $subscription = $user->subscription(); + $currentPlan = $user->currentPlan(); + + $currentInterval = null; + if ($currentPlan && $subscription) { + $currentInterval = $subscription->stripe_price === $currentPlan->stripe_price_id_yearly + ? 'yearly' + : 'monthly'; + } + return [ // Rückkehr aus dem Stripe-Checkout (?checkout=erfolg|abbruch) // bzw. Hinweis aus den Checkout-Guards (Session-Flash). 'checkoutResult' => request()->query('checkout'), 'checkoutNotice' => session('checkout-notice'), - 'creditSummary' => [ - 'total' => 17, - 'bonus' => 12, - 'paid' => 5, - 'auto_refill' => __('ab 10 Credits empfohlen'), - 'validity' => __('Bonus-Credits verfallen monatlich, gekaufte Credits bleiben 24 Monate gültig.'), - ], - 'currentPlan' => [ - 'name' => 'Starter', - 'price' => '19 €/Mo.', - 'press_releases' => '3 PMs/Monat', - 'bonus_credits' => 12, - ], - 'creditPackages' => [ - ['name' => 'Test', 'credits' => 10, 'price' => '10 €', 'rate' => '1,00 €', 'saving' => null], - ['name' => 'Standard', 'credits' => 50, 'price' => '45 €', 'rate' => '0,90 €', 'saving' => '10 %'], - ['name' => 'Plus', 'credits' => 150, 'price' => '120 €', 'rate' => '0,80 €', 'saving' => '20 %'], - ['name' => 'Pro', 'credits' => 500, 'price' => '375 €', 'rate' => '0,75 €', 'saving' => '25 %'], - ['name' => 'Business', 'credits' => 1500, 'price' => '1.050 €', 'rate' => '0,70 €', 'saving' => '30 %'], - ], - 'serviceGroups' => [ - [ - 'title' => __('Veröffentlichung'), - 'description' => __('Basisleistungen rund um Veröffentlichung, Korrektur und Aktualisierung.'), - 'services' => [ - ['name' => __('Standard-PM (Pay-as-you-go)'), 'credits' => '19', 'meta' => __('1 Veröffentlichung')], - ['name' => __('PM-Korrektur'), 'credits' => '8', 'meta' => __('Pfad C')], - ['name' => __('PM-Update'), 'credits' => '4', 'meta' => __('im ersten Jahr ggf. kostenlos')], - ['name' => __('Depublizierung'), 'credits' => '19–25', 'meta' => __('abhängig vom Aufwand')], - ], - ], - [ - 'title' => __('Bilder'), - 'description' => __('Stock- und KI-Bilder für mehr Sichtbarkeit in Listen und Detailseiten.'), - 'services' => [ - ['name' => __('Free-Stock'), 'credits' => '0', 'meta' => __('Unsplash, Pexels')], - ['name' => __('Premium-Stock'), 'credits' => '8', 'meta' => __('Adobe, Shutterstock')], - ['name' => __('KI-Bild generieren'), 'credits' => '4', 'meta' => __('neues Motiv')], - ['name' => __('KI-Bild Re-Generation'), 'credits' => '2', 'meta' => __('Variante erzeugen')], - ], - ], - [ - 'title' => __('KI-Textservices'), - 'description' => __('Qualität verbessern, Score-Stufe erreichen und bessere Headlines testen.'), - 'services' => [ - ['name' => __('Quality-Check'), 'credits' => '3', 'meta' => __('Stil und Pressestil')], - ['name' => __('Lektorat'), 'credits' => '8', 'meta' => __('sprachliche Prüfung')], - ['name' => __('Pressetext-Optimierung'), 'credits' => '15', 'meta' => __('Headlines und SEO')], - ['name' => __('Headline-Booster'), 'credits' => '5', 'meta' => __('nur Headlines')], - ['name' => __('PM aus Stichworten generieren'), 'credits' => '25', 'meta' => __('Entwurf aus Briefing')], - ['name' => __('Übersetzung DE/EN'), 'credits' => '12', 'meta' => __('pro Sprachrichtung')], - ], - ], - [ - 'title' => __('Distribution'), - 'description' => __('Zusätzliche Formate und externe Reichweite für passende Meldungen.'), - 'services' => [ - ['name' => __('PDF-Export mit Branding'), 'credits' => '2', 'meta' => __('für Weitergabe')], - ['name' => __('Social-Snippet-Generierung'), 'credits' => '3', 'meta' => __('Kurztexte')], - ['name' => __('Verteiler-Versand klein'), 'credits' => '39', 'meta' => __('branchenspezifisch')], - ['name' => __('Verteiler-Versand mittel'), 'credits' => '99', 'meta' => __('mehr Empfänger')], - ['name' => __('Verteiler-Versand groß'), 'credits' => '199', 'meta' => __('branchenübergreifend')], - ], - ], - [ - 'title' => __('Account & Profil'), - 'description' => __('Vertrauen, Wiedererkennung und zusätzliche Profilfunktionen.'), - 'services' => [ - ['name' => __('Verifiziertes Firmenprofil'), 'credits' => '79', 'meta' => __('einmalig')], - ['name' => __('Custom Subdomain'), 'credits' => '49', 'meta' => __('pro Jahr')], - ['name' => __('Erweiterte Statistiken'), 'credits' => '15', 'meta' => __('pro Monat')], - ], - ], - ], - 'placements' => [ - ['name' => __('Highlight Kategorie'), 'credits' => '15', 'duration' => __('3 Tage'), 'tier' => __('Standard'), 'score' => '30+'], - ['name' => __('Highlight Kategorie'), 'credits' => '30', 'duration' => __('7 Tage'), 'tier' => __('Standard'), 'score' => '30+'], - ['name' => __('Startseite-Highlight'), 'credits' => '39', 'duration' => __('24 h'), 'tier' => __('Geprüft'), 'score' => '60+'], - ['name' => __('Startseite-Highlight'), 'credits' => '89', 'duration' => __('3 Tage'), 'tier' => __('Geprüft'), 'score' => '60+'], - ['name' => __('Top-Slot Startseite'), 'credits' => '119', 'duration' => __('24 h'), 'tier' => __('Hochwertig'), 'score' => '80+'], - ['name' => __('Newsletter-Erwähnung'), 'credits' => '59', 'duration' => __('nächster Versand'), 'tier' => __('Geprüft'), 'score' => '60+'], - ['name' => __('Social-Share'), 'credits' => '25', 'duration' => __('offizieller Kanal'), 'tier' => __('Geprüft'), 'score' => '60+'], - ], - 'activeBookings' => [], - 'bookingHistory' => [], + + 'plans' => Plan::query()->active()->get(), + 'currentPlan' => $currentPlan, + 'currentInterval' => $currentInterval, + 'subscription' => $subscription, + + // Bestandstarife: laufende Legacy-Vereinbarungen (MAN-Kreis, + // unbegrenzte PMs — Entscheidung 12.06.2026). + 'legacyOptions' => $user->userPaymentOptions() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->orderBy('current_period_end') + ->get(), + + 'openPurchases' => $user->singlePurchases() + ->grantingSubmission() + ->orderBy('paid_at') + ->get(), + 'consumedPurchases' => $user->singlePurchases() + ->where('status', SinglePurchaseStatus::Consumed->value) + ->with('pressRelease') + ->latest('consumed_at') + ->limit(10) + ->get(), + + 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), + 'quotaTotal' => $user->pressReleaseQuotaTotal(), + 'singlePmPrice' => $this->formatEuro((int) config('billing.single_pm_price_cents')), + 'singlePmAvailable' => (bool) config('billing.single_pm_stripe_price_id'), ]; } }; ?> @@ -137,13 +108,12 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
{{ __('User Backend') }} {{ __('Mein Bereich · Finanzen') }} - {{ __('Konzeptstand Mai 2026') }}

{{ __('Buchungen & Add-ons') }}

- {{ __('Der Marktplatz für Credit-Pakete, KI-Services, Platzierungen und Firmen-Add-ons. Die Preise folgen dem neuen Credit-Modell: 1 Credit entspricht dem Listenwert von 1 €.') }} + {{ __('Tarif wählen oder einzelne Pressemitteilung buchen. Alle Preise sind Nettopreise zzgl. USt.; die Abrechnung erfolgt sicher über Stripe.') }}

@@ -151,221 +121,234 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Rechnungen') }} - - {{ __('Credits kaufen') }} - - {{-- ============== CREDIT-ÜBERSICHT ============== --}} -
-
-
- {{ __('Credit-Stand') }} - {{ __('Auto-Refill vorbereitet') }} -
-
-
-
- {{ $creditSummary['total'] }} -
-

- {{ __('verfügbare Credits') }} -

-
- -
-
-
{{ __('Bonus-Credits') }}
-
{{ $creditSummary['bonus'] }}
-
{{ __('monatlich verfallend') }}
-
-
-
{{ __('Gekaufte Credits') }}
-
{{ $creditSummary['paid'] }}
-
{{ __('24 Monate gültig') }}
-
-
- -
- -
- {{ $creditSummary['validity'] }} - {{ __('Für spätere Checkouts ist Auto-Refill :threshold vorgesehen.', ['threshold' => $creditSummary['auto_refill']]) }} -
-
-
-
- -
-
- {{ __('Aktueller Tarif') }} - {{ $currentPlan['name'] }} -
-
-
-
- {{ $currentPlan['price'] }} -
-

- {{ __('inkl. :credits Bonus-Credits und :pms', [ - 'credits' => $currentPlan['bonus_credits'], - 'pms' => $currentPlan['press_releases'], - ]) }} -

-
-
-
- {{ __('Nächster sinnvoller Schritt') }} -
-

- {{ __('Bei mehreren PMs mit KI-Optimierung oder Platzierungen ergänzt das Standard-Paket die monatlichen Bonus-Credits am saubersten.') }} -

-
-
-
-
- - {{-- ============== CREDIT-PAKETE ============== --}} -
+ {{-- ============== AKTUELLER TARIF ============== --}} +
- {{ __('Credit-Pakete') }} - {{ __('Volumenrabatt nach Paketgröße') }} + {{ __('Aktueller Tarif') }} + @if ($currentPlan) + {{ $currentPlan->name }} + @elseif ($legacyOptions->isNotEmpty()) + {{ __('Bestandstarif') }} + @else + {{ __('Kein Abo') }} + @endif
- - - {{ __('Paket') }} - {{ __('Credits') }} - {{ __('Preis') }} - {{ __('Effektiv/Credit') }} - {{ __('Ersparnis') }} - {{ __('Aktion') }} - - - @foreach ($creditPackages as $package) - - - {{ $package['name'] }} - - {{ number_format($package['credits'], 0, ',', '.') }} - - {{ $package['price'] }} - - {{ $package['rate'] }} - - @if ($package['saving']) - {{ $package['saving'] }} - @else - +
+
+ @if ($currentPlan) +
+ {{ $currentInterval === 'yearly' + ? $this->formatEuro($currentPlan->yearly_price_cents).' / '.__('Jahr') + : $this->formatEuro($currentPlan->monthly_price_cents).' / '.__('Monat') }} + {{ __('netto') }} +
+

+ {{ __(':quota Pressemitteilungen pro Monat', ['quota' => $currentPlan->press_release_quota]) }} + @if ($currentPlan->daily_limit) + · {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $currentPlan->daily_limit]) }} @endif - - - - {{ __('Kaufen') }} - - - - @endforeach - +

+ @if ($subscription?->onGracePeriod()) +

+ {{ __('Gekündigt — läuft am :date aus.', ['date' => $subscription->ends_at?->format('d.m.Y')]) }} +

+ @endif + @elseif ($legacyOptions->isNotEmpty()) + @foreach ($legacyOptions as $option) +
+
+ {{ data_get($option->legacy_conditions, 'name') ?? $option->paymentOption?->article_number ?? __('Bestehende Vereinbarung') }} +
+

+ {{ __('Unbegrenzte Pressemitteilungen (Bestandsschutz).') }} + @if ($option->current_period_end) + {{ __('Nächste Rechnung am :date.', ['date' => $option->current_period_end->format('d.m.Y')]) }} + @endif +

+
+ @endforeach +

+ {{ __('Ihre bisherigen Konditionen gelten unverändert weiter; die Abrechnung erfolgt wie gewohnt per Rechnung.') }} +

+ @elseif ($openPurchases->isNotEmpty()) +
+ {{ trans_choice(':count Einzel-Pressemitteilung verfügbar|:count Einzel-Pressemitteilungen verfügbar', $openPurchases->count(), ['count' => $openPurchases->count()]) }} +
+

+ {{ __('Jeder Kauf berechtigt zu genau einer Veröffentlichung — eingelöst wird er erst, wenn die Pressemitteilung live geht.') }} +

+ @else +
+ {{ __('Noch kein aktiver Tarif') }} +
+

+ {{ __('Wählen Sie unten einen Tarif oder buchen Sie eine einzelne Pressemitteilung ohne Abo.') }} +

+ @endif +
+ +
+
+
{{ __('PM-Kontingent diesen Monat') }}
+
+ @if (is_null($quotaRemaining)) + {{ __('Unbegrenzt') }} + @else + {{ $quotaRemaining }} / {{ $quotaTotal }} + @endif +
+
+ {{ __('Wird erst bei Veröffentlichung verbraucht.') }} +
+
+ @if ($subscription) + + {{ __('Abo verwalten') }} + +

+ {{ __('Zahlungsmethode, Rechnungen und Kündigung — sicher über das Stripe-Kundenportal.') }} +

+ @endif +
+
- {{-- ============== PLATZIERUNGEN ============== --}} -
-
- {{ __('Boost & Platzierungen') }} -

- {{ __('Sichtbarkeit buchen, wenn die Score-Stufe passt') }} -

-

- {{ __('Platzierungen bleiben an Qualitätsstufen gekoppelt: Standard reicht für Kategorie-Highlights, Geprüft für Startseite/Newsletter/Social und Hochwertig für den Top-Slot.') }} -

+ {{-- ============== TARIF-RASTER ============== --}} +
+
+
+ {{ __('Tarife') }} +

+ {{ __('Den passenden Tarif wählen') }} +

+

+ {{ __('Monatlich kündbar. Im Jahrestarif sind 2 Monate gratis — Sie zahlen 10 von 12 Monaten.') }} +

+
+ +
+ + +
-
- @foreach ($placements as $placement) -
-
-
-
-
- -
-
-

- {{ $placement['name'] }} -

-

- {{ $placement['duration'] }} -

-
-
-
-
{{ $placement['credits'] }}
-
{{ __('Credits') }}
-
+
+ @foreach ($plans as $plan) + @php($isCurrent = $currentPlan && $plan->is($currentPlan)) +
$isCurrent]) wire:key="plan-{{ $plan->slug }}"> +
+
+

{{ $plan->name }}

+ @if ($isCurrent) + {{ __('Aktuell') }} + @endif
-
-
-
{{ __('Mindeststufe') }}
-
{{ $placement['tier'] }}
+
+
+ {{ $this->formatEuro($plan->monthly_price_cents) }} + / {{ __('Monat') }}
- - {{ __('Score :score', ['score' => $placement['score']]) }} - +
+ {{ $this->formatEuro($plan->yearly_price_cents) }} + / {{ __('Jahr') }} +
+
{{ __('netto zzgl. USt.') }}
- - {{ __('Buchung vorbereiten') }} - +
    +
  • + + {{ __(':quota Pressemitteilungen pro Monat', ['quota' => $plan->press_release_quota]) }} +
  • +
  • + + @if ($plan->daily_limit) + {{ __('max. :limit Veröffentlichungen pro Tag', ['limit' => $plan->daily_limit]) }} + @else + {{ __('Ohne Tageslimit') }} + @endif +
  • +
  • + + {{ __('KI-Prüfung & Veröffentlichung inklusive') }} +
  • +
+ + @if ($subscription) + + {{ $isCurrent ? __('Ihr aktueller Tarif') : __('Wechsel über „Abo verwalten"') }} + + @else +
+ + {{ __('Monatlich buchen') }} + +
+
+ + {{ __('Jährlich buchen') }} + +
+ @endif
@endforeach
+ +

+ {{ __('Mehr als 60 Pressemitteilungen pro Monat, mehrere Teams oder Sonderkonditionen? Enterprise-Konditionen erhalten Sie auf Anfrage über den Support.') }} +

- {{-- ============== SERVICE-MARKTPLATZ ============== --}} -
-
- {{ __('Add-on-Marktplatz') }} -

- {{ __('Buchbare Services nach Kategorie') }} -

+ {{-- ============== EINZEL-PM (OHNE ABO) ============== --}} +
+
+
+
+ +
+
+

+ {{ __('Einzel-Pressemitteilung — ohne Abo') }} +

+

+ {{ __('Genau eine Veröffentlichung inklusive KI-Prüfung. Eingelöst wird der Kauf erst, wenn die Pressemitteilung live geht — Ablehnungen kosten nichts.') }} + @if ($openPurchases->isNotEmpty()) + + {{ trans_choice('Aktuell :count offener Kauf.|Aktuell :count offene Käufe.', $openPurchases->count(), ['count' => $openPurchases->count()]) }} + + @endif +

+
+
+
+
+
{{ $singlePmPrice }}
+
{{ __('netto zzgl. USt.') }}
+
+ + {{ __('Jetzt buchen') }} + +
- -
- @foreach ($serviceGroups as $group) -
-
-
- - {{ $group['title'] }} -
-
-
-

- {{ $group['description'] }} -

-
- @foreach ($group['services'] as $service) -
-
-
{{ $service['name'] }}
-
{{ $service['meta'] }}
-
-
-
{{ $service['credits'] }}
-
{{ __('Credits') }}
-
-
- @endforeach -
-
-
- @endforeach -
-
+
{{-- ============== AKTIVE BUCHUNGEN / VERLAUF ============== --}}
@@ -375,9 +358,54 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('läuft aktuell') }}
- @forelse ($activeBookings as $booking) -
{{ $booking }}
- @empty + @if ($subscription || $legacyOptions->isNotEmpty() || $openPurchases->isNotEmpty()) +
+ @if ($subscription && $currentPlan) +
+
+
+ {{ __('Abo: :plan', ['plan' => $currentPlan->name]) }} +
+
+ {{ $currentInterval === 'yearly' ? __('jährliche Abrechnung') : __('monatliche Abrechnung') }} · Stripe +
+
+ {{ __('aktiv') }} +
+ @endif + + @foreach ($legacyOptions as $option) +
+
+
+ {{ data_get($option->legacy_conditions, 'name') ?? $option->paymentOption?->article_number ?? __('Bestehende Vereinbarung') }} +
+
+ {{ __('Bestandstarif · Abrechnung per Rechnung') }} + @if ($option->current_period_end) + · {{ __('nächste Rechnung :date', ['date' => $option->current_period_end->format('d.m.Y')]) }} + @endif +
+
+ {{ __('aktiv') }} +
+ @endforeach + + @foreach ($openPurchases as $purchase) +
+
+
+ {{ $purchase->type->label() }} +
+
+ {{ __('gekauft am :date', ['date' => $purchase->paid_at?->format('d.m.Y')]) }} · {{ $this->formatEuro($purchase->price_cents) }} {{ __('netto') }} +
+
+ {{ __('einlösbar') }} +
+ @endforeach +
+ @else
@@ -387,22 +415,36 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Noch keine aktiven Buchungen') }}

- {{ __('Gebuchte Highlights, Newsletter-Platzierungen oder Add-ons erscheinen hier mit Laufzeit und zugehöriger Firma.') }} + {{ __('Ihr Abo, Bestandstarife und offene Einzelkäufe erscheinen hier mit Laufzeit und Abrechnungsart.') }}

- @endforelse + @endif
{{ __('Verlauf') }} - {{ __('verbrauchte Credits') }} + {{ __('eingelöste Käufe') }}
- @forelse ($bookingHistory as $booking) -
{{ $booking }}
- @empty + @if ($consumedPurchases->isNotEmpty()) +
+ @foreach ($consumedPurchases as $purchase) +
+
+
+ {{ $purchase->pressRelease?->title ?? $purchase->type->label() }} +
+
+ {{ __('eingelöst am :date', ['date' => $purchase->consumed_at?->format('d.m.Y')]) }} +
+
+ {{ __('eingelöst') }} +
+ @endforeach +
+ @else
@@ -412,10 +454,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte {{ __('Noch kein Buchungsverlauf') }}

- {{ __('Nach dem ersten Checkout werden Verbrauch, Rechnungsbezug und betroffene Pressemitteilung hier nachvollziehbar.') }} + {{ __('Eingelöste Einzelkäufe erscheinen hier mit der zugehörigen Pressemitteilung.') }}

- @endforelse + @endif
diff --git a/routes/customer.php b/routes/customer.php index cfd0040..fc073df 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -38,6 +38,8 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin ->name('checkout.subscription'); Route::get('checkout/einzel-pm', [CheckoutController::class, 'singlePm']) ->name('checkout.single-pm'); + Route::get('checkout/abo-verwalten', [CheckoutController::class, 'billingPortal']) + ->name('checkout.billing-portal'); Volt::route('invoices', 'customer.invoices')->name('invoices.index'); Route::get('legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('invoices.pdf'); Volt::route('tokens', 'customer.tokens')->name('tokens.index'); diff --git a/tests/Feature/Billing/BookingsPageTest.php b/tests/Feature/Billing/BookingsPageTest.php new file mode 100644 index 0000000..6b34aa7 --- /dev/null +++ b/tests/Feature/Billing/BookingsPageTest.php @@ -0,0 +1,156 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function bookingsTestCustomer(): User +{ + $user = User::factory()->create(); + $user->assignRole('customer'); + + return $user; +} + +test('the bookings page renders the active plans with checkout links', function () { + /** @var TestCase $this */ + Plan::factory()->create([ + 'name' => 'Business', + 'slug' => 'business', + 'monthly_price_cents' => 4900, + 'yearly_price_cents' => 49000, + 'press_release_quota' => 10, + 'daily_limit' => 2, + ]); + Plan::factory()->inactive()->create(['name' => 'Versteckt']); + + $this->actingAs(bookingsTestCustomer()); + + LivewireVolt::test('customer.bookings') + ->assertSee('Business') + ->assertSee('49 €') + ->assertSee('490 €') + ->assertSee('10 Pressemitteilungen pro Monat') + ->assertSee('max. 2 Veröffentlichungen pro Tag') + ->assertSee('2 Monate gratis') + ->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'monthly']), false) + ->assertSee(route('me.checkout.subscription', ['planSlug' => 'business', 'interval' => 'yearly']), false) + ->assertDontSee('Versteckt'); +}); + +test('the single pm block links to its checkout', function () { + /** @var TestCase $this */ + config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); + + $this->actingAs(bookingsTestCustomer()); + + LivewireVolt::test('customer.bookings') + ->assertSee('Einzel-Pressemitteilung — ohne Abo') + ->assertSee('19 €') + ->assertSee(route('me.checkout.single-pm'), false); +}); + +test('without any booking the page shows the empty state', function () { + /** @var TestCase $this */ + $this->actingAs(bookingsTestCustomer()); + + LivewireVolt::test('customer.bookings') + ->assertSee('Noch kein aktiver Tarif') + ->assertSee('Noch keine aktiven Buchungen'); +}); + +test('a subscriber sees the current plan and the manage button', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + $plan = Plan::factory()->create([ + 'name' => 'Pro', + 'press_release_quota' => 25, + 'stripe_price_id_monthly' => 'price_test_m_pro', + ]); + subscribeUserToPlan($user, $plan); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Ihr aktueller Tarif') + ->assertSee('Abo verwalten') + ->assertSee('Abo: Pro') + ->assertSee(route('me.checkout.billing-portal'), false); +}); + +test('a grandfathered legacy user sees the bestandstarif with unlimited quota', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + 'current_period_end' => now()->addMonths(3), + 'legacy_conditions' => ['name' => 'Presseverteiler Premium'], + ]); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('Bestandstarif') + ->assertSee('Presseverteiler Premium') + ->assertSee('Unbegrenzte Pressemitteilungen (Bestandsschutz).') + ->assertSee('Unbegrenzt'); +}); + +test('open and consumed single purchases appear in bookings and history', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + SinglePurchase::factory()->paid()->create(['user_id' => $user->id]); + SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]); + + $this->actingAs($user); + + LivewireVolt::test('customer.bookings') + ->assertSee('einlösbar') + ->assertSee('eingelöst am'); +}); + +test('the billing portal redirects without an active subscription', function () { + /** @var TestCase $this */ + $this->actingAs(bookingsTestCustomer()) + ->get(route('me.checkout.billing-portal')) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +}); + +test('the billing portal forwards a subscriber to stripe', function () { + /** @var TestCase $this */ + $user = bookingsTestCustomer(); + $user->forceFill(['stripe_id' => 'cus_test_portal'])->save(); + $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_portal']); + subscribeUserToPlan($user, $plan); + + $this->mock(StripeCheckoutService::class, function ($mock) { + $mock->shouldReceive('billingPortalUrl') + ->once() + ->andReturn('https://billing.stripe.com/p/session/test'); + }); + + $this->actingAs($user) + ->get(route('me.checkout.billing-portal')) + ->assertRedirect('https://billing.stripe.com/p/session/test'); +}); + +test('the checkout success banner is shown after returning from stripe', function () { + /** @var TestCase $this */ + $this->actingAs(bookingsTestCustomer()) + ->get(route('me.bookings.index', ['checkout' => 'erfolg'])) + ->assertOk() + ->assertSee('Vielen Dank für Ihre Buchung!'); +}); diff --git a/tests/Feature/PanelConsolidationTest.php b/tests/Feature/PanelConsolidationTest.php index 016ff86..5d39ef2 100644 --- a/tests/Feature/PanelConsolidationTest.php +++ b/tests/Feature/PanelConsolidationTest.php @@ -1,5 +1,6 @@ get(route('dashboard'))->assertForbidden(); }); -test('customer bookings page shows credit packages and add ons from pricing concept', function () { +test('customer bookings page shows the tariff grid and single pm block', function () { + // Seit Phase 9F zeigt die Seite das echte Tarif-Raster mit + // Stripe-Checkout statt des Credit-Konzept-Mocks (Credits → Phase 9I/2). /** @var TestCase $this */ $customer = User::factory()->create(['is_active' => true]); $customer->assignRole('customer'); + Plan::factory()->create([ + 'name' => 'Starter', + 'monthly_price_cents' => 2900, + 'press_release_quota' => 3, + ]); + $this->actingAs($customer) ->get(route('me.bookings.index')) ->assertSuccessful() - ->assertSee('Credit-Pakete') - ->assertSee('Standard') - ->assertSee('50') - ->assertSee('45 €') - ->assertSee('Pressetext-Optimierung') - ->assertSee('Top-Slot Startseite') - ->assertSee('Score 80+') + ->assertSee('Den passenden Tarif wählen') + ->assertSee('Starter') + ->assertSee('29 €') + ->assertSee('2 Monate gratis') + ->assertSee('Einzel-Pressemitteilung — ohne Abo') ->assertSee('Noch keine aktiven Buchungen'); });