12 KiB
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— verbindliche Launch-Entscheidungen (Tarife, Kontingente, Flow, Netto-Preise).docs/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
- Findet
user_payment_optionsmit Statusactive/grandfathered, ohnestripe_subscription_id, derencurrent_period_enderreicht ist. - Friert die Rechnungsadresse als Snapshot ein (
invoice_billing_addresses, inkl.vat_id). - Stellt die Rechnung aus: Netto-Basis × USt-Regel (Abschnitt 4),
MAN-Nummer, Zahlungsziel
billing.manual_due_days(Default 14 Tage). - Schaltet die Periode weiter (
monthly/yearlyauslegacy_conditionsbzw.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)
- 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()). - 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/paymentszeigt KPIs (aktive Abos, MRR netto, Umsatz 30 Tage, offene Einzel-PMs) plus Abo-, Einmalkauf- und Rechnungstabellen (STR/MAN) mit User-Suche;/admin/payments/planspflegt die Tarife mit Sofort-Sync nach Stripe (Abschnitt 5). Refund-Workflow direkt aus dem Admin bleibt offen (vorerst über das Stripe-Dashboard). - 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-planserneut ausführen (Live-Produkt-IDs). - VIES-Validierung der USt-ID (aktuell Formatprüfung; Stripe prüft die im Checkout erfasste USt-ID asynchron selbst).
- PDF-Erzeugung für MAN-/STR-Rechnungen (Layout inkl.
tax_note); Archiv-PDFs existieren bereits on-demand. ExpireGrandfatheredSubscriptions: Benachrichtigung zur Umstellung auf neue Tarife amgrandfathered_until(D-13-Rest); erst dann wird das unbegrenzte Bestandskontingent ggf. migriert.- Steuerberater-Abnahme der USt-Regeln und Rechnungstexte vor dem ersten produktiven MAN-/STR-Lauf.
- Tageslimit (
plans.daily_limit) durchsetzen — Phase 9G.