Downgrade-Vorkehrungen: Kontingent-Sperre beim Tarifwechsel, Gutschriften nicht spiegeln

QA-Befund 07.07.: Der Wechsel zurück in einen kleineren Tarif erzeugte
eine Proration-Gutschrift (negative Stripe-Rechnung) — die STR-Spiegelung
crashte an den unsigned-Spalten (Webhook-500), und der Wechsel war trotz
verbrauchtem Monatskontingent möglich.

- ProcessStripeWebhook: Belege ohne positiven Rechnungsbetrag
  (Gutschrift/Null-Rechnung) werden geloggt übersprungen statt gespiegelt
- CheckoutController::swap: Wechsel gesperrt, wenn der Monatsverbrauch
  das Kontingent des Ziel-Tarifs übersteigt (möglich ab Monatserstem)
- Buchungs-Modal: erklärt die Sperre inkl. Datum statt Bestätigungs-
  Button; Proration-Hinweis deckt jetzt auch die Guthaben-Richtung ab

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-07 13:22:30 +00:00
parent 14994e4748
commit 388ca0e620
6 changed files with 165 additions and 3 deletions

View file

@ -381,3 +381,28 @@ test('a swap booking for a subscriber on grace period announces the revoked canc
->assertSet('booking.resumes_cancellation', true)
->assertSee('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.');
});
test('a swap to a plan below the current monthly usage is blocked in the modal', function () {
/** @var TestCase $this */
$user = bookingsTestCustomer();
BillingAddress::factory()->create(['user_id' => $user->id]);
$user->forceFill(['press_release_quota_used_this_month' => 12])->save();
$currentPlan = Plan::factory()->create([
'stripe_price_id_monthly' => 'price_test_m_quota1',
'press_release_quota' => 20,
]);
$targetPlan = Plan::factory()->create([
'stripe_price_id_monthly' => 'price_test_m_quota2',
'press_release_quota' => 5,
]);
subscribeUserToPlan($user, $currentPlan);
$this->actingAs($user);
LivewireVolt::test('customer.bookings')
->call('selectBooking', 'subscription', $targetPlan->slug, 'monthly')
->assertSet('booking.quota_blocked', true)
->assertSee('Ein Wechsel in diesen Tarif ist erst ab dem')
->assertDontSee('Kostenpflichtig wechseln');
});

View file

@ -277,3 +277,54 @@ test('a failing plan swap reports a friendly notice instead of an error page', f
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
});
test('a plan swap is blocked when the monthly usage exceeds the target quota', function () {
/** @var TestCase $this */
$user = checkoutTestCustomer();
$user->forceFill(['press_release_quota_used_this_month' => 12])->save();
$currentPlan = Plan::factory()->create([
'stripe_price_id_monthly' => 'price_test_m_swap6a',
'press_release_quota' => 20,
]);
subscribeUserToPlan($user, $currentPlan);
$targetPlan = Plan::factory()->create([
'stripe_price_id_monthly' => 'price_test_m_swap6b',
'press_release_quota' => 5,
]);
$this->mock(StripeCheckoutService::class, function ($mock) {
$mock->shouldNotReceive('swapSubscription');
});
$this->actingAs($user)
->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly']))
->assertRedirect(route('me.bookings.index'))
->assertSessionHas('checkout-notice');
});
test('a plan swap is allowed when the monthly usage still fits the target quota', function () {
/** @var TestCase $this */
$user = checkoutTestCustomer();
$user->forceFill(['press_release_quota_used_this_month' => 5])->save();
$currentPlan = Plan::factory()->create([
'stripe_price_id_monthly' => 'price_test_m_swap7a',
'press_release_quota' => 20,
]);
subscribeUserToPlan($user, $currentPlan);
$targetPlan = Plan::factory()->create([
'stripe_price_id_monthly' => 'price_test_m_swap7b',
'press_release_quota' => 5,
]);
$this->mock(StripeCheckoutService::class, function ($mock) use ($targetPlan) {
$mock->shouldReceive('swapSubscription')
->once()
->withArgs(fn (User $u, Plan $p, string $interval) => $p->is($targetPlan) && $interval === 'monthly');
});
$this->actingAs($user)
->post(route('me.checkout.swap', ['planSlug' => $targetPlan->slug, 'interval' => 'monthly']))
->assertRedirect(route('me.bookings.index', ['checkout' => 'wechsel']));
});

View file

@ -160,3 +160,29 @@ test('the listener is wired to the cashier webhook event', function () {
ProcessStripeWebhook::class,
);
});
test('a proration credit with a negative total is skipped without an invoice', function () {
// Downgrade-Gutschrift (QA-Befund 07.07.): negative Beträge sprengten
// die unsigned-Spalten und beantworteten den Webhook mit 500.
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
'subtotal' => -6992,
'tax' => -1329,
'total' => -8321,
])));
expect(Invoice::count())->toBe(0);
});
test('a zero-total stripe invoice is skipped without an invoice', function () {
$user = User::factory()->create(['stripe_id' => 'cus_test_1']);
app(ProcessStripeWebhook::class)->handle(new WebhookReceived(stripeInvoicePayload($user, [
'subtotal' => 0,
'tax' => 0,
'total' => 0,
])));
expect(Invoice::count())->toBe(0);
});