# Bewertung des Statistik-Audits vom 09.09.2026 Gegenprüfung des [Auditberichts](audit-statistiken-2026-09-09.md) und des [Prüfprotokolls](audit-statistiken-2026-09-09-pruefprotokoll.md) 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](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A03** | Negative Monatssalden erscheinen im Drilldown; Übersicht und Detail stimmen überein | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A04** | Abgeschlossene Monate werden vollständig eingefroren, kein Live-Overlay mehr | [13](#13-nachtrag-10092026--a04-und-a12-behoben-abgeschlossene-monate-sind-eingefroren) · [eigenes Dokument](monatsabschluss-eingefrorene-statistik.md) | | **A06** | Zusatzpunkte einer stornierten Bestellung werden anteilig verrechnet | [11](#11-nachtrag-10092026--r01-r02-und-r03-behoben) | | **A08** | Prognose auch ohne bereits erreichte Qualifikationsstufe | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A09** | Summenzeile im Neupartner-CSV; Breitenprüfung für alle Exporte | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A10** | Kundenabo-Liste lädt die Lieferungszahl vor | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A12** | Kachel und verlinkte Liste stammen aus derselben Quelle | [13](#13-nachtrag-10092026--a04-und-a12-behoben-abgeschlossene-monate-sind-eingefroren) | | **A15** | Storno trägt seine Ursprungsart; Eigen- und Kundenpunkte überschneidungsfrei | [9](#9-nachtrag-09092026--status_turnover-bereinigt-a15a06-behoben) · [11](#11-nachtrag-10092026--r01-r02-und-r03-behoben) | | **A17** | Fälligkeitsgruppen ohne Monatsüberlauf | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A18** | Formelinhalte im CSV als Text gekennzeichnet, in beiden Exporten | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A19** | Fehlgeschlagene Provisionsberechnung ist als solche erkennbar | [12](#12-nachtrag-10092026--pakete-1-und-2-sowie-der-kleinkram-umgesetzt) | | **A01** | Aktiv-Schalter wirkt nur noch im Admin-Pfad; Mindestlaufzeit nicht mehr umgehbar | [14](#14-nachtrag-10092026--a01-und-a16-behoben-regel-und-protokoll) | | **A16** | Statuswechsel werden zentral am Model protokolliert, in jedem Schreibpfad | [14](#14-nachtrag-10092026--a01-und-a16-behoben-regel-und-protokoll) | | **A11** | Gleichstände jenseits des Auswahlfensters entscheiden wieder über den Namen | [15](#15-nachtrag-10092026--a11-a13-und-a14-behoben-randfälle) | | **A13** | Der heutige Liefertag zählt zur Prognose, solange die Ausführung aussteht | [15](#15-nachtrag-10092026--a11-a13-und-a14-behoben-randfälle) | | **A14** | Tabellen sortieren über unformatierte Werte statt über den Anzeigetext | [15](#15-nachtrag-10092026--a11-a13-und-a14-behoben-randfälle) | | **R01–R03** | Befunde der Nachprüfung an der eigenen Umsetzung | [11](#11-nachtrag-10092026--r01-r02-und-r03-behoben) | | **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](#8-nachtrag-09092026--stornoregel-entschieden-und-umgesetzt) - **Monatsabschluss** — ein abgerechneter Monat wird vollständig eingefroren, einschließlich Team-Struktur und Detaillisten. → [monatsabschluss-eingefrorene-statistik.md](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](deploy-2026-09-10.md). ```bash 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](#9-nachtrag-09092026--status_turnover-bereinigt-a15a06-behoben) 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](#9-nachtrag-09092026--status_turnover-bereinigt-a15a06-behoben). Die im ersten Punkt > offene Frage zum Zeitpunkt der Stornobuchung ist in > [Abschnitt 8](#8-nachtrag-09092026--stornoregel-entschieden-und-umgesetzt) 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 (Commit `2e71173`), 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](#13-nachtrag-10092026--a04-und-a12-behoben-abgeschlossene-monate-sind-eingefroren). --- ## 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: 1. **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_turnover` fest auf `2`) darf gar nicht übernommen werden. 2. **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:** 1. **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. 2. **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:** 1. `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. 2. `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 bei `status_turnover = 2` zusätzlich in die Shop-Punkte — genau die vom Audit beschriebene Doppelzählung. 3. `SalesVolumePoints::OWN_PREDICATE` schließt Shop-Stornos aus. Eigen- und Kundenpunkte sind damit überschneidungsfrei; jede Zeile zählt genau einmal. 4. Migration `2026_09_09_140000_...`: `status_turnover` wird 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](audit-statistiken-2026-09-09-nachpruefung.md). **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 1. **A06 korrigieren** — Nettowirkung je Bestellung auf wiederkehrende und einmalige Positionen bestimmen, erst danach je Berater summieren. 2. **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. 3. **R03 korrigieren** — Prüfung und Buchung auf `now()` umstellen, Monats- und Jahreswechsel unabhängig von der Systemuhr testen; den überholten Kommentar in `StornoClosedMonthTest.php` entfernen. --- ## 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](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: ```php $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:** `active` wird wahr → `reactivated`. `active` wird falsch → `cancelled` bei Status 4, sonst `deactivated`. - **Kanal und Akteur:** `\Auth::id()` ergibt sich von selbst; den Formularkanal (`me`, `ot`, `portal`, `admin`) setzt `AboRepository::update()` über `UserAbo::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 `active` nicht 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ückrechnung `deleted_at` aus. **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 `0` für `me`, `ot` und `portal`; 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`: die `manual_*`-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: 1. Kandidaten ohne Account fallen vorher weg — eine leichte Id-Abfrage, keine Hydrierung. 2. 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 entscheidet `localeCompare`. - **Leere Werte** ergaben eine 0 und landeten damit mitten zwischen den negativen Punkteständen aus Stornos. Sie sortieren jetzt geschlossen an ein Ende. - **`next_date` wurde doppelt formatiert**: Der Accessor liefert bereits einen formatierten String, den `formatDate()` 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 in `TeamAboOverviewTest.php` fü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 `de` und `en` in `CustomerAboListViewTest.php`, dazu zwei in `BackofficeStatisticsDrilldownTest.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**.