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>
This commit is contained in:
Kevin Adametz 2026-09-16 10:56:29 +00:00
parent ada41cb067
commit 36691a5b13
33 changed files with 3606 additions and 195 deletions

View file

@ -99,7 +99,51 @@ Täglicher Scheduler-Lauf (04:30): `billing:generate-manual-invoices`
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`.
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
@ -140,6 +184,7 @@ Rechnungsadresse bestimmt:
| 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) |
@ -155,7 +200,9 @@ Rechnungsadresse bestimmt:
|---|---|---|---|
| `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 |
| `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 |
@ -205,8 +252,9 @@ CLI ausgegebene `whsec_…` temporär als `STRIPE_WEBHOOK_SECRET` in die `.env`.
`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.
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.