Phase 9E (Abschluss): Checkout-Flows und Plan-Kontingent statt Quota-Stub

- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
  und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
  Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
  Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
  bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
  Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
  PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
  Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
  (STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
  CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
  Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E , Checkliste,
  STATUS-ABGLEICH, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 12:10:32 +00:00
parent 38fab64e10
commit c8dc99c3c8
24 changed files with 775 additions and 100 deletions

View file

@ -11,7 +11,9 @@
Wird in Detailansicht, Bearbeiten und Erstellen eingebunden. Der
`action`-Prop bestimmt die Livewire-Methode der Eltern-Komponente, die beim
Bestätigen ausgeführt wird (z. B. `submitForReview`, `saveAndSubmit`,
`save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben sind.
`save('review')`). Quota-Block wird nur angezeigt, wenn Werte übergeben
sind null bedeutet unbegrenztes Kontingent (Bestandsschutz bzw.
Launch-Schalter aus) und blendet den Block aus.
Submit-Gate (Decision-Update §5.1): Ohne aktive Buchung zeigt das Modal
statt des Prüf-Flows einen Buchungs-Hinweis der Button konvertiert,

View file

@ -9,6 +9,10 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
public function with(): array
{
return [
// Rückkehr aus dem Stripe-Checkout (?checkout=erfolg|abbruch)
// bzw. Hinweis aus den Checkout-Guards (Session-Flash).
'checkoutResult' => request()->query('checkout'),
'checkoutNotice' => session('checkout-notice'),
'creditSummary' => [
'total' => 17,
'bonus' => 12,
@ -99,6 +103,34 @@ new #[Layout('components.layouts.app'), Title('Buchungen & Add-ons')] class exte
}; ?>
<div class="space-y-8">
{{-- ============== CHECKOUT-RÜCKMELDUNG ============== --}}
@if ($checkoutResult === 'erfolg')
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">
<span class="font-semibold text-[color:var(--color-ink)]">{{ __('Vielen Dank für Ihre Buchung!') }}</span>
{{ __('Die Zahlung wird von Stripe bestätigt — die Buchung erscheint hier in wenigen Augenblicken. Die Rechnung finden Sie anschließend unter Rechnungen.') }}
</div>
</div>
@elseif ($checkoutResult === 'abbruch')
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-bg-subtle)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<div class="flex-1">
{{ __('Der Bezahlvorgang wurde abgebrochen. Es wurde nichts gebucht — Sie können den Checkout jederzeit erneut starten.') }}
</div>
</div>
@endif
@if ($checkoutNotice)
<div class="px-4 py-3 rounded-[5px] border text-[13px] flex items-start gap-3
bg-[color:var(--color-bg-subtle)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<flux:icon.information-circle class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-ink-3)]" />
<div class="flex-1">{{ $checkoutNotice }}</div>
</div>
@endif
{{-- ============== PAGE HEADER ============== --}}
<header class="grid items-end gap-8" style="grid-template-columns:1fr auto;">
<div class="min-w-0">

View file

@ -546,7 +546,7 @@ new #[Layout('components.layouts.app'), Title('Neue Pressemitteilung')] class ex
: Contact::query()->whereRaw('0 = 1')->get(),
'selectedPortalLabel' => $selectedCompany?->portal?->label() ?? __('Wird aus der Firma übernommen'),
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'quotaTotal' => (int) $user->press_release_quota,
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
];
}

View file

@ -524,7 +524,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung bearbeiten')] cl
'tagSuggestions' => $this->tagSuggestionsFor($selectedCompany),
'coverUrl' => $cover->coverUrl($pressRelease, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pressRelease),
'quotaTotal' => (int) $user->press_release_quota,
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
];
}

View file

@ -104,7 +104,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends
'categoryName' => $categoryName,
'coverUrl' => $cover->coverUrl($pr, 'cover'),
'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr),
'quotaTotal' => (int) $user->press_release_quota,
'quotaTotal' => $user->pressReleaseQuotaTotal(),
'quotaRemaining' => $user->pressReleaseQuotaRemaining(),
'canEdit' => auth()->user()->can('update', $pr)
&& in_array($pr->status->value, ['draft', 'rejected']),