presseportale/docs/user-admin/Billing-und-Rechnungskreise.md
Kevin Adametz 23ac8bc7f1 Phase 9F: Tarif-Seite mit Stripe-Checkout und Billing Portal
- Buchungs-Seite zeigt das echte 4-Tier-Raster aus plans (Monat/Jahr-
  Toggle, Jahrespreis als "2 Monate gratis") mit Checkout-Buttons,
  Einzel-PM als separaten No-Abo-Block und Enterprise-Hinweis;
  Credit-Konzept-Mock entfernt (Credits folgen mit 9I bzw. Phase 2)
- Aktueller-Tarif-Panel real: Abo (Preis, Kontingent, Kündigungsstatus),
  Bestandstarif (unbegrenzt, nächste MAN-Rechnung), offene Einzelkäufe;
  Kontingent-Kachel zeigt "Unbegrenzt" bei Bestandsschutz
- "Abo verwalten" über das Stripe Billing Portal
  (me.checkout.billing-portal; Zahlungsmethode, Rechnungen, Kündigung)
- Aktive Buchungen + Verlauf aus echten Daten (Abo, Legacy-Vereinbarung,
  offene/eingelöste Einzelkäufe mit PM-Verknüpfung)
- Tests: BookingsPageTest (9 Tests), PanelConsolidationTest angepasst;
  Suite 519 passed / 4 skipped
- Doku: PHASE-9-Plan 9F , Billing-Doku (Routen, Stripe Tax aktiviert),
  STATUS-ABGLEICH, Checkliste, PROGRESS

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 12:39:39 +00:00

200 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Billing & Rechnungskreise (hybrides Modell)
Stand: 12.06.2026 — Datenmodell, MAN-Kreis, USt-Behandlung (Phase 9D) sowie
Stripe-Sync, Webhook-Verarbeitung, Checkout-Flows, Plan-Kontingent
(Phase 9E) und Tarif-Seite/Checkout-UI (Phase 9F) umgesetzt.
Dieses Dokument ist die zentrale Referenz für das Abrechnungssystem:
Rechnungskreise, Tarif-Datenmodell, Steuerlogik, Befehle und Konfiguration.
Verwandte Dokumente:
- [`docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md`](../Decision-Update%20Preisstruktur%20&%20Ver%C3%B6ffentlichungs-Flow.md) — verbindliche Launch-Entscheidungen (Tarife, Kontingente, Flow, Netto-Preise).
- [`docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md`](../PHASE-9-FLOW-UND-TARIFE-PLAN.md) — Umsetzungsplan mit Päckchen-Status.
- `dev/migration 2026/05-DATABASE-MERGE.md` §5.5/§5.6 — Rechnungsarchiv (D-12) und Grandfathering (D-13).
---
## 1. Die drei Rechnungswelten
| Welt | Präfix | Tabelle | Inhalt |
|---|---|---|---|
| **Stripe-Shop** | `STR-` | `invoices` | Alle **neuen** Abschlüsse (Abos, Einzel-PM, Credits). Abwicklung komplett über Stripe; Rechnungen werden per Webhook in `invoices` gespiegelt und erhalten eine fortlaufende STR-Nummer. *(Spiegelung: Phase 9E)* |
| **Manuell/Legacy** | `MAN-` | `invoices` | Laufende, noch aktive Alt-Zahlungsvereinbarungen ab Relaunch. Fälligkeit wird täglich geprüft, Rechnung wie im Altsystem ausgestellt. |
| **Alt-Archiv** | — | `legacy_invoices` | Read-only Archiv aller importierten Legacy-Rechnungen (D-12). Wird nie verändert; PDFs werden on-demand aus den Archivdaten erzeugt. |
**Rechnungsnummern**: `InvoiceNumberGenerator` vergibt atomar (Row-Lock auf
`invoice_number_sequences`) fortlaufende, lückenlose Nummern pro Kreis:
`STR-00001`, `MAN-00001`, … (Padding: `billing.invoice_number_padding`).
---
## 2. Tarif-Datenmodell
| Tabelle | Zweck |
|---|---|
| `plans` | Tarif-Katalog (Starter/Business/Pro/Agency): Monats-/Jahrespreis **netto**, PM-Kontingent/Monat, Tageslimit, Stripe-Produkt-/Preis-IDs. Seeder: `PlanSeeder` (idempotent). |
| `subscriptions`, `subscription_items` | Laravel-Cashier-Tabellen — Zustand der Stripe-Abos. `User` ist `Billable`. |
| `single_purchases` | Einmalkäufe: Einzel-PM (19 €), Extra-PM, Boost, Veröffentlichungsnachweis-PDF. Status: pending → paid → consumed (oder refunded). |
| `payment_options` / `user_payment_options` | Legacy-Zahlungsvereinbarungen. Grandfathered-Einträge tragen die Netto-Vertragsbasis in `legacy_conditions`; versteckte Katalog-Platzhalter `LEGACY-{PE\|BP}-{Artikel}`. |
| `invoice_number_sequences` | Fortlaufende Nummern pro Rechnungskreis. |
**Submit-Gate** (`User::hasActiveBooking()`, hinter `billing.enforce_booking`):
Eine aktive Buchung ist ein Cashier-Abo **oder** ein bezahlter, noch nicht
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/9F — verdrahtet auf der Buchungs-Seite):
| 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`) |
| `me.checkout.billing-portal` (`/admin/me/checkout/abo-verwalten`) | Stripe Billing Portal (Zahlungsmethode, Rechnungen, Kündigung) |
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
Täglicher Scheduler-Lauf (04:30): `billing:generate-manual-invoices`
1. Findet `user_payment_options` mit Status `active`/`grandfathered`,
**ohne** `stripe_subscription_id`, deren `current_period_end` erreicht ist.
2. Friert die Rechnungsadresse als Snapshot ein (`invoice_billing_addresses`,
inkl. `vat_id`).
3. Stellt die Rechnung aus: Netto-Basis × USt-Regel (Abschnitt 4),
MAN-Nummer, Zahlungsziel `billing.manual_due_days` (Default 14 Tage).
4. Schaltet die Periode weiter (`monthly`/`yearly` aus `legacy_conditions`
bzw. `payment_options.interval`).
Nicht abrechenbare Fälle (fehlende Rechnungsadresse, kein Intervall) werden
geloggt, **die Periode bleibt stehen** — der nächste Lauf versucht es erneut.
Optionen: `--dry-run`, `--limit=50`.
**Befüllung**: `legacy:grandfather-subscriptions` (Migrations-Runbook, nach
`legacy:archive-invoices`) leitet die aktiven jährlichen Vereinbarungen aus
dem Rechnungsarchiv ab — Replay-fähig für den Lauf kurz vor Relaunch.
Details: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6.
---
## 4. USt-Behandlung (Entscheidung 12.06.2026)
**Alle neuen Preise sind Netto-Preise.** Die Steuer wird zur
Rechnungsstellung über `App\Services\Billing\VatResolver` aus der
Rechnungsadresse bestimmt:
| Fall | Behandlung | Rechnung |
|---|---|---|
| Deutschland | immer mit Steuer (`billing.vat_rate`, Default 19 %) | Netto + USt ausgewiesen |
| EU mit gültiger USt-ID | befreit (Reverse Charge) | `is_netto`, Pflichthinweis in `tax_note` |
| EU ohne USt-ID | mit Steuer | Netto + USt ausgewiesen |
| Drittland | grundsätzlich befreit | `is_netto`, Hinweis „nicht im Inland steuerbar" |
- Die USt-ID wird im Profil gepflegt (bestehendes Feld) und zusätzlich an
der Rechnungsadresse (`billing_addresses.vat_id`) gespeichert; jede
Rechnung friert sie im Adress-Snapshot ein.
- „Gültig" = vorhanden + formal plausibel (Länder-Präfix, EL für
Griechenland). **Offen: echte VIES-Validierung** — vor Aktivierung von
Gate/Checkout umsetzen.
- **Legacy-Umrechnung**: Das Altsystem fakturierte brutto (199 € inkl.
Steuer; Befreite mit Netto-Ausweis 167,23 €). Die Grandfather-Migration
leitet daraus die Netto-Basis ab (`legacy_conditions.net_cents`) — für
deutsche Bestandskunden bleibt der Bruttobetrag unverändert, die Steuer
wird künftig nur sauber ausgewiesen.
---
## 5. Befehle & Scheduler
| 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` | Monatlicher Reset des Plan-Kontingent-Zählers (`press_release_quota_used_this_month`) | monatlich, 1. um 00:05 |
---
## 6. Konfiguration
`config/billing.php`:
| Schlüssel | ENV | Default | Bedeutung |
|---|---|---|---|
| `enforce_booking` | `BILLING_ENFORCE_BOOKING` | `false` | Submit-Gate scharf schalten (Launch-Schalter) |
| `invoice_number_padding` | — | `5` | Stellen der laufenden Nummer |
| `manual_due_days` | `BILLING_MANUAL_DUE_DAYS` | `14` | Zahlungsziel MAN-Rechnungen |
| `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:
| ENV | Bedeutung |
|---|---|
| `STRIPE_KEY` / `STRIPE_SECRET` | Publishable/Secret Key (Test-Keys gesetzt) |
| `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 Phase 9E)
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 erledigt** (12.06.2026): Die Buchungs-Seite zeigt das echte
Tarif-Raster (Monat/Jahr-Toggle), den Einzel-PM-Block, Bestandstarife
und „Abo verwalten" (Stripe Billing Portal, `me.checkout.billing-portal`).
2. **Stripe Tax**: im Dashboard aktiviert (12.06.2026, Produkt-Steuercode
„SaaS business use", Steuer nicht im Preis enthalten — passt zu den
Netto-Preisen). Vor Relaunch 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.
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.