stripe_price_id_yearly : $plan->stripe_price_id_monthly; $this->syncCustomerFromBillingAddress($user); return $user ->newSubscription('default', $priceId) ->checkout($this->sessionOptions($user)); } /** * Downgrade-Erkennung (QA-Befund 07.07.): Es darf nie eine negative * Proration-Rechnung entstehen — eine Buchung ist nicht rückabwickelbar. * Sofort gewechselt wird nur, wenn der Periodenpreis des Ziel-Tarifs * mindestens den des aktuellen erreicht: Die Gutschrift für die * Restlaufzeit beträgt höchstens den vollen aktuellen Periodenpreis, * die Wechsel-Rechnung ist damit garantiert >= 0. Alles andere * (günstigerer Tarif, Jahres- auf Monatsabo, unbekannter Alt-Preis) * ist ein Downgrade und wird zum Periodenende vorgemerkt. */ public function isDowngrade(User $user, Plan $plan, string $interval): bool { $currentPriceId = $user->subscription()?->stripe_price; if (! $currentPriceId) { return true; } $currentPlan = Plan::query() ->where('stripe_price_id_monthly', $currentPriceId) ->orWhere('stripe_price_id_yearly', $currentPriceId) ->first(); if (! $currentPlan) { return true; } $currentCents = $currentPlan->stripe_price_id_monthly === $currentPriceId ? (int) $currentPlan->monthly_price_cents : (int) $currentPlan->yearly_price_cents; $targetCents = $interval === 'yearly' ? (int) $plan->yearly_price_cents : (int) $plan->monthly_price_cents; return $targetCents < $currentCents; } /** * Sofortiger Tarifwechsel nach oben (QA-Befund 07.07.) — ohne neuen * Checkout: Stripe stellt das Abo sofort um und stellt die anteilige * Differenz umgehend in Rechnung (swapAndInvoice). Cashier hebt dabei * eine vorgemerkte Kündigung auf (cancel_at_period_end = false); eine * bestehende Downgrade-Vormerkung wird vorher aufgelöst. * * @throws IncompletePayment wenn die Zahlung * eine Bestätigung braucht (SCA) */ public function swapSubscription(User $user, Plan $plan, string $interval): void { $priceId = $interval === 'yearly' ? $plan->stripe_price_id_yearly : $plan->stripe_price_id_monthly; $subscription = $user->subscription('default'); $this->releaseScheduledChange($subscription); $this->syncCustomerFromBillingAddress($user); $subscription->swapAndInvoice($priceId); } /** * Tarifwechsel nach unten: keine sofortige Umstellung, keine Gutschrift. * Ein Stripe Subscription Schedule stellt das Abo zum Ende der bereits * bezahlten Periode auf den neuen Preis um (proration_behavior none) — * es entsteht nie ein negativer Rechnungsbetrag. Die Vormerkung wird * lokal gespiegelt; der `customer.subscription.updated`-Webhook räumt * sie nach Vollzug wieder ab (ProcessStripeWebhook). */ public function scheduleDowngrade(User $user, Plan $plan, string $interval): CarbonInterface { $priceId = $interval === 'yearly' ? $plan->stripe_price_id_yearly : $plan->stripe_price_id_monthly; $subscription = $user->subscription('default'); // Wer wechselt, bleibt Kunde: eine vorgemerkte Kündigung wird // aufgehoben (das Bestätigungs-Modal kündigt das an). if ($subscription->onGracePeriod()) { $subscription->resume(); } $stripe = Cashier::stripe(); $schedule = $subscription->stripe_schedule_id ? $stripe->subscriptionSchedules->retrieve($subscription->stripe_schedule_id) : $stripe->subscriptionSchedules->create(['from_subscription' => $subscription->stripe_id]); // Phase 1 = laufende Periode unverändert (Stripe verlangt sie beim // Update identisch), Phase 2 = neuer Preis ab Periodenende. $currentPhaseStart = (int) $schedule->phases[0]->start_date; $currentPhaseEnd = (int) ($schedule->phases[0]->end_date ?? $subscription->currentPeriodEnd()?->getTimestamp()); $stripe->subscriptionSchedules->update($schedule->id, [ 'end_behavior' => 'release', 'phases' => [ [ 'items' => [['price' => $subscription->stripe_price, 'quantity' => $subscription->quantity ?? 1]], 'start_date' => $currentPhaseStart, 'end_date' => $currentPhaseEnd, ], [ 'items' => [['price' => $priceId, 'quantity' => $subscription->quantity ?? 1]], 'proration_behavior' => 'none', ], ], ]); $effectiveAt = Carbon::createFromTimestamp($currentPhaseEnd)->timezone(config('app.timezone')); $subscription->forceFill([ 'scheduled_stripe_price' => $priceId, 'scheduled_change_at' => $effectiveAt, 'stripe_schedule_id' => $schedule->id, ])->save(); return $effectiveAt; } /** * Bestehende Downgrade-Vormerkung auflösen (release lässt das Abo * unverändert weiterlaufen) — nötig, bevor Cashier das Abo direkt * umstellen darf. Ein bereits aufgelöster Schedule ist kein Fehler. */ private function releaseScheduledChange(Subscription $subscription): void { if (! $subscription->stripe_schedule_id) { return; } try { Cashier::stripe()->subscriptionSchedules->release($subscription->stripe_schedule_id); } catch (InvalidRequestException $exception) { Log::warning('Downgrade-Vormerkung war bei Stripe nicht (mehr) auflösbar.', [ 'subscription_id' => $subscription->id, 'stripe_schedule_id' => $subscription->stripe_schedule_id, 'error' => $exception->getMessage(), ]); } $subscription->forceFill([ 'scheduled_stripe_price' => null, 'scheduled_change_at' => null, 'stripe_schedule_id' => null, ])->save(); } /** * Stripe-Customer vor jedem Checkout auf den Stand der lokal gepflegten * Rechnungsadresse bringen: Name/Firma und Adresse (stripeName/ * stripeAddress-Hooks am User) plus USt-ID. Ohne diesen Sync stand die * Profil-Rechnungsadresse nur bei der Erstanlage am Customer und die im * Checkout getippte Adresse landete auf der Rechnung (QA-Befund 07.07.). * Fehler blockieren den Checkout nicht — Stripe erfasst die Adresse * dann selbst im Checkout. */ private function syncCustomerFromBillingAddress(User $user): void { try { $user->createOrGetStripeCustomer(); $user->syncStripeCustomerDetails(); } catch (\Throwable $exception) { Log::warning('Stripe-Customer konnte nicht mit der Rechnungsadresse synchronisiert werden.', [ 'user_id' => $user->id, 'error' => $exception->getMessage(), ]); } $this->syncTaxIdFromBillingAddress($user); } /** * Lokal gepflegte USt-ID vor dem Checkout an den Stripe-Customer * übergeben (User-Panel-Restarbeiten, 12.06.2026) — Stripe Tax * berücksichtigt sie dann ohne erneute Eingabe im Checkout. * * Vor jeder Buchung wird die ID zusätzlich per eVatR gegengeprüft * (Befund 08.07.2026: USt-IDs laufen ab, Kunden bestellen sonst über * eine veraltete ID steuerfrei): Eine definitiv abgelehnte ID geht * nicht an Stripe, und nicht (mehr) passende eu_vat-Einträge am * Customer werden entfernt — Stripe Tax rechnet damit wieder mit * Steuer statt Reverse Charge. Stripe-Fehler blockieren den Checkout * nicht: Stripe validiert die erfasste ID ohnehin selbst. */ private function syncTaxIdFromBillingAddress(User $user): void { // Ohne Profil-Rechnungsadresse erfasst Stripe die USt-ID selbst im // Checkout (tax_id_collection) — dort nichts anfassen. if (! $user->billingAddress) { return; } $vatId = $this->verifiedVatIdForCheckout($user); try { $user->createOrGetStripeCustomer(); $alreadySet = false; foreach ($user->taxIds() as $taxId) { if ($taxId->type !== 'eu_vat') { continue; } if ($vatId !== null && strtoupper((string) $taxId->value) === $vatId) { $alreadySet = true; continue; } // Veraltete oder entfernte ID: Ohne diesen Schritt bliebe // der alte Eintrag am Customer und die Buchung liefe // weiter steuerfrei. $user->deleteTaxId($taxId->id); } if ($vatId !== null && ! $alreadySet) { $user->createTaxId('eu_vat', $vatId); } } catch (\Throwable $exception) { Log::warning('USt-ID konnte nicht mit Stripe abgeglichen werden.', [ 'user_id' => $user->id, 'error' => $exception->getMessage(), ]); } } /** * Die USt-ID, die Stripe für diese Buchung erhalten soll — normalisiert * und per eVatR gegengeprüft (definitive Ergebnisse sind 6 h gecacht, * siehe VatIdValidationService). Fail-open: Nur eine DEFINITIV * abgelehnte ID wird zurückgehalten; ist die BZSt-API nicht erreichbar * oder die ID deutsch (eVatR prüft nur ausländische EU-IDs), bleibt * sie nutzbar — ein API-Ausfall darf Bestandskunden nicht plötzlich * Steuer berechnen. */ private function verifiedVatIdForCheckout(User $user): ?string { $vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id)); if ($vatId === '') { return null; } $prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2); if ($prefixCountry !== 'DE' && ! $this->vatResolver->isEuCountry($prefixCountry)) { return null; } $check = $this->vatIdValidation->check($vatId, (string) $user->billingAddress?->country_code); if (! $check['status']->isUsable()) { Log::warning('USt-ID vor der Buchung nicht bestätigt — sie wird nicht an Stripe übergeben.', [ 'user_id' => $user->id, 'vat_id' => $vatId, 'status' => $check['status']->value, 'message' => $check['message'], ]); return null; } return $vatId; } /** * Gemeinsame Session-Optionen. * * `automatic_tax` muss hier explizit gesetzt werden: * Cashier::calculateTaxes() greift nur bei direkter Abo-Erstellung, * NICHT bei Checkout-Sessions — ohne das Flag zeigt Stripe nur den * Nettopreis an (QA-Befund 07.07.). Voraussetzung im Stripe-Account: * Stripe Tax mit Origin-Adresse und DE-Registrierung (WS-7). * * Liegt eine Profil-Rechnungsadresse vor (Normalfall, das Buchungs-Gate * verlangt sie), darf der Checkout den Stripe-Customer NICHT verändern: * Mit `customer_update: auto` schrieb Stripe die Wallet-Daten (Apple * Pay: Kartenname + oft nur das Land) über die zuvor synchronisierte * Profil-Adresse und die Rechnung trug den falschen Empfänger * (QA-Befund 07.07.). Ohne `customer_update` bleibt der Customer * unangetastet; Stripe Tax rechnet mit dessen (synchronisierter) * Adresse, die USt-ID ist bereits per Sync hinterlegt. * * Nur ohne Profil-Adresse erfasst Stripe Adresse und USt-ID selbst im * Checkout und speichert sie am Customer — Stripe Tax braucht zwingend * eine Kundenadresse (`tax_id_collection` erzwingt dabei * `customer_update.name = auto`). * * @return array */ private function sessionOptions(User $user): array { $options = [ 'success_url' => route('me.bookings.index', ['checkout' => 'erfolg']), 'cancel_url' => route('me.bookings.index', ['checkout' => 'abbruch']), 'billing_address_collection' => 'auto', 'automatic_tax' => ['enabled' => true], ]; if ($user->stripeAddress() === null) { $options['billing_address_collection'] = 'required'; $options['tax_id_collection'] = ['enabled' => true]; $options['customer_update'] = [ 'address' => 'auto', 'name' => 'auto', ]; } return $options; } /** * Line-Item für Ad-hoc-Beträge (ohne Stripe-Price). `tax_behavior` * ist Pflicht, sobald `automatic_tax` aktiv ist — Cashiers * checkoutCharge() setzt es nicht und ist darum hier ersetzt. * * @return array */ private function adHocLineItem(int $priceCents, string $name): array { return [ 'price_data' => [ 'currency' => config('cashier.currency', 'eur'), 'product_data' => ['name' => $name], 'unit_amount' => $priceCents, 'tax_behavior' => 'exclusive', ], 'quantity' => 1, ]; } /** * URL zum Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung). * Rücksprung auf die Buchungs-Seite. */ public function billingPortalUrl(User $user): string { return $user->billingPortalUrl(route('me.bookings.index')); } /** * Stripe-Checkout für eine Einzel-PM. Die `single_purchase_id` in den * Session-Metadaten schließt den Kreis: `checkout.session.completed` * markiert den Kauf über ProcessStripeWebhook als bezahlt. */ public function forSinglePurchase(User $user, SinglePurchase $purchase): Checkout { $this->syncCustomerFromBillingAddress($user); return $user->checkout([config('billing.single_pm_stripe_price_id') => 1], [ ...$this->oneTimeSessionOptions($user), 'metadata' => ['single_purchase_id' => (string) $purchase->id], ]); } /** * Stripe-Checkout für eine Depublikation (T12). Ad-hoc-Charge über den * konfigurierten Netto-Preis (wie Credit-Topups, keine Stripe-Price-ID); * der Webhook depubliziert die PM nach Zahlungseingang. */ public function forDepublication(User $user, SinglePurchase $purchase): Checkout { $this->syncCustomerFromBillingAddress($user); return $user->checkout( [$this->adHocLineItem($purchase->price_cents, 'Depublikation einer Pressemitteilung')], [ ...$this->oneTimeSessionOptions($user), 'metadata' => ['single_purchase_id' => (string) $purchase->id], ], ); } /** * Stripe-Checkout für eine Wallet-Aufladung. Ad-hoc-Charge über den * Netto-Paketpreis (Stripe Tax ergänzt die USt.); die `credit_topup_id` * in den Metadaten schließt den Kreis im Webhook (Wallet-Gutschrift). */ public function forCreditTopup(User $user, CreditTopup $topup): Checkout { $this->syncCustomerFromBillingAddress($user); return $user->checkout( [$this->adHocLineItem($topup->price_cents, "Credit-Guthaben ({$topup->credits} Credits)")], [ ...$this->oneTimeSessionOptions($user), 'metadata' => ['credit_topup_id' => (string) $topup->id], ], ); } /** * Session-Optionen für Einmalkäufe (Payment-Mode: Einzel-PM, Depublikation, * Credit-Topup). Anders als im Subscription-Mode erzeugt Stripe hier ohne * `invoice_creation` keine Rechnung — und damit auch kein * `invoice.payment_succeeded`, das die STR-Spiegelung anstoßen könnte. * Im Subscription-Mode ist der Parameter unzulässig, daher nicht in * sessionOptions() selbst. * * @return array */ private function oneTimeSessionOptions(User $user): array { return [ ...$this->sessionOptions($user), 'invoice_creation' => ['enabled' => true], ]; } }