- billing:sync-stripe-plans: legt die Tarife als Netto-Produkte/Preise
(tax_behavior exclusive, EUR) in Stripe an und pflegt die IDs zurueck
nach plans; idempotent, --dry-run. Gegen Stripe Test-Mode ausgefuehrt —
alle 4 Tiers verknuepft.
- ProcessStripeWebhook (Listener auf Cashier WebhookReceived):
- invoice.payment_succeeded -> Spiegelung in den lokalen STR-Kreis
(fortlaufende Nummer via InvoiceNumberGenerator, Adress-Snapshot
bevorzugt aus dem Stripe-Payload inkl. lokaler USt-ID, Status paid,
idempotent gegen doppelte Zustellung)
- checkout.session.completed -> markiert den referenzierten
single_purchases-Datensatz als bezahlt (Metadata single_purchase_id)
- CASHIER_CURRENCY=eur (+ Locale de_DE); Cashier-Webhook-Route aktiv
- Doku: Billing-Referenz §7 + Phase-9-Plan (9E-Backbone) aktualisiert
Offen fuer 9E-Rest: Checkout-Flows (Abo + Einmalkauf), Webhook-Endpoint
im Stripe-Dashboard + STRIPE_WEBHOOK_SECRET, Slot-Logik auf
Plan-Kontingent (fachliche Frage: Grandfathered = unbegrenzt?).
Tests: StripeWebhookProcessingTest (7, inkl. Event-Wiring).
Suite: 497 passed, 4 skipped. Pint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
273 lines
14 KiB
Markdown
273 lines
14 KiB
Markdown
# Phase 9 · Veröffentlichungs-Flow (Launch) & Tarif-Modul
|
||
|
||
Stand: 2026-06-12 — **Block 1 (9A–9C) abgeschlossen**; Review-Stopp vor
|
||
Block 2 (9D–9J, Tarif-Modul). Suite nach Block 1: 451 passed, 4 skipped.
|
||
Vorgänger: Phase 8 (User-Panel-Konsolidierung) + KI-Prüf-Pipeline (beide abgeschlossen).
|
||
Verbindliche Entscheidungen: [`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](./Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md)
|
||
Abgleich-Doku: [`docs/STATUS-ABGLEICH-USER-PANEL.md`](./STATUS-ABGLEICH-USER-PANEL.md)
|
||
|
||
---
|
||
|
||
## 0. Worum es geht
|
||
|
||
Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken:
|
||
|
||
1. **Block 1 — Veröffentlichungs-Flow (9A–9C)**: Die Flow-Regeln, die
|
||
unabhängig vom Tarif-Modul gelten und auf denen das Tarif-Modul aufsetzt.
|
||
Funktioniert vollständig mit dem vorhandenen Quota-Stub.
|
||
2. **Block 2 — Tarif-Modul (9D–9I)**: Zahlung, Tarife, Einzel-PM, Tageslimit
|
||
und die drei Launch-Credit-Posten. Löst den Quota-Stub ab.
|
||
|
||
**Leitplanken aus dem Decision-Update:**
|
||
|
||
- Gelb geht zum Launch **direkt live** wie Grün — keine manuelle Prüf-Queue.
|
||
Nur Rot wird abgelehnt (Meldung mit Begründung an den Autor).
|
||
- Der PM-Slot zählt **bei Veröffentlichung** runter, nicht bei der Prüfung.
|
||
Rot verbraucht keinen Slot.
|
||
- „Speichern" bleibt immer frei; „Speichern & zur Prüfung einreichen" ist
|
||
hinter eine aktive Buchung gegated (der Button konvertiert, er verschwindet
|
||
nicht).
|
||
- Kein Re-Check zum Launch: eine Einreichung = eine Prüfung = (bei Gelb/Grün)
|
||
eine Veröffentlichung. Vorab-Prüfung/Redigieren sind Phase 2.
|
||
|
||
---
|
||
|
||
## 1. Sub-Päckchen-Übersicht
|
||
|
||
| ID | Thema | Größe | Risiko |
|
||
|---|---|---|---|
|
||
| **9A** ✅ | Gelb-Routing auf Direkt-Live umstellen (Routing, Scheduler, Tests) | S | gering |
|
||
| **9B** ✅ | Slot-Verbrauch von Einreichung auf Veröffentlichung umstellen (Rot = kein Slot) | M | mittel (Idempotenz) |
|
||
| **9C** ✅ | Submit-Gate-Schnittstelle (`hasActiveBooking()`-Stub, Modal-Hinweis, Server-Guard) + Fix: Create-Form lief am Funnel vorbei | M | gering |
|
||
| — | **Review-Stopp mit User** | | |
|
||
| **9D** ✅ | Tarif-Datenmodell: Pläne, Einzelkäufe, Cashier, Rechnungskreise STR-/MAN-, MAN-Fälligkeitslauf (Stub-Ablösung folgt mit 9E) | L | hoch (Datenmodell) |
|
||
| **9E** 🔄 | Stripe-Anbindung — Backbone ✅ (Produkt-Sync nach Stripe, Webhook-Listener mit STR-Spiegelung + Einmalkauf-Erfüllung); offen: Checkout-Flows, Webhook-Endpoint registrieren, Slot-Logik auf Plan-Kontingent | L | mittel |
|
||
| **9F** | Tarif-Seite + Checkout-UI (Raster, Einzel-PM-Block, „2 Monate gratis", Enterprise-Hinweis) | M | gering |
|
||
| **9G** | Tageslimit je Tier (Business 2 / Pro 3 / Agency 5; gilt auch für Extra-PMs) | S | gering |
|
||
| **9H** | Einzel-PM-Kauf (19 €) + Einzel→Abo-Brücke (Anrechnung 30 Tage) | M | mittel |
|
||
| **9I** | Launch-Credits: Extra-PM, Boost (nur Grün), Veröffentlichungsnachweis-PDF | L | mittel |
|
||
| **9J** | Abschluss: Tests, Pint, Build, Doku-Sync, PROGRESS-Eintrag | S | keine |
|
||
|
||
Nach jedem Päckchen Review-Stopp mit dem User; vor 9D ein größerer
|
||
(Datenmodell-Entscheidungen + Cashier-Freigabe).
|
||
|
||
---
|
||
|
||
## 2. Block 1 — Veröffentlichungs-Flow
|
||
|
||
### 9A · Gelb-Routing auf Direkt-Live
|
||
|
||
**Entscheidung (12.06.2026)**: Rot = nicht veröffentlichbar (rechtlich/
|
||
inhaltlich) → Ablehnung mit Meldung. Gelb/Grün = veröffentlichbar → geht in
|
||
der ersten Phase direkt online. Gelb bleibt als interne Markierung erhalten
|
||
(nicht boostbar, Admin-Signal), löst aber keine manuelle Prüfung aus.
|
||
|
||
**Anpassungen:**
|
||
|
||
- `PressReleaseService::routeByClassification()`: Gelb durchläuft denselben
|
||
Auto-Publish-Pfad wie Grün (`autoPublishGreen()` → generalisiert zu
|
||
`autoPublishApproved()`); Verzögerungsfenster
|
||
(`scoring.classification.green_delay_minutes`) gilt für beide.
|
||
- `PublishScheduledPressReleases`: Kandidaten-Query von
|
||
`classification = green` auf `classification IN (green, yellow)`.
|
||
- Admin-Review-Queue bleibt als **Fallback** bestehen: unklassifizierte PMs
|
||
(Job noch nicht gelaufen / KI-Ausfall ohne Fallback-Ergebnis) bleiben in
|
||
`review` und sind manuell behandelbar. KI-Badge und Klassifikations-Filter
|
||
im Admin bleiben unverändert.
|
||
|
||
**Tests:** `PressReleaseClassificationJobTest` (Gelb-sofort → published,
|
||
Gelb-geplant → bleibt review bis Termin), `PressReleaseSchedulingTest`
|
||
(gelbe fällige PM wird publiziert).
|
||
|
||
### 9B · Slot-Verbrauch bei Veröffentlichung
|
||
|
||
**Regel:** Der Slot zählt genau einmal pro PM, beim **ersten** Übergang zu
|
||
`published`. Rot abgelehnte PMs verbrauchen nichts.
|
||
|
||
**Anpassungen:**
|
||
|
||
- `submitForReview()`: Increment von `press_release_quota_used_this_month`
|
||
**entfernen**. Stattdessen Guard: Einreichen erfordert
|
||
`pressReleaseQuotaRemaining() > 0` (sonst würde eine grüne PM ohne
|
||
verfügbaren Slot veröffentlicht).
|
||
- `publish()`: Increment beim Statuswechsel auf `published`, idempotent —
|
||
nur wenn die PM zuvor noch nie veröffentlicht war (Prüfung über
|
||
`press_release_status_logs`, kein neues Schema-Feld). Zählt auf den
|
||
PM-Eigentümer (`user_id`).
|
||
- Veröffentlichungs-Modal: Text von „wird bei Einreichung verbraucht" auf
|
||
„wird bei Veröffentlichung verbraucht; abgelehnte PMs kosten keinen Slot".
|
||
|
||
**Tests:** Submit verbraucht keinen Slot; Publish (Admin, Auto-Publish,
|
||
Scheduler) verbraucht genau einen; Rot → kein Verbrauch; Archivieren +
|
||
erneutes Publizieren zählt nicht doppelt; Submit bei 0 Rest-Slots blockiert.
|
||
|
||
### 9C · Submit-Gate-Schnittstelle
|
||
|
||
**Ziel:** Das Gate aus dem Decision-Update §5.1, gebaut gegen eine schmale
|
||
Schnittstelle, die zunächst ein Stub bedient und in 9D/9E vom Tarif-Modul
|
||
implementiert wird — Modal und Service müssen dann nicht mehr angefasst werden.
|
||
|
||
**Anpassungen:**
|
||
|
||
- `User::hasActiveBooking(): bool` — Launch-Schnittstelle. Stub-Verhalten
|
||
über `config/billing.php` (`billing.enforce_booking`, Default `false`):
|
||
solange das Tarif-Modul fehlt, gibt die Methode `true` zurück; mit
|
||
aktiviertem Flag (und später echter Subscription-Prüfung) greift das Gate.
|
||
- Einreichungs-Modal (`press-release-submit-modal`): ohne aktive Buchung
|
||
zeigt das Modal statt des Prüf-Flows einen Buchungs-Hinweis mit CTA
|
||
(„Buchung erforderlich" → Tarif-Seite). Der Button bleibt sichtbar.
|
||
- Server-Guard: `submitForReview()` wirft ohne aktive Buchung eine Exception
|
||
(UI allein reicht nicht); API-Submit-Route antwortet mit **402**.
|
||
|
||
**Tests:** Gate aus (Default) → Verhalten unverändert; Gate an →
|
||
Modal-Hinweis statt Checkboxen, `submitForReview` wirft, API gibt 402.
|
||
|
||
---
|
||
|
||
## 3. Block 2 — Tarif-Modul (nach Review-Stopp)
|
||
|
||
### Entscheidung 12.06.2026 — Hybride Rechnungsarchitektur
|
||
|
||
Alle **neuen** Abschlüsse und Zahlungen laufen über **Stripe**. Die Umsetzung
|
||
ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand):
|
||
|
||
| Kreis | Präfix | Inhalt |
|
||
|---|---|---|
|
||
| **Stripe-Shop** | `STR-` | Alles Neue (Abos, Einzel-PM, Credits) — komplette Abwicklung über Stripe, fortlaufende Nummer im STR-Kreis |
|
||
| **Manuell/Legacy** | `MAN-` | Laufende, noch aktive Alt-Zahlungen ab Relaunch: Fälligkeit wird im Hintergrund geprüft, Rechnung wie im Legacy-System ausgestellt |
|
||
| Alt-Archiv | — | Die importierten Alt-Rechnungen (`legacy_invoices`, 864 Stück) bleiben unverändert bestehen |
|
||
|
||
### 9D · Tarif-Datenmodell — ✅ umgesetzt (12.06.2026)
|
||
|
||
- **Cashier installiert** (`laravel/cashier` ^16.5, freigegeben); `User` ist
|
||
`Billable`, Cashier-Tabellen (`subscriptions`, `subscription_items`,
|
||
Customer-Spalten) migriert. Die lokale `invoices()`-Relation überschreibt
|
||
bewusst die Cashier-Methode.
|
||
- **`plans`**: Starter/Business/Pro/Agency mit Monats-/Jahrespreis
|
||
(Jahres = 10 × Monat), PM-Kontingent, Tageslimit, Stripe-IDs (nullable,
|
||
werden in 9E gepflegt). `PlanSeeder` idempotent.
|
||
- **`single_purchases`**: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit
|
||
Status Pending/Paid/Consumed/Refunded und Stripe-Checkout-Referenzen.
|
||
- **`invoice_number_sequences` + `InvoiceNumberGenerator`**: atomare,
|
||
fortlaufende Nummern pro Kreis (`STR-00001`, `MAN-00001`; Padding
|
||
konfigurierbar in `config/billing.php`).
|
||
- **MAN-Kreis**: `ManualInvoiceService` + Command
|
||
`billing:generate-manual-invoices` (Scheduler täglich 04:30) — findet
|
||
aktive/grandfathered `user_payment_options` **ohne**
|
||
`stripe_subscription_id` mit erreichtem `current_period_end`, friert die
|
||
Rechnungsadresse als Snapshot ein, stellt eine MAN-Rechnung aus
|
||
(Zahlungsziel `billing.manual_due_days`) und schaltet die Periode weiter.
|
||
Konditions-Overrides pro Vereinbarung über `legacy_conditions`
|
||
(`amount_cents`/`tax_cents`/`total_cents`/`interval`); ohne Override
|
||
Netto-Preis der `payment_option` + `billing.vat_rate`. Nicht abrechenbare
|
||
Fälle (fehlende Rechnungsadresse) werden geloggt und erneut versucht.
|
||
- **`User::hasActiveBooking()`** prüft jetzt echt (hinter
|
||
`billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf
|
||
∨ aktive/grandfathered Legacy-Vereinbarung (MAN-Kreis).
|
||
- **USt-Behandlung (Einwand 12.06.)**: Alle neuen Preise sind **netto**.
|
||
`VatResolver` bestimmt die Steuer pro Rechnung aus der Rechnungsadresse:
|
||
DE immer mit Steuer, EU nur mit (formal plausibler) USt-ID befreit
|
||
(Reverse Charge inkl. Pflichthinweis in `invoices.tax_note`), Drittland
|
||
befreit. `vat_id` an `billing_addresses` + Snapshot, gepflegt über das
|
||
bestehende USt-ID-Feld im Profil. Grandfathered-Vereinbarungen rechnen
|
||
auf der Netto-Basis der letzten Legacy-Rechnung (`net_cents`, brutto ÷
|
||
1,19 bzw. Netto-Ausweis direkt). **Offen**: echte VIES-Validierung der
|
||
USt-ID (aktuell Formatprüfung) — Folgeschritt, vor Gate-Aktivierung
|
||
empfohlen.
|
||
- **Legacy-Migration (12.06.)**: `legacy:grandfather-subscriptions` leitet
|
||
die aktiven, jährlich wiederkehrenden Vereinbarungen aus dem
|
||
Rechnungsarchiv ab und schreibt sie als `grandfathered` in
|
||
`user_payment_options` (Replay-fähig — die Kern-Migration läuft kurz
|
||
vor dem Relaunch erneut). Details:
|
||
`dev/migration 2026/05-DATABASE-MERGE.md` §5.6 + `MIGRATION-STEPS.md`.
|
||
- **Noch offen in 9D** (folgt mit 9E, braucht Checkout/Webhooks):
|
||
Slot-Logik von `users.press_release_quota`-Stub auf Plan-Kontingent +
|
||
Periodenzähler umstellen und Stub-Spalten entfernen.
|
||
|
||
### 9E · Stripe (Laravel Cashier)
|
||
|
||
- Checkout für Abo (monatlich/jährlich) und Einmalzahlung (Einzel-PM, Credits);
|
||
Stripe-Produkte/Preise anlegen und IDs in `plans` pflegen.
|
||
- Webhooks (Subscription-Status, Zahlungsausfall, `invoice.paid`) + Spiegelung
|
||
der Stripe-Rechnungen in `invoices` mit STR-Nummer aus dem
|
||
`InvoiceNumberGenerator`.
|
||
- Slot-Logik auf Plan-Kontingent umstellen (siehe 9D-Rest), Stub ablösen.
|
||
- Benötigt `STRIPE_KEY`/`STRIPE_SECRET`/`STRIPE_WEBHOOK_SECRET` in `.env`
|
||
(aktuell nicht gesetzt).
|
||
|
||
### 9F · Tarif-Seite + Checkout-UI
|
||
|
||
- Raster mit 4 Tiers; Einzel-PM als separater No-Abo-Block (nicht als
|
||
billigste Spalte); Enterprise als dezenter Sales-Hinweis unter der Tabelle.
|
||
- Jahrespreis kommuniziert als „2 Monate gratis".
|
||
- Einstieg aus dem Submit-Gate-Hinweis (9C) und aus „Buchungen & Add-ons".
|
||
|
||
### 9G · Tageslimit
|
||
|
||
- `plans.daily_limit` (Starter ohne Limit); Prüfung beim Veröffentlichen
|
||
(nicht beim Einreichen), zählt veröffentlichte PMs des Users pro Kalendertag
|
||
(Europe/Berlin); gilt auch für Extra-PMs. Überschreitung → PM bleibt in
|
||
`review` mit Hinweis, Veröffentlichung am Folgetag durch den Scheduler.
|
||
|
||
### 9H · Einzel-PM + Abo-Brücke
|
||
|
||
- Einzel-PM-Kauf 19 € → genau eine Einreichung/Veröffentlichung.
|
||
- Brücke: Abo-Abschluss innerhalb 30 Tagen rechnet 19 € auf den ersten
|
||
Monat an (Stripe-Coupon oder Rabatt-Position).
|
||
|
||
### 9I · Launch-Credits
|
||
|
||
- Credit-Wallet (1 Credit = 1 € Listenpreis, Pakete mit Volumenrabatt).
|
||
- Posten: **Extra-PM** (Kontingent voll → einzelne PM nachkaufen),
|
||
**Boost** (nur für grün klassifizierte PMs, nachträglich),
|
||
**Veröffentlichungsnachweis-PDF**.
|
||
- Kein Dofollow-Backlink-Verkauf (bewusst ausgeschlossen).
|
||
|
||
### 9J · Abschluss
|
||
|
||
- Volle Suite grün, Pint clean, `npm run build` clean.
|
||
- Doku-Sync: `STATUS-ABGLEICH`, `checkliste-user-backend.md`, dieses Dokument,
|
||
`PROGRESS.md`-Eintrag.
|
||
|
||
---
|
||
|
||
## 4. Was außerhalb von Phase 9 bleibt
|
||
|
||
- Vorab-KI-Prüfung, Redigieren/Re-Check-Loop, Prüfzähler, Credit-Overflow
|
||
(Decision-Update §7 — Phase 2)
|
||
- Score-Feinstufung für Boost („nur Geprüft/Hochwertig boostbar")
|
||
- Magic-Link-Flow, Statistik-/Abrechnungs-Tabs, Anhänge-Reaktivierung,
|
||
Trust-Score, Notice-and-Action
|
||
|
||
---
|
||
|
||
## 5. Risiken & Annahmen
|
||
|
||
- **Idempotenz Slot-Verbrauch (9B)**: Prüfung über Status-Logs statt neuem
|
||
Feld — bei Alt-Daten mit unvollständigen Logs schlimmstenfalls ein
|
||
doppelter Zähler; akzeptabel für den Stub, wird mit 9D-Periodenzähler
|
||
sauber.
|
||
- **Gate-Stub (9C)**: `enforce_booking=false` als Default hält das System
|
||
bis zum Tarif-Modul voll funktionsfähig; das Flag erlaubt Tests und
|
||
frühe Aktivierung.
|
||
- **9D/9E Datenmodell + Cashier**: größter Block, eigener Review-Stopp davor;
|
||
Stub-Ablösung (`press_release_quota`-Spalten entfernen) erst nach
|
||
verifizierter Migration.
|
||
- **Rechtstexte** (Einreichungs-Modal) sind weiterhin Platzhalter —
|
||
anwaltliche Prüfung läuft parallel, unabhängig von Phase 9.
|
||
- **Betrieb**: Queue-Worker für `classification` in Produktion bleibt
|
||
Go-Live-Voraussetzung (unabhängig von Phase 9).
|
||
|
||
---
|
||
|
||
## 6. Akzeptanzkriterien Phase 9 gesamt
|
||
|
||
- [ ] Gelb klassifizierte PMs gehen ohne manuelle Prüfung live (sofort/Termin)
|
||
- [ ] Rot verbraucht keinen Slot; Slot zählt genau einmal, bei Veröffentlichung
|
||
- [ ] Einreichen ohne aktive Buchung zeigt Buchungs-Hinweis (UI) und wird
|
||
serverseitig abgelehnt (Gate aktiviert)
|
||
- [ ] Tarife buchbar (4 Tiers, monatlich/jährlich), Einzel-PM kaufbar
|
||
- [ ] Tageslimit greift je Tier, auch für Extra-PMs
|
||
- [ ] Extra-PM, Boost (nur Grün) und PDF-Nachweis als Credits kaufbar
|
||
- [ ] Quota-Stub vollständig abgelöst, `pressReleaseQuotaRemaining()` stabil
|
||
- [ ] Tests grün, Pint clean, Build clean, Doku synchron
|