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 }; ?>