215 lines
12 KiB
Markdown
215 lines
12 KiB
Markdown
# 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) |
|
||
|
||
**Buchungs-Voraussetzung** (12.06.2026): Jeder Checkout erfordert eine
|
||
vollständige Rechnungsadresse (`User::hasCompleteBillingAddress()`,
|
||
Pflicht: Nachname, Straße, PLZ, Ort, Land) — sonst Redirect aufs Profil
|
||
mit Hinweis. Lokale Adresse (`User::stripeAddress()`) und USt-ID
|
||
(`StripeCheckoutService::syncTaxIdFromBillingAddress`, Typ `eu_vat`)
|
||
werden an den Stripe-Customer übergeben.
|
||
|
||
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 |
|
||
| — Admin-UI: `/admin/payments/plans` | Tarif-Pflege (Preise, Kontingent, Tageslimit, aktiv/inaktiv) mit Sofort-Sync nach Stripe (`StripePlanSyncService`): Preisänderung legt ein neues Price-Objekt an und deaktiviert das alte; Bestandsabos behalten ihren Preis | — |
|
||
| `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) |
|
||
| `BILLING_OWN_VAT_ID` | Eigene deutsche USt-ID des Betreibers — schaltet die eVatR-Online-Bestätigung ausländischer EU-USt-IDs frei (BZSt-REST-API, `VatIdValidationService`); ohne sie bleibt es bei der Formatprüfung |
|
||
| `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`).
|
||
1b. **Admin-Zahlungsmodul erledigt** (12.06.2026): `/admin/payments` zeigt
|
||
KPIs (aktive Abos, MRR netto, Umsatz 30 Tage, offene Einzel-PMs) plus
|
||
Abo-, Einmalkauf- und Rechnungstabellen (STR/MAN) mit User-Suche;
|
||
`/admin/payments/plans` pflegt die Tarife mit Sofort-Sync nach Stripe
|
||
(Abschnitt 5). Refund-Workflow direkt aus dem Admin bleibt offen
|
||
(vorerst über das Stripe-Dashboard).
|
||
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.
|