From c8dc99c3c893ddd305bb6a7981aafef9e2bfe665 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 12 Jun 2026 12:10:32 +0000 Subject: [PATCH] Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich) und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper; Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage) - Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed + PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026, Bestandsschutz); Stub-Spalte users.press_release_quota entfernt - billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen - Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis) - Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben, CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed - Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen, Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E ✅, Checkliste, STATUS-ABGLEICH, PROGRESS Co-Authored-By: Claude Fable 5 --- app/Console/Commands/SyncStripePlans.php | 44 ++++++ app/Http/Controllers/CheckoutController.php | 69 +++++++++ app/Models/User.php | 83 +++++++++-- app/Providers/AppServiceProvider.php | 7 + .../Billing/StripeCheckoutService.php | 51 +++++++ .../PressRelease/PressReleaseService.php | 37 ++++- config/billing.php | 15 ++ ...op_press_release_quota_stub_from_users.php | 27 ++++ dev/frontend/hub-flux/PROGRESS.md | 25 ++++ docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md | 32 ++-- docs/STATUS-ABGLEICH-USER-PANEL.md | 12 +- .../user-admin/Billing-und-Rechnungskreise.md | 97 ++++++++---- docs/user-admin/checkliste-user-backend.md | 4 +- .../press-release-submit-modal.blade.php | 4 +- .../livewire/customer/bookings.blade.php | 32 ++++ .../customer/press-releases/create.blade.php | 2 +- .../customer/press-releases/edit.blade.php | 2 +- .../customer/press-releases/show.blade.php | 2 +- routes/customer.php | 6 + .../Api/V1/PressReleaseSubmitApiTest.php | 9 +- tests/Feature/Billing/CheckoutFlowTest.php | 132 +++++++++++++++++ .../PressReleasePublishModalPhase8iTest.php | 25 +++- tests/Feature/PressReleaseQuotaTest.php | 138 +++++++++++++----- tests/Pest.php | 20 +++ 24 files changed, 775 insertions(+), 100 deletions(-) create mode 100644 app/Http/Controllers/CheckoutController.php create mode 100644 app/Services/Billing/StripeCheckoutService.php create mode 100644 database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php create mode 100644 tests/Feature/Billing/CheckoutFlowTest.php diff --git a/app/Console/Commands/SyncStripePlans.php b/app/Console/Commands/SyncStripePlans.php index aa8023e..b12e977 100644 --- a/app/Console/Commands/SyncStripePlans.php +++ b/app/Console/Commands/SyncStripePlans.php @@ -77,9 +77,53 @@ class SyncStripePlans extends Command $this->info("{$plan->slug}: {$productId} · monatlich {$monthlyId} · jährlich {$yearlyId}"); } + $this->syncSinglePmPrice($stripe, $dryRun); + return self::SUCCESS; } + /** + * Legt das Einmal-Produkt „Einzel-Pressemitteilung" an (Netto-Preis aus + * billing.single_pm_price_cents). Die Price-ID landet bewusst in der ENV + * (STRIPE_PRICE_SINGLE_PM) statt in einer Tabelle — es gibt genau einen + * solchen Preis, und ohne ENV bleibt der Checkout deaktiviert. + */ + private function syncSinglePmPrice(object $stripe, bool $dryRun): void + { + if (config('billing.single_pm_stripe_price_id')) { + $this->line('einzel-pm: bereits verknüpft (STRIPE_PRICE_SINGLE_PM), übersprungen.'); + + return; + } + + $amount = (int) config('billing.single_pm_price_cents'); + + if ($dryRun) { + $this->line(sprintf( + '[dry-run] einzel-pm: Einmal-Produkt + Preis %s € (netto) anlegen.', + number_format($amount / 100, 2, ',', '.'), + )); + + return; + } + + $product = $stripe->products->create([ + 'name' => 'Einzel-Pressemitteilung', + 'metadata' => ['purpose' => 'single_pm'], + ]); + + $price = $stripe->prices->create([ + 'product' => $product->id, + 'currency' => 'eur', + 'unit_amount' => $amount, + 'tax_behavior' => 'exclusive', + 'metadata' => ['purpose' => 'single_pm'], + ]); + + $this->info("einzel-pm: {$product->id} · {$price->id}"); + $this->warn("Bitte in die .env eintragen: STRIPE_PRICE_SINGLE_PM={$price->id}"); + } + private function createPrice(object $stripe, string $productId, Plan $plan, int $unitAmount, string $interval): string { $price = $stripe->prices->create([ diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php new file mode 100644 index 0000000..440535c --- /dev/null +++ b/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,69 @@ +active()->where('slug', $planSlug)->firstOrFail(); + $user = $request->user(); + + if ($user->subscribed()) { + return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Ein Tarifwechsel ist aktuell über den Support möglich.')); + } + + $priceId = $interval === 'yearly' + ? $plan->stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + if (! $priceId) { + return $this->backToBookings(__('Dieser Tarif ist noch nicht buchbar. Bitte versuchen Sie es später erneut.')); + } + + return $this->checkout->forSubscription($user, $plan, $interval); + } + + public function singlePm(Request $request): Checkout|RedirectResponse + { + 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.')); + } + + $purchase = SinglePurchase::query()->create([ + 'user_id' => $request->user()->id, + 'type' => SinglePurchaseType::SinglePm->value, + 'status' => SinglePurchaseStatus::Pending->value, + 'price_cents' => (int) config('billing.single_pm_price_cents'), + 'currency' => 'EUR', + ]); + + return $this->checkout->forSinglePurchase($request->user(), $purchase); + } + + private function backToBookings(string $notice): RedirectResponse + { + return redirect() + ->route('me.bookings.index') + ->with('checkout-notice', $notice); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index f55ccf0..302ac87 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -45,7 +45,6 @@ class User extends Authenticatable 'legacy_portal', 'legacy_id', 'password', - 'press_release_quota', 'press_release_quota_used_this_month', ]; @@ -77,21 +76,87 @@ class User extends Authenticatable 'last_seen_at' => 'datetime', 'deleted_at' => 'datetime', 'password' => 'hashed', - 'press_release_quota' => 'integer', 'press_release_quota_used_this_month' => 'integer', ]; } /** - * Verbleibendes PM-Kontingent in diesem Monat. - * - * Temporärer Stub bis zum echten Tarif-/Credit-Modul. Die Schnittstelle - * (`pressReleaseQuotaRemaining()`) bleibt stabil, damit das - * Veröffentlichungs-Modal nicht neu gebaut werden muss. + * Der Tarif des aktiven Stripe-Abos, aufgelöst über die in `plans` + * gepflegten Stripe-Preis-IDs. Null ohne (gültiges) Abo. */ - public function pressReleaseQuotaRemaining(): int + public function currentPlan(): ?Plan { - return max(0, (int) $this->press_release_quota - (int) $this->press_release_quota_used_this_month); + $subscription = $this->subscription(); + + if (! $subscription?->valid()) { + return null; + } + + $priceId = $subscription->stripe_price; + + if (! $priceId) { + return null; + } + + return Plan::query() + ->where('stripe_price_id_monthly', $priceId) + ->orWhere('stripe_price_id_yearly', $priceId) + ->first(); + } + + /** + * Hat dieser User ein unbegrenztes PM-Kontingent? + * + * Entscheidung 12.06.2026: Bestandskunden (aktive/grandfathered + * Legacy-Vereinbarung) behalten ihren Bestandsschutz unverändert — + * das Alt-Produkt sah unbegrenzte PMs vor. Solange der Launch-Schalter + * `billing.enforce_booking` aus ist, gilt das Kontingent für niemanden. + */ + public function hasUnlimitedPressReleaseQuota(): bool + { + if (! config('billing.enforce_booking')) { + return true; + } + + return $this->userPaymentOptions() + ->whereIn('status', [ + UserPaymentOptionStatus::Active->value, + UserPaymentOptionStatus::Grandfathered->value, + ]) + ->exists(); + } + + /** + * Verbleibendes PM-Kontingent: Rest des Plan-Monatskontingents plus + * bezahlte, noch nicht eingelöste Einzel-/Extra-PM-Käufe. + * Null bedeutet unbegrenzt. + */ + public function pressReleaseQuotaRemaining(): ?int + { + if ($this->hasUnlimitedPressReleaseQuota()) { + return null; + } + + $planRemaining = max( + 0, + ($this->currentPlan()?->press_release_quota ?? 0) - (int) $this->press_release_quota_used_this_month, + ); + + return $planRemaining + $this->singlePurchases()->grantingSubmission()->count(); + } + + /** + * Gesamtes PM-Kontingent (Plan-Monatskontingent plus offene Einmalkäufe) + * für die Anzeige „verbleibend / gesamt". Null bedeutet unbegrenzt. + */ + public function pressReleaseQuotaTotal(): ?int + { + if ($this->hasUnlimitedPressReleaseQuota()) { + return null; + } + + return ($this->currentPlan()?->press_release_quota ?? 0) + + $this->singlePurchases()->grantingSubmission()->count(); } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 78558cf..51de0bb 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -22,6 +22,7 @@ use Illuminate\Database\Events\QueryExecuted; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Laravel\Cashier\Cashier; use Livewire\Livewire; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; @@ -51,6 +52,12 @@ class AppServiceProvider extends ServiceProvider URL::forceScheme('https'); } + // Stripe Tax berechnet die USt im Checkout automatisch nach den + // gleichen Regeln wie der VatResolver im MAN-Kreis (DE mit Steuer, + // EU nur mit USt-ID befreit, Drittland befreit). Aktiviert zugleich + // die USt-ID-Abfrage im Stripe Checkout. + Cashier::calculateTaxes(); + AdminPreset::observe(AdminPerformanceCacheObserver::class); Category::observe(AdminPerformanceCacheObserver::class); CategoryTranslation::observe(AdminPerformanceCacheObserver::class); diff --git a/app/Services/Billing/StripeCheckoutService.php b/app/Services/Billing/StripeCheckoutService.php new file mode 100644 index 0000000..f83ad31 --- /dev/null +++ b/app/Services/Billing/StripeCheckoutService.php @@ -0,0 +1,51 @@ +stripe_price_id_yearly + : $plan->stripe_price_id_monthly; + + return $user + ->newSubscription('default', $priceId) + ->checkout([ + 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), + 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), + ]); + } + + /** + * Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den + * Session-Metadaten schließt den Kreis: `checkout.session.completed` + * markiert den Kauf über ProcessStripeWebhook als bezahlt. + */ + public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout + { + return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [ + 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), + 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), + 'metadata' => ['single_purchase_id' => (string) $purchase->id], + ]); + } +} diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 38fedf9..87be64a 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -4,6 +4,7 @@ namespace App\Services\PressRelease; use App\Enums\PressReleaseClassification; use App\Enums\PressReleaseStatus; +use App\Enums\SinglePurchaseStatus; use App\Jobs\ClassifyPressRelease; use App\Jobs\ScorePressRelease; use App\Mail\PressReleasePublished; @@ -43,8 +44,11 @@ class PressReleaseService // Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht // (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn // noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne - // verfügbares Kontingent automatisch veröffentlicht. - if ($user && $user->pressReleaseQuotaRemaining() <= 0) { + // verfügbares Kontingent automatisch veröffentlicht. Null bedeutet + // unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf). + $quotaRemaining = $user?->pressReleaseQuotaRemaining(); + + if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) { throw new QuotaExceededException; } @@ -184,6 +188,11 @@ class PressReleaseService * PMs kosten nichts). Erneutes Publizieren — etwa nach Archivierung — * zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem * Schreiben des neuen Status-Logs aufgerufen werden. + * + * Verbrauchsreihenfolge: zuerst das Plan-Monatskontingent (Zähler + * `press_release_quota_used_this_month`), danach der älteste bezahlte + * Einzel-/Extra-PM-Kauf (wird mit der PM verknüpft und eingelöst). + * Unbegrenzte User (Bestandsschutz) verbrauchen nichts. */ private function consumePublishSlot(PressRelease $pressRelease): void { @@ -196,7 +205,29 @@ class PressReleaseService return; } - $pressRelease->user?->increment('press_release_quota_used_this_month'); + $user = $pressRelease->user; + + if (! $user || $user->hasUnlimitedPressReleaseQuota()) { + return; + } + + $plan = $user->currentPlan(); + + if ($plan && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) { + $user->increment('press_release_quota_used_this_month'); + + return; + } + + $user->singlePurchases() + ->grantingSubmission() + ->oldest('paid_at') + ->first() + ?->update([ + 'status' => SinglePurchaseStatus::Consumed->value, + 'consumed_at' => now(), + 'press_release_id' => $pressRelease->id, + ]); } /** diff --git a/config/billing.php b/config/billing.php index e5dd715..7cfdf37 100644 --- a/config/billing.php +++ b/config/billing.php @@ -34,6 +34,21 @@ return [ // Zahlungsziel für Rechnungen des manuellen Kreises (Tage). 'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14), + /* + |-------------------------------------------------------------------------- + | Einzel-Pressemitteilung (Pay-per-Release) + |-------------------------------------------------------------------------- + | + | Netto-Preis laut Decision-Update. Die Stripe-Price-ID wird einmalig + | von `billing:sync-stripe-plans` angelegt und hier per ENV verdrahtet — + | ohne sie ist der Einzel-PM-Checkout deaktiviert. + | + */ + + 'single_pm_price_cents' => 1900, + + 'single_pm_stripe_price_id' => env('STRIPE_PRICE_SINGLE_PM'), + /* |-------------------------------------------------------------------------- | USt-Behandlung (Entscheidung 12.06.2026) diff --git a/database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php b/database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php new file mode 100644 index 0000000..95e4641 --- /dev/null +++ b/database/migrations/2026_06_12_115633_drop_press_release_quota_stub_from_users.php @@ -0,0 +1,27 @@ +dropColumn('press_release_quota'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->unsignedInteger('press_release_quota')->default(3)->after('legacy_id'); + }); + } +}; diff --git a/dev/frontend/hub-flux/PROGRESS.md b/dev/frontend/hub-flux/PROGRESS.md index 4a389a1..bd30481 100644 --- a/dev/frontend/hub-flux/PROGRESS.md +++ b/dev/frontend/hub-flux/PROGRESS.md @@ -5,6 +5,31 @@ --- +## 2026-06-12 · Phase 9E · Stripe-Anbindung komplett ✅ + +- **Was**: Produkt-Sync nach Stripe (Tarife + Einzel-PM, Netto-Preise, + Test-Mode), Webhook-Verarbeitung (STR-Spiegelung + Einmalkauf-Erfüllung; + Endpoint `pressekonto.com/stripe/webhook` registriert, Secret gesetzt), + Checkout-Flows als Backend (`me.checkout.subscription`, + `me.checkout.single-pm`; Stripe Tax via `Cashier::calculateTaxes()`), + Slot-Logik vom Stub auf Plan-Kontingent umgestellt: Abo → Tarif-Quote, + danach Einmalkauf-Verbrauch (consumed + PM-Verknüpfung), + **Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz); + Stub-Spalte `users.press_release_quota` entfernt. +- **Dateien**: `app/Http/Controllers/CheckoutController.php`, + `app/Services/Billing/StripeCheckoutService.php`, + `app/Listeners/ProcessStripeWebhook.php`, + `app/Console/Commands/SyncStripePlans.php`, `app/Models/User.php`, + `app/Services/PressRelease/PressReleaseService.php`, + `routes/customer.php`, `config/billing.php`, Buchungs-Seite (Rückmeldung), + Submit-Modal/Views (Kontingent-Anzeige). +- **Build/Test**: Suite 510 passed / 4 skipped, Pint clean; Stripe-Sync + live gegen Test-Mode gelaufen (Einzel-PM: `STRIPE_PRICE_SINGLE_PM` in .env). +- **Offene Fragen**: Stripe Tax im Dashboard aktivieren (Ursprungsadresse), + sonst schlägt der Checkout fehl; Live-Mode-Sync vor Relaunch. +- **Nächster Schritt**: 9F Tarif-Seite/Buchungs-UI an die Checkout-Routen + anbinden (Mock ablösen), danach 9G Tageslimit. + ## 2026-06-12 · Phase 9D · Tarif-Datenmodell, Rechnungskreise & USt ✅ Zentrale Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`. diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index a0bb2c9..75a766c 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -41,7 +41,7 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken: | **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering | | — | **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 — Backbone ✅ (Produkt-Sync nach Stripe, Webhook-Listener mit STR-Spiegelung + Einmalkauf-Erfüllung); offen: Checkout-Flows, Webhook-Endpoint registrieren, Slot-Logik auf Plan-Kontingent | L | mittel | +| **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 | | **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 | @@ -180,20 +180,26 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand): `user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz vor dem Relaunch erneut). Details: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`. -- **Noch offen in 9D** (folgt mit 9E, braucht Checkout/Webhooks): - Slot-Logik von `users.press_release_quota`-Stub auf Plan-Kontingent + - Periodenzähler umstellen und Stub-Spalten entfernen. +- ~~Noch offen in 9D~~ → mit 9E erledigt: Slot-Logik auf Plan-Kontingent + umgestellt, Stub-Spalte entfernt. -### 9E · Stripe (Laravel Cashier) +### 9E · Stripe (Laravel Cashier) ✅ (12.06.2026) -- Checkout für Abo (monatlich/jährlich) und Einmalzahlung (Einzel-PM, Credits); - Stripe-Produkte/Preise anlegen und IDs in `plans` pflegen. -- Webhooks (Subscription-Status, Zahlungsausfall, `invoice.paid`) + Spiegelung - der Stripe-Rechnungen in `invoices` mit STR-Nummer aus dem - `InvoiceNumberGenerator`. -- Slot-Logik auf Plan-Kontingent umstellen (siehe 9D-Rest), Stub ablösen. -- Benötigt `STRIPE_KEY`/`STRIPE_SECRET`/`STRIPE_WEBHOOK_SECRET` in `.env` - (aktuell nicht gesetzt). +- ✅ Produkt-Sync: `billing:sync-stripe-plans` legt Tarife (Monats-/Jahres- + preise) und das Einzel-PM-Produkt als Netto-Preise in Stripe an + (Test-Mode gelaufen; IDs in `plans` bzw. `STRIPE_PRICE_SINGLE_PM`). +- ✅ Webhooks: `ProcessStripeWebhook` spiegelt bezahlte Stripe-Rechnungen + mit STR-Nummer in `invoices` und erfüllt Einmalkäufe; Endpoint + `https://pressekonto.com/stripe/webhook` registriert, Secret gesetzt. +- ✅ Checkout-Flows (Backend): `me.checkout.subscription` + + `me.checkout.single-pm` (CheckoutController → StripeCheckoutService); + Stripe Tax via `Cashier::calculateTaxes()` (Netto-Preise). UI-Anbindung + der Buttons folgt in 9F. +- ✅ Slot-Logik: Plan-Kontingent + Einmalkauf-Verbrauch statt Stub; + **Grandfathered = unbegrenzt** (Entscheidung 12.06.2026, Bestandsschutz). + Details: `docs/user-admin/Billing-und-Rechnungskreise.md` §2. +- Offen → §7 der Billing-Doku: Stripe Tax im Dashboard aktivieren, + Live-Mode-Sync vor Relaunch. ### 9F · Tarif-Seite + Checkout-UI diff --git a/docs/STATUS-ABGLEICH-USER-PANEL.md b/docs/STATUS-ABGLEICH-USER-PANEL.md index 661fd26..e3eab0d 100644 --- a/docs/STATUS-ABGLEICH-USER-PANEL.md +++ b/docs/STATUS-ABGLEICH-USER-PANEL.md @@ -128,7 +128,7 @@ 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 | **in Arbeit** (Phase 9E) | 📝 | +| 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) | | Zahlungsmethoden firmenscharf | **fehlt** | 📝 (Phase 2) | @@ -212,17 +212,17 @@ im öffentlichen Web-Frontend. |---|---| | Tarif-Raster Starter/Business/Pro/Agency (29/49/99/199 €, 3/10/25/60 PMs) | **nicht im Datenmodell** | | Einzel-PM 19 € (No-Abo-Block) + Einzel→Abo-Brücke | **fehlt** | -| Zahlung/Checkout (Stripe) | **fehlt** | +| Zahlung/Checkout (Stripe) | **Backend umgesetzt** (Phase 9E) — Checkout-Routen, Webhooks, STR-Spiegelung; UI folgt mit 9F | | Slot-Verbrauch **bei Veröffentlichung** (Rot = kein Slot) | **umgesetzt** (Phase 9B) — zählt idempotent beim ersten `published`-Übergang; Einreichen erfordert freien Slot, verbraucht aber keinen | -| Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **vorbereitet** (Phase 9C) — `User::hasActiveBooking()`-Stub hinter `billing.enforce_booking` (Default aus), Modal-Hinweis + Server-Guard + API 402; echte Buchungs-Prüfung kommt mit 9D/9E | +| Submit-Gate: „Zur Prüfung einreichen" gegated hinter Buchung | **vorbereitet** (Phase 9C) — `User::hasActiveBooking()`-Stub hinter `billing.enforce_booking` (Default aus), Modal-Hinweis + Server-Guard + API 402; echte Buchungs-Prüfung seit 9D/9E (Abo ∨ Einmalkauf ∨ Legacy-Vereinbarung) | | Tageslimit (Business 2 / Pro 3 / Agency 5) | **fehlt** | | Launch-Credits: Extra-PM, Boost (nur grün), Veröffentlichungsnachweis-PDF | **fehlt** | | Jahrespreis als „2 Monate gratis" | Kommunikations-Regel, greift mit Tarif-UI | | `user_payment_options`-Tabelle | **vorhanden** (Pivot zu Companies da, aber kein aktiver Flow) | **Bewertung**: 📝 — der **Launch-Block** und damit das größte ungebaute Feature. -Vorhandene Anschlusspunkte: Quota-Stub (`users.press_release_quota`, -`pressReleaseQuotaRemaining()`), Veröffentlichungs-Modal (zeigt Kontingent), +Vorhandene Anschlusspunkte: Plan-Kontingent (`pressReleaseQuotaRemaining()`, +null = unbegrenzt/Bestandsschutz), Veröffentlichungs-Modal (zeigt Kontingent), KI-Klassifikation (liefert das Rot/Gelb/Grün für den Slot-Verbrauch). Bewusst **nicht** zum Launch: Re-Check-Loop, Vorab-Prüfung, Prüfzähler (alles Phase 2, siehe Decision-Update §7). @@ -267,7 +267,7 @@ Bewusst **nicht** zum Launch: Re-Check-Loop, Vorab-Prüfung, Prüfzähler | `press_release_attachments`-Tabelle + Model | Migration `2026_05_20_143424_*` | UI auskommentiert, Tabelle bleibt → Doku-Anker für spätere Reaktivierung | | Background-Job für scheduled publishing | `app/Console/Commands/PublishScheduledPressReleases.php`, alle 5 Min via Scheduler; publiziert seit der KI-Anbindung nur noch **grün klassifizierte** fällige PMs | Im Konzept als „automatische Veröffentlichung zum geplanten Termin" hinzufügen | | Zeitzonen-Handling für geplante Termine | `PressRelease::DISPLAY_TIMEZONE` (Europe/Berlin), `scheduledAtLocal()`/`embargoAtLocal()`; Eingabe Berlin, Speicherung UTC | dokumentiert in `Umsetzung Pressemitteilung Bearbeitung Titelbild Veroeffentlichung.md`; `published_at`/`created_at` weiterhin UTC-Anzeige (Folgeschritt) | -| Monatlicher Quota-Reset | `press-releases:reset-monthly-quota` (Scheduler, 1. des Monats) | Stub — wird vom Tarif-Modul (Decision-Update) abgelöst | +| Monatlicher Quota-Reset | `press-releases:reset-monthly-quota` (Scheduler, 1. des Monats) | Setzt den Plan-Kontingent-Zähler zurück (seit 9E) | | FluxUI Toast für UX-Feedback | `Flux::toast()` durchgehend in Customer-Forms | Konzept-übergreifend, kein Konzept-Update nötig | | Smooth-Scroll zu Validation-Errors | `resources/js/portal-form-hooks.js` | UX-Detail, keine Konzept-Doku | | Pre-Submit-Check-Liste in PM-Forms | computed `presubmitChecks` | Im Konzept als „Pre-Submit-Check senkt Support-Aufwand" ergänzen | diff --git a/docs/user-admin/Billing-und-Rechnungskreise.md b/docs/user-admin/Billing-und-Rechnungskreise.md index 4285228..561413a 100644 --- a/docs/user-admin/Billing-und-Rechnungskreise.md +++ b/docs/user-admin/Billing-und-Rechnungskreise.md @@ -1,7 +1,8 @@ # Billing & Rechnungskreise (hybrides Modell) -Stand: 12.06.2026 — Datenmodell, MAN-Kreis und USt-Behandlung umgesetzt -(Phase 9D); Stripe-Checkout/Webhooks in Arbeit (Phase 9E). +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). Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem: Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration. @@ -44,6 +45,35 @@ eingelöster Einzel-/Extra-PM-Kauf **oder** eine aktive/grandfathered Legacy-Vereinbarung. Bestandskunden behalten damit nach Gate-Aktivierung volle Einreichungsrechte. +**PM-Kontingent** (`User::pressReleaseQuotaRemaining()`, null = unbegrenzt): + +| Wer | Kontingent | +|---|---| +| Launch-Schalter `billing.enforce_booking` aus | unbegrenzt (Vor-Launch-Zustand) | +| Bestandskunde (aktive/grandfathered Legacy-Vereinbarung) | **unbegrenzt** — Entscheidung 12.06.2026: Bestandsschutz gilt, das Alt-Produkt sah unbegrenzte PMs vor; eine Migration auf neue Tarife kommt ggf. später | +| Abonnent | Monatskontingent des Tarifs (`plans.press_release_quota`) plus offene Einmalkäufe | +| Nur Einmalkäufe | Anzahl bezahlter, noch nicht eingelöster Einzel-/Extra-PM-Käufe | + +**Slot-Verbrauch** (erst bei Veröffentlichung, Decision-Update §3.2; Ablehnung +kostet nichts; Re-Publish nach Archivierung zählt nicht doppelt): zuerst das +Plan-Monatskontingent (Zähler `users.press_release_quota_used_this_month`, +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): + +| 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`) | + +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 +denselben Regeln wie der VatResolver im MAN-Kreis, inkl. USt-ID-Abfrage im +Checkout. + --- ## 3. MAN-Kreis: Fälligkeitslauf für Legacy-Zahlungen @@ -102,8 +132,9 @@ Rechnungsadresse bestimmt: | Befehl | Zweck | Scheduler | |---|---|---| | `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 | +| `billing:sync-stripe-plans` | Tarife + Einzel-PM als Netto-Produkte/Preise nach Stripe synchronisieren (idempotent; `--dry-run`) | manuell | | `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) | -| `press-releases:reset-monthly-quota` | Quota-Stub-Reset (entfällt mit Plan-Kontingent, 9E) | monatlich, 1. um 00:05 | +| `press-releases:reset-monthly-quota` | Monatlicher Reset des Plan-Kontingent-Zählers (`press_release_quota_used_this_month`) | monatlich, 1. um 00:05 | --- @@ -119,36 +150,50 @@ Rechnungsadresse bestimmt: | `vat_rate` | `BILLING_VAT_RATE` | `0.19` | USt-Satz für steuerpflichtige Fälle | | `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung | -Stripe/Cashier (`config/cashier.php`): +Stripe/Cashier: | ENV | Bedeutung | |---|---| | `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) | -| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints — wird beim Einrichten des Endpoints gesetzt (9E) | -| `CASHIER_CURRENCY` | Default `usd` → für uns `eur` setzen (9E) | +| `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) | +| `CASHIER_CURRENCY` / `CASHIER_CURRENCY_LOCALE` | `eur` / `de_DE` (gesetzt) | + +**Benötigte Webhook-Events** am Stripe-Endpoint: `invoice.payment_succeeded` +(STR-Spiegelung), `checkout.session.completed` (Einmalkauf-Erfüllung) sowie +die Cashier-Standardevents `customer.subscription.created/updated/deleted`, +`customer.updated`, `customer.deleted` (Abo-Zustand). + +**Lokal testen** (der registrierte Endpoint zeigt auf die Live-Domain und +läuft bis zum Relaunch ins Leere — das ist unkritisch, Stripe versucht die +Zustellung nur erneut): Stripe CLI verwenden — +`stripe listen --forward-to pressekonto.test/stripe/webhook` und das von der +CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`. --- -## 7. Offene Punkte (Stand 12.06.2026, nach 9E-Backbone) +## 7. Offene Punkte (Stand 12.06.2026, nach Phase 9E) -0. **9E-Backbone erledigt**: Tarife liegen als Netto-Produkte/Preise in - Stripe (Test-Mode, `billing:sync-stripe-plans`, IDs in `plans`); - Webhook-Listener `ProcessStripeWebhook` spiegelt bezahlte - Stripe-Rechnungen in den STR-Kreis (fortlaufende Nummer, - Adress-Snapshot aus dem Stripe-Payload, idempotent) und erfüllt - Einmalkäufe (`checkout.session.completed` → - `single_purchases.status = paid`). Cashier-Route `POST /stripe/webhook` - ist aktiv. -1. **Phase 9E (Rest)**: Checkout-Flows (Abo + Einmalkauf) inkl. - Buchungs-Seite, Webhook-Endpoint im Stripe-Dashboard registrieren + - `STRIPE_WEBHOOK_SECRET` setzen, Slot-Logik von - `users.press_release_quota`-Stub auf Plan-Kontingent umstellen - (fachlich zu klären: Kontingent-Semantik für Grandfathered — - Legacy-Produkt war „unbegrenzte PMs pro Pressemappe"). -2. **VIES-Validierung** der USt-ID (aktuell Formatprüfung). -3. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`); +0. **9E erledigt**: Stripe-Sync (Tarife + Einzel-PM als Netto-Produkte, + Test-Mode), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung, + Endpoint + Secret eingerichtet), Checkout-Flows (Abo + Einzel-PM, + 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 + `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). +4. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`); Archiv-PDFs existieren bereits on-demand. -4. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung - auf neue Tarife am `grandfathered_until` (D-13-Rest). -5. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem +5. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung + auf neue Tarife am `grandfathered_until` (D-13-Rest); erst dann wird + das unbegrenzte Bestandskontingent ggf. migriert. +6. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem ersten produktiven MAN-/STR-Lauf. +7. **Tageslimit** (`plans.daily_limit`) durchsetzen — Phase 9G. diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md index 228b29b..d6bff00 100644 --- a/docs/user-admin/checkliste-user-backend.md +++ b/docs/user-admin/checkliste-user-backend.md @@ -128,7 +128,7 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic - [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6. - [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. -- [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen (netto, Steuer via Stripe Tax oder VatResolver), STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`. +- [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`. - [ ] 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). @@ -158,6 +158,6 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic - Phase 1, Phase 7 (PM-Form-Refactor), Phase 8 (User-Panel-Konsolidierung) und die KI-Pruef-Pipeline (Phasen 0–5) sind abgeschlossen — siehe Plan-Dokus oben. - Fuer Preise, Kontingente und den Veroeffentlichungs-Flow gilt ausschliesslich das Decision-Update vom 11.06.2026; aeltere Tarif-Tabellen in `Konzept-Update 1` und im Relaunch-Konzept sind ueberschrieben. -- Der Quota-Stub (3 PM/Monat, zaehlt beim Einreichen) bleibt bis zum Tarif-Modul aktiv; die Umstellung auf Slot-Verbrauch bei Veroeffentlichung ist Teil des Launch-Blocks. +- Das PM-Kontingent kommt aus dem Tarif (`plans.press_release_quota`) plus Einmalkaeufen; Bestandskunden (Grandfathered) sind unbegrenzt. Solange `billing.enforce_booking` aus ist, gilt kein Kontingent (Launch-Schalter). - Die KI-Klassifikation laeuft asynchron — in Produktion wird ein Queue-Worker fuer die Queue `classification` benoetigt (Test-Drain: `php artisan classification:work`). - Anhaenge sind aktuell aus Sicherheitsgruenden deaktiviert, Tabelle und Komponente bleiben aber erhalten und werden in einem separaten Audit-Track reaktiviert. diff --git a/resources/views/components/press-release-submit-modal.blade.php b/resources/views/components/press-release-submit-modal.blade.php index 959640e..8e7d094 100644 --- a/resources/views/components/press-release-submit-modal.blade.php +++ b/resources/views/components/press-release-submit-modal.blade.php @@ -11,7 +11,9 @@ Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der `action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`, - `save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind. + `save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben + sind — null bedeutet unbegrenztes Kontingent (Bestandsschutz bzw. + Launch-Schalter aus) und blendet den Block aus. Submit-Gate (Decision-Update §5.1): Ohne aktive Buchung zeigt das Modal statt des Prüf-Flows einen Buchungs-Hinweis — der Button konvertiert, diff --git a/resources/views/livewire/customer/bookings.blade.php b/resources/views/livewire/customer/bookings.blade.php index 923c1aa..fde55b5 100644 --- a/resources/views/livewire/customer/bookings.blade.php +++ b/resources/views/livewire/customer/bookings.blade.php @@ -9,6 +9,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte public function with(): array { 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, @@ -99,6 +103,34 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte }; ?>
+ {{-- ============== CHECKOUT-RÜCKMELDUNG ============== --}} + @if ($checkoutResult === 'erfolg') +
+ +
+ {{ __('Vielen Dank für Ihre Buchung!') }} + {{ __('Die Zahlung wird von Stripe bestätigt — die Buchung erscheint hier in wenigen Augenblicken. Die Rechnung finden Sie anschließend unter Rechnungen.') }} +
+
+ @elseif ($checkoutResult === 'abbruch') +
+ +
+ {{ __('Der Bezahlvorgang wurde abgebrochen. Es wurde nichts gebucht — Sie können den Checkout jederzeit erneut starten.') }} +
+
+ @endif + + @if ($checkoutNotice) +
+ +
{{ $checkoutNotice }}
+
+ @endif + {{-- ============== PAGE HEADER ============== --}}
diff --git a/resources/views/livewire/customer/press-releases/create.blade.php b/resources/views/livewire/customer/press-releases/create.blade.php index b555fc5..d5eb01f 100644 --- a/resources/views/livewire/customer/press-releases/create.blade.php +++ b/resources/views/livewire/customer/press-releases/create.blade.php @@ -546,7 +546,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex : Contact::query()->whereRaw('0 = 1')->get(), 'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'), 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), - 'quotaTotal' => (int) $user->press_release_quota, + 'quotaTotal' => $user->pressReleaseQuotaTotal(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), ]; } diff --git a/resources/views/livewire/customer/press-releases/edit.blade.php b/resources/views/livewire/customer/press-releases/edit.blade.php index e8cb440..187c0ac 100644 --- a/resources/views/livewire/customer/press-releases/edit.blade.php +++ b/resources/views/livewire/customer/press-releases/edit.blade.php @@ -524,7 +524,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl 'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany), 'coverUrl' => $cover->coverUrl($pressRelease, 'cover'), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease), - 'quotaTotal' => (int) $user->press_release_quota, + 'quotaTotal' => $user->pressReleaseQuotaTotal(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), ]; } diff --git a/resources/views/livewire/customer/press-releases/show.blade.php b/resources/views/livewire/customer/press-releases/show.blade.php index e214f1e..306c6c7 100644 --- a/resources/views/livewire/customer/press-releases/show.blade.php +++ b/resources/views/livewire/customer/press-releases/show.blade.php @@ -104,7 +104,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends 'categoryName' => $categoryName, 'coverUrl' => $cover->coverUrl($pr, 'cover'), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), - 'quotaTotal' => (int) $user->press_release_quota, + 'quotaTotal' => $user->pressReleaseQuotaTotal(), 'quotaRemaining' => $user->pressReleaseQuotaRemaining(), 'canEdit' => auth()->user()->can('update', $pr) && in_array($pr->status->value, ['draft', 'rejected']), diff --git a/routes/customer.php b/routes/customer.php index 89e3c2b..cfd0040 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -1,5 +1,6 @@ where('id', '[0-9]+'); Volt::route('buchungen-add-ons', 'customer.bookings')->name('bookings.index'); + Route::get('checkout/abo/{planSlug}/{interval}', [CheckoutController::class, 'subscription']) + ->whereIn('interval', ['monthly', 'yearly']) + ->name('checkout.subscription'); + Route::get('checkout/einzel-pm', [CheckoutController::class, 'singlePm']) + ->name('checkout.single-pm'); 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/Api/V1/PressReleaseSubmitApiTest.php b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php index d65ca16..0316645 100644 --- a/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php @@ -3,6 +3,7 @@ use App\Enums\PressReleaseStatus; use App\Models\Category; use App\Models\Company; +use App\Models\Plan; use App\Models\PressRelease; use App\Models\PressReleaseStatusLog; use App\Models\User; @@ -79,10 +80,16 @@ test('api submit responds 402 when the booking gate is enforced', function () { test('api submit responds 422 when the monthly quota is exhausted', function () { /** @var TestCase $this */ + config()->set('billing.enforce_booking', true); + $user = User::factory()->create([ - 'press_release_quota' => 3, 'press_release_quota_used_this_month' => 3, ]); + $plan = Plan::factory()->create([ + 'press_release_quota' => 3, + 'stripe_price_id_monthly' => 'price_test_m_submit', + ]); + subscribeUserToPlan($user, $plan); $pressRelease = PressRelease::factory()->create([ 'user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value, diff --git a/tests/Feature/Billing/CheckoutFlowTest.php b/tests/Feature/Billing/CheckoutFlowTest.php new file mode 100644 index 0000000..6ace2f8 --- /dev/null +++ b/tests/Feature/Billing/CheckoutFlowTest.php @@ -0,0 +1,132 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +function checkoutTestCustomer(): User +{ + $user = User::factory()->create(); + $user->assignRole('customer'); + + return $user; +} + +/** + * Stripe-Checkout-Stub: Der Controller gibt das Checkout-Objekt zurück, + * der Router ruft toResponse() — wir leiten auf eine Fake-Stripe-URL um. + */ +function fakeStripeCheckout(): Checkout +{ + $checkout = Mockery::mock(Checkout::class); + $checkout->shouldReceive('toResponse') + ->andReturn(new RedirectResponse('https://checkout.stripe.com/c/pay/test')); + + return $checkout; +} + +test('guests are redirected to the login', function () { + /** @var TestCase $this */ + $plan = Plan::factory()->create(); + + $this->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) + ->assertRedirect(); +}); + +test('an unknown plan or invalid interval responds 404', function () { + /** @var TestCase $this */ + $this->actingAs(checkoutTestCustomer()); + + $this->get('/admin/me/checkout/abo/gibts-nicht/monthly')->assertNotFound(); + + $plan = Plan::factory()->create(); + $this->get("/admin/me/checkout/abo/{$plan->slug}/weekly")->assertNotFound(); +}); + +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]); + + $this->actingAs(checkoutTestCustomer()) + ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +}); + +test('an already subscribed user is redirected instead of double-booking', function () { + /** @var TestCase $this */ + $user = checkoutTestCustomer(); + $plan = Plan::factory()->create(['stripe_price_id_monthly' => 'price_test_m_1']); + subscribeUserToPlan($user, $plan); + + $this->actingAs($user) + ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'monthly'])) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); +}); + +test('the subscription checkout hands plan and interval to stripe', function () { + /** @var TestCase $this */ + $user = checkoutTestCustomer(); + $plan = Plan::factory()->create(['stripe_price_id_yearly' => 'price_test_y_1']); + + $this->mock(StripeCheckoutService::class, function ($mock) use ($plan) { + $mock->shouldReceive('forSubscription') + ->once() + ->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($plan) && $interval === 'yearly') + ->andReturn(fakeStripeCheckout()); + }); + + $this->actingAs($user) + ->get(route('me.checkout.subscription', ['planSlug' => $plan->slug, 'interval' => 'yearly'])) + ->assertRedirect('https://checkout.stripe.com/c/pay/test'); +}); + +test('the single pm checkout is disabled without a configured stripe price', function () { + /** @var TestCase $this */ + config()->set('billing.single_pm_stripe_price_id', null); + + $this->actingAs(checkoutTestCustomer()) + ->get(route('me.checkout.single-pm')) + ->assertRedirect(route('me.bookings.index')) + ->assertSessionHas('checkout-notice'); + + expect(SinglePurchase::count())->toBe(0); +}); + +test('the single pm checkout creates a pending purchase and hands it to stripe', function () { + /** @var TestCase $this */ + config()->set('billing.single_pm_stripe_price_id', 'price_test_single_pm'); + config()->set('billing.single_pm_price_cents', 1900); + + $user = checkoutTestCustomer(); + + $this->mock(StripeCheckoutService::class, function ($mock) use ($user) { + $mock->shouldReceive('forSinglePurchase') + ->once() + ->withArgs(fn (User $u, SinglePurchase $p) => $u->is($user) && $p->exists) + ->andReturn(fakeStripeCheckout()); + }); + + $this->actingAs($user) + ->get(route('me.checkout.single-pm')) + ->assertRedirect('https://checkout.stripe.com/c/pay/test'); + + $purchase = SinglePurchase::sole(); + expect($purchase->user_id)->toBe($user->id); + expect($purchase->type)->toBe(SinglePurchaseType::SinglePm); + expect($purchase->status)->toBe(SinglePurchaseStatus::Pending); + expect($purchase->price_cents)->toBe(1900); +}); diff --git a/tests/Feature/PressReleasePublishModalPhase8iTest.php b/tests/Feature/PressReleasePublishModalPhase8iTest.php index fec2ea1..00c2732 100644 --- a/tests/Feature/PressReleasePublishModalPhase8iTest.php +++ b/tests/Feature/PressReleasePublishModalPhase8iTest.php @@ -1,6 +1,7 @@ set('billing.enforce_booking', true); + ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); - $customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 1]); + $customer->update(['press_release_quota_used_this_month' => 1]); + $plan = Plan::factory()->create([ + 'press_release_quota' => 3, + 'stripe_price_id_monthly' => 'price_test_m_modal', + ]); + subscribeUserToPlan($customer, $plan); $this->actingAs($customer); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) @@ -28,10 +38,21 @@ test('customer show renders the publish confirmation modal with legal note and q ->assertSee('2 / 3'); }); +test('the quota block is hidden for users with an unlimited quota', function () { + /** @var TestCase $this */ + ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); + $this->actingAs($customer); + + // Launch-Schalter aus → Kontingent unbegrenzt → kein Quota-Block. + LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) + ->assertSee('Pressemitteilung zur Prüfung einreichen') + ->assertDontSee('PM-Kontingent diesen Monat'); +}); + test('submitting from the show modal moves the draft into review without consuming quota', function () { /** @var TestCase $this */ ['customer' => $customer, 'pr' => $pr] = makeCustomerForShowPhase8a(); - $customer->update(['press_release_quota' => 3, 'press_release_quota_used_this_month' => 0]); + $customer->update(['press_release_quota_used_this_month' => 0]); $this->actingAs($customer); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) diff --git a/tests/Feature/PressReleaseQuotaTest.php b/tests/Feature/PressReleaseQuotaTest.php index b84152d..efdb310 100644 --- a/tests/Feature/PressReleaseQuotaTest.php +++ b/tests/Feature/PressReleaseQuotaTest.php @@ -2,10 +2,15 @@ use App\Console\Commands\ResetMonthlyPressReleaseQuota; use App\Enums\PressReleaseStatus; +use App\Enums\SinglePurchaseStatus; +use App\Enums\UserPaymentOptionStatus; use App\Models\Category; use App\Models\Company; +use App\Models\Plan; use App\Models\PressRelease; +use App\Models\SinglePurchase; use App\Models\User; +use App\Models\UserPaymentOption; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; use Database\Seeders\RolesAndPermissionsSeeder; @@ -15,6 +20,9 @@ use Tests\TestCase; beforeEach(function (): void { /** @var TestCase $this */ $this->seed(RolesAndPermissionsSeeder::class); + + // Das Kontingent greift erst mit dem Launch-Schalter (sonst unbegrenzt). + config()->set('billing.enforce_booking', true); }); function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelease @@ -31,25 +39,72 @@ function quotaTestPressRelease(User $user, string $status = 'draft'): PressRelea ]); } -test('remaining quota reflects the used counter', function () { +function quotaTestSubscriber(int $planQuota = 3, int $used = 0): User +{ $user = User::factory()->create([ - 'press_release_quota' => 3, - 'press_release_quota_used_this_month' => 1, + 'press_release_quota_used_this_month' => $used, + ]); + $user->assignRole('customer'); + + $plan = Plan::factory()->create([ + 'press_release_quota' => $planQuota, + 'stripe_price_id_monthly' => 'price_test_m_'.fake()->unique()->randomNumber(6), ]); + subscribeUserToPlan($user, $plan); + + return $user; +} + +test('without the launch switch the quota is unlimited', function () { + config()->set('billing.enforce_booking', false); + + $user = User::factory()->create(); + + expect($user->pressReleaseQuotaRemaining())->toBeNull(); + expect($user->pressReleaseQuotaTotal())->toBeNull(); +}); + +test('a subscriber inherits the monthly quota of the plan', function () { + $user = quotaTestSubscriber(planQuota: 3, used: 1); + expect($user->pressReleaseQuotaRemaining())->toBe(2); + expect($user->pressReleaseQuotaTotal())->toBe(3); +}); + +test('paid single purchases extend the quota', function () { + $user = quotaTestSubscriber(planQuota: 3, used: 3); + SinglePurchase::factory()->paid()->count(2)->create(['user_id' => $user->id]); + SinglePurchase::factory()->consumed()->create(['user_id' => $user->id]); + + expect($user->pressReleaseQuotaRemaining())->toBe(2); + expect($user->pressReleaseQuotaTotal())->toBe(5); +}); + +test('a grandfathered legacy user has an unlimited quota', function () { + // Entscheidung 12.06.2026: Bestandsschutz — das Alt-Produkt sah + // unbegrenzte PMs vor, am Kontingent wird nichts umgestellt. + $user = User::factory()->create(); + $user->assignRole('customer'); + UserPaymentOption::factory()->create([ + 'user_id' => $user->id, + 'status' => UserPaymentOptionStatus::Grandfathered->value, + ]); + + expect($user->pressReleaseQuotaRemaining())->toBeNull(); + + $pr = quotaTestPressRelease($user, 'review'); + app(PressReleaseService::class)->publish($pr); + + expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); + expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); test('submitting a press release does not consume a quota slot', function () { // Decision-Update §3.2: Der Slot zählt erst bei Veröffentlichung runter. Queue::fake(); - $user = User::factory()->create([ - 'press_release_quota' => 3, - 'press_release_quota_used_this_month' => 0, - ]); - $user->assignRole('customer'); - + $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user); app(PressReleaseService::class)->submitForReview($pr); @@ -58,13 +113,8 @@ test('submitting a press release does not consume a quota slot', function () { expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); -test('publishing consumes exactly one quota slot', function () { - $user = User::factory()->create([ - 'press_release_quota' => 3, - 'press_release_quota_used_this_month' => 0, - ]); - $user->assignRole('customer'); - +test('publishing consumes exactly one plan slot', function () { + $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->publish($pr); @@ -74,12 +124,7 @@ test('publishing consumes exactly one quota slot', function () { }); test('re-publishing after archive does not consume a second slot', function () { - $user = User::factory()->create([ - 'press_release_quota' => 3, - 'press_release_quota_used_this_month' => 0, - ]); - $user->assignRole('customer'); - + $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); $service = app(PressReleaseService::class); @@ -91,12 +136,7 @@ test('re-publishing after archive does not consume a second slot', function () { }); test('a rejected press release does not consume a quota slot', function () { - $user = User::factory()->create([ - 'press_release_quota' => 3, - 'press_release_quota_used_this_month' => 0, - ]); - $user->assignRole('customer'); - + $user = quotaTestSubscriber(); $pr = quotaTestPressRelease($user, 'review'); app(PressReleaseService::class)->reject($pr, 'Unzulässiger Inhalt.', 'ki'); @@ -105,15 +145,44 @@ test('a rejected press release does not consume a quota slot', function () { expect($user->fresh()->press_release_quota_used_this_month)->toBe(0); }); +test('publishing past the plan quota consumes the oldest paid purchase', function () { + $user = quotaTestSubscriber(planQuota: 1, used: 1); + $older = SinglePurchase::factory()->paid()->create([ + 'user_id' => $user->id, + 'paid_at' => now()->subDays(2), + ]); + $newer = SinglePurchase::factory()->paid()->create([ + 'user_id' => $user->id, + 'paid_at' => now()->subDay(), + ]); + + $pr = quotaTestPressRelease($user, 'review'); + app(PressReleaseService::class)->publish($pr); + + expect($user->fresh()->press_release_quota_used_this_month)->toBe(1); + expect($older->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); + expect($older->fresh()->press_release_id)->toBe($pr->id); + expect($newer->fresh()->status)->toBe(SinglePurchaseStatus::Paid); +}); + +test('a purchase-only user consumes the purchase on publish', function () { + $user = User::factory()->create(); + $user->assignRole('customer'); + $purchase = SinglePurchase::factory()->paid()->create(['user_id' => $user->id]); + + expect($user->pressReleaseQuotaRemaining())->toBe(1); + + $pr = quotaTestPressRelease($user, 'review'); + app(PressReleaseService::class)->publish($pr); + + expect($purchase->fresh()->status)->toBe(SinglePurchaseStatus::Consumed); + expect($purchase->fresh()->consumed_at)->not->toBeNull(); +}); + test('submitting with an exhausted quota is blocked', function () { Queue::fake(); - $user = User::factory()->create([ - 'press_release_quota' => 3, - 'press_release_quota_used_this_month' => 3, - ]); - $user->assignRole('customer'); - + $user = quotaTestSubscriber(planQuota: 3, used: 3); $pr = quotaTestPressRelease($user); expect(fn () => app(PressReleaseService::class)->submitForReview($pr)) @@ -123,6 +192,7 @@ test('submitting with an exhausted quota is blocked', function () { }); test('monthly reset command zeroes the used counter', function () { + /** @var TestCase $this */ User::factory()->count(2)->create(['press_release_quota_used_this_month' => 2]); $untouched = User::factory()->create(['press_release_quota_used_this_month' => 0]); diff --git a/tests/Pest.php b/tests/Pest.php index a232678..c2de277 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,7 @@ stripe_price_id_yearly : $plan->stripe_price_id_monthly; + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_test_'.fake()->unique()->randomNumber(6), + 'stripe_status' => 'active', + 'stripe_price' => $priceId, + 'quantity' => 1, + ]); +}