A1-A4 und E1 live seit 23.09., B2, A07, Abo-Warnung und E1-Nachtraege live seit 25.09., Settings auf 0, Montenegro beendet. Offen: erster user:cleanup-Lauf, Seeder fuer den Challenge-Start 09.10. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
53 KiB
Bewertung des Statistik-Audits vom 09.09.2026
Gegenprüfung des Auditberichts und des Prüfprotokolls am Arbeitsstand vom 09.09.2026.
Auftrag: nur prüfen und bewerten, keine Umsetzung. Es wurde kein Anwendungscode geändert.
Methode dieser Gegenprüfung: jede zitierte Codestelle gelesen; zusätzlich — und das ist der Unterschied zum Audit — lesende Abfragen gegen die Entwicklungsdatenbank, um zu klären, welche der beschriebenen Fehlerbilder in den vorhandenen Daten tatsächlich auftreten und welche rein theoretisch sind. Der Audit hat ausschließlich mit synthetischen Fixtures gearbeitet und das auch offen so benannt; genau diese Lücke schließt die folgende Bewertung.
Stand: 10.09.2026
Wie dieses Dokument zu lesen ist. Die Abschnitte 1–7 sind die ursprüngliche Bewertung vom 09.09. — sie beschreiben den Stand vor der Umsetzung und sind an einzelnen Stellen inzwischen überholt (jeweils oben im Abschnitt vermerkt). Die Abschnitte 8–13 sind die Nachträge zur Umsetzung. Maßgeblich ist immer der jüngste Abschnitt. Diese Tabelle hier ist der Einstieg.
Erledigt
| Befund | Was behoben wurde | Details |
|---|---|---|
| A02 | Persönliche Statistik zählt nur noch freigegebene Buchungen; Storno im Segment seiner Ursprungsbuchung | 12 |
| A03 | Negative Monatssalden erscheinen im Drilldown; Übersicht und Detail stimmen überein | 12 |
| A04 | Abgeschlossene Monate werden vollständig eingefroren, kein Live-Overlay mehr | 13 · eigenes Dokument |
| A06 | Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet | 11 |
| A08 | Prognose auch ohne bereits erreichte Qualifikationsstufe | 12 |
| A09 | Summenzeile im Neupartner-CSV; Breitenprüfung für alle Exporte | 12 |
| A10 | Kundenabo-Liste lädt die Lieferungszahl vor | 12 |
| A12 | Kachel und verlinkte Liste stammen aus derselben Quelle | 13 |
| A15 | Storno trägt seine Ursprungsart; Eigen- und Kundenpunkte überschneidungsfrei | 9 · 11 |
| A17 | Fälligkeitsgruppen ohne Monatsüberlauf | 12 |
| A18 | Formelinhalte im CSV als Text gekennzeichnet, in beiden Exporten | 12 |
| A19 | Fehlgeschlagene Provisionsberechnung ist als solche erkennbar | 12 |
| A01 | Aktiv-Schalter wirkt nur noch im Admin-Pfad; Mindestlaufzeit nicht mehr umgehbar | 14 |
| A16 | Statuswechsel werden zentral am Model protokolliert, in jedem Schreibpfad | 14 |
| A11 | Gleichstände jenseits des Auswahlfensters entscheiden wieder über den Namen | 15 |
| A13 | Der heutige Liefertag zählt zur Prognose, solange die Ausführung aussteht | 15 |
| A14 | Tabellen sortieren über unformatierte Werte statt über den Anzeigetext | 15 |
| R01–R03 | Befunde der Nachprüfung an der eigenen Umsetzung | 11 |
| A07 | Payline-Kontext (Home-Kacheln „Teampartner/Abos in der Payline“, Payline-Prognose) nimmt die Mitglieder aus dem Provisionsbaum wie Team-Statistik und Level-Prognose — umgesetzt 23.09., live seit 25.09. An der Live-Kopie betrifft das 102 von 162 Beratern mit Payline (z. B. 527 → 403 Mitglieder). cache:clear nach dem Deploy ist erledigt (6-h-Cache payline_context_*). |
PaylineForecastService::buildPaylineContext(), Test in PaylineForecastServiceTest |
Dazu zwei Regeln, die dabei entschieden wurden:
- Stornoregel — ein Storno mindert nur den Monat der Ursprungsbestellung; abgeschlossene Monate bleiben unberührt. Schalter in den Settings, standardmäßig aus. → Abschnitt 8
- Monatsabschluss — ein abgerechneter Monat wird vollständig eingefroren, einschließlich Team-Struktur und Detaillisten. → monatsabschluss-eingefrorene-statistik.md
Offen
| Befund | Warum offen |
|---|---|
| A22, A23 | Kein Problem in dieser Größenordnung (7 VIP-Konten) · Cache-Strategie ohnehin offen |
| A05, A20, A21 | Kundenentscheidungen: Bedeutung der Spalte „Abos" · Sichtbarkeit der Profilfotos · Spanisch im Backoffice (44 Konten) |
Deployment des gesamten Pakets
Erledigt am 10.09.2026, ohne Fehler — Einzelheiten und Auswirkung auf die historischen Zahlen in deploy-2026-09-10.md.
php artisan migrate
php artisan backoffice:store-statistics-snapshots --force # einmalig, Payload-Version 2
php artisan config:clear && php artisan view:clear && php artisan cache:clear
1. Gesamturteil
Der Audit ist fachlich solide und ehrlich gearbeitet. Die zitierten Codestellen stimmen, die Zeilennummern stimmen, die Reproduktionen sind nachvollziehbar, und die Grenzen der eigenen Aussage werden an jeder Stelle mitgeschrieben ("nicht mit Produktionsdaten ermittelt", "kein End-to-End-Test"). Die angegebenen Testergebnisse ließen sich exakt reproduzieren:
| Prüfung | Audit | Nachgeprüft |
|---|---|---|
| Statistik-Kernmodule | 109 bestanden, 678 Assertions | 109 bestanden, 678 Assertions ✔ |
LocalizationSyncTest |
2 fehlgeschlagen (es/fr payment.php) |
2 fehlgeschlagen ✔ |
Von 23 Befunden sind 19 als Codefehler bestätigt. Bei 4 Befunden stimmt die Diagnose, aber die Auswirkung ist in den echten Daten heute null oder deutlich kleiner als dargestellt. Bei einem Befund (A15) ist die Ursachenanalyse richtig, die daraus abgeleitete Wirkung aber falsch — und die tatsächliche, live wirksame Auswirkung ist eine andere als die beschriebene.
Die Kernaussage des Audits — "die Aussage ‚alles umgesetzt und Zahlen korrekt' ist nicht belastbar" — ist berechtigt. Kein Befund entwertet die gelieferten Pakete K1–K9; es sind Rand- und Sonderfälle plus zwei Systematiken (Storno-Zuordnung, Zeitbezug historischer Auswertungen).
2. Bewertung je Befund
Legende: bestätigt = Fehler im Code und in den Daten wirksam · bestätigt, latent = Fehler im Code, tritt mit den vorhandenen Daten heute nicht auf · eingeschränkt = Diagnose richtig, Auswirkung anders als beschrieben · Bewertungssache = kein Fehler, sondern eine Produkt- oder Beschriftungsentscheidung.
| ID | Audit | Meine Bewertung | Begründung aus der Gegenprüfung |
|---|---|---|---|
| A01 | P1 | bestätigt, aber P2 | Umgehung real; betrifft aber nur den Eigentümer auf seinem eigenen Abo, nicht fremde Konten. |
| A02 | P2 | bestätigt | Kein Statusfilter. In den Daten: 1 Zeile Status 3 mit user_id, 1 Zeile Status 0 ohne. |
| A03 | P2 | bestätigt | HAVING > 0 in allen Metriken; Übersicht und Detail können auseinanderlaufen. |
| A04 | P2 | bestätigt | overlayCurrentAboStock() rechnet die komplette Kundenabo-Topliste live neu, auch die abgerechneten Monatspunkte. |
| A05 | P2 | Bewertungssache, P3 | Spalte heißt nur „Abos"; die Seite trägt oben den Hinweis „Abo-Bestände tagesaktuell". Tooltip genügt. |
| A06 | P2 | bestätigt, andere Ursache | Siehe Abschnitt 3: gemeinsame Wurzel mit A15, aber nicht über status_turnover. |
| A07 | P2 | bestätigt | paylineContext() filtert nicht, lineByUserId() schon — zwei Mitgliedermengen auf einer Seite. |
| A08 | P2 | bestätigt | ! is_array($qualLevel) → return null. Trifft neue Berater und jeden Monatsanfang. |
| A09 | P2 | bestätigt | Kopf 9 Spalten, Summenzeile 8 Werte. Eindeutiger Off-by-one. |
| A10 | P3 | bestätigt | user_abo_orders fehlt im Eager Loading; getCountPaidOrders() lädt je Zeile nach. |
| A11 | P3 | bestätigt, kosmetisch | Setzt ≥26 exakt gleichauf liegende Kandidaten voraus. |
| A12 | P2 | bestätigt | Kachel rechnet den Monatsendbestand zurück, der verlinkte Drilldown filtert heutige Aktivabos. |
| A13 | P2 | bestätigt, P3 | Entspricht der dokumentierten Formel; Robustheitslücke am Liefertag. |
| A14 | P2 | bestätigt, kleiner Kreis | Betrifft nur en: es/fr nutzen deutsche Zahl-/Datumsformate. Aktuell 5 Nutzer auf en. |
| A15 | P1 | eingeschränkt | Prädikate überlappen tatsächlich — die beschriebene Doppelzählung gibt es in den Daten aber nicht. Siehe Abschnitt 3. |
| A16 | P2 | bestätigt, latent | Kein einziger reactivated-Eintrag in den Daten, und alle 10 deaktivierten Abos sind heute noch inaktiv → bisher kein falscher Historienwert. |
| A17 | P3 | bestätigt | Carbon-Überlauf; greift nur, wenn heute der 29.–31. ist. |
| A18 | P2 | bestätigt, P3 | fputcsv() ohne Textschutz. Quelle sind eigene Teamdaten, kein fremder Input. |
| A19 | P2 | bestätigt | commission() verliert das failed-Flag; Oberfläche zeigt 0,00 €. |
| A20 | P2 | Bewertungssache | Öffentliche Disk, Dateiname mit uniqid() — nicht aufzählbar, aber ohne Anmeldung abrufbar. Produktentscheidung. |
| A21 | P3 | bestätigt | Gruppenlabels, Metrik-Labels und CSV-Überschriften stehen hart in PHP. |
| A22 | P2 | bestätigt, unkritisch | Der Batch läuft über 7 VIP-Konten bei 901 Nutzern gesamt. Kein Speicherproblem in dieser Größenordnung. |
| A23 | P2 | bestätigt, bekannt | Deckt sich mit der bereits im Septemberplan offen geführten Cache-Invalidierung. |
3. Die wichtigste Korrektur: A15 und A06
Teilweise überholt (10.09.2026). Die Analyse der Ursache gilt weiterhin. Die Aussage am Ende des Abschnitts, die Provisionswirkung sei null und nur die Darstellung betroffen, ist durch Abschnitt 9 widerlegt: Die Shop-Provisionsbasis war sehr wohl betroffen. A15 und A06 sind inzwischen behoben.
Der Audit setzt A15 als P1 an und schreibt, Shop-Stornos würden doppelt von den Gesamtpunkten abgezogen, wodurch Gesamtpunkte, Firstlines, Recruiter- und Top-3-Volumen zu niedrig sein könnten.
Die Ursachenanalyse ist richtig, die Wirkung nicht.
Richtig ist: SalesVolumePoints::OWN_PREDICATE (status IN (1,4,5,6)) und SHOP_PREDICATE
(status = 2 OR (status = 6 AND status_turnover = 2)) überschneiden sich, und totalPoints()
addiert beide. Eine Zeile mit Status 6 und status_turnover = 2 zählt zweimal.
Nicht richtig ist die Schlussfolgerung, denn diese Kombination existiert nicht:
status | status_turnover | Zeilen
0 | NULL | 1
1 | NULL | 19.525
2 | NULL | 24.151
3 | NULL | 8
4 | 1 | 76
4 | 2 | 13
5 | NULL | 647
6 | NULL | 20 ← alle Stornos, keiner mit status_turnover = 2
status_turnover wird beim regulären Bestellweg gar nicht gesetzt (SalesPointsVolume::create…
schreibt das Feld nicht), sondern nur beim manuellen Gutschrift-Dialog und in
BusinessStoreOptimized (dort fest 1). Der Stornoweg übernimmt den Wert der Ursprungsbuchung —
und der ist bei Shop-Bestellungen NULL. Im Gegenprobencode des Audits setzt die Hilfsfunktion
auditVolume() dagegen jeder Zeile fest 'status_turnover' => 2. Die Doppelzählung ist damit
ein Artefakt der Fixture, kein beobachtbarer Produktionszustand.
Der real wirksame Fehler ist ein anderer — und er ist es wert, behoben zu werden. Weil alle
Storno-Zeilen status_turnover = NULL haben, fallen sie ausschließlich unter OWN_PREDICATE. Ein
storniertes Kunden-/Shop-Geschäft wird damit von den Eigenpunkten abgezogen statt von den
Kundenpunkten. In den Daten:
- 20 Storno-Zeilen insgesamt, davon 13 auf Bestellungen mit
payment_for = 6(Kunde-Shop), Beträge bis −157,50 Punkte, verteilt über 02/2026 bis 08/2026. - Folge: Eigenpunkte zu niedrig, Kundenpunkte zu hoch — betroffen sind genau die drei Punktespalten aus K1, die Kundenabo-Ranglisten und die Aufteilung „reine Abo-Punkte / einmalig dazu".
- Die Gesamtpunkte bleiben korrekt, weil jede Zeile genau einmal gezählt wird. Damit sind Firstlines, Recruiter-Volumen, Top-3 und der 1000-Punkte-Shop-Filter nicht betroffen — anders als im Audit unter B3/B4 und in der Priorisierung angenommen.
Dasselbe erklärt A06 zutreffender als der Audit: Bei einem stornierten Abo-Geschäft greift der
Storno gar nicht in customerAboPoints(), weil diese Auswertung SHOP_PREDICATE verlangt. Die
Kundenabo-Punkte werden durch einen Storno also überhaupt nicht gemindert, und die
Einmal-Zusätze der stornierten Bestellung bleiben in voller Höhe in der Aufteilung stehen. Die
Behebung ist für A15 und A06 dieselbe: die Storno-Zeile braucht eine belastbare Herkunftsangabe
(Ursprungsstatus statt des heute unzuverlässigen status_turnover), und die Kategorien müssen
disjunkt werden.
Bewertung: A15 bleibt P1, aber mit anderer Begründung — nicht wegen falscher Summen, sondern wegen der falschen Aufteilung, die live und rückwirkend seit Februar wirkt.
4. Weitere Korrekturen an der Gewichtung
A16 — kein Datenschaden bisher. Der Audit ist beim Codebefund richtig:
AboHelper::advanceAboAfterRecoveryPayment() schreibt active = true, status = 2 ohne
Historieneintrag. Wirksam wird das aber erst, wenn ein deaktiviertes Abo wieder aktiviert wird. In
den Daten stehen 44 cancelled, 10 deactivated, 6 paused und 0 reactivated Einträge, und
alle 10 deaktivierten Abos sind bis heute inaktiv. Es gibt also aktuell keinen verfälschten
historischen Bestand. Der Befund ist eine Vorsorge-Korrektur, nicht die Reparatur eines Schadens —
das ändert seine Dringlichkeit erheblich.
A22 — Größenordnung stimmt nicht. Der Snapshot-Batch iteriert über Nutzer mit
admin >= 1 AND admin < 4; das sind 7 Konten bei 901 Nutzern insgesamt. Die vom Audit
beschriebene Summierung der Teilbäume über „viele VIPs" trifft in dieser Größenordnung nicht zu.
Der Code-Hinweis bleibt richtig, die Priorität P2 nicht.
A14 — Kreis kleiner als dargestellt. Util::_thousands_separator() und formatDate() schalten
nur bei en um; es und fr laufen im deutschen Format und passen damit zum Parser. Betroffen
sind derzeit 5 Nutzer. Die Korrektur (unformatierte Sortierwerte als data-Attribute) ist trotzdem
sinnvoll, weil sie den Fehler grundsätzlich beseitigt.
A01 — P1 ist zu hoch gegriffen. Der Weg ist real: das Selfservice-Formular sendet den aktuellen
Zustand in einem versteckten Feld, und updateStatus() übernimmt jeden gesendeten Wert. Wer den
Request manipuliert, kann sein eigenes Abo dauerhaft deaktivieren und so die Mindestlaufzeit
aushebeln. Das ist eine Umgehung einer Geschäftsregel durch den Berechtigten auf seinem eigenen
Datensatz — ärgerlich, aber kein Zugriff auf fremde Konten, keine Datenpreisgabe, kein Geldfluss.
P2 mit kurzer Umsetzung (Feld im Selfservice-Pfad serverseitig ignorieren) ist angemessen.
A20 — kein Fehler, sondern eine Entscheidung. Die Datei liegt auf der öffentlichen Disk und der
Pfad ist mit uniqid() vorangestellt, also nicht aufzählbar; wer die URL kennt, kommt ohne
Anmeldung heran. Das ist bei allen Bildern der Anwendung so. Ob das für teaminterne Profilfotos
akzeptabel ist, sollte die Kundin entscheiden — technisch ist beides machbar.
5. Was der Audit nicht anspricht
Teilweise überholt (10.09.2026). Der zweite Punkt („Rückwirkung auf bereits gezahlte Provisionen … ist null") gilt nicht mehr, siehe Abschnitt 9. Die im ersten Punkt offene Frage zum Zeitpunkt der Stornobuchung ist in Abschnitt 8 entschieden.
- Was heißt „Kundenpunkte" nach einem Storno? Alle Korrekturen zu A15/A06 setzen eine fachliche
Festlegung voraus: Mindert ein Storno den Monat der Ursprungsbestellung oder den Monat der
Stornierung? Heute ist es Letzteres (
cancelSalesPointsVolume()schreibt in den aktuellen Monat). Eine Umstellung würde abgeschlossene Monate rückwirkend verändern. Das ist eine Frage an die Kundin, keine Entwicklerentscheidung. - Rückwirkung auf bereits gezahlte Provisionen. Der Audit sagt richtig, dass die Auswirkung auf reale Auszahlungen nicht ermittelt wurde. Nach der Analyse in Abschnitt 3 ist sie null, weil die Gesamtpunkte je Berater korrekt sind und die Provision auf diesen aufsetzt. Nur die Darstellung der Aufteilung ist falsch. Das ist die entscheidende Entlastung für die Kundenkommunikation.
- Die 25 fehlenden
manual_*-Schlüssel in es/fr stammen aus dem Stripe-/Manuelle-Zahlungen-Paket (Commit2e71173), nicht aus den Statistikpaketen. Der Audit ordnet das korrekt ein; für die Planung heißt es: eigenes Ticket, nicht Teil der Statistik-Nacharbeit.
6. Vorschlag für die Reihenfolge
Historisch (Stand 09.09.2026). Der Plan ist abgearbeitet bis auf die oben unter „Offen" genannten Punkte. Die Statusspalte zeigt, was daraus geworden ist.
Aufwand grob geschätzt, jeweils inklusive Regressionstest.
| Stufe | Befunde | Warum zuerst | Aufwand | Status |
|---|---|---|---|---|
| 1 | A09 | Eindeutiger Off-by-one, falsche Zahlen in einer ausgelieferten CSV. | < 1 h | erledigt |
| 1 | A15 + A06 | Einzige live wirksame Systematik; betrifft die Punkteaufteilung seit 02/2026. Braucht vorab die Festlegung aus Abschnitt 5. | 1 Tag | erledigt |
| 2 | A02, A03 | Übersicht, Detail und persönliche Statistik müssen dieselbe Punktedefinition benutzen — das hat die Kundin bereits mehrfach angemerkt. | 0,5 Tag | erledigt |
| 2 | A08, A19 | Beides sichtbare Löcher für neue Berater bzw. im Fehlerfall; kleine, isolierte Änderungen. | 0,5 Tag | erledigt |
| 3 | A04, A12 | Zeitbezug historischer Auswertungen sauber trennen. Konzeptionell die aufwendigste Gruppe. | 1–2 Tage | erledigt |
| 3 | A07 | Gemeinsamer Payline-Kontext; berührt K11 und sollte mit dessen Entscheidung zusammen geplant werden. | 0,5 Tag | erledigt, live 25.09. |
| 4 | A10, A17, A18 | Kleine, unabhängige Korrekturen; als Sammelpaket sinnvoll. | 1 Tag | erledigt |
| 4 | A21, A14 | Mehrsprachigkeit und lokalisierte Sortierung. | offen | |
| 5 | A01, A16 | Vorsorge: Regel serverseitig festziehen, Statushistorie in allen Schreibpfaden protokollieren. Kein aktueller Schaden. | 0,5 Tag | offen |
| — | A05, A11, A13, A20, A22, A23 | Tooltip, Randfall, Produktentscheidung oder bereits offen geführtes Thema. Nach Absprache. | — | offen |
Nicht empfohlen: eine Nachberechnung abgeschlossener Monate. Überholt: Die Kundin hat am
10.09. entschieden, dass abgeschlossene Monate vollständig eingefroren werden; dafür war eine
einmalige Nachberechnung der Snapshots nötig. Die Punkte der Altmonate wurden dabei nicht verändert —
siehe Abschnitt 13.
7. Umgang mit dem Audit als Dokument
Der Audit ist als Prüfbericht brauchbar und sollte im Ordner bleiben. Zwei Hinweise für die Weiterverwendung:
- Die Gegenproben aus dem Prüfprotokoll dürfen nicht als Spezifikation übernommen werden. Das
sagt das Protokoll selbst; wichtig ist es trotzdem, weil die Erwartungen dort bewusst das
fehlerhafte Verhalten festschreiben. Bei einer Behebung müssen sie umgedreht werden — und die
A15-Fixture (
status_turnoverfest auf2) darf gar nicht übernommen werden. - Für die Kundenkommunikation eignet sich der Bericht nicht unverändert. Formulierungen wie „Gesamtpunkte können zu niedrig sein" würden dort Sorgen auslösen, die nach Abschnitt 3 unbegründet sind. Falls die Befunde nach außen gehen sollen, gehören sie in eine eigene, auf die tatsächliche Auswirkung reduzierte Fassung.
8. Nachtrag 09.09.2026 — Stornoregel entschieden und umgesetzt
Die in Abschnitt 5 offene Frage ist von der Kundin beantwortet:
Befindet sich die Stornierung innerhalb eines Monats, wird sie angewandt und mindert. Ist der Monat abgeschlossen, passiert nichts mehr, denn die Monate werden nach der monatlichen Auswertung fixiert und es werden Gutschriften erstellt. Ein Minus in den neuen Monat mitzunehmen ist nicht nachvollziehbar; so oft kommen Stornierungen nicht vor. Falls es doch zu viel wird, ziehen wir es im laufenden Monat ab — dafür soll es einen Schalter in den Settings geben, zunächst auf
false.
Umgesetzt in SalesPointsVolume::cancelSalesPointsVolume():
| Fall | Verhalten |
|---|---|
| Ursprungsbuchung im laufenden Monat | Punktekorrektur wird gebucht (unverändert). |
| Monat abgeschlossen, Schalter aus (Standard) | Keine Punktekorrektur. Die Stornorechnung wird trotzdem erstellt; der Vorgang wird protokolliert. |
| Monat abgeschlossen, Schalter an | Punktekorrektur wandert als Minus in den laufenden Monat (bisheriges Verhalten). |
Schalter: storno-points-current-month (Typ bool), Adminbereich → Einstellungen → „Rechnungen /
Gutschriften Einstellungen". Ohne Datensatz liefert Setting::getContentBySlug() false — der
Standard gilt also, ohne dass eine Zeile angelegt werden muss.
Abgrenzung: Die Rechnungs-/Gutschriftenerstellung ist nicht betroffen, nur die Punkte- und
Umsatzbuchung. Tests: tests/Feature/StornoClosedMonthTest.php.
Zwei Punkte bleiben dadurch offen:
- Incentive-Buchung. Wird die Punktekorrektur übersprungen, unterbleibt auch die
Incentive-Gegenbuchung (
IncentiveTracker::trackStorno()setzt die Storno-Zeile voraus). Für einen abgeschlossenen Monat ist das konsequent; läuft die Incentive-Aktion aber noch, behält der Berater die Incentive-Punkte einer stornierten Bestellung. Zu klären, ob das gewollt ist. - A15/A06 bleiben bestehen. Die Regel klärt den Zeitpunkt der Stornobuchung, nicht ihre Zuordnung. Ein Storno im laufenden Monat mindert weiterhin die Eigenpunkte statt der Kundenpunkte (siehe Abschnitt 3). Diese Korrektur steht noch aus und wirkt ab sofort nur noch auf Stornos innerhalb des laufenden Monats — die 13 Altfälle bleiben nach der neuen Regel unangetastet.
Randbefund: Die Migration definiert user_sales_volumes.status_turnover als NOT NULL DEFAULT 1,
in der Produktionsdatenbank enthält die Spalte jedoch rund 44.000 NULL-Werte. Schema und Daten sind
also auseinandergelaufen. Das erklärt zusätzlich, warum die Storno-Herkunft über dieses Feld nicht
belastbar ist.
9. Nachtrag 09.09.2026 — status_turnover bereinigt (A15/A06 behoben)
Der in Abschnitt 8 als Randbefund notierte Schema-Drift war die Spitze eines Fehlers mit Geldwirkung. Die Nachverfolgung ergab:
Was status_turnover tatsächlich war. Von 44.441 Buchungen tragen nur 89 überhaupt einen Wert,
und alle 89 sind manuell erfasste Gutschriften (Status 4). Der reguläre Bestellweg schreibt das Feld
nie. Die Stornobuchung übernahm es unverändert aus der Ursprungsbuchung — also NULL. Damit galt
jeder Storno als Eigengeschäft, auch der eines Kundenauftrags.
Die Folge in der Provisionsberechnung. In reCalculateSalesPointsVolume() lief Status 6 dadurch
immer in den else-Zweig: Punkte nach KP/TP, Nettobetrag nach month_total_net. Die Shop-Werte
blieben unberührt. commission_shop_sales errechnet sich aber aus
sales_volume_total_shop = month_shop_total_net. Ein Shop-Storno minderte die
Shop-Provisionsbasis also nicht. Beispiel Benutzer 6, April 2026: Shopumsatz 7.284,11 €,
Storno −49,92 € im selben Monat — month_shop_total_net blieb bei 7.284,11 €, während
month_total_net auf −49,92 € rutschte (der eigene Nettoumsatz wurde negativ ausgewiesen).
Quantifiziert: In sieben Fällen zwischen 04/2026 und 08/2026 wurde ein Kundenstorno im selben Monat gebucht, ohne die Shop-Provisionsbasis zu mindern — bei 30 % Marge zusammen 178,82 € zu viel gezahlte Shop-Provision. Die übrigen sechs Kundenstornos fielen in einen späteren Monat als die Ursprungsbestellung und werden nach der Regel aus Abschnitt 8 künftig ohnehin nicht mehr gebucht.
Umgesetzt:
SalesPointsVolume::stornoTurnoverFor()leitet die Herkunft aus dem Status der Ursprungsbuchung ab (Status 2 → Shop, Gutschrift mit Verrechnung auf Shop → Shop, sonst Eigen) statt das leere Feld zu kopieren.reCalculateSalesPointsVolume()Case 6 arbeitet jetzt exklusiv: ein Shop-Storno mindert die Shop-Werte (Spiegelbild von Case 2), ein Eigen-Storno die Eigenwerte (Spiegelbild von Case 1). Vorher lief der Punktebetrag immer in KP/TP und beistatus_turnover = 2zusätzlich in die Shop-Punkte — genau die vom Audit beschriebene Doppelzählung.SalesVolumePoints::OWN_PREDICATEschließt Shop-Stornos aus. Eigen- und Kundenpunkte sind damit überschneidungsfrei; jede Zeile zählt genau einmal.- Migration
2026_09_09_140000_...:status_turnoverwird nullable ohne Default — der Stand, den die Produktionsdatenbank ohnehin hat. Auf Produktion praktisch wirkungslos, aber Testdatenbank und Neuinstallationen verhalten sich dadurch wie Produktion.
Bewusst nicht gemacht: kein Nachziehen der 20 Altzeilen. Sie haben status_turnover NULL und
laufen deshalb unverändert durch den Eigenpunkte-Zweig — abgeschlossene Monate bleiben so, wie sie
abgerechnet wurden. Das entspricht der Fixierungsregel aus Abschnitt 8. Die 178,82 € sind damit ein
abgeschlossener Sachverhalt; ob sie verrechnet werden sollen, ist eine kaufmännische Entscheidung,
keine technische.
Tests: tests/Feature/StornoPointsCategoryTest.php (6 Tests, ohne die Korrektur 4 rot).
Gesamtlauf der betroffenen Bereiche: 245 bestanden, 1 vorbestehender, sachfremder Fehler
(IncentiveTeaserPageTest, Bildergalerie).
Damit sind A15 und A06 erledigt; die Priorisierung in Abschnitt 6 reduziert sich um Stufe 1.
10. Nachtrag 09.09.2026 — Bewertung der Nachprüfung
Zur Nachprüfung. Alle drei Befunde sind berechtigt; zwei davon sind Fehler in meiner Arbeit. Ich habe R01 und R02 unabhängig nachgestellt und exakt dieselben Werte erhalten wie dort dokumentiert.
| Befund | Bewertung |
|---|---|
| R01 — A06 besteht weiter | Berechtigt. Nachgestellt: Gesamt 100, Zusatz 20, wiederkehrend 80 statt 0/100. |
| R02 — Sonderfall Status 4 | Berechtigt, von mir eingebaut. Nachgestellt: KP bleibt +100, Shop-Punkte −100. |
| R03 — Tests hängen an der Systemuhr | Berechtigt. date() gegen eingefrorenes Carbon. |
R01 — meine Aussage „A06 erledigt" war falsch
Ich habe die gemeinsame Wurzel von A15 und A06 korrigiert — die fehlende Herkunft der Stornobuchung —
und daraus geschlossen, dass damit auch A06 erledigt sei. Das stimmt nicht.
BackofficeDashboardService::customerAboAddonPoints() sammelt weiterhin Bestellungen und addiert
deren ursprünglich positive Positionspunkte, ohne Vorzeichen und Nettowirkung der zugehörigen
Buchungen. Diese Datei habe ich gar nicht angefasst. Der Audit hatte die Positionsursache von Anfang
an getrennt beschrieben; ich habe sie mit der Herkunftsursache vermengt. A06 bleibt offen.
R02 — der Status-4-Zweig war ein Fehler von mir
stornoTurnoverFor() behandelt eine Gutschrift mit Shop-Verrechnung wie eine Shop-Bestellung. Der
Status-4-Zweig der Neuberechnung macht aber etwas anderes: Punkte gehen immer nach KP/TP, nur der
Nettoumsatz folgt status_turnover. Nachgestellt (vorher KP 100 / Shop-Netto 100):
| Nach dem Storno | Ist | Soll |
|---|---|---|
| KP | 100 | 0 |
| Shop-Punkte | −100 | 0 |
| Shop-Netto | 0 | 0 |
Der Nettoumsatz wird also korrekt zurückgenommen, die Punkte nicht. Ein einzelnes Feld mit den Werten 1/2 kann „Punkte eigen, Umsatz Shop" nicht ausdrücken — das ist der Kern des Befundes.
Ergänzung zur Reichweite: Alle 89 Status-4-Zeilen haben user_invoice_id = NULL, und der einzige
aufrufende Pfad (InvoiceRepository::correctPointsForCancellation()) sucht die Ursprungsbuchung
genau über diese Spalte. Der Fall ist über die Anwendung heute nicht erreichbar, es sind also
keine Daten betroffen. Das entschuldigt den Zweig nicht — ich habe ihn bewusst eingebaut, ohne den
Status-4-Pfad der Neuberechnung dagegen zu halten.
R03 — Uhr
isStornoMonthOpen() und die Stornobuchung verwenden date(), die Tests frieren Carbon ein. Dass
beide heute übereinstimmen, ist Zufall des laufenden Kalendermonats. Nach dem Monatswechsel brechen
die Tests, ohne dass sich fachlich etwas geändert hat.
Präzisierungen zur Nachprüfung
- „Eine Monatsabrechnung weicht von der Formel ab": Es handelt sich um einen Cent (2.278,69 € gespeichert gegen 2.278,70 € gerechnet, Benutzer 6, 05/2026) — eine Rundungsdifferenz, keine sachliche Abweichung. Die übrigen fünf stimmen exakt.
- 178,82 € gegen 178,81 €: Reihenfolge von Runden und Summieren, beide Wege sind belegt.
- „zu viel ausgezahlt": Die Kritik ist berechtigt. Belegt ist die Differenz der Shop-Provisionsbasis, kein Abgleich tatsächlicher Auszahlungen. Die Formulierung in Abschnitt 9 ist entsprechend zu lesen.
- Widerspruch innerhalb dieses Dokuments: Die Abschnitte 3, 5 und 6 sagen, die Provisionswirkung sei null und nur die Darstellung betroffen. Das ist durch Abschnitt 9 überholt und gilt nicht mehr. Maßgeblich ist der jüngste Abschnitt.
- A05: Der Einwand trifft. Der Septemberplan formuliert bei K1 ausdrücklich „Nur bereits abgerechnete Abos." Ob das auch die Spalte „Abos" meint, ist eine Kundenfrage und nicht durch einen Tooltip entschieden.
- A18, A20, A01, A07, A16, A22: Die Einwände gegen meine Begründungen nehme ich an. Insbesondere bei A18 trägt „kein fremder Input" nicht — Namen und Kontaktdaten im Team stammen von anderen Personen.
- „21 Befunde nur neu bewertet, nicht behoben": Richtig, und so war es auch gemeint.
Offene Punkte daraus
- A06 korrigieren — Nettowirkung je Bestellung auf wiederkehrende und einmalige Positionen bestimmen, erst danach je Berater summieren.
- R02 korrigieren — Herkunft von Punkten und Umsatz getrennt abbilden. Sauber: die
Ursprungsbuchungsart auf der Gegenbuchung festhalten und Case 6 danach spiegeln. Minimal: den
Status-4-Zweig aus
stornoTurnoverFor()entfernen, solange der Fall nicht erreichbar ist. - R03 korrigieren — Prüfung und Buchung auf
now()umstellen, Monats- und Jahreswechsel unabhängig von der Systemuhr testen; den überholten Kommentar inStornoClosedMonthTest.phpentfernen.
11. Nachtrag 10.09.2026 — R01, R02 und R03 behoben
R02 — Ursprungsart der Stornobuchung
Neue Spalte user_sales_volumes.storno_origin_status (nullable, Migration
2026_09_10_090000_...). cancelSalesPointsVolume() schreibt dort den Status der
Ursprungsbuchung hinein. Damit trennt Case 6 der Neuberechnung endlich die beiden Regeln, die
status_turnover allein nicht ausdrücken kann:
| Storno von | Punkte | Umsatz |
|---|---|---|
| Shop-Bestellung (2) | Shop | Shop |
| Eigenbestellung (1), Registrierung (5) | KP/TP | Eigen |
| Gutschrift (4) mit Shop-Verrechnung | KP/TP | Shop |
| Altzeile ohne Ursprungsart | KP/TP | Eigen (unverändert) |
Die letzte Zeile ist der Grund, warum keine Altdaten angefasst werden mussten: ohne Ursprungsart
fällt die Auswertung auf das bisherige Verhalten zurück. SalesVolumePoints benutzt dieselbe
Regel über die Konstante STORNO_ORIGIN — mit COALESCE/CASE statt NOT (...), weil ein
NOT über NULL-Spalten die Zeile aus beiden Kategorien geworfen hätte.
stornoTurnoverFor() beschreibt jetzt ausschließlich das Umsatzziel; eine Gutschrift behält ihre
eigene Zuordnung, statt pauschal als Shop-Bestellung zu gelten.
R01 / A06 — Aufteilung der Zusatzpunkte
customerAboAddonPoints() ermittelt je Berater und Bestellung zusätzlich die gebuchte
Nettosumme und die ursprünglich positive Summe. Aus deren Verhältnis ergibt sich, wie viel
von der Bestellung im Monat stehen geblieben ist: Vollstorno 0, unstorniert 1, Teilstorno anteilig.
Die Zusatzpunkte der Bestellung werden mit diesem Faktor gewichtet.
Der anteilige Ansatz beim Teilstorno ist bewusst eine Näherung: Die Stornobuchung hat keine Positionsaufteilung, es ist ihr also nicht zu entnehmen, ob der Einmal-Zusatz oder die Abo-Position storniert wurde. Das ist im Code vermerkt.
R03 — Uhr
isStornoMonthOpen() und die Stornobuchung verwenden now() statt date(). Prüfung, gebuchter
Monat, Datum und Protokolltext folgen damit derselben Uhr und sind testbar. Der überholte Kommentar
in StornoClosedMonthTest.php ist entfernt.
Tests
| Datei | Umfang |
|---|---|
tests/Feature/CustomerAboAddonSplitTest.php |
neu, 3 Tests: Vollstorno, unstornierte Bestellung, Teilstorno |
tests/Feature/StornoPointsCategoryTest.php |
auf 9 Tests erweitert: Gutschrift-Spiegelung, Ursprungsart, Eigenpunkte-Zuordnung |
tests/Feature/StornoClosedMonthTest.php |
auf 6 Tests erweitert: Unabhängigkeit von der Systemuhr |
tests/Unit/Services/BackofficeDashboardServiceTest.php |
Spalte im handgebauten Testschema ergänzt |
Rot-Nachweis mit chirurgisch zurückgedrehten Hunks statt pauschalem Stash — die betroffenen Dateien tragen auch ältere, noch nicht eingecheckte Arbeit:
- nur A06-Hunk zurück → 2 der 3 neuen Aufteilungstests rot, der unstornierte Fall bleibt grün
- nur R02/R03-Hunks zurück → genau die 4 neuen Tests rot
Gesamtlauf der betroffenen Bereiche: 266 bestanden, 1 vorbestehender Fehler
(IncentiveTeaserPageTest, Bildergalerie, unabhängig gegengeprüft).
Deployment
Zwei Migrationen: 2026_09_09_140000 (status_turnover nullable) und 2026_09_10_090000
(storno_origin_status). Beide additiv, keine Datenänderung. Danach php artisan view:clear.
Damit sind A15, A06, R01, R02 und R03 erledigt. Offen aus dem Audit bleiben die übrigen Befunde gemäß Abschnitt 6 — sie wurden neu bewertet, aber nicht behoben.
12. Nachtrag 10.09.2026 — Pakete 1 und 2 sowie der Kleinkram umgesetzt
Paket 1 — dieselbe Zahl an allen Stellen
A09 — Neupartner-CSV. Die Summenzeile hat jetzt neun Werte für neun Spalten; Kundenpunkte und Gesamtpunkte stehen unter ihren Überschriften statt unter „Sponsor" und „Punkte seiner Kunden". Statt die Exportspalten zu zentralisieren — das wäre ein größerer Umbau — sichert ein Datensatz-Test für alle fünf Exportarten ab, dass Kopf- und Summenzeile gleich breit bleiben. Damit fällt dieselbe Fehlerklasse künftig sofort auf.
A02 — persönliche Statistik. Die Abfrage filtert auf SalesVolumePoints::countableStatuses()
(neu, die eine Liste für „zählbare Buchung"), Status 0 und 3 zählen also nicht mehr mit. Zusätzlich
wird ein Storno dem Segment seiner Ursprungsbuchung zugeordnet statt pauschal „Sonstige" — möglich
geworden durch storno_origin_status aus Abschnitt 11. Altzeilen ohne Ursprungsart bleiben unter
„Sonstige", ihre Herkunft ist nicht rekonstruierbar.
A03 — negative Salden im Drilldown. HAVING > 0 ist zu <> 0 geworden; nur die echte
Qualifikationsliste „mindestens 1000 Punkte" behält ihre Schwelle. Zusätzlich waren in
index.blade.php die Detail-Links mit > 0 bewacht — ein negativer Wert war also nicht anklickbar.
Auch das ist auf != 0 umgestellt.
Warum A03 dringlicher wurde: Der Fehler bestand vorher auch, aber die negativen Beträge lagen bei den Eigenpunkten. Seit der Stornokorrektur landen sie bei den Kundenpunkten — also in genau den Kennzahlen, in die aus der Übersicht hineingeklickt wird.
Paket 2 — sichtbare Löcher
A08 — Prognose ohne erreichte Stufe. „Noch nicht qualifiziert" ist jetzt ein gültiger
Ausgangszustand. Abgebrochen wird nur noch bei einem echten Berechnungsfehler oder wenn es weder eine
erreichte noch eine nächste Stufe gibt — dann fehlt der Prognose jeder Bezugspunkt. Ohne erreichte
Stufe bleibt die PP-Provision fachlich korrekt bei 0; damit die Anzeige nicht „auf Basis Level ."
schreibt, gibt es den neuen Satz home.forecast_commission_no_level in allen vier Sprachen.
A19 — fehlgeschlagene Berechnung. commission() reicht failed durch. Die Kachel zeigt dann
„—" statt „0,00 €", wird grau statt gelb, verliert das Live-Abzeichen und trägt den Hinweis
home.commission_unavailable (ebenfalls vier Sprachen).
Kleinkram
| Befund | Änderung |
|---|---|
| A10 | user_abo_orders wird vorgeladen; die Relationsliste steht als Konstante im Controller, damit der Test an der Produktionsdefinition hängt statt sie zu kopieren. |
| A17 | addMonthNoOverflow() statt addMonth() — vom 31.01. endet „innerhalb eines Monats" am 28.02., nicht am 03.03. |
| A18 | Neue csvSafeRow() kennzeichnet Felder, die mit =, +, - oder @ beginnen, als Text. Greift in beiden Exporten (Drilldown und Übersicht); Zahlen bleiben unangetastet. |
Tests
tests/Feature/StatisticsConsistencyTest.php ist neu (18 Tests, A09/A02/A03/A18/A10/A17),
PaylineForecastServiceTest und LiveCommissionServiceTest sind um zusammen fünf Tests gewachsen.
Rot-Nachweis wieder mit chirurgisch zurückgedrehten Hunks: Paket 1 → 5 von 11 rot, Paket 2 → 3 rot, Kleinkram → 6 von 18 rot.
Gesamtlauf über Statistik, Storno, Provision, Abo, Incentive, DATEV und Sprachdateien:
341 bestanden, 3 vorbestehende Fehler — zwei davon LocalizationSyncTest (die 25 manual_*
Schlüssel aus dem Stripe-Paket fehlen in es/fr), einer IncentiveTeaserPageTest (Bildergalerie).
Beide unabhängig gegengeprüft, keiner hängt an diesen Änderungen. Die neuen home.php-Schlüssel sind
in allen vier Sprachen paritätisch.
Stand der Befundliste
Erledigt: A02, A03, A06, A08, A09, A10, A15, A17, A18, A19.
Offen und bewusst nicht angefasst:
- A04, A12 — Zeitbezug historischer Auswertungen; braucht vorher die Entscheidung, ob ein Klick auf einen vergangenen Monat den Stand von damals oder den korrigierten Stand von heute zeigt.
- A07 — gemeinsamer Payline-Kontext; sinnvoll zusammen mit K11.
- A01, A16, A22, A14, A11, A23 — kein Schaden in den Daten bzw. Randfälle.
- A05, A20, A21 — Kundenentscheidungen: Bedeutung der Spalte „Abos", Sichtbarkeit der Profilfotos, und ob das Backoffice überhaupt auf Spanisch genutzt wird (44 Konten).
13. Nachtrag 10.09.2026 — A04 und A12 behoben: abgeschlossene Monate sind eingefroren
Die Kundin hat Variante 1 gewählt, mit einer Begründung, die über die Statistik hinausgeht: Am ersten des Folgemonats läuft die Abrechnung, es werden Gutschriften ausgestellt, der Monat ist kaufmännisch geschlossen. Teampartner können danach dazukommen oder verschoben werden — der abgerechnete Monat darf sich dadurch nicht mehr ändern. Dieselbe Logik, die schon für Stornos gilt.
Die vollständige Beschreibung steht in monatsabschluss-eingefrorene-statistik.md. Hier nur der Abgleich mit den Befunden:
A04 — Snapshot fror nur einen Teil ein. Das Live-Overlay ist entfallen: ein abgeschlossener Monat wird unverändert aus dem Snapshot ausgeliefert. Damit ist auch die Top-10-Tabelle eingefroren, deren Spalte „Abgerechnet (Punkte)" vorher live nachgerechnet wurde. Zusätzlich lasen die Detaillisten bisher grundsätzlich heutige Daten; sie kommen jetzt aus dem gespeicherten Stand.
A12 — Kachel und verlinkte Liste. Beide stammen jetzt aus derselben Quelle. Ein im August gestartetes und im September gekündigtes Abo bleibt in der August-Kachel und in der verlinkten Liste.
Payload-Version 2. Die Linienzeilen tragen zusätzlich abo_ids (Team-/Kundenabos, jeweils auch
„neu"); user_ids waren schon vorher enthalten. Aus beiden bauen sich die Detaillisten. Abos werden
dort mit withTrashed() geladen — sonst wäre die Liste kürzer als die Zahl, aus der sie aufgerufen
wurde. Snapshots der Version 1 bleiben lesbar und fallen bei den Detaillisten auf die Live-Auswertung
zurück.
Nachberechnung. backoffice:store-statistics-snapshots --force wurde ausgeführt: 56 Snapshots
(7 VIP-Konten × 8 Monate), alle jetzt Version 2, rund 36 KB je Snapshot. Die eingefrorene Abo-Zahl
und die Länge der zugehörigen ID-Liste stimmen überein.
Grenze, die im Dokument steht und in die Kundenkommunikation gehört: Für Monate vor dem 10.09.2026 friert der Rebuild die Struktur des Ausführungstags ein, nicht die des damaligen Monatsendes — frühere Sponsor-Zuordnungen sind nirgends historisiert. Einheitlichkeit über alle Monate war hier die bewusste Abwägung. Ab dem Abschluss 01.10.2026 ist der eingefrorene Stand der tatsächliche Monatsstand.
Anzeige. Das Abzeichen „Abo-Bestände tagesaktuell" ist ersetzt durch „Monat abgeschlossen · Stand eingefroren" mit einem Tooltip, der die Regel benennt.
Tests. tests/Feature/BackofficeSnapshotFreezeTest.php neu (6 Tests: Korrektur nach Abschluss,
Kündigung, später hinzugekommener Teampartner, gelöschtes Abo in der Detailliste, Kachel gegen Liste,
laufender Monat weiterhin live). Ohne die Drilldown-Änderung sind 3 davon rot.
Zwei Tests in BackofficeStatisticsListsTest.php hielten die alte Regel fest (August-Entscheidung
F7, „Abo-Bestände tagesaktuell"). Sie beschreiben jetzt die neue — umgeschrieben, nicht entfernt, mit
Verweis auf die Entscheidung im Testkommentar.
Gesamtlauf: 293 bestanden, dazu die drei bekannten vorbestehenden Fehler
(2 × LocalizationSyncTest, 1 × IncentiveTeaserPageTest).
Deployment: php artisan migrate, dann einmalig
php artisan backoffice:store-statistics-snapshots --force, dann php artisan view:clear.
Damit sind aus Abschnitt 6 auch A04 und A12 erledigt. Offen bleiben A07 (gehört zu K11), die Randfälle ohne Datenschaden und die drei Kundenfragen.
14. Nachtrag 10.09.2026 — A01 und A16 behoben (Regel und Protokoll)
Beide Befunde sind Vorsorge: in den Daten steckt heute kein Schaden. A01 beschreibt eine umgehbare Geschäftsregel, A16 eine Protokolllücke, die erst bei der ersten Reaktivierung eines deaktivierten Abos sichtbar geworden wäre. Sie gehören zusammen, weil beide am selben Schreibpfad hängen.
A01 — Aktiv-Schalter im Selbstbedienungspfad
Das Selbstbedienungsformular hat keinen Aktiv-Schalter; es sendet den aktuellen Zustand nur in
einem versteckten Feld zurück. updateStatus() hat diesen Wert trotzdem übernommen. Wer den Request
manipulierte, konnte sein eigenes Abo dauerhaft deaktivieren und damit die Mindestlaufzeit umgehen —
der Ein-Zyklus-Pausenpfad mit Sperrfrist, Mindestlaufzeit und Pausenabstand wurde dabei komplett
übersprungen. Ein fehlendes Feld zählte zusätzlich als „aus".
app/Repositories/AboRepository.php liest das Feld jetzt nur noch im Admin-Pfad; sonst gilt der
gespeicherte Zustand:
$active = $isAdminUpdate
? (bool) ($data['abo_is_active'] ?? false)
: (bool) $this->model->active;
Damit laufen Zustandswechsel im Selbstbedienungspfad ausschließlich über die zugelassenen Aktionen Pause und Kündigung — beide prüfen ihre Regeln bereits serverseitig. Der Admin-Pfad bleibt unverändert.
A16 — Statusprotokoll in allen Schreibpfaden
Bisher protokollierte nur das Repository. Die Systempfade — Recovery-Zahlung
(AboHelper::advanceAboAfterRecoveryPayment()), Retry (AboRetryPaymentService::markAboSuccess()),
externe Erledigung (AboExternalCompletionService) und die DSGVO-Anonymisierung — schreiben
active direkt auf das Modell, ohne Eintrag.
Das ist deshalb heikel, weil die historische Bestandsrückrechnung in
TeamAboOverviewService::aboStockQuery() ein Abo verwirft, solange eine protokollierte Deaktivierung
nicht durch eine spätere protokollierte Reaktivierung aufgehoben ist. Ein stiller
active = true nimmt das Abo dauerhaft aus allen Folgemonaten.
Der Eintrag liegt jetzt am Model, nicht in den einzelnen Diensten. UserAbo::booted() hängt sich
an updated und protokolliert jeden Wechsel von active
(UserAbo::logStatusTransition()). Damit kann ein künftiger Schreibpfad ihn nicht mehr vergessen,
und der Eintrag entsteht innerhalb derselben Transaktion wie die Statusänderung.
- Richtung:
activewird wahr →reactivated.activewird falsch →cancelledbei Status 4, sonstdeactivated. - Kanal und Akteur:
\Auth::id()ergibt sich von selbst; den Formularkanal (me,ot,portal,admin) setztAboRepository::update()überUserAbo::withStatusLogView()für die Dauer des Schreibvorgangs. Systempfade lassen ihn leer und erscheinen als „System". - Was das Repository behält: die Pause protokolliert weiterhin selbst — sie ändert
activenicht und führt die Datumsverschiebung mit. - Grenzen: Model-Events feuern nicht bei Query-Builder-Massenupdates und nicht bei
saveQuietly(). Das Löschen braucht bewusst keinen Eintrag; dort wertet die Rückrechnungdeleted_ataus.
Nebeneffekt, der eine Lücke schließt: Die DSGVO-Anonymisierung setzt active = false ohne
cancel_date. Bisher fiel ein so abgeschaltetes Abo rückwirkend aus allen vergangenen Monaten,
weil es weder aktiv war noch ein datierbares Ende hatte. Jetzt entsteht ein datierter Eintrag, und
die vergangenen Monate bleiben korrekt.
Gleicher Zeitstempel
created_at ist sekundengenau. Deaktivierung und Reaktivierung im selben Request tragen denselben
Wert, und der Vergleich r.created_at > h.created_at ließ die Reaktivierung dann unter den Tisch
fallen — das Abo verschwand aus dem Bestand. aboStockQuery() zieht bei Gleichstand jetzt die
Reihenfolge der Einträge (r.id > h.id) heran. Das ist der Fall, den die Abnahmekriterien des Audits
ausdrücklich verlangen.
Tests
tests/Feature/AboStatusGuardTest.php neu, 13 Tests:
- A01: Aktiv-Feld auf
0fürme,otundportal; fehlendes Feld; Reaktivierungsversuch; wiederholtes Umschalten; Mindestlaufzeit trotz manipuliertem Feld; Admin darf weiterhin. - A16: Kanal und Akteur aus dem Admin-Formular; Kündigung aus dem Selbstbedienungspfad; Reaktivierung aus einem Systemschreibpfad ohne Kanal; kein Eintrag ohne Zustandswechsel; kein Eintrag beim Löschen.
In TeamAboOverviewTest.php zwei weitere: ein systemseitig reaktiviertes Abo zählt ab dem
Reaktivierungsmonat wieder, und die Reaktivierung in derselben Sekunde gilt. Ohne die
Gleichstandsregel ist der zweite rot — gegengeprüft.
In AboLockDaysSettingTest.php hielt ein Test genau das Verhalten fest, das A01 beschreibt: ein
Selbstbedienungs-Request ohne abo_is_active deaktivierte das Abo, und der Test hieß dabei
„erlaubt Pausieren". Er prüft jetzt die echte Pause-Aktion — das Abo bleibt aktiv, nur der Liefertag
wandert in den Folgemonat.
Vorbestehende Fehler im Gesamtlauf
Der Gesamtlauf zeigt Fehler, die nicht aus diesem Paket stammen — gegengeprüft durch einen Lauf mit zurückgesetzten Änderungen: keine neue Rotfärbung, im Gegenteil 13 Tests weniger rot.
LocalizationSyncTest/FrenchLocalizationTest: diemanual_*-Schlüssel aus dem Stripe-/Manuell-Zahlung-Commit fehlen in en, es und fr. Diesen Fehler hat schon der Audit selbst gemeldet.AboPendingInitialPaymentTest > zeigt gesendete Abo-Zahlungslinks: rendert eine View mit Spatie-Formular ohne Session. Auch mit zurückgesetzten View-Änderungen rot.- Diverse Unit-Tests mit „no such table: settings".
Die gehören in ein eigenes Aufräumpaket und sind hier bewusst nicht mitbehandelt.
15. Nachtrag 10.09.2026 — A11, A13 und A14 behoben (Randfälle)
Drei Befunde ohne Datenschaden, aber jeder mit einem klaren Fehlverhalten. Sie stehen zusammen, weil sie alle die Darstellung betreffen und keine Entscheidung der Kundin brauchen.
A11 — Gleichstände in den Top 3
TopResultsService::rankRows() sortierte die Kandidaten nach Wert, schnitt auf 25 ab und wandte den
Namens-Tiebreak danach an. Ein alphabetisch vorrangiger Kandidat auf Position 26 konnte damit
nicht mehr gewinnen — bei Gleichstand entschied die Reihenfolge der Datenbankabfrage statt des
Namens. Zusätzlich konnten Kandidaten ohne Account das Auffüllen der drei Plätze verhindern, weil sie
erst nach dem Abschneiden aussortiert wurden.
Zwei Schritte vor dem Hydrieren, beide in derselben Methode:
- Kandidaten ohne Account fallen vorher weg — eine leichte Id-Abfrage, keine Hydrierung.
- Die Auswahlgrenze wird um alle Gleichstände an dieser Grenze erweitert
(
takeIncludingTies()). Damit bleibt jeder Kandidat in der Auswahl, der die Top 3 über den Namens-Tiebreak noch erreichen kann.
Als letzte Regel entscheidet die user_id: bei gleichem Wert und gleichem Namen war die
Reihenfolge sonst nicht reproduzierbar — für eine gecachte Rangliste ein echtes Problem.
A13 — Der Ausführungstag
Offene Ausführungen mussten next_date > heute 23:59:59 erfüllen. Ein heute fälliges Abo galt damit
ab Mitternacht als erledigt — der Ausführungsjob läuft aber erst um 04:00. In diesem Fenster
fehlten die Punkte in der Prognose und im Ist: die Oberfläche behauptete „steckt schon im Ist",
bevor überhaupt gebucht war.
Neu ist ein gemeinsamer Scope UserAbo::scopeOpenExecutionUntil(), den alle drei Prognosen
benutzen (Payline-Kachel, persönliche Terminvorschau, Team-Übersicht). Er zählt den heutigen
Liefertag mit, solange für das Abo heute noch keine Ausführung angelegt wurde. Genau diese
Bedingung verhindert die Doppelzählung: sobald der Job die Ausführung erzeugt hat, stecken die Punkte
im Ist und das Abo fällt aus der Prognose.
Ein liegengebliebener Vortag zählt bewusst nicht — das gehört in die Nachbearbeitung, nicht in eine Prognose. Und am 25. (letzter Liefertag) endet das Fenster jetzt erst mit dem Tag, nicht mit seinem Beginn.
A14 — Sortierung in fremden Sprachen
Util::_thousands_separator() und formatDate() schalten bei en auf 1,000.00 und Y-m-d um.
Die Tabellensortierung las den angezeigten Zellentext: der Datumsparser erkannte nur
dd.mm.yyyy (also alles 0 unter en), und der Zahlenparser machte aus 1,000.00 eine 1 und aus
999.00 eine 99900 — die Reihenfolge konnte sich umkehren.
Die Zellen tragen jetzt data-sort-value mit dem unformatierten Wert: Zahl mit Punkt als
Dezimaltrenner, Datum als Y-m-d. Der Anzeigetext bleibt lokalisiert. Fehlt das Attribut, gilt
weiter der alte Textweg — der Umbau bricht also keine Tabelle, die noch keine Sortierwerte liefert.
Betroffen sind beide Tabellen mit dieser Sortierung: die Kundenabo-Liste und die
Statistik-Detailliste. Der Drilldown-Dienst liefert dafür *_sort-Schlüssel über ein neues
sortDate() neben dem bestehenden formatDate(); die CSV-Exporte holen ihre Spalten namentlich und
bleiben unberührt.
Zwei Dinge kamen dabei mit:
- Textspalten wurden mit
</>verglichen, wodurch Umlaute hinter dem Z landeten. Jetzt entscheidetlocaleCompare. - Leere Werte ergaben eine 0 und landeten damit mitten zwischen den negativen Punkteständen aus Stornos. Sie sortieren jetzt geschlossen an ein Ende.
next_datewurde doppelt formatiert: Der Accessor liefert bereits einen formatierten String, denformatDate()erneut geparst hat. Das ging nur gut, weil beide Formate zufällig parsebar sind. Der Dienst liest den Rohwert.
Tests
- A11: drei neue in
TopResultsServiceTest.php— 26 Gleichstände mit „Alpha" an letzter Position, 25 Kandidaten ohne Account vor einem mit, und Namensgleichheit. Die ersten beiden sind auf dem alten Stand rot, gegengeprüft. - A13: vier neue in
PaylineForecastServiceTest.php(heute fällig ohne Ausführung, heute bereits ausgeführt, gestern fällig, persönliche Vorschau) plus eine inTeamAboOverviewTest.phpfür den 25./26. Zwei davon sind auf dem alten Stand rot, die beiden Gegenproben grün — der Test prüft also wirklich die Änderung und nicht sich selbst. - A14: eine Datensatz-Prüfung über
deundeninCustomerAboListViewTest.php, dazu zwei inBackofficeStatisticsDrilldownTest.php(ISO-Sortierwerte aus dem Dienst, Attribute in der gerenderten Tabelle).
Gesamtlauf unverändert gegenüber dem Stand vor diesem Paket: dieselben vorbestehenden Fehler, kein neuer. Die JavaScript-Sortierung selbst ist nicht automatisiert geprüft — getestet ist, dass die richtigen Werte in der Seite ankommen.
Damit sind aus Abschnitt 6 alle Befunde erledigt, für die keine Entscheidung der Kundin nötig ist. Offen bleiben A07 (gehört zu K11), A22/A23 (kein Problem in dieser Größenordnung bzw. Cache-Strategie) und die drei Kundenfragen A05, A20, A21.