active()->where('slug', $planSlug)->firstOrFail(); $user = $request->user(); if (! $user->hasCompleteBillingAddress()) { return $this->backToProfile(); } if ($redirect = $this->requireEntrepreneurConfirmation($request)) { return $redirect; } if ($user->subscribed()) { return $this->backToBookings(__('Es besteht bereits ein aktives Abo. Nutzen Sie den Tarifwechsel direkt an der gewünschten Tarif-Karte.')); } $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); } /** * Tarifwechsel eines aktiven Abos (POST aus dem Buchungs-Modal) — kein * neuer Stripe-Checkout: Stripe stellt das bestehende Abo sofort um und * verrechnet anteilig (swapAndInvoice). Eine vorgemerkte Kündigung wird * durch den Wechsel aufgehoben. */ public function swap(Request $request, string $planSlug, string $interval): RedirectResponse { $plan = Plan::query()->active()->where('slug', $planSlug)->firstOrFail(); $user = $request->user(); if (! $user->hasCompleteBillingAddress()) { return $this->backToProfile(); } if ($redirect = $this->requireEntrepreneurConfirmation($request)) { return $redirect; } $subscription = $user->subscription(); if (! $subscription || ! $subscription->valid()) { return $this->backToBookings(__('Es besteht kein aktives Abo — bitte buchen Sie den gewünschten Tarif direkt.')); } $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.')); } if ($subscription->stripe_price === $priceId) { return $this->backToBookings(__('Dieser Tarif ist bereits Ihr aktueller Tarif.')); } try { // Downgrade-Vorkehrung (QA-Befund 07.07.): Ein günstigerer Tarif // wird erst zum Ende der bezahlten Periode wirksam — es entsteht // nie eine Gutschrift oder ein negativer Rechnungsbetrag. Nur // Upgrades werden sofort umgestellt und anteilig nachberechnet. if ($this->checkout->isDowngrade($user, $plan, $interval)) { $this->checkout->scheduleDowngrade($user, $plan, $interval); return redirect()->route('me.bookings.index', ['checkout' => 'wechsel-geplant']); } $this->checkout->swapSubscription($user, $plan, $interval); } catch (IncompletePayment $exception) { return redirect()->route('cashier.payment', [ $exception->payment->id, 'redirect' => route('me.bookings.index', ['checkout' => 'wechsel']), ]); } catch (\Throwable $exception) { Log::channel('billing')->error('Tarifwechsel fehlgeschlagen.', [ 'user_id' => $user->id, 'plan' => $plan->slug, 'interval' => $interval, 'error' => $exception->getMessage(), ]); return $this->backToBookings(__('Der Tarifwechsel konnte nicht durchgeführt werden. Bitte versuchen Sie es erneut oder wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')])); } return redirect()->route('me.bookings.index', ['checkout' => 'wechsel']); } public function singlePm(Request $request): Checkout|RedirectResponse { if (! $request->user()->hasCompleteBillingAddress()) { return $this->backToProfile(); } if ($redirect = $this->requireEntrepreneurConfirmation($request)) { return $redirect; } 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' => config('billing.default_currency'), ]); return $this->checkout->forSinglePurchase($request->user(), $purchase); } /** * Wallet-Aufladung über ein Credit-Paket. Wie jeder Checkout setzt das * eine vollständige Rechnungsadresse voraus; die Erfüllung (Gutschrift) * übernimmt der Webhook über die `credit_topup_id`-Metadaten. */ public function creditTopup(Request $request, string $pack, CreditTopupService $topups): Checkout|RedirectResponse { if (! $request->user()->hasCompleteBillingAddress()) { return $this->backToProfile(); } if ($redirect = $this->requireEntrepreneurConfirmation($request)) { return $redirect; } if (! $topups->findPack($pack)) { return $this->backToBookings(__('Dieses Credit-Paket ist nicht verfügbar.')); } $topup = $topups->startTopup($request->user(), $pack); return $this->checkout->forCreditTopup($request->user(), $topup); } /** * Depublikation einer eigenen, veröffentlichten PM (T12, Meldeverfahren * Weg 3). Config-gated: ohne konfigurierten Preis ist der Weg * deaktiviert. Die Entfernung selbst übernimmt der Webhook nach * Zahlungseingang (Tombstone bleibt öffentlich sichtbar). */ public function depublication(Request $request, int $pressReleaseId): Checkout|RedirectResponse { $user = $request->user(); $priceCents = (int) config('billing.depublication_price_cents'); if ($priceCents <= 0) { return $this->backToBookings(__('Die Depublikation ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')])); } $pressRelease = PressRelease::query() ->accessibleBy($user) ->whereKey($pressReleaseId) ->firstOrFail(); if ($pressRelease->status !== PressReleaseStatus::Published) { return $this->backToBookings(__('Nur veröffentlichte Pressemitteilungen können depubliziert werden.')); } if (! $user->hasCompleteBillingAddress()) { return $this->backToProfile(); } if ($redirect = $this->requireEntrepreneurConfirmation($request)) { return $redirect; } // Doppelbuchungs-Bremse: nur eine BEZAHLTE Depublikation blockiert. // Ein offener Pending-Kauf (abgebrochener Stripe-Checkout) wird // wiederverwendet, damit der Abbruch den Weg nicht dauerhaft // versperrt (QA-Befund 07.07.). $existing = SinglePurchase::query() ->where('press_release_id', $pressRelease->id) ->where('type', SinglePurchaseType::Depublication->value) ->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value]) ->latest('id') ->first(); if ($existing && $existing->status !== SinglePurchaseStatus::Pending) { return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein Depublikations-Vorgang vor.')); } $purchase = $existing ?? SinglePurchase::query()->create([ 'user_id' => $user->id, 'type' => SinglePurchaseType::Depublication->value, 'status' => SinglePurchaseStatus::Pending->value, 'price_cents' => $priceCents, 'currency' => config('billing.default_currency'), 'press_release_id' => $pressRelease->id, ]); return $this->checkout->forDepublication($user, $purchase); } /** * Kostenpflichtige Änderung einer eigenen, veröffentlichten PM * (Meldeverfahren v2, Spur D). Voraussetzung ist die im Panel bestandene * KI-Vorprüfung: ohne offene Change-Anfrage mit gelb/grünem Vorprüf- * Ergebnis gibt es keinen Checkout. Die Umsetzung übernimmt der Webhook * nach Zahlungseingang (CorrectionService::applyPaid). */ public function change(Request $request, int $pressReleaseId): Checkout|RedirectResponse { $user = $request->user(); $priceCents = (int) config('billing.change_price_cents'); if ($priceCents <= 0) { return $this->backToBookings(__('Die kostenpflichtige Änderung ist derzeit nicht online buchbar. Bitte wenden Sie sich an :email.', ['email' => (string) config('legal.contact_email')])); } $pressRelease = PressRelease::query() ->accessibleBy($user) ->whereKey($pressReleaseId) ->firstOrFail(); if ($pressRelease->status !== PressReleaseStatus::Published) { return $this->backToBookings(__('Nur veröffentlichte Pressemitteilungen können geändert werden.')); } if (! $user->hasCompleteBillingAddress()) { return $this->backToProfile(); } if ($redirect = $this->requireEntrepreneurConfirmation($request)) { return $redirect; } $legalRequest = LegalRequest::query() ->where('press_release_id', $pressRelease->id) ->where('type', LegalRequestType::Change->value) ->open() ->latest('id') ->first(); if (! $legalRequest || ! in_array($legalRequest->payload['ki_check']['classification'] ?? null, ['green', 'yellow'], true)) { return $this->backToBookings(__('Für diese Pressemitteilung liegt keine freigegebene Änderungs-Anfrage vor. Bitte starten Sie die Änderung an der Pressemitteilung neu.')); } // Doppelbuchungs-Bremse wie bei der Depublikation: nur ein BEZAHLTER // Kauf blockiert, ein abgebrochener Pending-Checkout wird // wiederverwendet. $existing = SinglePurchase::query() ->where('press_release_id', $pressRelease->id) ->where('type', SinglePurchaseType::Change->value) ->whereIn('status', [SinglePurchaseStatus::Pending->value, SinglePurchaseStatus::Paid->value]) ->latest('id') ->first(); if ($existing && $existing->status !== SinglePurchaseStatus::Pending) { return $this->backToBookings(__('Für diese Pressemitteilung liegt bereits ein bezahlter Änderungs-Vorgang vor.')); } $purchase = $existing ?? SinglePurchase::query()->create([ 'user_id' => $user->id, 'type' => SinglePurchaseType::Change->value, 'status' => SinglePurchaseStatus::Pending->value, 'price_cents' => $priceCents, 'currency' => config('billing.default_currency'), 'press_release_id' => $pressRelease->id, ]); if ($legalRequest->single_purchase_id !== $purchase->id) { $legalRequest->forceFill(['single_purchase_id' => $purchase->id])->save(); } return $this->checkout->forChange($user, $purchase); } /** * Stripe Billing Portal: Selbstverwaltung des Abos (Zahlungsmethode, * Rechnungen, Kündigung). Nur mit aktivem Abo sinnvoll. */ public function billingPortal(Request $request): RedirectResponse { $user = $request->user(); if (! $user->hasStripeId() || ! $user->subscribed()) { return $this->backToBookings(__('Es besteht kein aktives Abo, das verwaltet werden könnte.')); } return redirect()->away($this->checkout->billingPortalUrl($user)); } private function backToBookings(string $notice): RedirectResponse { return redirect() ->route('me.bookings.index') ->with('checkout-notice', $notice); } /** * B2B-Gate (T1neu, AGB v0.6 § 1 Abs. 4): Jeder Stripe-Checkout setzt eine * zeitnah protokollierte Unternehmer-Bestätigung voraus (Pflicht-Checkbox * im Buchungs-Modal). Direktaufrufe der Checkout-Routen ohne Bestätigung * werden auf die Buchungsseite zurückgeleitet. */ private function requireEntrepreneurConfirmation(Request $request): ?RedirectResponse { if (BookingConfirmation::query()->recentFor($request->user())->exists()) { return null; } return $this->backToBookings( __('Bitte bestätigen Sie im Buchungsdialog, dass Sie als Unternehmer bestellen — das Angebot richtet sich ausschließlich an Unternehmen, Selbstständige und Agenturen.') ); } /** * Buchungs-Voraussetzung (12.06.2026): ohne vollständige * Rechnungsadresse kein Checkout — der Hinweis erscheint direkt auf * der Profil-Seite über dem Rechnungsadress-Formular. */ private function backToProfile(): RedirectResponse { return redirect() ->route('me.profile') ->with('checkout-notice', __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung und wird an Stripe übergeben.')); } }