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

@ -1,7 +1,8 @@
# Billing & Rechnungskreise (hybrides Modell)
Stand: 12.06.2026 — Datenmodell, MAN-Kreis und USt-Behandlung umgesetzt
(Phase 9D); Stripe-Checkout/Webhooks in Arbeit (Phase 9E).
Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie
Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows und Plan-Kontingent
(Phase 9E) umgesetzt. Es fehlt die Checkout-UI (Phase 9F).
Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem:
Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration.
@ -44,6 +45,35 @@ eingelöster Einzel-/Extra-PM-Kauf **oder** eine aktive/grandfathered
Legacy-Vereinbarung. Bestandskunden behalten damit nach Gate-Aktivierung
volle Einreichungsrechte.
**PM-Kontingent** (`User::pressReleaseQuotaRemaining()`, null = unbegrenzt):
| Wer | Kontingent |
|---|---|
| Launch-Schalter `billing.enforce_booking` aus | unbegrenzt (Vor-Launch-Zustand) |
| Bestandskunde (aktive/grandfathered Legacy-Vereinbarung) | **unbegrenzt** — Entscheidung 12.06.2026: Bestandsschutz gilt, das Alt-Produkt sah unbegrenzte PMs vor; eine Migration auf neue Tarife kommt ggf. später |
| Abonnent | Monatskontingent des Tarifs (`plans.press_release_quota`) plus offene Einmalkäufe |
| Nur Einmalkäufe | Anzahl bezahlter, noch nicht eingelöster Einzel-/Extra-PM-Käufe |
**Slot-Verbrauch** (erst bei Veröffentlichung, Decision-Update §3.2; Ablehnung
kostet nichts; Re-Publish nach Archivierung zählt nicht doppelt): zuerst das
Plan-Monatskontingent (Zähler `users.press_release_quota_used_this_month`,
monatlicher Reset), danach wird der älteste bezahlte Einmalkauf eingelöst
(`single_purchases.status → consumed`, verknüpft mit der PM). Die frühere
Stub-Spalte `users.press_release_quota` ist entfernt.
**Checkout-Einstiege** (Phase 9E; UI-Anbindung folgt in 9F):
| Route | Zweck |
|---|---|
| `me.checkout.subscription` (`/admin/me/checkout/abo/{slug}/{monthly\|yearly}`) | Stripe-Checkout für ein Tarif-Abo |
| `me.checkout.single-pm` (`/admin/me/checkout/einzel-pm`) | Stripe-Checkout Einzel-PM (legt `single_purchases`-Eintrag `pending` an; Webhook setzt `paid`) |
Erfolg/Abbruch landen auf der Buchungs-Seite (`?checkout=erfolg|abbruch`).
Die Steuer ergänzt **Stripe Tax** automatisch (`Cashier::calculateTaxes()`
im AppServiceProvider, Netto-Preise mit `tax_behavior: exclusive`) — nach
denselben Regeln wie der VatResolver im MAN-Kreis, inkl. USt-ID-Abfrage im
Checkout.
---
## 3. MAN-Kreis: Fälligkeitslauf für Legacy-Zahlungen
@ -102,8 +132,9 @@ Rechnungsadresse bestimmt:
| Befehl | Zweck | Scheduler |
|---|---|---|
| `billing:generate-manual-invoices` | MAN-Fälligkeitslauf (Abschnitt 3) | täglich 04:30 |
| `billing:sync-stripe-plans` | Tarife + Einzel-PM als Netto-Produkte/Preise nach Stripe synchronisieren (idempotent; `--dry-run`) | manuell |
| `legacy:grandfather-subscriptions` | Aktive Legacy-Abos aus dem Archiv migrieren | manuell (Migrations-Runbook) |
| `press-releases:reset-monthly-quota` | Quota-Stub-Reset (entfällt mit Plan-Kontingent, 9E) | monatlich, 1. um 00:05 |
| `press-releases:reset-monthly-quota` | Monatlicher Reset des Plan-Kontingent-Zählers (`press_release_quota_used_this_month`) | monatlich, 1. um 00:05 |
---
@ -119,36 +150,50 @@ Rechnungsadresse bestimmt:
| `vat_rate` | `BILLING_VAT_RATE` | `0.19` | USt-Satz für steuerpflichtige Fälle |
| `eu_country_codes` | — | EU-27 ohne DE | Basis der Drittland-/EU-Unterscheidung |
Stripe/Cashier (`config/cashier.php`):
Stripe/Cashier:
| ENV | Bedeutung |
|---|---|
| `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) |
| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints — wird beim Einrichten des Endpoints gesetzt (9E) |
| `CASHIER_CURRENCY` | Default `usd` → für uns `eur` setzen (9E) |
| `STRIPE_WEBHOOK_SECRET` | Signatur-Prüfung des Webhook-Endpoints (gesetzt; Endpoint `https://pressekonto.com/stripe/webhook` im Dashboard registriert, 12.06.2026) |
| `STRIPE_PRICE_SINGLE_PM` | Stripe-Price-ID der Einzel-PM (legt `billing:sync-stripe-plans` an; ohne sie ist der Einzel-PM-Checkout deaktiviert) |
| `CASHIER_CURRENCY` / `CASHIER_CURRENCY_LOCALE` | `eur` / `de_DE` (gesetzt) |
**Benötigte Webhook-Events** am Stripe-Endpoint: `invoice.payment_succeeded`
(STR-Spiegelung), `checkout.session.completed` (Einmalkauf-Erfüllung) sowie
die Cashier-Standardevents `customer.subscription.created/updated/deleted`,
`customer.updated`, `customer.deleted` (Abo-Zustand).
**Lokal testen** (der registrierte Endpoint zeigt auf die Live-Domain und
läuft bis zum Relaunch ins Leere — das ist unkritisch, Stripe versucht die
Zustellung nur erneut): Stripe CLI verwenden —
`stripe listen --forward-to pressekonto.test/stripe/webhook` und das von der
CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`.
---
## 7. Offene Punkte (Stand 12.06.2026, nach 9E-Backbone)
## 7. Offene Punkte (Stand 12.06.2026, nach Phase 9E)
0. **9E-Backbone erledigt**: Tarife liegen als Netto-Produkte/Preise in
Stripe (Test-Mode, `billing:sync-stripe-plans`, IDs in `plans`);
Webhook-Listener `ProcessStripeWebhook` spiegelt bezahlte
Stripe-Rechnungen in den STR-Kreis (fortlaufende Nummer,
Adress-Snapshot aus dem Stripe-Payload, idempotent) und erfüllt
Einmalkäufe (`checkout.session.completed`
`single_purchases.status = paid`). Cashier-Route `POST /stripe/webhook`
ist aktiv.
1. **Phase 9E (Rest)**: Checkout-Flows (Abo + Einmalkauf) inkl.
Buchungs-Seite, Webhook-Endpoint im Stripe-Dashboard registrieren +
`STRIPE_WEBHOOK_SECRET` setzen, Slot-Logik von
`users.press_release_quota`-Stub auf Plan-Kontingent umstellen
(fachlich zu klären: Kontingent-Semantik für Grandfathered —
Legacy-Produkt war „unbegrenzte PMs pro Pressemappe").
2. **VIES-Validierung** der USt-ID (aktuell Formatprüfung).
3. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`);
0. **9E erledigt**: Stripe-Sync (Tarife + Einzel-PM als Netto-Produkte,
Test-Mode), Webhook-Verarbeitung (STR-Spiegelung, Einmalkauf-Erfüllung,
Endpoint + Secret eingerichtet), Checkout-Flows (Abo + Einzel-PM,
Routen siehe Abschnitt 2), Slot-Logik auf Plan-Kontingent umgestellt
(Grandfathered = unbegrenzt, Entscheidung 12.06.2026), Stub-Spalte
entfernt, Stripe Tax aktiviert (`Cashier::calculateTaxes()`).
1. **Phase 9F**: Tarif-Seite/Buchungs-UI an die Checkout-Routen anbinden
(die Buchungs-Seite ist noch Konzept-Mock mit deaktivierten Buttons);
echte Tarif-/Buchungsdaten statt Platzhalter anzeigen.
2. **Stripe Tax im Dashboard aktivieren** (Ursprungsadresse/Registrierung
hinterlegen) — ohne das schlägt der Checkout mit automatischer Steuer
fehl. Im Test-Mode prüfen, dann im Live-Mode wiederholen; dort auch
`billing:sync-stripe-plans` erneut ausführen (Live-Produkt-IDs).
3. **VIES-Validierung** der USt-ID (aktuell Formatprüfung; Stripe prüft
die im Checkout erfasste USt-ID asynchron selbst).
4. **PDF-Erzeugung** für MAN-/STR-Rechnungen (Layout inkl. `tax_note`);
Archiv-PDFs existieren bereits on-demand.
4. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung
auf neue Tarife am `grandfathered_until` (D-13-Rest).
5. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem
5. **`ExpireGrandfatheredSubscriptions`**: Benachrichtigung zur Umstellung
auf neue Tarife am `grandfathered_until` (D-13-Rest); erst dann wird
das unbegrenzte Bestandskontingent ggf. migriert.
6. **Steuerberater-Abnahme** der USt-Regeln und Rechnungstexte vor dem
ersten produktiven MAN-/STR-Lauf.
7. **Tageslimit** (`plans.daily_limit`) durchsetzen — Phase 9G.

View file

@ -128,7 +128,7 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic
- [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6.
- [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich).
- [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung.
- [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen (netto, Steuer via Stripe Tax oder VatResolver), STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`.
- [x] Stripe-Checkout + Webhooks (Phase 9E, 12.06.): Produkt-Sync nach Stripe (Tarife + Einzel-PM, netto, Stripe Tax), STR-Rechnungsspiegelung + Einmalkauf-Erfuellung per Webhook (Endpoint registriert), Checkout-Flows als Backend (`me.checkout.subscription`/`me.checkout.single-pm`), Slot-Logik auf Plan-Kontingent umgestellt (Grandfathered = unbegrenzt, Bestandsschutz), Quota-Stub-Spalte entfernt. UI-Anbindung folgt in 9F. Doku: `docs/user-admin/Billing-und-Rechnungskreise.md`.
- [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs.
- [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €.
- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen).
@ -158,6 +158,6 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic
- Phase 1, Phase 7 (PM-Form-Refactor), Phase 8 (User-Panel-Konsolidierung) und die KI-Pruef-Pipeline (Phasen 05) sind abgeschlossen — siehe Plan-Dokus oben.
- Fuer Preise, Kontingente und den Veroeffentlichungs-Flow gilt ausschliesslich das Decision-Update vom 11.06.2026; aeltere Tarif-Tabellen in `Konzept-Update 1` und im Relaunch-Konzept sind ueberschrieben.
- Der Quota-Stub (3 PM/Monat, zaehlt beim Einreichen) bleibt bis zum Tarif-Modul aktiv; die Umstellung auf Slot-Verbrauch bei Veroeffentlichung ist Teil des Launch-Blocks.
- Das PM-Kontingent kommt aus dem Tarif (`plans.press_release_quota`) plus Einmalkaeufen; Bestandskunden (Grandfathered) sind unbegrenzt. Solange `billing.enforce_booking` aus ist, gilt kein Kontingent (Launch-Schalter).
- Die KI-Klassifikation laeuft asynchron — in Produktion wird ein Queue-Worker fuer die Queue `classification` benoetigt (Test-Drain: `php artisan classification:work`).
- Anhaenge sind aktuell aus Sicherheitsgruenden deaktiviert, Tabelle und Komponente bleiben aber erhalten und werden in einem separaten Audit-Track reaktiviert.