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

@ -186,3 +186,58 @@ test('a zero-total stripe invoice is skipped without an invoice', function () {
expect(Invoice::count())->toBe(0);
});
test('an applied scheduled downgrade is cleared from the local subscription', function () {
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
$subscription = $user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_sched_1',
'stripe_status' => 'active',
'stripe_price' => 'price_old',
'quantity' => 1,
]);
$subscription->forceFill([
'scheduled_stripe_price' => 'price_new',
'scheduled_change_at' => now()->addDays(3),
'stripe_schedule_id' => 'sub_sched_x1',
])->save();
app(ProcessStripeWebhook::class)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_sched_1',
'items' => ['data' => [['price' => ['id' => 'price_new']]]],
]],
]));
$fresh = $subscription->fresh();
expect($fresh->scheduled_stripe_price)->toBeNull();
expect($fresh->scheduled_change_at)->toBeNull();
expect($fresh->stripe_schedule_id)->toBeNull();
});
test('a subscription update to a different price keeps the scheduled downgrade', function () {
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
$subscription = $user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_sched_2',
'stripe_status' => 'active',
'stripe_price' => 'price_old',
'quantity' => 1,
]);
$subscription->forceFill([
'scheduled_stripe_price' => 'price_new',
'scheduled_change_at' => now()->addDays(3),
'stripe_schedule_id' => 'sub_sched_x2',
])->save();
app(ProcessStripeWebhook::class)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_sched_2',
'items' => ['data' => [['price' => ['id' => 'price_old']]]],
]],
]));
expect($subscription->fresh()->scheduled_stripe_price)->toBe('price_new');
});