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>
This commit is contained in:
Kevin Adametz 2026-07-07 13:38:57 +00:00
parent 388ca0e620
commit 80e04e26e0
8 changed files with 419 additions and 89 deletions

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Vorgemerkter Tarifwechsel (QA-Befund 07.07.): Downgrades werden nicht
* sofort ausgeführt (keine Proration-Gutschrift, kein negativer Betrag),
* sondern per Stripe Subscription Schedule zum Ende der laufenden
* Abrechnungsperiode vorgemerkt. Diese Spalten spiegeln die Vormerkung
* lokal für Anzeige und Aufräumen nach Vollzug (Webhook).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->string('scheduled_stripe_price')->nullable()->after('stripe_price');
$table->timestamp('scheduled_change_at')->nullable()->after('scheduled_stripe_price');
$table->string('stripe_schedule_id')->nullable()->after('scheduled_change_at');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->dropColumn(['scheduled_stripe_price', 'scheduled_change_at', 'stripe_schedule_id']);
});
}
};