mivita/dev/_tickets/entwicklungsplan-protokoll-september-2026.md
Kevin f2aa30243f Recruiting Challenge: Firmenkonto ohne Rang und Praemie, Bedingungen Punkt 4 ergaenzt
Kundenentscheid 25.09.2026 zu den Regeln 4 bis 7:
- Firmenkonto (mivita.company_user_id) steht in keiner Rangliste und
  bekommt keine Praemie; getrackt wird weiter, damit ein ueber den
  Firmenlink registrierter Partner nach dem Umhaengen mit umzieht
- Bedingungen Punkt 4 (de/en/es/fr): auch Kunden, die Berater werden,
  zaehlen; massgeblich ist der aktuell hinterlegte Sponsor
- Test: Anmeldung ueber den Firmenlink, Umhaengen, Wertung beim neuen
  Sponsor
- Doku: Regeln 4 bis 7 entschieden

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 11:02:02 +00:00

82 KiB
Raw Blame History

Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026

Stand: 25.09.2026 · Alles bis Commit 792d553 ist live. A1–A4 und E1 seit dem Deploy vom 23.09., B2, A07, Warnung für überfällige Abos (abo:check-overdue), E1-Nachträge (Gleichstand, Zählung nach Zahlungsdatum, Schalter „Abos berücksichtigen“) und die Kundenrückmeldungen vom 24.09. (Punkte-Legende entfernt, Prognose-Titel „Stand Monatsende“) seit 25.09.; Migrationen, Cache-Befehle und die Sperrfrist-Settings (0) sind ausgeführt. Montenegro ist beendet und ausgewertet. Nach dem 25.09. umgesetzt, Deploy offen: B2 Berater ohne Sponsor → Firmenkonto; E1 Storno nimmt Neupartner aus der Wertung, Rang/Auszahlung nur mit bestätigter Teilnahme, Unbestätigte nur für VIP sichtbar. Offen: erster Lauf von user:cleanup (B2, Dry-Run vom 25.09. liegt vor), Start der Recruiting Challenge am 09.10. (nach dem Deploy Seeder einspielen, Freigabe nach Verkündung im Call), dann A5, B1, D1, D2. Kundenübersicht B2: 2026-09-23-mitgliedschaft-ablauf-uebersicht.md. Audit vom 18.09.: 2026-09-18-audit-commits-16-09-2026.md. 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) – live seit 23.09. (Lager/Versand: Freigabe Kevin 16.09.) Ticket #1, Protokoll 3–4 PT mittel –
A2 Änderungssperre erst ab 0:00 Uhr am Ausführungstag – live seit 23.09., Settings auf 0 seit 25.09. Ticket #1 1 PT niedrig zusammen mit A1
A3 Abo-Status und Grund sichtbar (Zahlung / Kunde / Admin) – live seit 23.09. Ticket #2, Protokoll 2–3 PT niedrig –
A4 Pausieren/Kündigen beschriften, Kündigungs-Hinweis (6 Ausführungen) – live seit 23.09. 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 – live seit 25.09., erster Lauf nach Freigabe der --dry-run-Liste 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) 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) – live seit 23.09. (nur VIP), Start 09.10. Protokoll 0,5 PT Konfiguration + 1–2 PT Erweiterung niedrig Bonushöhe/Texte 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 09.10., bis 24.09. geplant 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) — live seit 23.09.

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

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()).
  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): 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) und „Punkte aus meinen Kundenabos zum 5. / 10. / 20. / 25.“ (abo.php:55) 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 — live seit 23.09., Settings auf 0 seit 25.09.

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: 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(), Produkt-Guards $changeLocked in User\AboController:206-286 und Portal\AboController:110-131. 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:

    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 — live seit 23.09.

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: 2 okay, 3 angehalten (= Zahlung fehlgeschlagen, gesetzt in UserMakeAboOrder::updateAboOnError()), 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()). 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 (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 — live seit 23.09.

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 nur sichtbar, wenn canCancel (Mindestlaufzeit erreicht, AboHelper::canCancelAbo(), 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()). Zahlungs-Token (payone_userid, stripe_customer_id) hängen am Abo.
  • Ausführungszähler getCountPaidOrders() 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') (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 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() (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() → normaler Checkout.
  • Erinnerungen: CheckPaymentsAccount (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(), StripeGateway::chargeAbo() (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.

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) Technisch nichts. active bleibt 1, m_sponsor bleibt. Der Berater steht weiter im Provisionsbaum: readParentsBusinessUsers() 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() 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() → reactiveUserResetChilds(): active = 1, Sponsor zurück, Teampartner aus den Logs zurück Kunden müssen nicht zurück – sie waren nie weg.
Tag +60 – deleteInactiveUsers() Kunden (shopping_users.member_id) → Sponsor (shopping_user_member_logs), Berater selbst wird ShoppingUser des Sponsors (setUserToClient()), 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) – 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), 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 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. R3 beantwortet: 2 Tage Versatz für alle Zahlungsarten.

Umsetzung B2 (23.09.2026) — live seit 25.09.

  • Ein Weg für alle drei Pfade: ConsultantSuspensionService mit suspend(), reinstate(), terminate(). user:cleanup, der Zahlungseingang (Payment::paymentStatusPaidAction) und das Ablaufdatum im Admin (AdminUserController, save-account) rufen nur noch diese Methoden.
  • Fristen in config/mivita.php: cleanup_suspend_days = 2, cleanup_delete_days = 28 (Env MIVITA_CLEANUP_SUSPEND_DAYS / MIVITA_CLEANUP_DELETE_DAYS).
  • Stufe 1 (Ablauftag + 2 Kalendertage, Nachtlauf 03:30): Team wie bisher über user_clean_up_logs, dazu jetzt Kunden (shopping_users, auch gelöschte), Kundenabos (user_abos.member_id) und Portal-Logins (customers). Jede Umhängung steht in der neuen Tabelle consultant_suspension_transfers (Migration 2026_09_23_140613); Kunden zusätzlich wie bisher in shopping_user_member_logs, damit sie in der User-Bereinigung sichtbar bleiben. Kurzinfo an den Sponsor (SponsorSuspensionNoticeMail), keine Mail an das Firmenkonto.
  • Rückgabe: Team über die bestehenden Logs, Kundendaten über consultant_suspension_transfers — nur Datensätze, die noch bei dem Sponsor liegen, der sie bekommen hat. Von Hand weiter übertragene Kunden bleiben, wo sie sind.
  • Stufe 2 (Ablauftag + 28 Kalendertage; Zahlung bis Tag 27 gibt alles zurück): was noch am Berater hängt (auch Altfälle, die nach der alten 2-Wochen-Regel inaktiv sind und deren Kunden nie umgezogen wurden), geht endgültig an den Sponsor. Der Berater wird Kunde des Sponsors — ohne Duplikat, wenn dort schon ein Kundendatensatz mit seiner E-Mail oder seinem Login liegt. Listen-Mail (SponsorTransferMail, Vorname/Nachname/Telefon/E-Mail) an jeden Sponsor, der Kunden oder Team dieses Beraters hält; beim Sponsor des harten Cuts steht der Berater selbst mit auf der Liste.
  • Texte: Mail 35 und Dashboard-Hinweis nennen jetzt das Datum der Pause und die Frist für die Rückgabe (:suspend_date, :deadline), de/en/es/fr. Neue Sprachdatei suspension.php.
  • user:cleanup --dry-run listet beide Stufen mit Team, Kunden, Kundenabos, Portal-Logins und bereits geparkten Datensätzen, ohne etwas zu ändern oder zu senden.
  • Nebenbefunde behoben: Kernel referenziert UserCleanUp (vorher UserCleanup), AdminUserCleanupController::restore() ruft user:restore nur noch einmal auf.
  • Testschema: shopping_users.auth_user_id ist auf Produktion nullable, in der Ursprungsmigration NOT NULL — setUserToClient() scheiterte deshalb nur in der Testdatenbank. Migration 2026_09_23_141152 zieht das nach und ändert nur, wo die Spalte noch NOT NULL ist (auf Produktion nichts).
  • Tests: ConsultantSuspensionTest (12 Tests): Tag +1 nichts, Tag +2 Stufe 1 komplett, Kurzinfo mit Frist ohne Listen, keine Mail an Firma, Trockenlauf, Rückgabe per Zahlung, manuell verschobener Kunde bleibt, harter Cut mit Mail-Inhalt, kein Duplikat, Altfall mit liegengebliebenen Kunden, Admin-Pfad, user:restore einmal.

Messung an der Live-Kopie (23.09.): Der erste Lauf würde 6 Berater pausieren (64 Kundendatensätze, 2 Teampartner) und 20 Berater endgültig entfernen (150 Kundendatensätze, 1 Portal-Login). Keine aktiven Kundenabos betroffen. Heute liegen bei 24 inaktiven Beratern 162 Kundendatensätze, deren Umsätze bei niemandem ankommen.

Dry-Run auf Produktion (25.09.): 19 Berater für den harten Cut (alle ohne Team, zusammen 141 Kundendatensätze, 2 Portal-Logins), 8 zum Pausieren (5 Teampartner, 112 Kundendatensätze, 2 Portal-Logins). Keine aktiven Kundenabos und keine eigenen Abos betroffen. Befund: ein Berater wurde 2025 ohne Sponsor registriert (m_sponsor und pre_sponsor leer) und wäre jede Nacht übersprungen worden. Behoben am 25.09.: ohne Sponsor geht alles an das Firmenkonto (ohne Mail). Drei Sponsoren bekommen im ersten Lauf mehrere Mails (einmal 3, zweimal 2).

Deploy (erledigt 25.09.):

php artisan migrate                   # consultant_suspension_transfers (+ Schema-Angleichung, auf Produktion ohne Wirkung)
php artisan view:clear && php artisan config:clear
php artisan user:cleanup --dry-run    # Liste an Alois, erst danach laufen lassen

Der reguläre Lauf um 03:30 übernimmt danach. Kein Snapshot-Neuaufbau. Offen: Freigabe der --dry-run-Liste und der erste Lauf (Thema folgt).

Offen / bewusst nicht gebaut: eigenes Berater-Abo (is_for = me) eines pausierten Beraters läuft zu Partnerkonditionen weiter (heute kein Fall); Admin-Restore nach dem harten Cut holt wie bisher nur das Team über die Logs zurück.


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) Struktur (TreeCalcBotOptimized)
1 Punkteart rohe user_sales_volumes.points ohne status_points-Filter (SalesVolumePoints:43-47) → KP-only-Buchungen zählen mit month_TP_points + month_shop_points (UserSalesVolume:239), 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())
3 Rundung 2 Dezimalen (Util::formatNumber) total_pp cast int (UserBusiness:156)
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() (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)

  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() nach jedem Insert über alle Zeilen des Users neu rechnet. Die Provisionsberechnung liest nur den Laufstand der letzten Zeile (UserSalesVolume::getPointsTPSum() über User::getUserSalesVolumeBy()).

Die Zeilen entstehen in Payment::paymentStatusPaidAction(), aufgerufen aus den Zahlungs-Webhooks (PayoneWebhookHandler:189, StripeWebhookHandler:363) — 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 täglich 02:45 im Scheduler (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, den auch statistics:compare-trees nutzt. umgesetzt
2 Ursache SalesPointsVolume::runningTotalRows() 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. 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). 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

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 (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 — live seit 23.09. (nur VIP), Start 09.10.

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 (qualification_start/end, calculation_end, points_partner_onetime, max_winners), IncentiveTracker::trackNewPartner() feuert exakt bei „bezahlte Registrierung mit Starterpaket“ (ShoppingOrder::qualifiesForIncentiveTrackedPartner()), 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 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.

Umsetzung (18.09.2026)

Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt unverändert (alle neuen Felder leer bzw. Schalter an).

  • Neue Felder (incentives, Migration 2026_09_18_130000_add_payout_fields_to_incentives_table): amount_per_partner (€ je Neupartner, NULL = keine Prämie), bonus_rank_1/2/3 (Zusatz-Bonus je Platz), include_sales_points (Standard 1). Admin-Formular: neue Karte „Prämie / Auszahlung“.
  • Wertung nach Neupartnern: Mit include_sales_points = 0 schreibt der Tracker keine Umsatzpunkte der Neupartner/Neuabos mehr (IncentiveTracker::trackSalesVolume, Neuaufbau und Reparatur ebenso). Mit points_partner_onetime = 1 und points_abo_onetime = 0 entspricht der Punktestand damit der Anzahl Neupartner mit Starterpaket; die Rangliste bleibt die vorhandene (updateRanking). Neupartner = bezahlte Registrierung mit Starterpaket (ShoppingOrder::wherePaidRegistrationIncludesStarterKit), reine Mitgliedschaft zählt nicht.
  • Prämie: IncentivePayoutService: qualified_partners × amount_per_partner plus Rang-Bonus, letzterer nur für qualifizierte Teilnehmer auf Platz 1–max_winners. Grundlage ist der gespeicherte Stand des Teilnehmers (derselbe wie in der Rangliste).
  • Auszahlungs-Export: Admin-Detailseite → Button „Auszahlungs-Export (XLSX)“ (/admin/incentives/{id}/payout-export): Rang, Berater-ID, Name, E-Mail, Teilnahme bestätigt, Neupartner, Betrag je Neupartner, Prämie Neupartner, Rang-Bonus, Prämie gesamt, Punkte, qualifiziert. Nur Zeilen mit Prämie > 0. Dazu Summenblock (Empfänger, Summen) und Prämienspalte in der Admin-Rangliste. Gutschrift über Credit ist nicht gebaut – die Februar-Auszahlung läuft über den Export in die Buchhaltung; Gutschriften ließen sich später aus derselben Liste erzeugen.
  • Mehrere aktive Incentives: Sidenav listet alle aktiven Incentives (bei mehreren mit Namen), Dashboard zeigt je Incentive eine Karte mit eigenen Modal-IDs; HomeController liefert activeIncentives/incentiveParticipants (die alten Einzelvariablen bleiben befüllt). Montenegro kann so bis zum Abschluss parallel stehen bleiben.
  • Berater-Ansichten: Teaser und Rangliste zeigen bei Geldprämie ohne Mindestqualifikation eine Prämien-Kachel statt „Dein Ticket“, blenden die Abo-Blöcke aus, wenn Abos keine Rolle spielen, und ergänzen die Rangliste um die Spalte „Prämie“; Header/Details zeigen „Deine Prämie bisher“. Der Punkte-Boost-Text erscheint nur, wenn Umsatzpunkte zählen.
  • Nur für VIP sichtbar (22.09.): Haken am Incentive (incentives.vip_only, Migration 2026_09_22_100000). Aktives Incentive erscheint dann nur für VIP-Konten (Sidenav, Dashboard, Teaser, Rangliste, Details; andere bekommen 404), Tracking läuft für alle. Zur Abstimmung mit dem Kunden; danach Haken entfernen. Ranglisten-Seite umgebaut: Teilnahme-Block mit Prämien-Chips und Bedingungen steht oben, solange die Teilnahme nicht bestätigt ist, darunter Trennlinie und die Inhalte; Überschriften vergrößert. Inhalte/Texte: 2026-09-18-recruiting-challenge-inhalte.md, Seeder RecruitingChallenge2026Seeder.
  • Bilder je Incentive (22.09.): Titelbild und Galerie liegen in public/img/incentive/<slug>/ (Incentive::heroImagePath(), galleryImages()); der Hauptordner dient nur noch als Fallback fürs Titelbild. Montenegro-Fotos nach montenegro-incentive-2026/ verschoben (beim Deploy mit hochladen). Teaser: Call to Action ganz oben, bei Geldprämie Kacheln „Deine Prämie“ (50 € je Teampartner) und „Top 3 Bonus“ statt Punkte, Abschnitt „So funktioniert die Challenge“.
  • Eine Incentive-Seite (22.09.): Teaser und Rangliste zusammengefasst unter /incentive/{slug} (Teilnahme-Block oder Kopf mit Stand, Titelbild, Text, Kacheln, „So funktioniert es“, Galerie, Live-Ranking mit Sprungmarke #ranking). Die alte URL /incentive/{slug}/ranking leitet dorthin um, show.blade.php entfällt.
  • Tests: IncentiveVipOnlyTest, RecruitingChallengeSeederTest, IncentivePayoutTest, IncentiveSalesPointsToggleTest, IncentiveAdminPayoutTest. Incentive-Suite: 48 grün, 1 vorbestehender Fehlschlag (IncentiveTeaserPageTest, Hero-Bild in der Galerie – unabhängig von E1).

Konfiguration der Challenge (Admin → Incentives → Neu):

Feld Wert
Qualifikationsbeginn / -ende 09.10.2026 (bis 24.09.: 01.10.) / 31.01.2027
Berechnungsende 31.01.2027 (ohne Umsatzpunkte gibt es keinen Nachlauf; 28.02. wäre nur nötig, wenn Umsätze zählen)
Einmalpunkte pro Partner / pro Abo 1 / 0
Mind. direkte Partner / Kundenabos 0 / 0
Max. Gewinner 3
Prämie je Neupartner 50,00
Bonus Platz 1 / 2 / 3 offen – Dani/Alois
Umsatzpunkte mitzählen aus
Abos berücksichtigen aus (seit 25.09.)
Status Aktiv (früher anlegen ist unschädlich, getrackt wird nur im Zeitraum ab 09.10.)
Bild, Untertitel, Beschreibung, Teilnahmebedingungen Dani/Alois – ohne Beschreibung erscheint der Montenegro-Fallbacktext („Adria“), also Beschreibung unbedingt füllen

Nachtragen nach dem 09.10.: php artisan incentive:calculate {id} zieht Neupartner ab Qualifikationsbeginn nach (läuft ohnehin täglich 05:00 für alle aktiven Incentives).

Offen / bewusst nicht gebaut

  • Bonushöhe Top 3, Beschreibungstexte, Bild (Dani/Alois).
  • Punktgleichstand in den Top 3 — umgesetzt 23.09. (Teilnahmebedingungen Punkt 6): bei gleichen Punkten bestätigte Teilnahme vor anonym, danach wer den Stand zuerst erreicht hat (Zeitpunkt des letzten zählenden Neupartners bzw. Neuabos aus incentive_new_partners.registered_at / incentive_new_abos.activated_at), zuletzt die Teilnehmer-ID. IncentiveTracker::updateRanking(), Tests in IncentiveRankingTest. An der Live-Kopie gegengerechnet: Montenegro tauscht nur Plätze ab 81, die 20 Gewinner bleiben gleich.
  • Neupartner zählen ab der Zahlung (23.09.): wie in der Statistik (Kundenentscheid 16.09.) zählt users.partner_paid_at, nicht das Bestelldatum — im Live-Tracking, in der nächtlichen Reparatur und im Neuaufbau (IncentiveTracker::newPartnerCountedAt()). Wer am 07.10. bestellt und am 09.10. zahlt, zählt in der Challenge. Montenegro ist beendet und ausgewertet (Status „Beendet“ seit 25.09.) und bleibt unberührt.
  • Schalter „Abos berücksichtigen“ (24.09., live 25.09.): Die Challenge erfasste Eigen- und Kundenabos mit 0 Punkten (Live-Kopie: 175 Eigenabos); sie standen in „Meine Berechnung“ und im Admin und hätten Berater ohne Neupartner in die Rangliste gebracht. Neues Feld incentives.include_abos (Migration 2026_09_24_133443, für die Challenge aus). Aus = kein Abo-Tracking in Tracker, Neuaufbau und Reparatur, incentive:calculate räumt vorhandene Abo-Zeilen ab, Abo-Punkte und Mindestabos werden auf 0 gesetzt, Abo-Spalten und -Bereiche entfallen. Tests: IncentiveIncludeAbosToggleTest.
  • Start 9. Oktober und Übersetzungen (25.09.): Qualifikationsbeginn 09.10.2026 statt 01.10. (Kundenentscheid 25.09.); Beschreibung und Teilnahmebedingungen nennen den 9. Oktober. Name, Untertitel, Beschreibung und Bedingungen liegen jetzt auch auf Englisch, Spanisch und Französisch vor (trans_*). Der Seeder gleicht Texte, Übersetzungen und den Beginn ab, alle anderen Admin-Werte bleiben unberührt. Einspielen: php artisan db:seed --class=RecruitingChallenge2026Seeder --force, danach im Admin Datum und Texte prüfen. Wer vom 01.10. bis 08.10. bezahlt, zählt nicht.
  • Stornos/Rücktritte: Ein einmal getrackter Neupartner bleibt gezählt — umgesetzt 25.09. (Kundenentscheid 25.09.): Ist jede bezahlte Starterpaket-Bestellung eines Neupartners storniert (Stornorechnung setzt txaction = cancelled, paid bleibt 1), bekommt die Tracking-Zeile incentive_new_partners.cancelled_at (Migration 2026_09_25_124017), eine Storno-Logzeile nimmt die Einmalpunkte zurück. Der Partner zählt nicht mehr, steht in „Meine Berechnung“ aber durchgestrichen mit „Storniert am …, zählt nicht“. Läuft im Nachtlauf (IncentivePointsLogRepairService::markCancelledPartners()) und im Neuaufbau. Stichtag bleibt die Auszahlung: ein beendetes Incentive wird nicht mehr neu gerechnet.
  • Nur bestätigte Teilnehmer in Rangliste und Auszahlung (Kundenentscheid 25.09.): Gezählt wird für alle Berater. Einen Rang (und damit einen Gewinnerplatz) bekommen nur Teilnehmer mit bestätigter Teilnahme; ausgezahlt (Export, Summen) wird nur an sie. Aus Datenschutzgründen sehen andere Berater Unbestätigte in der Rangliste gar nicht mehr, auch nicht anonym; VIP-Konten sehen sie (mit Kennzeichen), der Berater sieht seinen eigenen Eintrag. Unbestätigte sehen ihre Prämie als „Deine Prämie nach Bestätigung“, im Admin steht „Teilnahme nicht bestätigt, keine Auszahlung“. Teilnahmebedingungen Punkt 2 und 7 in allen vier Sprachen angepasst (Seeder). Die Bestätigung wirkt rückwirkend: wer erst am 31.01. bestätigt, bekommt alle Neupartner ab 09.10. (entschieden 25.09., so gebaut). Keine automatische Erinnerung, das läuft über die internen Calls.
  • Freigabe: bleibt auf „Nur für VIP sichtbar“, bis die Challenge im Call (voraussichtlich KW 40) verkündet ist.
  • Regeln 4 bis 7 (entschieden 25.09.): Auszahlung über den Export, den Rest regelt der Kunde. Jedes Starterpaket zählt 50 € (Staffel später möglich). Jeder neue Berater zählt, auch wenn er vorher Kunde war (Satz in Punkt 4 der Bedingungen). VIP-Konten spielen mit; das Firmenkonto bekommt keinen Rang und keine Prämie (IncentiveParticipant::excludedUserIds()), wird aber weiter getrackt. Es zählt immer der aktuelle Sponsor im System: Wer sich über den falschen Link (oft das Firmenkonto) anmeldet und umgehängt wird, zählt nach dem nächsten Nachtlauf beim neuen Sponsor (removeStaleSponsorTrackings + syncMissingTrackingPartners, Test in IncentivePartnerRepairSponsorChangeTest).
  • Gutschrift-Erzeugung aus dem Export (siehe oben).
  • Firmenweite Rangliste (D2) unverändert offen; die Challenge-Rangliste ist wie Montenegro für alle aktiven Berater sichtbar.

Deploy (erledigt 23.09., Nachträge 25.09.)

php artisan migrate        # incentives: amount_per_partner, bonus_rank_1..3, include_sales_points, vip_only
php artisan db:seed --class=RecruitingChallenge2026Seeder   # Challenge als Entwurf, nur VIP, mit Texten
php artisan view:clear && php artisan route:clear

Danach im Admin: Bild, Status „Aktiv“, zur Abstimmung bleibt „Nur für VIP sichtbar“ gesetzt. Kein Neuaufbau, keine Datenänderung an Montenegro.


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 (erledigt 25.09.); C1 braucht cache:clear und Snapshot-Neuberechnung (erledigt 14.09.); B2 braucht --dry-run-Liste vor dem ersten Lauf (offen).
  • Pint nach jeder Änderung.