presseportale/docs/user-admin/Billing-und-Rechnungskreise.md
Kevin Adametz a00f9dc79a billing: MAN-Rechnungen fuer Ueberweisungskunden bearbeiten, hold-Altlast bereinigen
Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf
fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein
Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund:
dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md.

Bereinigung (legacy:settle-unpaid, wiederholbar):
- Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete
  Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert.
  Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto
  standardmaessig ausgeblendet.
- pending/reminder (recurring) = offene Vorgaenge: Periode auf den
  Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung
  neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number).
- pending/reminder (single) = abgeschrieben (uncollectible).
- legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus.

Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit
Eintrag im Aktivitaets-Verlauf):
- Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer,
  Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand.
- Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist.
- Bezahlt: Zahlungseingang laut Kontoauszug verbuchen.
- Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel
  gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale
  Kundenmail.
- Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist
  fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen".

Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at,
reminder_count, voided_at, void_reason, replaces_legacy_invoice_number
(inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des
Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber
admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 10:56:29 +00:00

263 lines
16 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) |
**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.
Solche Vereinbarungen erscheinen in `/admin/payments` im Panel **„Blockierte
Vereinbarungen"** (Grund + Link ins User-Profil, „Jetzt abrechnen" nach dem
Nachtragen der Adresse). Optionen: `--dry-run`, `--limit=50`.
Jede MAN-Rechnung trägt `user_payment_option_id` (Link zur Vereinbarung) und
ggf. `replaces_legacy_invoice_number`, wenn sie eine offene Alt-Rechnung aus
dem Archiv ersetzt (siehe 3a).
### 3a. Bearbeitung nach der Ausstellung (Überweisungskunden, 16.09.2026)
MAN-Kunden zahlen per Überweisung. Der Weg nach dem 04:30-Lauf ist ein
Knopfdruck-Workflow in `/admin/payments` → „Rechnungsausgang", Filter
„Offene MAN-Rechnungen" (Default). Logik: `ManualInvoiceDunningService`,
jede Aktion mit Eintrag im Aktivitäts-Verlauf (`ActivityType::ManualInvoice`).
| Aktion | Wirkung |
|---|---|
| **Rechnung senden** | Mail `ManualInvoiceIssued` mit PDF-Anhang (`ManualInvoicePdfRenderer`), Bankverbindung aus `billing.issuer`, Hinweis auf Bestandsschutz-Ende. Setzt `sent_at` und **`due_date = heute + manual_due_days`** — die Frist zählt ab Versand. Verweigert bei Adresse auf der Mail-Sperrliste. |
| **Erinnerung** | Gleiche Mail im Mahn-Ton (`isReminder`), `reminded_at`/`reminder_count`. Verstrichene Frist → Nachfrist `billing.reminder_due_days` (7) ab heute, sonst bleibt `due_date`. |
| **Bezahlt** | `status = paid`, `paid_at` = Datum laut Kontoauszug. |
| **Bestandsschutz beenden** | Rechnung `void` (`voided_at`, `void_reason`), weitere offene MAN-Rechnungen des Kunden ebenfalls `void`, `LegacySubscriptionService::cancelBundleFor()`; optional Kundenmail `GrandfatheringTerminated`. Rechnungen werden nie gelöscht (Nummernfolge, GoBD). |
| **Entfernen** (Panel „Blockierte Vereinbarungen") | Nicht abrechenbare Vereinbarung ohne Rechnung beenden (`abandonAgreement`): Bündel gekündigt, offene MAN-Rechnungen `void`, offene Alt-Rechnung im Archiv `uncollectible`. Keine Kundenmail. |
PDF-Routen: `admin.invoices.pdf` (Admin) und `me.invoices.manual-pdf`
(Kunde, `InvoicePolicy::downloadPdf`); STR-Rechnungen liefern 404 — dort
bleibt Stripe der Beleg.
### 3b. Altlast aus unbezahlten Legacy-Rechnungen (`legacy:settle-unpaid`)
Legacy-Status `hold` = nie bezahlt, ersatzlos erledigt (Einordnung
16.09.2026). `pending`/`reminder` = offene Vorgänge. Der Befehl
`legacy:settle-unpaid` (`--dry-run`, `--no-invoice`) räumt auf:
- Bestandsschutz-Posten mit `hold`-Quellrechnung → Bündel gekündigt, offene
MAN-Rechnungen storniert. Die Archivzeilen bleiben erhalten und werden im
Admin-Archiv sowie im Kundenkonto standardmäßig ausgeblendet
(`LegacyInvoice::scopeWithoutHold`).
- `pending`/`reminder` (recurring) → Periode auf den Leistungsbeginn der
offenen Rechnung zurückgedreht (`legacy_conditions.open_legacy_invoice`),
sofort MAN-Rechnung ausgestellt; die Alt-Rechnung erhält Status
`superseded` (+ `raw_snapshot.superseded_by_invoice_number`).
- `pending`/`reminder` (single) → `uncollectible`.
`legacy:grandfather-subscriptions` wertet den Zahlungsstatus seit 16.09.2026
selbst aus (hold → übersprungen, offen → Periode auf Leistungsbeginn).
**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 |
| `legacy:settle-unpaid` | Altlast unbezahlter Legacy-Rechnungen auflösen (Abschnitt 3b) | manuell, wiederholbar |
| `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 (ab Versand) |
| `reminder_due_days` | `BILLING_REMINDER_DUE_DAYS` | `7` | Nachfrist einer Zahlungserinnerung |
| `issuer.*` | `BILLING_ISSUER_*` | adametz.media | Aussteller, Bankverbindung, Steuernummer auf MAN-Rechnungen und in der Zahlungsmail |
| `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**: MAN-Rechnungen erledigt (16.09.2026,
`ManualInvoicePdfRenderer`, inkl. `tax_note`); STR-Belege kommen weiter
von Stripe.
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.