Eine Seite unter /incentive/{slug}: oben Teilnahme-Block mit Bedingungen
(oder fuer bestaetigte Teilnehmer der Kopf mit Rang, Punkten, Praemie und
Bedingungen zum Aufklappen), darunter Titelbild, Text, Kacheln, "So
funktioniert es", Galerie und das Live-Ranking mit Sprungmarke #ranking.
Die alte URL /incentive/{slug}/ranking leitet dorthin um, Dashboard und
Detailseite verlinken auf die Sprungmarke, show.blade.php entfaellt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
72 KiB
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) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort. E1 (Recruiting-Challenge) umgesetzt 18.09., Deploy offen — Konfiguration im Admin, Details unten.
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) | 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) – umgesetzt 18.09., Deploy offen (Migration + Anlage im Admin) | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Bonushöhe/Texte Dani/Alois |
Reihenfolge (festgelegt 14.09.):
- 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“).
- Abo-Paket – A4 → A3 → A1+A2 (A1 und A2 greifen ineinander, gemeinsam testen).
- 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).
- Backoffice – D1 (Events-Reiter).
- 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]. Alle Verbraucher lesen dieses Array: Select-OptionenHTMLHelper::getAboDeliveryOptions(), ValidierungAboRepository::validate()undPortal\AboController, Admin-FilterAdmin\AboController, PrognosenPaylineForecastServiceundTeamAboOverviewService. - 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()rechnetfirstOfMonth()->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 inAboRepository::calculateAdminNextDate(). Die richtige Klammerung existiert bereits inadvanceAboCandidateOneMonth()(min($tag, daysInMonth)), wird aber nur für die Erst-Ausführung genutzt. - Änderungslogik heute:
calculateNewNextDate()nimmt den Monat vonnext_dateals Referenz (liegtnext_dateschon 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– bleibt.
Umsetzung
UserAbo::$aboDeliveryDaysdurchUserAbo::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()).- Zentrale Datumsfunktion
AboHelper::dateForDayInMonth(Carbon $monat, int $tag)mitmin($tag, daysInMonth);setNextDate(),calculateAdminNextDate(),computeFirstAboCandidateWithoutMinDays()darauf umstellen. Wichtig: der Folgemonat wird immer ausabo_intervalberechnet, nie aus dem geklammerten Vortermin (sonst bleibt ein 31er-Abo nach dem Februar dauerhaft auf dem 28.). - Neue Änderungsregel in
AboRepository::validate()(ersetzt die 10-Tage-Prüfungerror_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“.
- Referenzmonat = Monat von
- Admin-Modal (
modal_abo_update.blade.php:44-63): Monat 0–3 voraus bleibt, Tag 1–31. - Admin-Filter „nächste Ausführung am …“: statt Dropdown mit 31 Werten ein Datumsfilter (von/bis) auf
next_date. - 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 beliebigennext_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. - Sprachdateien de/en/es/fr (
LocalizationSyncTesterzwingt 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:abo-lock-days-change(Default 10, Tag + Produkte) undabo-lock-days-pause-cancel(Default 3). Beide Getter fallen bei 0 auf den Default zurück ($days > 0 ? $days : DEFAULT) –AboLockDaysSettingTest::32sichert das sogar ab. Sperre auf 0 stellen funktioniert heute nicht. - Bedingung
daysUntil < lockDaysmitnow()->diffInDays(next_date): am Ausführungstag selbst istdaysUntil = 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$changeLockedinUser\AboController:206-286undPortal\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
- Getter-Semantik: Setting leer/nicht vorhanden → Default; Setting
0→ 0 (explizit erlaubt). Test „fällt bei 0 auf den Default zurück“ entsprechend umschreiben. - Sperrbedingung auf Kalendertage umstellen (
AboHelper::calendarDaysUntil()existiert) und als „gesperrt ab 0:00 Uhr des Tagesnext_date − lockDays“ definieren, alsodaysUntil <= lockDays. MitlockDays = 0ist 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. - Setting
abo-lock-days-changein den Admin-Einstellungen auf 0 setzen (Deploy-Schritt, kein Code). Default-Konstante auf 0 ändern, damit ein Test-/Neusystem gleich richtig läuft. - 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.
- Hinweistexte („Änderungen müssen mindestens :lock Tage vorher erfolgen“) für
lock = 0umformulieren: „Ä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::$aboDeliveryDaysersetzt durchUserAbo::allowedDeliveryDays()(1–31) undisAllowedDeliveryDay(); 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 ausabo_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 → Fehlererror_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 weiterhinerror_abo_interval_too_soon. -
Sperre (A2, R2): Defaults
abo-lock-days-changeundabo-lock-days-pause-cancel= 0; Setting leer → Default, Setting0→ 0 (vorher fiel 0 auf 10/3 zurück). Bedingung jetztKalendertage bis next_date <= lock, zentral inAboHelper::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 überAboHelper::lockMessage(): bei 0 die_zero-Varianten („…wird heute ausgeführt. Nach der Lieferung kannst du…“), sonst die Varianten mit:lockTagen. -
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); angepasstAboLockDaysSettingTest,AboRepositoryDateChangeTest,AboPauseTest,AboPauseCancelSectionTest,PaylineForecastServiceTest. 335 Tests der Abo-/Prognose-Suite grün (bis auf den vorbestehenden Fehlschlag inAboPendingInitialPaymentTest). -
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 — 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: 2 okay, 3 angehalten (= Zahlung fehlgeschlagen, gesetzt inUserMakeAboOrder::updateAboOnError()), 4 storniert, 5 beendet, 6 inaktiv (nur Admin), 7 kulanz, 8 extern erledigt. - Pause hat keinen Status: Pausieren verschiebt nur
next_dateum einen Zyklus, setztlast_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/neuenext_date) – wird nirgends angezeigt. - Zahlungsfehler:
user_abo_ordershat nurstatus+paid, keine Fehlermeldung. Der Grund (Karte abgelaufen, Deckung, PayPal) geht verloren. - Anzeigen: Detail
_detail.blade.php:27-28(Status-Badge + Aktiv-Badge), Listenuser/abo/index,user/abo/_customer_abo_list,user/team/customer_abos, Admin-Datatable, Portalportal/abo/my_abo.
Umsetzung
- Datenmodell (Migration):
user_abos.status_reason(string, nullable; Wertepayment_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 aususer_abo_status_historiesfür Bestandsdaten. - Schreiben an den bestehenden Stellen:
updateAboOnError()(Fehlertext aus der Payone-/Stripe-Antwort mitnehmen),AboRetryPaymentService::markAboError()/markAboSuccess(),pauseOneMonthLocked()(paused_until = neues next_date, Grund ausview),updateStatus()(Kündigung/Deaktivierung), Erfolgs-Ausführung setztstatus_reason = null,paused_until = null. - 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“. - 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“. - Pausen-Badge auch ohne Migration herleitbar (
pause_count > 0 && last_pause_date > last_date) – als Fallback für alte Daten. - 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()imsaving-Event, statt in jedem der sechs Schreibpfade): Status 3 ohne Grund →payment_failed; Status 4 / 6 →cancelled_by_*/deactivated_by_*mit dem Formular-Kanal auswithStatusLogView()(portal → customer, me/ot → consultant, admin → admin, sonst system); zurück in den Lauf (2/7/8) → Grund gelöscht; Änderung vonlast_date(= eine Ausführung lief, Erfolg oder Fehler) →paused_untilgelöscht. Die Pause imAboRepositorysetztpaused_until(= neuesnext_date) undpaused_by_*. - Fehlertext der Zahlung:
UserMakeAboOrder::updateAboOnError()undAboRetryPaymentService::markAboError()schreibenuser_abo_orders.error_message([Code] customermessage/errormessage, max. 255 Zeichen, überAboStatusExplainer::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 aususer_abo_status_histories, die Pause auspause_count/last_pause_datevs.last_date(Fallback inUserAbo::pausedUntil()). - Anzeige über
admin/abo/_status_explain.blade.phpin 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 (ScopesUserAbo::paused()/paymentFailed()). - Backfill
php artisan abo:backfill-status-reasons [--dry-run]: Status 3/4/6 ohne Grund und laufende Pausen ohnepaused_until(Query-Builder, keine Model-Events,updated_atunverändert). - Sprachdateien de/en/es/fr (
state_*,state_text_*,actor_by_*,filter_state). Tests:AboStatusExplainTest(11 Tests), alle 40 Abo-Testdateien grün bis aufAboPendingInitialPaymentTest„zeigt gesendete Abo-Zahlungslinks“, der schon auf dem committeten Stand fehlschlägt (rendertuser/abo/indexmitForm::openohne 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 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
- 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.
- 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 ausCustomerDeleteTest/ Kunden-löschen-Modal). - Vor Erreichen der Mindestlaufzeit: Hinweis, ab wann pausieren/kündigen möglich ist (Text
info_min_duration_orders_leftexistiert). - 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 neuAboHelper::isPauseCancelLocked(), spiegelt die Prüfung ausAboRepository. - 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 — Erfolgsmeldungabo.cancel_success, kein Neuberechnen vonnext_dateund 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), bestehendeAboPauseTest,AboStatusGuardTest,AboUpdateAuthorizationTest,AboLockDaysSettingTestgrün. - Deploy: Dateien hochladen,
php artisan view:clear. Keine Migration, kein Setting, keine Daten.LocalizationSyncTestschlägt auf dem Branch weiterhin wegenmsg.php(Berater-Übertragung, nicht committet) undpayment.php(es/fr seit2e71173unvollstä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 Zuordnunguser_id/member_id/shopping_user_id(AboHelper::createNewAbo()). Zahlungs-Token (payone_userid,stripe_customer_id) hängen am Abo. - Ausführungszähler
getCountPaidOrders()zähltuser_abo_ordersdesselben 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 überShop::getCompProducts('abo-me')(show_on = 12), gesteuert über Settingis_comp_me_abo. Fürot-Abos gibt es heute keine Kompensation. - Es existiert kein Umwandlungspfad.
Web\RegisterControllerlegt nur denUseran und schaut nicht nachShoppingUser/UserAbo. Wiederverwendbar:AboPaymentMethodService(Zahlungsart neu hinterlegen),AboItemHistoryService,UserAboStatusHistory,AboMoveOrders(Muster).
Umsetzung (In-Place)
- Erkennung: nach Freischaltung des Partners (
wizard = 100, bezahlte Registrierung) aktiveot-Abos mit gleicher E-Mail (user_abos.emailbzw.shopping_users.billing_email) suchen → Dashboard-Banner „Kundenabo in Partnerabo umwandeln – 30 % günstiger“. - Umwandlungsseite (CRM,
user/abos/convert/{abo}): Vorschau der Positionen mit Partnerpreisen, Kompensationsprodukte als Slots (comp > 0) wählbar, Summe/Punkte neu. - Zahlung bestätigen: neuen Token über den bestehenden Zahlungsart-ändern-Flow (
AboPaymentMethodService, Payone/Stripe jePaymentProviderManager), da Betrag und Vertragspartei wechseln. - Schreiben in einer Transaktion:
is_for = 'me',user_id = neuer User,member_idwie beime-Abos,shopping_user_id= eigenerShoppingUsermitauth_user_id, Items ersetzen, Preise neu snapshotten,UserAboStatusHistoryneue Aktionconverted, Item-History.user_abo_ordersbleiben → Zähler läuft weiter. - 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, Incentiveincentive_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_itemshaben 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 inPayment::paymentStatusPaidAction()(Produkt-Actionpayment_for_account). Verlängerungsproduktshow_renewalseit Commitc3dc7eb; 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 überUserHistory), Configmivita.remind_*. - Berater haben kein gespeichertes Zahlungsmittel. Keine Token-Spalte auf
users;user_accounts.payment_dataenthält nur alte ELV-Daten (SEPA abgeschafft). Wiederkehrende Token existieren nur an Abos (payone_userid,stripe_customer_id) undshopping_users.stripe_customer_id. - Wiederverwendbar: Off-Session-Charge
UserMakeOrder::makePayment(),StripeGateway::chargeAbo()(aufUserAbotypisiert → verallgemeinern), Retry-MusterAboRetryPaymentService, Karten-HinterlegungAboPaymentMethodService. 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
- Migration:
users.renewal_payment_provider,renewal_payment_token(Payoneuseridbzw. Stripecustomer_id+payment_method_id),renewal_auto_at(optional). - 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). - Service
MembershipRenewalService::charge(User): erstellt Verlängerungs-ShoppingOrder(Logik ausMembershipController::storePayment()herauslösen), bucht off-session über den gespeicherten Token, Erfolg → bestehenderpaymentStatusPaidAction-Pfad (verlängert, reaktiviert, Rechnung wie jede Bestellung). - Command
membership:auto-renew(täglich ~02:30, voruser: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. 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.- 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.):
- Zahlung bleibt aus → Account wird pausiert / inaktiv.
- Kurz danach (Versatz ca. 2 Tage) wandern Kunden und Team zum Sponsor, damit die Punkte bei ihm eingehen.
- 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.
- 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 zusetNewSponsorToChilds()/resetChildsToSponsor(), (2) eine Service-KlasseConsultantSuspensionServicemitsuspend(),reinstate(),terminate(), die Cron, Zahlungs-Hook und Admin-Pfad gemeinsam nutzen (heute drei abweichende Pfade), (3) MailableSponsorTransferMail, (4) Config +--dry-run, (5) Tests. - Keine neue Tabelle:
shopping_user_member_logshat bereitspre_member_id; für Abos und Portal-Logins entweder zwei kleine Log-Tabellen nach demselben Muster oder eine Spaltetypeinshopping_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
config/mivita.php:cleanup_suspend_days = 2,cleanup_delete_days = 28(beide Stellen inUserCleanUpsind heute hart codiert:-2 weeks,-2 month).UserUtil:setCustomerDataToNewMember($from, $to)(Kunden + Abos + Logins, protokolliert) undresetCustomerDataToMember($userId)(nur Datensätze, deren aktuellermember_idnoch dem im Log eingetragenen Ziel entspricht).ConsultantSuspensionService:suspend(User)= Stufe 1 in einer Transaktion;reinstate(User)= Rückgabe;terminate(User)= Stufe 2 + Mail.UserCleanUp,Payment::paymentStatusPaidAction(),AdminUserController(Datum setzen) undAdminUserCleanupController::restore()rufen nur noch diese drei Methoden.- Zwei Mailables an den Sponsor (Queue):
SponsorSuspensionNoticeMailbei Stufe 1 (Name des Beraters, Frist als Datum und Resttage, kein Listeninhalt) undSponsorTransferMailbei 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. - Texte: Stufe 35 (Berater), neue Sponsor-Mail, Dashboard-Banner
_reminder.blade.php; de/en/es/fr. user:cleanup --dry-runlistet beide Stufen mit Anzahl Kunden/Abos/Teampartner je Berater.- 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 (
AnnualFeeDueServicebleibt davon unberührt, sie arbeitet überpayment_account). - Berater selbst als Kunde:
setUserToClient()legt immer einen neuenShoppingUseran; 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ßtUserCleanUp(funktioniert nur durch case-insensitives Autoload);AdminUserCleanupController::restore()ruftuser:restorezweimal 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) |
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
- 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. - Gemeinsames Prädikat aus
PaylineForecastServicein einenCommissionTreeDefinition-Service heben;lineBuckets()bekommt die Provisionsbaum-Variante; Punkte-Metrik der Statistik auf TP (status_points != 2) umstellen. - Umschalter „inkl. abgelaufene Berater“ (Empfehlung aus F4), damit die Jahresgebühr-Nachfassliste nicht verschwindet.
- Struktur: Dezimalen anzeigen (Cast/Formatierung, nicht die Speicherung), Snapshot-Alter anzeigen bzw. „live“ für den laufenden Monat.
BackofficeStatisticsSnapshotfür geschlossene Monate neu rechnen,cache:clearnach Livegang (6-h-Caches ohne Invalidierung).- Test:
StatisticsConsistencyTestum „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_pointshaben 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-totalsrechnet die Laufstände mitSalesPointsVolume::reCalculateSalesPointsVolume()neu. Im laufenden Monat rechnet die Struktur live aus diesen Spalten – nach der Reparatur stimmt sie sofort (nurcache:clearfür die Provisions-Kachel). Nichtbusiness:store-optimizedfü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--cleardoppelte 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)
php artisan migrate(markiert bestehende Snapshots alsdownline).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).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.php artisan cache:clear(6-h-Caches der Prognosen).- 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
- News:
DashboardNews(title, teaser, content, trans_*, file_links, active, display_date), Admin-CRUD inSitesController:24-113, User-SeiteHomeController::newsArchive(), Routencrm.php:79und: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/imageDeletemit Slim-Cropper,ProductImage(pos,active),Storage::disk('public'), Viewadmin/product/images.blade.php.
Umsetzung
- Migrationen
events(title, teaser, content, event_date, active, sort, optionaltrans_*wie News) undevent_images(event_id, filename, original_name, ext, mime, size, pos, active). - Admin-CRUD
Admin\EventControllernach 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. - User-Seite
/events(Liste nach Datum, Kachel mit Titelbild, Detail mit Galerie und Lightbox-Modal), Sidenav neben „News Archiv“, Berechtigung wie News (Admin-Rolleadmin >= 2; Susi braucht diese Rolle). - Routen im
admin-Block voncrm.php,navigation.phpde/en/es/fr. - 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 — umgesetzt 18.09., Deploy offen
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
- Nur Konfiguration (0,5 PT): Incentive anlegen (01.10.2026–31.01.2027,
calculation_end28.02.2027,points_partner_onetime = 1,points_abo_onetime = 0,min_direct_partners = 0,min_customer_abos = 0,max_winners = 3), Texte/Bild. - Erweiterung (1–2 PT): Felder
amount_per_partner(50 €) undbonus_rank_1/2/3; Auszahlungs-Export (XLSX, Muster Newsletter-Export) für die Februar-Auszahlung bzw. Gutschrift überCredit; Sidenav-Verlinkung für mehrere parallele aktive Incentives (HomeController:38-41nimmt heute nurfirst()– Montenegro läuft bis Anfang Oktober parallel). - 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, Migration2026_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 = 0schreibt der Tracker keine Umsatzpunkte der Neupartner/Neuabos mehr (IncentiveTracker::trackSalesVolume, Neuaufbau und Reparatur ebenso). Mitpoints_partner_onetime = 1undpoints_abo_onetime = 0entspricht 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_partnerplus 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 überCreditist 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;
HomeControllerliefertactiveIncentives/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, Migration2026_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, SeederRecruitingChallenge2026Seeder. - 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 nachmontenegro-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}/rankingleitet dorthin um,show.blade.phpentfä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 | 01.10.2026 / 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 |
| Status | Aktiv (ab 01.10.; früher anlegen ist unschädlich, getrackt wird nur im Zeitraum) |
| Bild, Untertitel, Beschreibung, Teilnahmebedingungen | Dani/Alois – ohne Beschreibung erscheint der Montenegro-Fallbacktext („Adria“), also Beschreibung unbedingt füllen |
Nachtragen nach dem 01.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: Rangfolge ist dann bestätigte Teilnahme vor anonym, sonst Datenbankreihenfolge. Falls es auf die Boni ankommt, Regel festlegen (z. B. wer die Anzahl zuerst erreicht hat) – Umsetzung 0,5 PT.
- Stornos/Rücktritte: Ein einmal getrackter Neupartner bleibt gezählt, auch wenn die Registrierung später storniert wird (Verhalten wie Montenegro). Vor der Auszahlung die Liste gegen stornierte Registrierungsbestellungen prüfen oder Regel mit Dani/Alois klären.
- 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
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 ausConsultantTransferTest. - Settings/Deploy: A2 setzt
abo-lock-days-change(und ggf.-pause-cancel) auf 0; C1 brauchtcache:clearund Snapshot-Neuberechnung; B2 braucht--dry-run-Liste vor dem ersten Lauf. - Pint nach jeder Änderung.