mivita/dev/_tickets/entwicklungsplan-protokoll-september-2026.md
Kevin 03416b0afe Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin
F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit
paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der
Backfill nimmt das Aktivierungsdatum nur noch ohne jede
Registrierungsbestellung (offene Rechnung bleibt leer).
F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt
genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel
(Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste
uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig.
F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit;
eine Ausfuehrung loescht auch den paused_by_*-Grund.
F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits
committeten Test nachgezogen.
F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false).
Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig
verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal.

Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 08:59:45 +00:00

473 lines
65 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.

# Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026
**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort.
**Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3)
**Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig)
---
## 0. Übersicht und empfohlene Reihenfolge
| # | Thema | Quelle | Aufwand | Risiko | Blockiert durch |
|---|---|---|---|---|---|
| **A1** | Abo-Ausführungstag frei wählbar (1–31, Monatsende-Regel) – **umgesetzt 16.09., Deploy offen** (Lager/Versand: Freigabe Kevin 16.09.) | Ticket #1, Protokoll | 3–4 PT | mittel | – |
| **A2** | Änderungssperre erst ab 0:00 Uhr am Ausführungstag – **umgesetzt 16.09., Deploy offen (Settings auf 0!)** | Ticket #1 | 1 PT | niedrig | zusammen mit A1 |
| **A3** | Abo-Status und Grund sichtbar (Zahlung / Kunde / Admin) – **umgesetzt 16.09., Deploy offen (Migration + Backfill)** | Ticket #2, Protokoll | 2–3 PT | niedrig | – |
| **A4** | Pausieren/Kündigen beschriften, Kündigungs-Hinweis (6 Ausführungen) – **umgesetzt 16.09., Deploy offen** | Ticket #3, Protokoll | 1 PT | niedrig | – |
| **A5** | Kundenabo → Partnerabo automatisch umwandeln | Protokoll | 4–6 PT | hoch | Rückfragen R5 |
| **B1** | Jahresgebühr automatisch abbuchen (Opt-in-Häkchen) | Protokoll | 6–8 PT | hoch | Rückfragen R6 |
| **B2** | Berater fällt aus: Pausieren nach 2 Tagen, Rückgabe bei Zahlung, harter Cut nach 4 Wochen, Sponsor-Mail | Protokoll, Rückmeldung 14.09. | 3–4 PT | mittel (einmaliger Massen-Effekt, akzeptiert) | – |
| **C1** | Team-Statistik = Team-Struktur (gleiche Punkte) – **live seit 14.09.** ([Deploy-Notiz](../2026-07-22/deploy-2026-09-14.md)) | Protokoll | 3–5 PT | hoch (jede Zahl ändert sich) | – (F4: Umschalter) |
| **C2** | Laufstand-Race: Provision liest zu niedrigen Monatsstand bei zeitgleichen Buchungen – **Stufe 1+2 live seit 14.09.**, Stufe 3 offen | Befund aus C1 | 0,5 + 1 PT | hoch (Provision) | – (keine Nachzahlung, Entscheid 14.09.) |
| **D1** | Neuer Reiter „Events“ mit Foto-Galerien (wie News-Archiv) | Protokoll | 2–3 PT | niedrig | Fotos liegen vor |
| **D2** | Top 3 firmenweit im Backoffice | Protokoll | 0,5 PT | rechtlich | Network-Anwalt (F3) |
| **E1** | Recruiting-Challenge Okt–Jan (50 €/Neupartner, Top 3) | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Details Dani/Alois |
**Reihenfolge (festgelegt 14.09.):**
1. **Team-Statistik zuerst (C1)** – die Seite wird ständig angeschaut, und die abweichenden Zahlen erzeugen laufend Rückfragen der Kundin. Schritt 1 (Mess-Command) und Schritt 2 (gemeinsames Baum-Prädikat) können sofort starten; nur die Umstellung selbst (Schritt 3) braucht vorher die Antwort auf F4 (abgelaufene Berater: Empfehlung Provisionssicht als Standard mit Umschalter „inkl. abgelaufene“).
2. **Abo-Paket** – A4 → A3 → A1+A2 (A1 und A2 greifen ineinander, gemeinsam testen).
3. **Termingebunden** – E1 (Challenge startet 01.10., Nachtragen ist möglich, s. u.), B2 (Ausfall-Ablauf komplett in einem Paket: Pausieren, Rückgabe, harter Cut, Sponsor-Mails – nicht in Teilen ausliefern, sonst entstehen Zwischenzustände ohne Rückweg).
4. **Backoffice** – D1 (Events-Reiter).
5. **Groß, mit offenen Rückfragen** – B1, A5. Ohne die Antworten in Abschnitt 6 nicht beginnen.
**Gesamtaufwand grob:** 25–36 PT, davon ~10 PT im Abo-Paket.
---
## 1. Abo-Paket
### A1 – Ausführungstag frei wählbar (1–31) — umgesetzt 16.09., Deploy offen
**Ziel (Ticket #1):** Jeder Tag 1–31 wählbar; bei 29/30/31 in kürzeren Monaten der letzte Monatstag. Genau eine Ausführung pro Monat. Änderung nur „nach vorne“ (10. → 24. ja, 10. → 5. nein, wenn der 5. schon vorbei ist). Wird am Ausführungstag 0:00 Uhr fixiert (→ A2).
**Ist-Stand**
- Einzige Quelle der erlaubten Tage: [`UserAbo::$aboDeliveryDays = [5, 10, 20, 25]`](../../app/Models/UserAbo.php#L228). Alle Verbraucher lesen dieses Array: Select-Optionen [`HTMLHelper::getAboDeliveryOptions()`](../../app/Services/HTMLHelper.php#L114-L127), Validierung [`AboRepository::validate()`](../../app/Repositories/AboRepository.php#L270) und [`Portal\AboController`](../../app/Http/Controllers/Portal/AboController.php#L748), Admin-Filter [`Admin\AboController`](../../app/Http/Controllers/Admin/AboController.php#L44), Prognosen [`PaylineForecastService`](../../app/Services/Abo/PaylineForecastService.php#L296) und [`TeamAboOverviewService`](../../app/Services/Abo/TeamAboOverviewService.php#L438).
- Der Tag steht in `user_abos.abo_interval` (TinyInt, trotz des Namens ein Monatstag) – **kein Schema-Change nötig**, bestehende Werte 5/10/20/25 bleiben gültig.
- **Monatsende-Problem:** [`AboHelper::setNextDate()`](../../app/Services/AboHelper.php#L269-L275) rechnet `firstOfMonth()->addDays($tag - 1)`. Für Tag 31 im Februar landet das im März, für Tag 30 im Februar auf dem 1./2. März → **das Abo würde einen Monat überspringen oder doppelt laufen**. Gleiches Muster in [`AboRepository::calculateAdminNextDate()`](../../app/Repositories/AboRepository.php#L341-L346). Die richtige Klammerung existiert bereits in [`advanceAboCandidateOneMonth()`](../../app/Services/AboHelper.php#L367-L374) (`min($tag, daysInMonth)`), wird aber nur für die Erst-Ausführung genutzt.
- Änderungslogik heute: [`calculateNewNextDate()`](../../app/Repositories/AboRepository.php#L324-L339) nimmt den Monat von `next_date` als Referenz (liegt `next_date` schon im Folgemonat, landet der neue Tag im Folgemonat). Eine „nur nach vorne“-Regel gibt es nicht; stattdessen verlangt die Sperre 10 Tage Abstand.
- Erst-Ausführung: [`MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION = 10`](../../app/Services/AboHelper.php#L27) – bleibt.
**Umsetzung**
1. `UserAbo::$aboDeliveryDays` durch `UserAbo::allowedDeliveryDays(): array` (= `range(1, 31)`) ersetzen; alle sieben Verwendungen umstellen. Select bekommt 31 Einträge, Label „31. (bzw. letzter Tag des Monats)“ für 29–31 ([`getAboStrLang()`](../../app/Services/HTMLHelper.php#L129-L132)).
2. Zentrale Datumsfunktion `AboHelper::dateForDayInMonth(Carbon $monat, int $tag)` mit `min($tag, daysInMonth)`; `setNextDate()`, `calculateAdminNextDate()`, `computeFirstAboCandidateWithoutMinDays()` darauf umstellen. Wichtig: der Folgemonat wird immer aus `abo_interval` berechnet, nie aus dem geklammerten Vortermin (sonst bleibt ein 31er-Abo nach dem Februar dauerhaft auf dem 28.).
3. Neue Änderungsregel in `AboRepository::validate()` (ersetzt die 10-Tage-Prüfung `error_abo_interval_too_soon`):
- Referenzmonat = Monat von `next_date` (wie heute).
- Neues Datum = gewählter Tag in diesem Monat (geklammert).
- Muss **nach heute** liegen (`> today`), sonst Fehler „Der :tag. ist in diesem Monat schon vorbei – die Änderung gilt ab dem nächsten Monat, bitte nach der Lieferung erneut einstellen“ (oder alternativ: automatisch für den Folgemonat setzen – **Rückfrage R1**).
- Der Monat darf sich durch die Änderung **nie** verschieben → garantiert „einmal pro Monat, kein Monat übersprungen“.
4. Admin-Modal ([`modal_abo_update.blade.php:44-63`](../../resources/views/admin/abo/modal_abo_update.blade.php#L44-L63)): Monat 0–3 voraus bleibt, Tag 1–31.
5. Admin-Filter „nächste Ausführung am …“: statt Dropdown mit 31 Werten ein Datumsfilter (von/bis) auf `next_date`.
6. **Prognose-Kacheln** (frisch ausgeliefert, Commits `8f4e105`, `4062c46`): „Payline-Prognose zum 5. / 10. / 20. / 25.“ ([home.php:53](../../resources/lang/de/home.php#L53)) und „Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.“ ([abo.php:55](../../resources/lang/de/abo.php#L55)) benutzen die Tage als Stichtage. Die Stichtage funktionieren mit beliebigen `next_date`-Werten weiter, **aber Abos nach dem 25. fallen aus der letzten Spalte** → Spalte „Monatsende“ ergänzen und die Überschriften anpassen. `TeamAboOverviewService::438` (`max(aboDeliveryDays)`) auf Monatsende umstellen.
7. Sprachdateien de/en/es/fr (`LocalizationSyncTest` erzwingt Parität).
**Tests:** `AboRepositoryDateChangeTest` (bestehende 6 Tests anpassen), neu: Klammerung 29/30/31 in Feb/Apr/Jun/Sep/Nov, Folgemonat nach Klammerung wieder auf 31, „nach vorne“-Regel, kein Monatssprung, Prognose-Spalte Monatsende, `UserMakeAboOrder` läuft an Tag 31 im 30-Tage-Monat am 30.
**Machbarkeit / Risiken**
- Technisch gut machbar; die zentrale Stelle ist eine Zeile plus die Datumsklammerung.
- **Fachliches Risiko Logistik:** Heute gibt es vier Versandtage, das Lager packt gebündelt (das war der Grund für die 10-Tage-Sperre). Mit freien Tagen kommen täglich Abo-Bestellungen um 04:00 Uhr. Das ist ausdrücklich Alois' Wunsch („Teufelsküche an den Abo-Tagen“), sollte aber dem Lager/Versand bewusst sein.
- **Rückfrage R1:** Wenn der gewünschte Tag im laufenden Monat schon vorbei ist – Fehlermeldung, oder Änderung automatisch für den Folgemonat übernehmen (dann würde diese Monatslieferung noch am alten Tag laufen)?
- Bestehende Abos ändern sich nicht; keine Datenmigration.
### A2 – Änderungssperre erst am Ausführungstag ab 0:00 Uhr — umgesetzt 16.09., Deploy offen
**Ziel (Ticket #1):** Tag und Produkte bis 23:59 Uhr des Vortags änderbar, dann fixiert. Die Sperre bleibt im Code (Setting), Default effektiv 0. Einmalprodukte behalten ihr eigenes Fenster (4 Tage vorher).
**Ist-Stand**
- Zwei Sperren in [`AboRepository`](../../app/Repositories/AboRepository.php#L13-L39): `abo-lock-days-change` (Default 10, Tag + Produkte) und `abo-lock-days-pause-cancel` (Default 3). Beide Getter fallen bei **0 auf den Default zurück** (`$days > 0 ? $days : DEFAULT`) – `AboLockDaysSettingTest::32` sichert das sogar ab. **Sperre auf 0 stellen funktioniert heute nicht.**
- Bedingung `daysUntil < lockDays` mit `now()->diffInDays(next_date)`: am Ausführungstag selbst ist `daysUntil = 0`, `0 < 0` = falsch → **am Ausführungstag vor 04:00 wäre eine Änderung heute sogar erlaubt**, obwohl der Cron gleich läuft.
- UI-Spiegel [`AboHelper::isAboChangeLocked()`](../../app/Services/AboHelper.php#L204-L213), Produkt-Guards `$changeLocked` in [`User\AboController:206-286`](../../app/Http/Controllers/User/AboController.php#L206) und [`Portal\AboController:110-131`](../../app/Http/Controllers/Portal/AboController.php#L110). Alle greifen auf den Getter zu → eine Änderung reicht.
- Einmalprodukte: eigenes, gegenläufiges Fenster `abo-onetime-window-days` (nur in den letzten 4 Tagen erlaubt) – unberührt.
**Umsetzung**
1. Getter-Semantik: Setting **leer/nicht vorhanden** → Default; Setting **`0`** → 0 (explizit erlaubt). Test „fällt bei 0 auf den Default zurück“ entsprechend umschreiben.
2. Sperrbedingung auf Kalendertage umstellen (`AboHelper::calendarDaysUntil()` existiert) und als „gesperrt ab 0:00 Uhr des Tages `next_date − lockDays`“ definieren, also `daysUntil <= lockDays`. Mit `lockDays = 0` ist das exakt die Ticketregel. Achtung: für einen gepflegten Wert >0 verschiebt sich die Sperre damit um einen Tag nach vorn (heute: 10 Tage = Tag 0–9, neu: Tag 0–10). Da der Wert auf 0 gesetzt wird, ist das praktisch irrelevant, muss aber in Tests und Fehlertext (`error_change_locked`) sauber sein.
3. Setting `abo-lock-days-change` in den Admin-Einstellungen auf 0 setzen (Deploy-Schritt, kein Code). Default-Konstante auf 0 ändern, damit ein Test-/Neusystem gleich richtig läuft.
4. **Rückfrage R2:** Gilt „keine Sperre mehr“ auch für Pausieren/Kündigen (heute 3 Tage)? Empfehlung: ja, gleiche Regel (bis 23:59 Uhr des Vortags), sonst ist es für Kunden nicht erklärbar.
5. Hinweistexte („Änderungen müssen mindestens :lock Tage vorher erfolgen“) für `lock = 0` umformulieren: „Änderungen sind bis zum Vortag der Lieferung möglich.“
**Tests:** `AboLockDaysSettingTest` anpassen (0 = keine Vorlaufsperre, Sperre am Ausführungstag selbst greift, Vortag 23:59 frei), `AboOneTimeWindowTest` unverändert grün.
**Risiko:** niedrig. Zeitliche Lücke 0:00–04:00 Uhr ist durch die neue Bedingung geschlossen.
**Umsetzung A1+A2 16.09.2026 (ein Commit auf `payment/phase-r-payone-refactoring`):**
- **Liefertage:** `UserAbo::$aboDeliveryDays` ersetzt durch `UserAbo::allowedDeliveryDays()` (1–31) und `isAllowedDeliveryDay()`; alle sieben Verwendungen umgestellt. Select mit 31 Einträgen, Tage 29–31 mit Zusatz „(in kürzeren Monaten der letzte Tag)“. Kein Schema-Change, bestehende Werte 5/10/20/25 unverändert.
- **Datum:** eine Stelle `AboHelper::dateForDayInMonth($monat, $tag)` (Klammerung auf Monatsende). `setNextDate()`, Erst-Ausführung (`getFirstAboDate`), Admin-Termin (`calculateAdminNextDate`) und Pause rechnen darüber. Der Folgemonat kommt immer aus `abo_interval`: 31. Januar → 28. Februar → 31. März → 30. April (Test). Nebenbei behoben: Tag 1 mit Referenz Monatsanfang sprang bisher einen Monat weiter.
- **Änderungsregel (R1):** `calculateNewNextDate()` = gewählter Tag im Referenzmonat (Monat des nächsten Termins, wenn der schon im Folgemonat liegt, sonst laufender Monat), nie ein anderer Monat. Liegt der Tag nicht nach heute → Fehler `error_abo_interval_passed` („Der 5. ist in diesem Monat schon vorbei. Die nächste Lieferung bleibt am …“). Die 10-Tage-Prüfung entfällt; bei gepflegter Sperrfrist > 0 gilt weiterhin `error_abo_interval_too_soon`.
- **Sperre (A2, R2):** Defaults `abo-lock-days-change` und `abo-lock-days-pause-cancel` = 0; Setting leer → Default, Setting `0` → 0 (vorher fiel 0 auf 10/3 zurück). Bedingung jetzt `Kalendertage bis next_date <= lock`, zentral in `AboHelper::isWithinLock()` — mit 0 ist genau der Ausführungstag ab 0:00 Uhr gesperrt, der Vortag bis 23:59 Uhr frei. Gilt für Liefertag, Produkte (AJAX-Guards), Pause und Kündigung; Admin unverändert frei. Texte über `AboHelper::lockMessage()`: bei 0 die `_zero`-Varianten („…wird heute ausgeführt. Nach der Lieferung kannst du…“), sonst die Varianten mit `:lock` Tagen.
- **Admin-Filter:** „nächste Ausführung“ als Datumsbereich (von/bis auf `next_date`) statt Tages-Dropdown.
- **Prognosen:** Kacheln 5./10./20./25. bleiben, dazu die Kachel **Monatsende** (`is_month_end`, Label „Prognose zum Monatsende (30.09.2026)“) in Dashboard und Kundenabo-Liste; Nachzügler-Buchungen hängen jetzt an dieser Kachel. Team-Abos: „bis Monatsende erwartet“ statt „bis zum 25.“ (`TeamAboOverviewService`, Fenster bis Monatsende).
- Sprachdateien de/en/es/fr (abo.php, home.php). Tests: neu `AboDeliveryDayTest` (7 Tests: Tage 1–31, Klammerung, 31er-Kette, Erstausführung, „nur nach vorn“, Sperre am Ausführungstag, Admin-Klammerung); angepasst `AboLockDaysSettingTest`, `AboRepositoryDateChangeTest`, `AboPauseTest`, `AboPauseCancelSectionTest`, `PaylineForecastServiceTest`. 335 Tests der Abo-/Prognose-Suite grün (bis auf den vorbestehenden Fehlschlag in `AboPendingInitialPaymentTest`).
- **Deploy:** Dateien hochladen, `php artisan view:clear`, dann die beiden Settings auf 0 setzen — sonst bleiben die auf Produktion gepflegten Werte (10 bzw. 3) wirksam:
```bash
php artisan tinker --execute="App\\Models\\Setting::setContentBySlug('abo-lock-days-change', 0, 'int'); App\\Models\\Setting::setContentBySlug('abo-lock-days-pause-cancel', 0, 'int');"
```
Keine Migration, keine Datenänderung. Danach prüfen: Abo-Einstellungen zeigen 31 Tage; ein Abo mit Termin morgen lässt sich noch ändern; heute fälliges Abo ist gesperrt.
- **Nicht enthalten:** die Mindestfrist 10 Tage bis zur Erst-Ausführung bleibt (`MIN_DAYS_UNTIL_FIRST_ABO_EXECUTION`); das Cron-Fenster 04:00 Uhr bleibt; Einmalprodukte behalten ihr eigenes 4-Tage-Fenster.
### A3 – Status und Grund eines Abos sichtbar machen — umgesetzt 16.09., Deploy offen
**Ziel (Ticket #2, Protokoll):** Für Admin, Berater und Kunde sofort erkennbar: aktiv und ordnungsgemäß ausgeführt / pausiert bis wann und durch wen / Zahlung fehlgeschlagen (und dass sie fehlgeschlagen ist) / gekündigt durch wen.
**Ist-Stand**
- Status-Codes [`UserAbo::$statusTypes`](../../app/Models/UserAbo.php#L230-L240): 2 okay, 3 angehalten (= Zahlung fehlgeschlagen, gesetzt in [`UserMakeAboOrder::updateAboOnError()`](../../app/Console/Commands/UserMakeAboOrder.php#L414-L470)), 4 storniert, 5 beendet, 6 inaktiv (nur Admin), 7 kulanz, 8 extern erledigt.
- **Pause hat keinen Status:** Pausieren verschiebt nur `next_date` um einen Zyklus, setzt `last_pause_date`/`pause_count`, Status bleibt 2 ([`pauseOneMonthLocked()`](../../app/Repositories/AboRepository.php#L188-L232)). In allen Listen sieht ein pausiertes Abo aus wie ein laufendes – genau Alois' Problem im Büro.
- Wer etwas getan hat, steht nur in `user_abo_status_histories` (`action`, `view` = me/ot/portal/admin, `actor_user_id`, alte/neue `next_date`) – wird nirgends angezeigt.
- Zahlungsfehler: `user_abo_orders` hat nur `status` + `paid`, **keine Fehlermeldung**. Der Grund (Karte abgelaufen, Deckung, PayPal) geht verloren.
- Anzeigen: Detail [`_detail.blade.php:27-28`](../../resources/views/admin/abo/_detail.blade.php#L27) (Status-Badge + Aktiv-Badge), Listen `user/abo/index`, `user/abo/_customer_abo_list`, `user/team/customer_abos`, Admin-Datatable, Portal `portal/abo/my_abo`.
**Umsetzung**
1. **Datenmodell (Migration):** `user_abos.status_reason` (string, nullable; Werte `payment_failed`, `paused_by_customer`, `paused_by_consultant`, `paused_by_admin`, `cancelled_by_customer`, `cancelled_by_consultant`, `cancelled_by_admin`, `deactivated_by_admin`, `pending_initial_payment`), `user_abos.paused_until` (date, nullable), `user_abo_orders.error_message` (string, nullable). Backfill-Command aus `user_abo_status_histories` für Bestandsdaten.
2. **Schreiben** an den bestehenden Stellen: `updateAboOnError()` (Fehlertext aus der Payone-/Stripe-Antwort mitnehmen), `AboRetryPaymentService::markAboError()/markAboSuccess()`, `pauseOneMonthLocked()` (`paused_until = neues next_date`, Grund aus `view`), `updateStatus()` (Kündigung/Deaktivierung), Erfolgs-Ausführung setzt `status_reason = null`, `paused_until = null`.
3. **Lesen:** Service `AboStatusExplainer::explain(UserAbo): AboStatusInfo` (Label, Farbe, Erklärungstext, Datum, Akteur, empfohlene Aktion). Beispiele: „Pausiert bis 24.10.2026 – vom Kunden am 12.09.2026“, „Zahlung am 10.09.2026 fehlgeschlagen (Karte abgelaufen) – Abo läuft nicht, bis die Zahlung nachgeholt ist“, „Gekündigt am 01.08.2026 durch Berater“, „Aktiv – letzte Lieferung 10.09.2026, nächste 10.10.2026“.
4. **Anzeige** in allen sechs Listen/Detailseiten über ein Blade-Partial `abo/_status_explain.blade.php`; im Admin-Datatable als eigene Spalte + Filter „pausiert“ / „Zahlung fehlgeschlagen“.
5. Pausen-Badge auch ohne Migration herleitbar (`pause_count > 0 && last_pause_date > last_date`) – als Fallback für alte Daten.
6. Sprachdateien de/en/es/fr.
**Tests:** Explainer-Unit-Tests je Zustand, Feature-Test: Pause → Liste zeigt „pausiert bis“, Zahlungsfehler → Fehlermeldung gespeichert und angezeigt, Kündigung durch Portal → „durch Kunde“.
**Risiko:** niedrig. Reine Zusatzinformation, keine Verhaltensänderung.
**Umsetzung 16.09.2026 (Commit auf `payment/phase-r-payone-refactoring`):**
- **Migration** `2026_09_16_120000_add_status_reason_to_user_abos_table`: `user_abos.status_reason` (string 40, nullable), `user_abos.paused_until` (date, nullable), `user_abo_orders.error_message` (string 255, nullable). Keine bestehende Zeile wird verändert; auf dem Dev-MySQL hin- und zurückmigriert.
- **Grund zentral am Model** (`UserAbo::applyStatusReasonDefaults()` im `saving`-Event, statt in jedem der sechs Schreibpfade): Status 3 ohne Grund → `payment_failed`; Status 4 / 6 → `cancelled_by_*` / `deactivated_by_*` mit dem Formular-Kanal aus `withStatusLogView()` (portal → customer, me/ot → consultant, admin → admin, sonst system); zurück in den Lauf (2/7/8) → Grund gelöscht; Änderung von `last_date` (= eine Ausführung lief, Erfolg oder Fehler) → `paused_until` gelöscht. Die Pause im `AboRepository` setzt `paused_until` (= neues `next_date`) und `paused_by_*`.
- **Fehlertext der Zahlung:** `UserMakeAboOrder::updateAboOnError()` und `AboRetryPaymentService::markAboError()` schreiben `user_abo_orders.error_message` (`[Code] customermessage/errormessage`, max. 255 Zeichen, über `AboStatusExplainer::paymentErrorMessage()`); bei Exceptions der Exception-Text.
- **Lesen:** `App\Services\Abo\AboStatusExplainer::explain(UserAbo): AboStatusInfo` (Zustand, Label, Farbe, Satz, Datum, Akteur, Fehlertext). Zustände: aktiv (letzte/nächste Lieferung), pausiert (bis wann, durch wen, seit wann), Zahlung fehlgeschlagen (Datum + Grund laut Anbieter), gekündigt (Datum, durch wen), deaktiviert, wartet auf erste Zahlung, beendet, Kulanz, extern erledigt. Für Bestandsdaten ohne Grund kommt der Akteur aus `user_abo_status_histories`, die Pause aus `pause_count`/`last_pause_date` vs. `last_date` (Fallback in `UserAbo::pausedUntil()`).
- **Anzeige** über `admin/abo/_status_explain.blade.php` in allen Listen/Details: Abo-Detail (`admin/abo/_detail`, damit CRM me/ot, Kundenportal und Admin), CRM-Abo-Übersicht, Kundenabo-Liste, Team-Kundenabos, Admin-Datatable (Spalte Status). Admin-Filter „Zustand“: pausiert / Zahlung fehlgeschlagen (Scopes `UserAbo::paused()` / `paymentFailed()`).
- **Backfill** `php artisan abo:backfill-status-reasons [--dry-run]`: Status 3/4/6 ohne Grund und laufende Pausen ohne `paused_until` (Query-Builder, keine Model-Events, `updated_at` unverändert).
- Sprachdateien de/en/es/fr (`state_*`, `state_text_*`, `actor_by_*`, `filter_state`). Tests: `AboStatusExplainTest` (11 Tests), alle 40 Abo-Testdateien grün bis auf `AboPendingInitialPaymentTest` „zeigt gesendete Abo-Zahlungslinks“, der schon auf dem committeten Stand fehlschlägt (rendert `user/abo/index` mit `Form::open` ohne Session) — nicht Teil von A3.
- **Deploy:** Dateien hochladen, dann `php artisan migrate`, `php artisan abo:backfill-status-reasons --dry-run` (Zahlen prüfen), `php artisan abo:backfill-status-reasons`, `php artisan view:clear`. Keine Settings.
### A4 – Pausieren/Kündigen beschriften, Kündigungs-Hinweis — umgesetzt 16.09., Deploy offen
**Ziel (Ticket #3, Protokoll):** Im Partnershop/Portal sofort sichtbar, dass und wo man pausieren/kündigen kann. Beim Kündigen deutlicher Hinweis: neues Abo braucht wieder 6 Ausführungen; Vorschlag, stattdessen zu pausieren.
**Ist-Stand:** Buttons im Modal [`modal_abo_update.blade.php:118-142`](../../resources/views/admin/abo/modal_abo_update.blade.php#L118) nur sichtbar, wenn `canCancel` (Mindestlaufzeit erreicht, [`AboHelper::canCancelAbo()`](../../app/Services/AboHelper.php#L125-L134), Setting `abo-min-duration` = 6 **bezahlte** Ausführungen). Kündigung = Status 4 nach JS-`confirm()`. Echtes Löschen gibt es nur für Admins.
**Umsetzung**
1. Eigener Abschnitt „Abo pausieren oder kündigen“ auf der Detailseite (Portal + Berater), nicht nur im Bearbeiten-Modal: Fortschritt „4 von 6 Lieferungen“, ab Erreichen die beiden Aktionen mit Erklärung.
2. Kündigung über ein Bootstrap-Modal statt `confirm()`: Text mit Konsequenzen (Abo endet, Punkte/Konditionen entfallen, ein neues Abo beginnt wieder mit 6 Pflichtlieferungen), Button „Lieber 1 Monat pausieren“ als primäre Aktion, „Trotzdem kündigen“ mit Bestätigungs-Checkbox (Muster aus `CustomerDeleteTest` / Kunden-löschen-Modal).
3. Vor Erreichen der Mindestlaufzeit: Hinweis, ab wann pausieren/kündigen möglich ist (Text `info_min_duration_orders_left` existiert).
4. Sprachdateien de/en/es/fr.
**Risiko:** keins. 1 PT.
**Umsetzung 16.09.2026 (Commit auf `payment/phase-r-payone-refactoring`):**
- Neues Partial `resources/views/admin/abo/_pause_cancel.blade.php`, eingebunden unter den Ausführungen auf der CRM-Abo-Seite (`user/abo/detail`, Sicht me und ot) und im Kundenportal (`portal/abo/my_abo`). Zeigt Fortschritt „X von 6 Lieferungen ausgeführt“ (Balken), vor der Mindestlaufzeit nur den Hinweis auf die restlichen Lieferungen, danach die Pause (Formular, bestehende Regeln) und den Kündigen-Button. Innerhalb der Sperrfrist vor der Ausführung (`abo-lock-days-pause-cancel`) erscheint statt der Buttons ein Hinweis mit Lieferdatum — dafür neu `AboHelper::isPauseCancelLocked()`, spiegelt die Prüfung aus `AboRepository`.
- Kündigung über Bootstrap-Modal statt `confirm()`: drei Folgen (Abo endet sofort, Konditionen/Punkte entfallen, neues Abo wieder 6 Pflichtlieferungen), Hinweis „lieber pausieren“, Button „Lieber 1 Monat pausieren“ als primäre Aktion (nur wenn die Pause gerade möglich ist), „Trotzdem kündigen“ erst nach Bestätigungs-Checkbox aktiv.
- Einstellungs-Modal: die Pause bleibt dort, der Kündigen-Button schließt das Modal und springt zum neuen Abschnitt (kein zweiter Kündigungspfad mehr).
- `AboRepository`: Kündigung ist wie die Pause eine eigenständige Aktion — Erfolgsmeldung `abo.cancel_success`, kein Neuberechnen von `next_date` und kein „nächste Ausführung in N Tagen“-Hinweis mehr nach der Kündigung. Server-Regeln (Mindestlaufzeit, Sperrfrist) unverändert.
- Sprachdateien de/en/es/fr (16 neue Keys `pause_cancel_*`, `cancel_*`). Tests: `AboPauseCancelSectionTest` (9 Tests), bestehende `AboPauseTest`, `AboStatusGuardTest`, `AboUpdateAuthorizationTest`, `AboLockDaysSettingTest` grün.
- **Deploy:** Dateien hochladen, `php artisan view:clear`. Keine Migration, kein Setting, keine Daten. `LocalizationSyncTest` schlägt auf dem Branch weiterhin wegen `msg.php` (Berater-Übertragung, nicht committet) und `payment.php` (es/fr seit `2e71173` unvollständig) fehl — nicht Teil von A4.
### A5 – Kundenabo → Partnerabo automatisch umwandeln
**Ziel (Protokoll):** Nach der Partnerregistrierung erkennt das System ein laufendes Kundenabo → Umwandlung anbieten, Zahlung neu bestätigen, Kompensationsprodukte wählen, **Mindestlaufzeit läuft weiter** (3 Lieferungen als Kunde + Monat 4 als Partner).
**Ist-Stand**
- Unterschied Kunden-/Partnerabo ist nur `user_abos.is_for` (`ot` / `me`), plus Zuordnung `user_id`/`member_id`/`shopping_user_id` ([`AboHelper::createNewAbo()`](../../app/Services/AboHelper.php#L438-L446)). Zahlungs-Token (`payone_userid`, `stripe_customer_id`) hängen am Abo.
- Ausführungszähler [`getCountPaidOrders()`](../../app/Models/UserAbo.php#L517-L533) zählt `user_abo_orders` **desselben Abos** → eine Umwandlung **im selben Datensatz** erfüllt „Mindestlaufzeit läuft weiter“ automatisch. Ein neues Abo würde bei 0 beginnen – deshalb In-Place-Umwandlung.
- Kompensationsprodukte: `user_abo_items.comp`, Sortiment über [`Shop::getCompProducts('abo-me')`](../../app/Services/Shop.php#L270-L309) (`show_on = 12`), gesteuert über Setting `is_comp_me_abo`. Für `ot`-Abos gibt es heute keine Kompensation.
- **Es existiert kein Umwandlungspfad.** [`Web\RegisterController`](../../app/Http/Controllers/Web/RegisterController.php#L78-L136) legt nur den `User` an und schaut nicht nach `ShoppingUser`/`UserAbo`. Wiederverwendbar: `AboPaymentMethodService` (Zahlungsart neu hinterlegen), `AboItemHistoryService`, `UserAboStatusHistory`, `AboMoveOrders` (Muster).
**Umsetzung (In-Place)**
1. Erkennung: nach Freischaltung des Partners (`wizard = 100`, bezahlte Registrierung) aktive `ot`-Abos mit gleicher E-Mail (`user_abos.email` bzw. `shopping_users.billing_email`) suchen → Dashboard-Banner „Kundenabo in Partnerabo umwandeln – 30 % günstiger“.
2. Umwandlungsseite (CRM, `user/abos/convert/{abo}`): Vorschau der Positionen mit Partnerpreisen, Kompensationsprodukte als Slots (`comp > 0`) wählbar, Summe/Punkte neu.
3. Zahlung bestätigen: neuen Token über den bestehenden Zahlungsart-ändern-Flow (`AboPaymentMethodService`, Payone/Stripe je `PaymentProviderManager`), da Betrag und Vertragspartei wechseln.
4. Schreiben in einer Transaktion: `is_for = 'me'`, `user_id = neuer User`, `member_id` wie bei `me`-Abos, `shopping_user_id` = eigener `ShoppingUser` mit `auth_user_id`, Items ersetzen, Preise neu snapshotten, `UserAboStatusHistory` neue Aktion `converted`, Item-History. `user_abo_orders` bleiben → Zähler läuft weiter.
5. **Nicht** `IncentiveTracker::trackAboActivated()` auslösen (kein neues Abo), Ein-Abo-Regel (`AboHelper::userHasAbo()`) berücksichtigen.
**Machbarkeit / Risiken (hoch – Freigabe vor Start nötig)**
- **Provisions-/Statistikwirkung:** Der Umsatz wandert von den Kundenpunkten des Beraters in die Eigenpunkte des Neupartners (Ebene 1 des Sponsors). Das verschiebt Bonus-Kategorien, Kundenabo-Zählungen (`TeamAboOverviewService`, Incentive `incentive_new_abos`) und die Kundenabo-Prognose. Muss kommuniziert werden.
- **Kompensationsprodukte ohne Freigabe** = jeder Partner kann sich Kompensation eintragen. Entweder Admin-Freigabe (Status „Umwandlung beantragt“) oder nur das reguläre Comp-Sortiment ohne Begründungsfeld.
- Zahlungs-Token gehört rechtlich dem Kunden als Person – dieselbe Person, aber der Vertrag ändert sich → deshalb Neubestätigung (Protokoll fordert das ohnehin).
- E-Mail der Registrierung ≠ E-Mail des Abos → Erkennung schlägt fehl; Admin braucht einen manuellen Umwandlungsknopf als Fallback.
- Preis-Snapshots der Items (`user_abo_one_time_items` haben Snapshots, Abo-Items rechnen live) – prüfen, wie der Abo-Betrag beim nächsten Lauf gebildet wird (`getFormattedAmount()`).
**Rückfragen R5:** (a) Wer gibt Kompensationsprodukte frei? (b) Sponsor des Neupartners = bisheriger Berater des Kunden automatisch? (c) Darf der Partner den Umwandlungszeitpunkt frei wählen (sofort vs. ab nächster Lieferung)?
---
## 2. Mitgliedschaft / Jahresgebühr
### B1 – Jahresgebühr automatisch abbuchen (Opt-in-Häkchen)
**Ziel (Protokoll):** Berater setzen ein Häkchen → Jahresgebühr wird automatisch abgebucht. Ohne Häkchen weiterhin Erinnerungsmails und manuelle Zahlung. (Das ist Opt-in – entschärft die rechtliche Frage F6 aus dem Septemberbrief, die Opt-out vorsah.)
**Ist-Stand**
- Mitgliedschaft = `users.payment_account` (bezahlt bis), Verlängerung + Reaktivierung zentral in [`Payment::paymentStatusPaidAction()`](../../app/Services/Payment.php#L192-L233) (Produkt-Action `payment_for_account`). Verlängerungsprodukt `show_renewal` seit Commit `c3dc7eb`; Produkttext sagt ausdrücklich „Eine automatische Verlängerung gibt es nicht“.
- Manuelle Zahlung: [`MembershipController::storePayment()`](../../app/Http/Controllers/User/MembershipController.php#L125-L186) → normaler Checkout.
- Erinnerungen: [`CheckPaymentsAccount`](../../app/Console/Commands/CheckPaymentsAccount.php) (Stufen 31/33/34/35, dedupliziert über `UserHistory`), Config `mivita.remind_*`.
- **Berater haben kein gespeichertes Zahlungsmittel.** Keine Token-Spalte auf `users`; `user_accounts.payment_data` enthält nur alte ELV-Daten (SEPA abgeschafft). Wiederkehrende Token existieren nur an Abos (`payone_userid`, `stripe_customer_id`) und `shopping_users.stripe_customer_id`.
- Wiederverwendbar: Off-Session-Charge [`UserMakeOrder::makePayment()`](../../app/Cron/UserMakeOrder.php#L69-L98), [`StripeGateway::chargeAbo()`](../../app/Services/Payment/StripeGateway.php#L26-L40) (auf `UserAbo` typisiert → verallgemeinern), Retry-Muster `AboRetryPaymentService`, Karten-Hinterlegung `AboPaymentMethodService`. Per-User-Flag: `users.settings` (`User::setSetting()`), keine Migration nötig für das Häkchen selbst.
- Alte Texte für genau dieses Feature liegen ungenutzt in [payment.php:15-18](../../resources/lang/de/payment.php#L15-L18).
**Umsetzung**
1. Migration: `users.renewal_payment_provider`, `renewal_payment_token` (Payone `userid` bzw. Stripe `customer_id` + `payment_method_id`), `renewal_auto_at` (optional).
2. Mitgliedschaftsseite: Checkbox „Jahresgebühr automatisch abbuchen“ → führt in einen „Zahlungsart hinterlegen“-Flow (0-€-/Verifizierungs-Transaktion bei Payone bzw. Stripe SetupIntent `usage: off_session`), Mandats-/Einwilligungstext, jederzeit abwählbar (Token löschen).
3. Service `MembershipRenewalService::charge(User)`: erstellt Verlängerungs-`ShoppingOrder` (Logik aus `MembershipController::storePayment()` herauslösen), bucht off-session über den gespeicherten Token, Erfolg → bestehender `paymentStatusPaidAction`-Pfad (verlängert, reaktiviert, Rechnung wie jede Bestellung).
4. Command `membership:auto-renew` (täglich ~02:30, vor `user:cleanup`): Kandidaten = Häkchen gesetzt, `payment_account − X Tage <= heute`, keine Verlängerungsbestellung im Zeitraum. Fehler → Mail „Abbuchung fehlgeschlagen“ + Rückfall in die normalen Erinnerungsstufen, Retry täglich bis Ablauf.
5. `CheckPaymentsAccount`: Berater mit Häkchen bekommen statt Stufe 31 eine Info „wir buchen am X ab“ und nach Erfolg eine Bestätigung; Stufen 33–35 nur bei gescheiterter Abbuchung.
6. Produkt-/Seitentexte („keine automatische Verlängerung“) anpassen, de/en/es/fr.
**Machbarkeit / Risiken (hoch)**
- Zwei Zahlungsanbieter parallel (Payone/Stripe, `PaymentProviderManager`): Empfehlung, den für **neue** Zahlungen aktiven Anbieter zu nehmen und nur dessen Token-Flow zu bauen.
- SCA/3-D-Secure: Off-Session-Abbuchungen können abgelehnt werden → Fallback auf Zahlungslink-Mail (Muster `AboPaymentFailedMail`).
- Rechtlich: Opt-in mit ausdrücklichem Mandatstext und Widerruf; keine rückwirkende Anwendung auf Bestandsberater.
- Rechnungsstellung läuft über die normale Bestellung – prüfen, dass die Verlängerungsbestellung ohne Warenkorb-Session sauber entsteht (Muster `UserMakeOrder::makeShoppingOrder()`).
**Rückfragen R6:** (a) Wie viele Tage vor Ablauf abbuchen (alter Text: 14)? (b) Nur Stripe, nur Payone, oder je nach Anbieterstatus? (c) Soll die Abbuchung auch Berater reaktivieren, die schon pausiert sind (Stufe 1 in B2)?
### B2 – Berater fällt aus: Pausieren, Rückgabe bei Zahlung, harter Cut, Sponsor-Mail
**Ziel (Protokoll + Rückmeldung 14.09.):**
1. Zahlung bleibt aus → Account wird pausiert / inaktiv.
2. Kurz danach (Versatz ca. 2 Tage) wandern **Kunden und Team** zum Sponsor, damit die Punkte bei ihm eingehen.
3. Zahlt der Berater innerhalb von 4 Wochen, wird er reaktiviert und bekommt Kunden und Team zurück. Umsätze und Provisionen der Zwischenzeit bleiben beim Sponsor.
4. Nach 4 Wochen harter Cut wie heute: alles bleibt endgültig beim Sponsor, der Berater wird entfernt. Zu diesem Zeitpunkt bekommt der Sponsor die Mail mit beiden Listen (Vorname, Nachname, Telefon, E-Mail).
#### Antwort auf die Frage „passiert bei Ablauf schon etwas?“ – Ist-Ablauf heute
| Zeitpunkt | Was passiert | Was **nicht** passiert |
|---|---|---|
| Tag 0 – `payment_account` abgelaufen | Mail Stufe 35 „Account pausiert“ ([`CheckPaymentsAccount`](../../app/Console/Commands/CheckPaymentsAccount.php)) | **Technisch nichts.** `active` bleibt 1, `m_sponsor` bleibt. Der Berater steht weiter im Provisionsbaum: [`readParentsBusinessUsers()`](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php#L1088) prüft nur `payment_account != null`, nicht das Datum. Das Flag `active_account` in `user_businesses` ist reine Anzeige/Filter (`BusinessControllerOptimized`) und blockiert keine Provision. Kunden- und Abo-Umsätze laufen 14 Tage weiter auf ihn – und werden ihm ausgezahlt (die Mail 35 verspricht das sogar). |
| Tag +14 – [`cleanUpInActiveUser()`](../../app/Console/Commands/UserCleanUp.php#L145) | Teampartner → nächster aktiver Sponsor (`user_clean_up_logs`, umkehrbar), `active = 0`, `pre_sponsor` gemerkt, `m_sponsor = null` → raus aus dem Baum. Login bleibt möglich (Mitgliedschaft bezahlen). | **Kunden bleiben beim inaktiven Berater** (Kommentar im Code sagt das ausdrücklich). Da er nicht mehr im Baum hängt, kommen deren Punkte bei **niemandem** an – weder bei ihm noch beim Sponsor. |
| Tag +14 bis +60 | Zahlung → [`Payment::paymentStatusPaidAction()`](../../app/Services/Payment.php#L227) → [`reactiveUserResetChilds()`](../../app/Services/UserUtil.php#L275): `active = 1`, Sponsor zurück, Teampartner aus den Logs zurück | Kunden müssen nicht zurück – sie waren nie weg. |
| Tag +60 – [`deleteInactiveUsers()`](../../app/Console/Commands/UserCleanUp.php#L63) | Kunden (`shopping_users.member_id`) → Sponsor (`shopping_user_member_logs`), Berater selbst wird `ShoppingUser` des Sponsors ([`setUserToClient()`](../../app/Services/UserUtil.php#L68)), Soft-Delete | **Kundenabos (`user_abos.member_id`) und Portal-Logins (`customers.member_id`) werden nie umgehängt.** Abo-Ausführungen nehmen `member_id` aus dem Abo ([`UserMakeOrder:278`](../../app/Cron/UserMakeOrder.php#L278)) – die Punkte landen dauerhaft beim gelöschten Berater. Keine Mail an den Sponsor. |
Dazu gibt es einen dritten, abweichenden Pfad: setzt ein Admin das Datum in die Vergangenheit ([`AdminUserController:155-175`](../../app/Http/Controllers/AdminUserController.php#L155)), läuft nur die Team-Stufe (`deactiveUserNewSponsorChilds()`), nie die Kunden.
**Fazit Ist:** Zwischen Tag +14 und +60 verschwinden Kundenpunkte im Nichts, Kundenabos fallen komplett durch das Raster, und 14 Tage lang wird ein unbezahlter Account voll verprovisioniert. Der Wunsch aus der Rückmeldung schließt genau diese Lücken.
#### Soll-Ablauf – Empfehlung: zwei Stufen behalten, Zeitpunkte und Umfang ändern
| Stufe | Zeitpunkt | Aktion |
|---|---|---|
| **1 „Pausieren“** | Ablauf + 2 Tage (Config `cleanup_suspend_days`) | wie heute `deactiveUser()` + Team umhängen (vorhanden, protokolliert, umkehrbar) **plus neu:** Kunden, Kundenabos und Portal-Logins **umkehrbar** umhängen (gleiches Log-Muster mit `pre_member_id`). Mail an den Berater (Stufe 35 umformulieren: „Dein Team und deine Kunden sind bis zum :date vorübergehend deinem Sponsor zugeordnet. Zahlst du bis dahin, bekommst du sie zurück.“). **Kurzinfo an den Sponsor, ohne Listen** (entschieden 14.09.): „Dein Teampartner :name hat seine Mitgliedschaft wegen ausstehender Zahlung nicht verlängert. Er hat noch bis zum :date (:days Tage) Zeit, sie wieder aufzunehmen, sonst wird die Mitgliedschaft dauerhaft entfernt. Bis dahin sind seine Kunden und Teampartner dir zugeordnet.“ |
| **Rückgabe** | Zahlung bis Ablauf + 28 Tage (Config `cleanup_delete_days`) | bestehender Hook `reactiveUserResetChilds()` **plus neu:** Rückgabe der Kunden/Abos/Logins aus den Logs. Nur Datensätze, die noch beim Sponsor liegen (wurde ein Kunde zwischenzeitlich manuell woanders hin übertragen, bleibt er dort). |
| **2 „Harter Cut“** | Ablauf + 28 Tage | Nichts mehr umhängen (ist schon passiert) → `setUserToClient()` + `deleteUser()` wie heute. **Neu:** Sponsor-Mail mit beiden Listen aus den Logs der Stufe 1 plus dem Berater selbst als neuem Kunden. Logs bleiben als Historie (Seiten „User Bereinigung“). |
**Warum „Provisionen laufen in der Zwischenzeit beim Sponsor“ von allein stimmt:** Umsätze werden **zum Bestellzeitpunkt** zugeordnet – [`SalesPointsVolume:164`](../../app/Services/BusinessPlan/SalesPointsVolume.php#L164) nimmt `shopping_orders.auth_user_id` bzw. `member_id` der Bestellung, Abo-Ausführungen `user_abos.member_id`. Sobald Kunde und Abo beim Sponsor hängen, landen alle Bestellungen der Pausenzeit auf dessen `user_sales_volumes` und bleiben dort auch nach der Rückgabe. Es muss nichts umgebucht werden; abgeschlossene Monate sind ohnehin eingefrorene Snapshots.
**Warum das die einfachste Variante ist**
- Alle Bausteine existieren: Deaktivieren, Team umhängen + zurückholen, Kunden umhängen, Berater-zu-Kunde, Soft-Delete, Zahlungs-Hook, Admin-Restore. Die Kundenübertragung wandert nur von Stufe 2 nach Stufe 1 und bekommt ihr Gegenstück „zurückholen“ (heute fehlt es, weil es nie nötig war).
- Neu zu schreiben: (1) eine umkehrbare „Kunden umhängen / zurückholen“-Hilfe für `shopping_users`, `user_abos`, `customers` – analog zu `setNewSponsorToChilds()` / `resetChildsToSponsor()`, (2) eine Service-Klasse `ConsultantSuspensionService` mit `suspend()`, `reinstate()`, `terminate()`, die Cron, Zahlungs-Hook **und** Admin-Pfad gemeinsam nutzen (heute drei abweichende Pfade), (3) Mailable `SponsorTransferMail`, (4) Config + `--dry-run`, (5) Tests.
- Keine neue Tabelle: `shopping_user_member_logs` hat bereits `pre_member_id`; für Abos und Portal-Logins entweder zwei kleine Log-Tabellen nach demselben Muster oder eine Spalte `type` in `shopping_user_member_logs`.
- `ConsultantTransferService` (manuelle Übertragung, Ticket 08.09.) hängt Kunden, Abos und Portal-Logins bereits alle drei um – dieselbe Liste, gleiche Reihenfolge; die Hilfe lässt sich dort wiederverwenden.
**Umsetzung**
1. `config/mivita.php`: `cleanup_suspend_days = 2`, `cleanup_delete_days = 28` (beide Stellen in `UserCleanUp` sind heute hart codiert: `-2 weeks`, `-2 month`).
2. `UserUtil`: `setCustomerDataToNewMember($from, $to)` (Kunden + Abos + Logins, protokolliert) und `resetCustomerDataToMember($userId)` (nur Datensätze, deren aktueller `member_id` noch dem im Log eingetragenen Ziel entspricht).
3. `ConsultantSuspensionService`: `suspend(User)` = Stufe 1 in einer Transaktion; `reinstate(User)` = Rückgabe; `terminate(User)` = Stufe 2 + Mail. `UserCleanUp`, `Payment::paymentStatusPaidAction()`, `AdminUserController` (Datum setzen) und `AdminUserCleanupController::restore()` rufen nur noch diese drei Methoden.
4. Zwei Mailables an den Sponsor (Queue): `SponsorSuspensionNoticeMail` bei Stufe 1 (Name des Beraters, Frist als Datum und Resttage, kein Listeninhalt) und `SponsorTransferMail` bei Stufe 2 (Blade-Tabellen mit Kunden und Teampartnern aus den Logs der Stufe 1 für genau diesen Berater plus der Berater selbst als neuer Kunde). Sponsor = Firma (User 6) → keine Mail.
5. Texte: Stufe 35 (Berater), neue Sponsor-Mail, Dashboard-Banner `_reminder.blade.php`; de/en/es/fr.
6. `user:cleanup --dry-run` listet beide Stufen mit Anzahl Kunden/Abos/Teampartner je Berater.
7. Tests (`Carbon::setTestNow()`): Tag +1 nichts, Tag +2 Stufe 1 komplett (Team, Kunden, Abos, Logins, Baum-Ausschluss, Kurzinfo an Sponsor ohne Listen), Zahlung Tag +10 gibt alles zurück, manuell verschobener Kunde bleibt, Tag +28 harter Cut + Mail-Inhalt = Log-Inhalt, keine Mail an User 6, Admin-Pfad nutzt denselben Service.
**Risiken / Entscheidungen**
- **Einmaliger Massen-Effekt beim Deploy (entschieden 14.09.: akzeptiert):** Beim ersten Lauf werden alle Berater mit Ablauf > 2 Tagen sofort pausiert (heute 14) und alle > 28 Tagen gelöscht (heute 60); die Status werden dabei bewusst umgestellt. Ablauf: `--dry-run`-Liste ziehen und an Alois geben, dann der reguläre 03:30-Lauf. Beide Sponsor-Mails gehen in diesem ersten Lauf gesammelt raus – Sponsoren mit mehreren betroffenen Teampartnern bekommen mehrere Mails; falls unerwünscht, für den ersten Lauf eine Sammel-Mail je Sponsor vorsehen.
- **Wofür die 2 Tage?** Nur Vorkasse/Überweisung braucht den Puffer; Karte/PayPal buchen sofort. Als Config einfach änderbar – 0 Tage wäre genauso möglich.
- **Team-Statistik:** Pausierte Berater verschwinden ab Tag +2 aus dem Provisionsbaum und damit (nach C1) aus der Statistik – passt zur Provisionslogik, ist aber genau die Personengruppe der Jahresgebühr-Nachfassliste (`AnnualFeeDueService` bleibt davon unberührt, sie arbeitet über `payment_account`).
- **Berater selbst als Kunde:** `setUserToClient()` legt immer einen neuen `ShoppingUser` an; hat der Berater bereits einen (`auth_user_id`), entsteht ein Duplikat – vorher per E-Mail prüfen (Bezug Ticket „Kunden löschen“ 08.09.).
- **Admin-Restore nach dem harten Cut** (`user:restore`) holt heute nur das Team über die Logs zurück. Laut Kundin ist nach dem Cut eine Neuregistrierung mit Starterpaket der Regelfall; Restore bleibt Admin-Ausnahme und holt zurück, was die Logs noch hergeben – so dokumentieren.
- Nebenbefunde: Kernel referenziert `UserCleanup::class`, Klasse heißt `UserCleanUp` (funktioniert nur durch case-insensitives Autoload); `AdminUserCleanupController::restore()` ruft `user:restore` zweimal auf (Zeilen 319/325).
**Aufwand:** 3–4 PT. **Entschieden (14.09.):** Sponsor bekommt bei Stufe 1 die Kurzinfo ohne Listen, bei Stufe 2 die Listen-Mail; der Status-Umbruch beim Deploy ist akzeptiert. **Offen (R3):** 2 Tage Versatz auch für Karten-/PayPal-Zahler oder dort sofort?
---
## 3. Statistik
### C1 – Team-Statistik und Team-Struktur zeigen dieselben Punkte
**Ziel (Protokoll):** Beispiel 30.756,50 (Statistik) vs. 30.281 (Struktur). Beide Seiten sollen die verprovisionierten Umsätze zeigen (Struktur = Referenz).
**Ist-Stand / Diagnose (sechs Ursachen, in Reihenfolge der Wirkung)**
| # | Ursache | Statistik ([`BackofficeDashboardService`](../../app/Services/Backoffice/BackofficeDashboardService.php)) | Struktur ([`TreeCalcBotOptimized`](../../app/Services/BusinessPlan/TreeCalcBotOptimized.php)) |
|---|---|---|---|
| 1 | Punkteart | rohe `user_sales_volumes.points` ohne `status_points`-Filter ([`SalesVolumePoints:43-47`](../../app/Services/Backoffice/SalesVolumePoints.php#L43)) → KP-only-Buchungen zählen mit | `month_TP_points + month_shop_points` ([`UserSalesVolume:239`](../../app/Models/UserSalesVolume.php#L239)), KP-only (`status_points = 2`) ausgeschlossen. Die Statistik kennt die Differenz schon: `nonPaylinePointsSum()` + Hinweis in `index.blade.php:264-272` |
| 2 | Baumdefinition | jeder nicht gelöschte Downline-User, Tiefe 100, kein Kappen | nur `m_level` + `payment_account` gesetzt, `admin < 4`, `active_date <= Monatsende`, Tiefe 20, Unterbaum unter ausgefiltertem Knoten gekappt ([`readParentsBusinessUsers()`](../../app/Services/BusinessPlan/BusinessUserItemOptimized.php#L1068-L1092)) |
| 3 | Rundung | 2 Dezimalen (`Util::formatNumber`) | `total_pp` cast `int` ([`UserBusiness:156`](../../app/Models/UserBusiness.php#L156)) |
| 4 | Storno | negative Zeilen mindern die Summe | Knoten mit `TP_sum <= 0` werden übersprungen (`if ($points > 0)`, Zeilen 290/858) |
| 5 | Live vs. Snapshot | laufender Monat live, geschlossene eingefroren | liest gespeicherte `UserBusinessStructure`, sobald eine existiert; hinkt dem Nachtlauf hinterher |
| 6 | Aggregat vs. Zeilen | SUM über Zeilen | kumulative Monatsspalten (`reCalculateSalesPointsVolume`) – Drift bei verpasster Neuberechnung |
Ursache 1 und 2 erklären in der Regel den Großteil; für den konkreten Fall zuerst prüfen, ob `non_payline_points` des Users im Monat ≈ 475,50 ergibt.
Plan K11 aus `entwicklungsplan-2026-09-07.md` (3 Schritte) bleibt gültig; Vorarbeit existiert in [`PaylineForecastService::lineByUserId()`](../../app/Services/Abo/PaylineForecastService.php) (Provisionsbaum-Prädikat mit Regressionstest).
**Umsetzung**
1. **Messen:** Command `statistics:compare-trees {month} {year}` – für alle VIPs beide Definitionen nebeneinander (Berater, Neupartner, Abos, Punkte je Ebene, Summe). Ergebnis der Kundin zeigen, bevor etwas umgestellt wird.
2. **Gemeinsames Prädikat** aus `PaylineForecastService` in einen `CommissionTreeDefinition`-Service heben; `lineBuckets()` bekommt die Provisionsbaum-Variante; Punkte-Metrik der Statistik auf TP (`status_points != 2`) umstellen.
3. Umschalter „inkl. abgelaufene Berater“ (Empfehlung aus F4), damit die Jahresgebühr-Nachfassliste nicht verschwindet.
4. Struktur: Dezimalen anzeigen (Cast/Formatierung, nicht die Speicherung), Snapshot-Alter anzeigen bzw. „live“ für den laufenden Monat.
5. `BackofficeStatisticsSnapshot` für geschlossene Monate neu rechnen, `cache:clear` nach Livegang (6-h-Caches ohne Invalidierung).
6. Test: `StatisticsConsistencyTest` um „Statistik-Summe == Struktur-Summe für denselben Baum“ erweitern.
**Umsetzungsstand (14.09.2026) — umgesetzt, Tests grün (125 Tests der Statistik-Suite)**
| Baustein | Datei |
|---|---|
| Provisionspunkte-Definition (TP + Shop, ohne Nur-KP, Knotenregel je User) | `app/Services/Backoffice/SalesVolumePoints.php` (`commissionPoints()`, `commissionPointsByUser()`, `floorPerUser()`) |
| Provisionsbaum als zweite Baum-Definition + Umschalter, gespeicherte Struktur für abgeschlossene Monate | `app/Services/Backoffice/BackofficeDashboardService.php` (`TREE_COMMISSION`/`TREE_DOWNLINE`, `commissionLineBuckets()`, `storedCommissionLineBuckets()`, `countsInCommissionTree()`, `liveOverview()`) |
| Detaillisten folgen der Baum-Definition; Gesamtpunkte je Berater = Provisionspunkte | `app/Services/Backoffice/BackofficeDrilldownService.php` |
| Payline-Prognose nutzt dasselbe Prädikat (kein Duplikat mehr) | `app/Services/Abo/PaylineForecastService.php` |
| Snapshot je Baum-Definition (Spalte `tree`, Altbestand = `downline`) | Migration `2026_09_14_140000_add_tree_to_backoffice_statistics_snapshots_table`, `BackofficeStatisticsSnapshot`, `backoffice:store-statistics-snapshots` (speichert beide Varianten, `--tree=`) |
| Umschalter „Provisionsbaum (wie Team-Struktur)“ / „Gesamte Downline“, Hinweistexte, Session-Merker | `BackofficeStatisticsController`, `resources/views/user/backoffice/statistics/{index,details}.blade.php` |
| Struktur-Detail: „Gesamt“ = Summe der Linien mit Dezimalen (statt `total_pp` als int) | `resources/views/admin/business_optimized/_user_detail_in.blade.php` |
| Mess-Command | `php artisan statistics:compare-trees {monat} {jahr} [--user=ID] [--lines] [--live-structure] [--repair-running-totals]` |
| Tests | `tests/Feature/StatisticsCommissionTreeTest.php` (neu), Anpassungen in `StatisticsConsistencyTest`, `BackofficeStatisticsListsTest`, `BackofficeStatisticsDrilldownTest`, `BackofficeStatisticsAccessTest`, `tests/Unit/Services/BackofficeDashboardServiceTest` |
**Ergebnis des Messlaufs (Dev-DB, 14.09.):**
- Die Punkte-Differenz DL vs. PB ist bei allen sieben VIPs 0 – die ausgefilterten Berater (49–297 je VIP) haben keinen Umsatz. Die Ursache der Kundinnen-Beobachtung liegt also in der Punkteart: Nur-KP-Buchungen (600–1.690 Punkte je VIP im September, 4.800–9.090 im August) waren in der Statistik enthalten, in der Struktur nicht. Diese Punkte werden jetzt unter der Tabelle gesondert ausgewiesen.
- September (beide live): Statistik PB = Struktur bis auf 216 Punkte (mivita/Claudia/Go4) bzw. 129 (Rosa). Das ist exakt die **Laufstand-Drift** zweier User (848: −129, 1543: −87): zwei Abo-Ausführungen liefen fast zeitgleich (04:04:19 / 04:04:35), die kumulativen Spalten `month_shop_points` haben sich überholt, die letzte Zeile ist zu niedrig. **Der TreeCalcBot liest genau diese Spalte – der Struktur und damit der Provision fehlen die Punkte.** Die Statistik summiert die Einzelzeilen und ist richtig. `--repair-running-totals` rechnet die Laufstände mit `SalesPointsVolume::reCalculateSalesPointsVolume()` neu. Im laufenden Monat rechnet die Struktur live aus diesen Spalten – nach der Reparatur stimmt sie sofort (nur `cache:clear` für die Provisions-Kachel). **Nicht** `business:store-optimized` für den Monat starten: das ist der Monatsabschluss (Gutschriften, Level-Update, Bonuspunkte), läuft per Tages-Guard nur am 1. für den Vormonat und würde mit `--clear` doppelte Gutschriften erzeugen.
- August (Struktur = Nachtlauf 01.09.): zunächst 245,50 Differenz = 82 Drift (User 1210) + 163,50 einer Beraterin, die am 01.09. deaktiviert war und am 08.09. reaktiviert wurde. Deshalb liest die Statistik für **abgeschlossene** Monate die Mitglieder aus dem gespeicherten Provisionslauf (`user_businesses`/`user_business_structures`) statt aus dem heutigen Baum → Restdifferenz nur noch die 82 Drift-Punkte.
- Storno-Knotenregel (R4) wurde **nicht** in der Provisionsberechnung angefasst; die Statistik spiegelt sie und weist den nicht abgezogenen Negativsaldo aus.
**Deploy-Schritte** — erledigt am 14.09. (Verlauf und Ergebnis: [deploy-2026-09-14.md](../2026-07-22/deploy-2026-09-14.md))
1. `php artisan migrate` (markiert bestehende Snapshots als `downline`).
2. `php artisan backoffice:store-statistics-snapshots --force` (beide Baum-Definitionen mit neuer Punkte-Definition für alle abgeschlossenen Monate; Provisionsbaum-Variante kommt aus dem jeweils gespeicherten Provisionslauf).
3. `php artisan sales:repair-running-totals` (repariert die Laufstände im laufenden Monat; Struktur ist danach sofort richtig). **August nicht anfassen** (Entscheid 14.09.: keine Nachzahlung, keine Nachberechnung) – die Laufstände des August bleiben so, wie sie abgerechnet wurden; der Fix gilt ab September. Zur Kontrolle vorab: `php artisan statistics:compare-trees 9 2026`.
4. `php artisan cache:clear` (6-h-Caches der Prognosen).
5. Kundenmitteilung: „Umsatz (Punkte)“ zeigt ab jetzt die Provisionspunkte; Nur-KP-Punkte stehen als eigene Zahl darunter; Umschalter „Gesamte Downline“ für die bisherige Sicht.
**Offener Folgepunkt (neu, wichtig) → eigenes Ticket C2, siehe unten.**
**Machbarkeit / Risiken (hoch, aber beherrschbar)**
- `lineBuckets()` ist die Basis **aller** Kacheln (Home-Dashboard, Neupartner, Abos, Top 3, Führungskräfte) – jede Zahl ändert sich, auch rückwirkend in der Anzeige. Nur mit Vorab-Messung und Kundenmitteilung ausrollen.
- **Ursache 4 nicht nebenbei „fixen“:** Das `> 0`-Guard sitzt in der Provisionsberechnung. Es zu ändern, verändert Auszahlungen (Stornos der Downline würden erstmals Provision mindern). Das ist eine eigene fachliche Entscheidung (**Rückfrage R4**), nicht Teil der Angleichung. Bis dahin gleicht sich die Statistik der Struktur an, nicht umgekehrt.
- Offene Frage F4 (abgelaufene Berater) muss vorher beantwortet sein.
### C2 – Laufstand-Race in `user_sales_volumes` (Provision zu niedrig) — Stufe 1+2 live seit 14.09., Stufe 3 offen
**Befund (14.09.):** Jede Buchung schreibt eine Zeile mit ihren eigenen Punkten (`points`) **und** dem kumulierten Monatsstand (`month_TP_points`, `month_shop_points`, `month_KP_points`), den [`SalesPointsVolume::reCalculateSalesPointsVolume()`](../../app/Services/BusinessPlan/SalesPointsVolume.php#L77) nach jedem Insert über alle Zeilen des Users neu rechnet. Die Provisionsberechnung liest **nur den Laufstand der letzten Zeile** ([`UserSalesVolume::getPointsTPSum()`](../../app/Models/UserSalesVolume.php#L239) über `User::getUserSalesVolumeBy()`).
Die Zeilen entstehen in [`Payment::paymentStatusPaidAction()`](../../app/Services/Payment.php#L170), aufgerufen aus den Zahlungs-Webhooks ([`PayoneWebhookHandler:189`](../../app/Services/Payment/Payone/PayoneWebhookHandler.php#L189), [`StripeWebhookHandler:363`](../../app/Services/Payment/Stripe/StripeWebhookHandler.php#L363)) — jeder Webhook ist ein eigener Web-Request in einer eigenen Transaktion (Lock nur auf der Bestellung, nicht auf dem User). Laufen für **denselben Berater** zwei Abo-Ausführungen fast zeitgleich (04:00-Cron, zwei Kundenabos), sieht jede Transaktion unter `REPEATABLE READ` nur ihren eigenen Snapshot: Zeile B wird ohne die noch nicht committete Zeile A summiert. Die höhere ID (B) ist dann die „letzte Zeile“ mit einem zu niedrigen Laufstand.
**Es geht keine Buchung verloren** – alle `points` sind korrekt, die Statistik (Zeilensumme) stimmt. Falsch ist der Laufstand, den die Provision liest. Der Fehler heilt sich nur, wenn derselbe User im selben Monat später noch eine Buchung bekommt (dann wird alles neu summiert). Ist die betroffene Zeile die letzte des Monats, geht der Monatsabschluss mit dem falschen Wert in die Provision.
**Ausmaß (Dev-DB, alle Monate seit 2025):** 06/2026: 1 User / 66 Punkte · 08/2026: 1 User / 82 Punkte (abgerechnet!) · 09/2026: 2 User / 216 Punkte (offen). Nachweis: `php artisan statistics:compare-trees {monat} {jahr}` (Abschnitt „Laufstand-Drift“).
**Entscheid (14.09.):** Keine Nachzahlung und keine Nachberechnung für Juni/August. Der Fix gilt ab September 2026; abgerechnete Monate bleiben unverändert.
**Umsetzung (Stufe 1 + 2 umgesetzt, Stufe 3 offen)**
| Stufe | Was | Stand |
|---|---|---|
| 1 Sicherheitsnetz | Neues Kommando [`sales:repair-running-totals`](../../app/Console/Commands/SalesRepairRunningTotals.php) täglich **02:45** im Scheduler ([`Kernel.php`](../../app/Console/Kernel.php)), also vor dem Provisionslauf um 03:00. Prüft alle User mit Buchungen im laufenden Monat und im Vormonat (Vormonat nur, solange er noch nicht abgerechnet ist – kein Anfassen abgeschlossener Monate). Abweichende User werden neu gerechnet, Ergebnis geht ins `cron`-Log. Manuell: `{monat} {jahr}`, `--dry-run`; ein abgerechneter Monat wird ohne `--force` verweigert. Logik im Service [`SalesVolumeRunningTotalService`](../../app/Services/BusinessPlan/SalesVolumeRunningTotalService.php), den auch `statistics:compare-trees` nutzt. | umgesetzt |
| 2 Ursache | [`SalesPointsVolume::runningTotalRows()`](../../app/Services/BusinessPlan/SalesPointsVolume.php) liest die Zeilen des Beraters jetzt sperrend (`SELECT … FOR UPDATE`): ein sperrendes Lesen liefert immer den zuletzt committeten Stand und wartet auf die parallele Insert-Transaktion – die zweite Buchung sieht die erste. Neuer Index (`user_id`, `year`, `month`) (Migration `2026_09_14_160000`), damit nur der Monatsbereich des Beraters gesperrt wird. Beide Webhook-Handler (Payone, Stripe) laufen den Paid-Pfad über `DB::transaction($fn, 3)`: bei Deadlock/Lock-Timeout wird die ganze Transaktion wiederholt. In `Payment::paymentStatusPaidAction()` werden Deadlock/Lock-Timeout aus der Rechnungserstellung **nicht mehr geschluckt** (vorher hätte der try/catch dort eine bereits zurückgerollte Transaktion als „bezahlt“ bestätigt) – neuer Helfer [`ConcurrencyError`](../../app/Services/Database/ConcurrencyError.php). | umgesetzt |
| 3 Strukturell | Provision aus der Zeilensumme rechnen statt aus dem Laufstand der letzten Zeile (`getPointsTPSum()` als `SUM(...)` je User/Monat) – Teil des Ledger-Plans `dev/2026-09-10-business-plan/`. Dann sind die `month_*`-Spalten nur noch informativ. | offen |
**Grenzen des Tests:** Die Pest-Suite läuft auf SQLite, das kein `FOR UPDATE` kennt; der Race selbst ist dort nicht reproduzierbar. Getestet sind Erkennung, Reparatur, Monatsauswahl des Nachtlaufs, die Sperr-Flagge der Abfrage und die Deadlock-Erkennung ([`SalesVolumeRunningTotalTest`](../../tests/Feature/SalesVolumeRunningTotalTest.php)). Auf MySQL wirkt die Sperre über den Index; Nachweis im Betrieb: `sales:repair-running-totals --dry-run` meldet über mehrere Nächte „keine Abweichung“.
**Nebenwirkung Retry:** Ein Retry wiederholt den ganzen Paid-Pfad. Alles darin ist Datenbank-Arbeit bis auf das Dispatch des Stripe-Rechnungsreferenz-Jobs; der könnte im seltenen Deadlock-Fall doppelt in die Queue gehen (idempotent, setzt nur die Referenz).
**Deploy-Schritte C2** (erledigt 14.09.): `php artisan migrate` (Index) · `php artisan sales:repair-running-totals` (September einmalig reparieren; danach übernimmt der Nachtlauf) · `php artisan cache:clear` (Provisions-Kachel).
---
## 4. Backoffice-Inhalte
### D1 – Neuer Reiter „Events“ mit Foto-Galerien
**Ziel (Protokoll):** Reiter ähnlich „News Archiv“, in dem Susi regelmäßig Fotos von Veranstaltungen und Calls hochlädt (Jahresevent, Firmencalls). Fotos liegen Kevin bereits vor.
**Ist-Stand**
- News: [`DashboardNews`](../../app/Models/DashboardNews.php) (`title, teaser, content, trans_*, file_links, active, display_date`), Admin-CRUD in [`SitesController:24-113`](../../app/Http/Controllers/SitesController.php#L24), User-Seite [`HomeController::newsArchive()`](../../app/Http/Controllers/HomeController.php#L42), Routen `crm.php:79` und `:270-274`, Sidenav `:51-57` (User) und `:548-559` (Admin). **Keine Bild-Spalte, keine Galerie** – nur Links ins Download-Center.
- Galerie-Muster vorhanden: [`ProductController::imageUpload/imageDelete`](../../app/Http/Controllers/ProductController.php#L151-L215) mit Slim-Cropper, `ProductImage` (`pos`, `active`), `Storage::disk('public')`, View `admin/product/images.blade.php`.
**Umsetzung**
1. Migrationen `events` (`title, teaser, content, event_date, active, sort`, optional `trans_*` wie News) und `event_images` (`event_id, filename, original_name, ext, mime, size, pos, active`).
2. Admin-CRUD `Admin\EventController` nach dem News-Muster + Bild-Upload nach dem Produkt-Muster (Mehrfach-Upload nacheinander per JS, serverseitig auf max. 1920 px verkleinern + Thumbnail 400 px über GD/Imagick, beides installiert). Sortierung per Drag & Drop wie Produktbilder.
3. User-Seite `/events` (Liste nach Datum, Kachel mit Titelbild, Detail mit Galerie und Lightbox-Modal), Sidenav neben „News Archiv“, Berechtigung wie News (Admin-Rolle `admin >= 2`; Susi braucht diese Rolle).
4. Routen im `admin`-Block von `crm.php`, `navigation.php` de/en/es/fr.
5. Tests: CRUD-Feature-Test, Upload-Test mit `Storage::fake('public')`, Zugriff nur für aktive Accounts.
**Risiken:** niedrig. Speicherplatz und Upload-Limits (`upload_max_filesize`/`post_max_size` prüfen) → Einzel-Uploads statt eines Multipart-Requests mit 50 Fotos.
### D2 – Top 3 firmenweit im Backoffice
**Ziel (Protokoll):** Nach Klärung mit dem Network-Anwalt die Top 3 jeden Monat firmenweit anzeigen; bis dahin nur teamintern (Ist-Zustand).
**Ist-Stand:** [`TopResultsService`](../../app/Services/Team/TopResultsService.php) (`SCOPE = 'team'`, Kommentar beschreibt den Firmen-Umbau über `scopedUserIds()`). Offene Punkte aus K10: Cache-Key ohne User, „ich“-Markierung nach dem Cache setzen, Einwilligungstext Profilfoto (Platzhalter).
**Empfehlung:** Nichts bauen, bis die schriftliche Freigabe (F3) vorliegt. Umsetzung dann 0,5 PT plus Einwilligungstext.
---
## 5. Aktionen
### E1 – Recruiting-Challenge Oktober–Januar
**Ziel (Protokoll):** Pro Neupartner mit Paket 50 € im Qualifikationszeitraum, Top 3 erhalten zusätzlich einen Bonus, Auszahlung gesammelt im Februar, wieder ein Ranking. Details klären Dani und Alois.
**Ist-Stand:** Incentive-Modul passt fast vollständig: [`Incentive`](../../app/Models/Incentive.php) (`qualification_start/end`, `calculation_end`, `points_partner_onetime`, `max_winners`), [`IncentiveTracker::trackNewPartner()`](../../app/Services/Incentive/IncentiveTracker.php#L24-L66) feuert exakt bei „bezahlte Registrierung mit Starterpaket“ ([`ShoppingOrder::qualifiesForIncentiveTrackedPartner()`](../../app/Models/ShoppingOrder.php#L443)), Ranking, Teilnahme, Detailansicht und Admin-CRUD vorhanden. `IncentiveParticipant::rebuildFromSourceTables()` erlaubt nachträgliches Aufsetzen – **Start am 01.10. ist also kein harter Termin für die Entwicklung.**
**Umsetzung**
1. **Nur Konfiguration (0,5 PT):** Incentive anlegen (01.10.2026–31.01.2027, `calculation_end` 28.02.2027, `points_partner_onetime = 1`, `points_abo_onetime = 0`, `min_direct_partners = 0`, `min_customer_abos = 0`, `max_winners = 3`), Texte/Bild.
2. **Erweiterung (1–2 PT):** Felder `amount_per_partner` (50 €) und `bonus_rank_1/2/3`; Auszahlungs-Export (XLSX, Muster Newsletter-Export) für die Februar-Auszahlung bzw. Gutschrift über `Credit`; Sidenav-Verlinkung für **mehrere parallele** aktive Incentives ([`HomeController:38-41`](../../app/Http/Controllers/HomeController.php#L38) nimmt heute nur `first()` – Montenegro läuft bis Anfang Oktober parallel).
3. Sobald D2 freigegeben ist, dieselbe Rangliste firmenweit.
**Risiko:** niedrig. Offene Details (Bonushöhe Top 3, Anrechnung von Stornos/Rücktritten innerhalb des Zeitraums – `IncentivePointsLog.is_storno` existiert) mit Dani/Alois klären.
---
## 6. Rückfragen an die Kundin / Alois (vor Start des jeweiligen Pakets)
| # | Frage | Betrifft |
|---|---|---|
| **R1** | Gewünschter Tag im laufenden Monat schon vorbei (heute 12., Wunsch 5.): Fehlermeldung, oder automatisch für den Folgemonat übernehmen? | A1 |
#Einstellungen können immer nur im aktuellen Monat vorgenommen werden, bis die Ausführung erfolgt ist. Nach erfolgreicher Ausführung kann der Termin im Folgemonat festgelegt werden.
| **R2** | Gilt „keine Sperre mehr“ auch für Pausieren/Kündigen (heute 3 Tage vorher)? Empfehlung: ja, bis 23:59 Uhr des Vortags. | A2 |
#ja
| **R3** | Berater-Ausfall: 2 Tage Versatz auch bei Karten-/PayPal-Zahlern, oder dort sofort pausieren? (Sponsor-Kurzinfo bei Stufe 1 und Deploy-Effekt sind am 14.09. entschieden.) | B2 |
#auch zwei tages versatz
| **R4** | Sollen Stornos der Downline die Team-Punkte (und damit Provisionen) mindern? Heute werden negative Monate einzelner Partner im Provisionsbaum ignoriert. | C1 |
#müsste noch einmal genau geprüft werden. Eigentlich sollten Stornos immer für den aktuellen Monat abgezogen werden. Würde damit auch Team Punkte mindern, da die Punkte ja aus der Summierung rausfallen. Ist der Monat abgeschlossen, werden die Zahlen ja fest ermittelt und auch Provisionen erstellt. Dann dürfen stornos nicht mehr rückwirkend die Statistik oder punkte verändern.
| **R5** | Kundenabo → Partnerabo: Freigabe der Kompensationsprodukte durch wen? Sponsor automatisch der bisherige Berater? Zeitpunkt der Umstellung? | A5 |
#das macht der neue Partner selber wenn er sich wieder einloggt hat er die Möglichkeit sein Abo zu Transformieren d.h. zu einem Berater Abo mit entsprechenden Preisen und Kompensationsprodukt was er auswählen muss. Das kann nur er machen. Er muss es auch akzeptieren und bestätigen.
| **R6** | Auto-Abbuchung: wie viele Tage vor Ablauf, welcher Zahlungsanbieter, auch Reaktivierung bereits deaktivierter Berater? | B1 |
STRIPE Wie Abo fünf Tage vorher wird versucht zu buchen. Das muss auch kommuniziert werden über die Mails Hinweise etc.
Wäre auch eine Ankündigung, wenn eine automatische Buchung kommt zwei Tage vorher, Reaktivierung ist nicht möglich, weil das über sepa lief.
| **F3/F4 (alt)** | Anwaltsfreigabe Top 3 firmenweit; Statistik mit/ohne abgelaufene Berater. | D2, C1 |
#Hier warten wir auf Freigabe
---
## 7. Querschnitt
- **Sprachdateien:** jede Textänderung in de/en/es/fr (`LocalizationSyncTest`).
- **Tests:** Suite läuft auf SQLite in-memory; Stand 14.09.: 92 vorbestehende Fehlschläge (siehe `2026-09-08.md`), neue Tests müssen isoliert grün sein. Admin-Routen-Tests brauchen den CRM-Host-Trick aus `ConsultantTransferTest`.
- **Settings/Deploy:** A2 setzt `abo-lock-days-change` (und ggf. `-pause-cancel`) auf 0; C1 braucht `cache:clear` und Snapshot-Neuberechnung; B2 braucht `--dry-run`-Liste vor dem ersten Lauf.
- **Pint** nach jeder Änderung.