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

@ -107,9 +107,22 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
$subscription = $user->subscription();
$isSwap = $type === 'subscription' && (bool) $subscription?->valid();
// Downgrade-Vorkehrung: Übersteigt der Monatsverbrauch das Kontingent
// des Ziel-Tarifs, ist der Wechsel bis zum Monatsersten gesperrt —
// das Modal erklärt das, der Checkout-Controller erzwingt es.
$targetQuota = $isSwap
? Plan::query()->active()->where('slug', $ref)->value('press_release_quota')
: null;
$quotaBlocked = $targetQuota !== null
&& (int) $user->press_release_quota_used_this_month > (int) $targetQuota;
$this->booking = [
'is_swap' => $isSwap,
'resumes_cancellation' => $isSwap && $subscription->onGracePeriod(),
'quota_blocked' => $quotaBlocked,
'quota_used' => (int) $user->press_release_quota_used_this_month,
'quota_target' => (int) $targetQuota,
'quota_available_from' => now()->addMonth()->startOfMonth()->format('d.m.Y'),
'type' => $type,
'ref' => $ref,
'title' => $title,
@ -1019,9 +1032,9 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
{{ __('Die endgültige Steuer berechnet Stripe anhand Ihrer Rechnungsadresse. Die Abrechnung erfolgt sicher über Stripe.') }}
</p>
@if ($booking['is_swap'] ?? false)
@if (($booking['is_swap'] ?? false) && ! ($booking['quota_blocked'] ?? false))
<p class="text-[11.5px] text-[color:var(--color-ink-2)] m-0">
{{ __('Der Wechsel gilt sofort: Bereits gezahlte Beträge werden anteilig gutgeschrieben, die Differenz wird umgehend in Rechnung gestellt.') }}
{{ __('Der Wechsel gilt sofort. Bereits gezahlte Beträge werden anteilig verrechnet: Ein Mehrbetrag wird umgehend in Rechnung gestellt, ein Guthaben verbleibt bei Stripe und mindert Ihre nächsten Rechnungen.') }}
@if ($booking['resumes_cancellation'] ?? false)
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Ihre vorgemerkte Kündigung wird durch den Wechsel aufgehoben.') }}</span>
@endif
@ -1029,7 +1042,23 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
@endif
</div>
@if ($booking['needs_address'])
@if ($booking['quota_blocked'] ?? false)
{{-- Downgrade gesperrt: Monatsverbrauch übersteigt das
Ziel-Kontingent — sonst gäbe es die anteilige
Gutschrift für einen bereits konsumierten Monat. --}}
<div class="px-4 py-3 rounded-[5px] border text-[13px] bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
{{ __('Ein Wechsel in diesen Tarif ist erst ab dem :date möglich: Sie haben in diesem Monat bereits :used Pressemitteilungen genutzt — dieser Tarif enthält :quota pro Monat.', [
'date' => $booking['quota_available_from'],
'used' => $booking['quota_used'],
'quota' => $booking['quota_target'],
]) }}
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-black/10 dark:border-white/15">
<flux:modal.close>
<flux:button variant="filled">{{ __('Verstanden') }}</flux:button>
</flux:modal.close>
</div>
@elseif ($booking['needs_address'])
<div class="px-4 py-3 rounded-[5px] border text-[13px] bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
{{ __('Bitte hinterlegen Sie zuerst eine vollständige Rechnungsadresse — sie ist Voraussetzung für jede Buchung.') }}
</div>