presseportale/app/Services/Billing/StripeCheckoutService.php
Kevin Adametz 80e04e26e0 Downgrades ohne Gutschrift: Tarifwechsel nach unten erst zum Periodenende
QA-Entscheidung 07.07.: Eine Buchung ist nicht rückabwickelbar — es darf
nie eine negative Proration-Rechnung oder ein Stripe-Guthaben entstehen.

- StripeCheckoutService::isDowngrade: sofortiger Wechsel nur, wenn der
  Periodenpreis des Ziel-Tarifs den aktuellen erreicht (Wechsel-Rechnung
  damit garantiert >= 0); sonst Downgrade
- scheduleDowngrade: Stripe Subscription Schedule stellt das Abo zum Ende
  der bezahlten Periode um (proration_behavior none), hebt eine
  vorgemerkte Kündigung auf und spiegelt die Vormerkung lokal
  (neue Spalten scheduled_stripe_price/scheduled_change_at/
  stripe_schedule_id an subscriptions)
- Sofort-Upgrade löst eine bestehende Vormerkung vorher auf (release)
- Webhook customer.subscription.updated räumt die Vormerkung nach
  Vollzug ab
- Buchungs-Modal unterscheidet Upgrade (sofort, Differenz-Rechnung) und
  Downgrade (Vormerkung, keine Zahlung heute); Banner + Hinweis auf der
  Paket-Karte zeigen den vorgemerkten Wechsel mit Datum
- Kontingent-Sperre aus 4b7f6d3 entfernt — ohne Gutschrift gegenstandslos

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:38:57 +00:00

384 lines
14 KiB
PHP

<?php
namespace App\Services\Billing;
use App\Models\CreditTopup;
use App\Models\Plan;
use App\Models\SinglePurchase;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Exceptions\IncompletePayment;
use Laravel\Cashier\Subscription;
use Stripe\Exception\InvalidRequestException;
/**
* Dünner Wrapper um die Cashier-Checkout-Erzeugung (Phase 9E).
*
* Hier passiert ausschließlich der Stripe-Aufruf — alle Guards (Tarif
* synchronisiert, kein Doppel-Abo, Preis konfiguriert) liegen im
* CheckoutController. So bleibt der Controller ohne Stripe-Anbindung
* testbar, indem dieser Service im Container gemockt wird.
*/
class StripeCheckoutService
{
public function __construct(private readonly VatResolver $vatResolver) {}
/**
* Stripe-Checkout für ein Tarif-Abo (monatlich/jährlich). Die Steuer
* ergänzt Stripe Tax automatisch (Cashier::calculateTaxes, Netto-Preise).
*/
public function forSubscription(User $user, Plan $plan, string $interval): Checkout
{
$priceId = $interval === 'yearly'
? $plan->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. Fehler
* (z. B. von Stripe abgelehnte ID) blockieren den Checkout nicht:
* Stripe validiert die im Checkout erfasste ID ohnehin selbst.
*/
private function syncTaxIdFromBillingAddress(User $user): void
{
$vatId = strtoupper((string) preg_replace('/\s+/', '', (string) $user->billingAddress?->vat_id));
if ($vatId === '') {
return;
}
$prefixCountry = substr($vatId, 0, 2) === 'EL' ? 'GR' : substr($vatId, 0, 2);
if ($prefixCountry !== 'DE' && ! $this->vatResolver->isEuCountry($prefixCountry)) {
return;
}
try {
$user->createOrGetStripeCustomer();
$alreadySet = collect($user->taxIds())
->contains(fn (object $taxId): bool => strtoupper((string) $taxId->value) === $vatId);
if (! $alreadySet) {
$user->createTaxId('eu_vat', $vatId);
}
} catch (\Throwable $exception) {
Log::warning('USt-ID konnte nicht an Stripe übergeben werden.', [
'user_id' => $user->id,
'error' => $exception->getMessage(),
]);
}
}
/**
* 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<string, mixed>
*/
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<string, mixed>
*/
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->sessionOptions($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->sessionOptions($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->sessionOptions($user),
'metadata' => ['credit_topup_id' => (string) $topup->id],
],
);
}
}