Phase 9D: Tarif-Datenmodell, Cashier und hybride Rechnungskreise STR-/MAN-

Tarif-Datenmodell (Decision-Update):
- plans: Starter/Business/Pro/Agency mit Monats-/Jahrespreis (Jahres =
  10 x Monat), PM-Kontingent, Tageslimit, Stripe-IDs; idempotenter Seeder
- single_purchases: Einzel-PM, Extra-PM, Boost, PDF-Nachweis mit
  Status-Lifecycle und Stripe-Checkout-Referenzen
- laravel/cashier ^16.5 installiert (freigegeben); User ist Billable,
  Cashier-Migrationen published + ausgefuehrt; lokale invoices()-Relation
  ueberschreibt bewusst die Cashier-Methode

Hybride Rechnungskreise (Entscheidung 12.06.2026):
- invoice_number_sequences + InvoiceNumberGenerator: atomare fortlaufende
  Nummern pro Kreis (STR- fuer den neuen Stripe-Shop, MAN- fuer den
  manuellen Legacy-Kreis); Alt-Archiv legacy_invoices bleibt unveraendert
- ManualInvoiceService + billing:generate-manual-invoices (Scheduler
  taeglich 04:30): prueft aktive/grandfathered user_payment_options ohne
  Stripe-Subscription auf erreichtes Periodenende, friert die
  Rechnungsadresse als Snapshot ein, stellt die MAN-Rechnung aus
  (Zahlungsziel billing.manual_due_days) und schaltet die Periode weiter;
  Konditions-Overrides via legacy_conditions, sonst Netto-Preis +
  billing.vat_rate; nicht abrechenbare Faelle werden geloggt und
  beim naechsten Lauf erneut geprueft

Submit-Gate:
- User::hasActiveBooking() prueft jetzt echt (hinter
  billing.enforce_booking): Cashier-Abo, bezahlter Einzel-/Extra-PM-Kauf
  oder laufende Legacy-Vereinbarung (MAN-Kreis)

Suite: 468 passed, 4 skipped (17 neue Billing-Tests). Pint clean.
Offen fuer 9E: Stripe-Checkout/Webhooks, STR-Spiegelung, Slot-Logik auf
Plan-Kontingent, Migration der aktiven Legacy-Zahlungen in
user_payment_options.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 10:15:46 +00:00
parent 4419d9ff43
commit d548f4b235
28 changed files with 1545 additions and 25 deletions

View file

@ -40,8 +40,8 @@ Phase 9 setzt das Decision-Update vom 11./12.06.2026 um — in zwei Blöcken:
| **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, Subscriptions, Einzel-PM-Käufe; Quota-Stub ablösen | L | hoch (Datenmodell) |
| **9E** | Stripe-Anbindung (Laravel Cashier — **Dependency-Freigabe nötig**) | L | mittel |
| **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 (Cashier installiert ✅): Checkout, Webhooks, STR-Rechnungsspiegelung, 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 |
@ -126,25 +126,60 @@ Modal-Hinweis statt Checkboxen, `submitForReview` wirft, API gibt 402.
## 3. Block 2 — Tarif-Modul (nach Review-Stopp)
### 9D · Tarif-Datenmodell
### Entscheidung 12.06.2026 — Hybride Rechnungsarchitektur
- Tabellen (Arbeitsstand, final beim Review-Stopp vor 9D):
`plans` (Starter/Business/Pro/Agency: Preis mtl./jährl., PMs/Monat,
Tageslimit), `subscriptions` (User, Plan, Zyklus, Status, Periodenstart/-ende),
`single_purchases` (Einzel-PM, Extra-PM, Boost, PDF — Typ, Preis, Status,
`applied_to_press_release_id`).
- `User::hasActiveBooking()` prüft echte Subscription oder offenen Einzel-PM-Kauf.
- Slot-Logik wechselt von `users.press_release_quota` auf Plan-Kontingent +
Periodenzähler; Stub-Spalten werden nach Migration entfernt.
- Kontingent-Anzeige (Modal, Editor) liest aus der neuen Quelle —
Schnittstelle `pressReleaseQuotaRemaining()` bleibt stabil.
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).
- **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. Voraussetzung:
Die aktiven Legacy-Zahlungen müssen noch in `user_payment_options`
migriert werden (Tabelle ist aktuell leer — eigener Migrations-Schritt).
### 9E · Stripe (Laravel Cashier)
- **Vor Start freizugeben:** `laravel/cashier` als neue Dependency.
- Checkout für Abo (monatlich/jährlich) und Einmalzahlung (Einzel-PM, Credits).
- Webhooks (Subscription-Status, Zahlungsausfall) + lokale Spiegelung.
- Rechnungen an bestehende `invoices`-Struktur anbinden (Klärung beim Review).
- 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