diff --git a/app/Console/Commands/AboBackfillStatusReasons.php b/app/Console/Commands/AboBackfillStatusReasons.php index 238d21f4..73db7743 100644 --- a/app/Console/Commands/AboBackfillStatusReasons.php +++ b/app/Console/Commands/AboBackfillStatusReasons.php @@ -11,8 +11,9 @@ use Illuminate\Console\Command; * Einmaliger Nachtrag fuer Bestandsdaten (Ticket A3): fuellt `status_reason` * und `paused_until` aus Status, Pausenfeldern und dem Statusprotokoll * (`user_abo_status_histories`). Laesst bereits gefuellte Werte unberuehrt und - * schreibt ueber Query-Builder, damit weder Model-Events noch `updated_at` - * angefasst werden. + * schreibt ueber den reinen Query-Builder (`toBase()`), damit weder Model-Events + * noch `updated_at` angefasst werden — der Eloquent-Builder wuerde den + * Zeitstempel mitschreiben (Audit 18.09., F5). */ class AboBackfillStatusReasons extends Command { @@ -40,7 +41,7 @@ class AboBackfillStatusReasons extends Command $counts[$bucket]++; if (! $dryRun) { - UserAbo::withTrashed()->whereKey($abo->id)->update(['status_reason' => $reason]); + UserAbo::withTrashed()->whereKey($abo->id)->toBase()->update(['status_reason' => $reason]); } } }); @@ -63,7 +64,7 @@ class AboBackfillStatusReasons extends Command $counts['paused']++; if (! $dryRun) { - UserAbo::whereKey($abo->id)->update([ + UserAbo::whereKey($abo->id)->toBase()->update([ 'paused_until' => $until->toDateString(), 'status_reason' => $abo->status_reason ?: UserAbo::REASON_PREFIX_PAUSED.$this->actorFromHistory($abo, UserAboStatusHistory::ACTION_PAUSED), ]); diff --git a/app/Http/Controllers/User/TeamController.php b/app/Http/Controllers/User/TeamController.php index c8907bf9..845aee0b 100644 --- a/app/Http/Controllers/User/TeamController.php +++ b/app/Http/Controllers/User/TeamController.php @@ -758,6 +758,13 @@ class TeamController extends Controller 'activeCount' => $firstlines ->filter(fn (User $member) => $dashboardService->isActiveConsultant($member)) ->count(), + // Dritte Zahl, weil die Zeilenmarkierung eine ANDERE Frage beantwortet + // als die Kopfzahl: "Berater" heisst m_level + Abrechnungskonto gesetzt, + // die Markierung zeigt die abgelaufene Jahresgebuehr. Ohne diese Zahl + // ergaebe das Abzaehlen der unmarkierten Zeilen nie die Kopfzahl. + 'expiredCount' => $firstlines + ->reject(fn (User $member) => $member->isActiveAccount()) + ->count(), 'selectedMonth' => $month, 'selectedYear' => $year, 'filterMonths' => HTMLHelper::getTransMonths(), diff --git a/app/Models/UserAbo.php b/app/Models/UserAbo.php index 50c6f320..83c073ca 100644 --- a/app/Models/UserAbo.php +++ b/app/Models/UserAbo.php @@ -130,7 +130,8 @@ class UserAbo extends Model * Kanal des laufenden Formulars ({@see withStatusLogView()}), sonst `system`. * - Zurueck in den Lauf (2, 7, 8) => Grund geloescht. * - `last_date` aendert sich nur durch eine Ausfuehrung (Erfolg oder Fehler): - * damit ist eine Pause vorbei, `paused_until` wird geloescht. + * damit ist eine Pause vorbei, `paused_until` und ein `paused_by_*`-Grund + * werden geloescht. */ protected function applyStatusReasonDefaults(): void { @@ -146,6 +147,10 @@ class UserAbo extends Model if ($this->isDirty('last_date') && ! $this->isDirty('paused_until')) { $this->paused_until = null; + + if (! $this->isDirty('status_reason') && str_starts_with((string) $this->status_reason, self::REASON_PREFIX_PAUSED)) { + $this->status_reason = null; + } } } diff --git a/app/Repositories/AboRepository.php b/app/Repositories/AboRepository.php index fe431717..c71b4647 100644 --- a/app/Repositories/AboRepository.php +++ b/app/Repositories/AboRepository.php @@ -102,6 +102,11 @@ class AboRepository extends BaseRepository ? $this->calculateAdminNextDate($data) : $this->calculateNewNextDate((int) $data['abo_interval']); $this->model->next_date = $nextDate; + // Laufende Pause: ihr Ende ist der naechste Liefertermin — wandert + // der, wandert das Pausenende mit (Audit 18.09., F3). + if ($this->model->getRawOriginal('paused_until')) { + $this->model->paused_until = $nextDate->copy()->startOfDay(); + } $this->model->save(); $daysUntilNext = AboHelper::calendarDaysUntil(now(), $nextDate); @@ -148,6 +153,14 @@ class AboRepository extends BaseRepository return; } + // Die Folgen der Kuendigung (Ticket A4) muessen bestaetigt sein — auch + // serverseitig, nicht nur ueber den deaktivierten Button (Audit 18.09.). + if (! $isAdminUpdate && ! filter_var($data['abo_cancel_confirm'] ?? false, FILTER_VALIDATE_BOOLEAN)) { + \Session()->flash('alert-error', __('abo.cancel_confirm_required')); + + return; + } + // Status 4 = abo_cancel (storniert/gekündigt) $this->model->status = 4; $this->model->active = false; diff --git a/app/Services/Abo/AboStatusExplainer.php b/app/Services/Abo/AboStatusExplainer.php index 955d4346..b23de843 100644 --- a/app/Services/Abo/AboStatusExplainer.php +++ b/app/Services/Abo/AboStatusExplainer.php @@ -196,9 +196,32 @@ class AboStatusExplainer return $this->latestHistory($abo, $action)?->created_at?->copy(); } + /** + * Letzter Protokolleintrag je Aktion — pro Abo und Aktion nur einmal geladen, + * aus der vorgeladenen Relation, wenn vorhanden (Listen: `with('status_histories')`). + * + * @var array + */ + private array $historyMemo = []; + private function latestHistory(UserAbo $abo, string $action): ?UserAboStatusHistory { - return $abo->status_histories()->where('action', $action)->orderByDesc('id')->first(); + $key = $abo->id.':'.$action; + + if (array_key_exists($key, $this->historyMemo)) { + return $this->historyMemo[$key]; + } + + if ($abo->relationLoaded('status_histories')) { + $entry = $abo->status_histories + ->filter(fn (UserAboStatusHistory $history) => $history->action === $action) + ->sortByDesc('id') + ->first(); + } else { + $entry = $abo->status_histories()->where('action', $action)->orderByDesc('id')->first(); + } + + return $this->historyMemo[$key] = $entry; } private function actorLabel(?string $actor): string diff --git a/app/Services/Backoffice/BackofficeDashboardService.php b/app/Services/Backoffice/BackofficeDashboardService.php index 51c081be..8af9de71 100644 --- a/app/Services/Backoffice/BackofficeDashboardService.php +++ b/app/Services/Backoffice/BackofficeDashboardService.php @@ -611,13 +611,18 @@ class BackofficeDashboardService $customerAboIds = $this->aboIds($userIds, 'member_id', 'ot'); $partnerAboIdsNew = $this->aboIds($userIds, 'user_id', 'me', $month, $year); $customerAboIdsNew = $this->aboIds($userIds, 'member_id', 'ot', $month, $year); + // Neupartner ebenfalls als IDs festhalten: die Regel (Zahlungsmonat seit + // 16.09.2026) kann sich aendern, die Liste hinter der eingefrorenen Kachel + // muss trotzdem genau die damals gezaehlten Partner zeigen (Audit 18.09., F2). + $newPartnerIds = $this->newPartnerIds($users, $month, $year); return [ 'line' => $line, 'label' => 'Linie '.$line, 'user_ids' => $userIds, 'consultants' => $this->activeConsultants($users), - 'new_partners' => $this->newPartners($users, $month, $year), + 'new_partners' => count($newPartnerIds), + 'new_partner_ids' => $newPartnerIds, 'team_partner_abos' => count($partnerAboIds), 'team_partner_abos_new' => count($partnerAboIdsNew), 'team_customer_abos' => count($customerAboIds), @@ -701,10 +706,22 @@ class BackofficeDashboardService * @param Collection $users */ private function newPartners(Collection $users, int $month, int $year): int + { + return count($this->newPartnerIds($users, $month, $year)); + } + + /** + * @param Collection $users + * @return int[] + */ + private function newPartnerIds(Collection $users, int $month, int $year): array { return $users ->filter(fn (User $user) => $user->isNewPartnerIn($month, $year)) - ->count(); + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->values() + ->all(); } private function activeAboQuery(): Builder diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php index 37fa76c0..6bed266f 100644 --- a/app/Services/Backoffice/BackofficeDrilldownService.php +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -49,7 +49,7 @@ class BackofficeDrilldownService $rows = match ($metric) { 'consultants' => $this->consultantRows($userIds), - 'new_partners' => $this->newPartnerRows($userIds, $month, $year), + 'new_partners' => $this->newPartnerRows($userIds, $month, $year, $frozen !== null ? $this->frozenNewPartnerIds($frozen, $line) : null, $frozen !== null), 'team_partner_abos' => $this->partnerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds), 'team_customer_abos' => $this->customerAboRows($userIds, $month, $year, $onlyNew, $frozenAboIds), 'own_points', 'external_points', 'customer_abo_points', 'customer_single_order_points', 'customer_other_points', 'total_points', 'shop_1000' => $this->pointsRows($userIds, $month, $year, $metric), @@ -149,13 +149,19 @@ class BackofficeDrilldownService * @param int[] $userIds * @return array> */ - private function newPartnerRows(array $userIds, int $month, int $year): array + private function newPartnerRows(array $userIds, int $month, int $year, ?array $frozenIds = null, bool $frozen = false): array { + // Eingefrorener Monat: exakt die im Snapshot gezaehlten Partner. Aeltere + // Snapshots ohne gespeicherte IDs wurden mit der Regel "Registrierungsmonat" + // gebaut — die Liste rechnet dann mit derselben Regel, sonst zeigt die + // Kachel 1 und die Liste 0 (Audit 18.09., F2). $partners = User::query() ->with(['account', 'user_level']) - ->whereIn('id', $userIds) - ->newPartnerIn($month, $year) + ->whereIn('id', $frozenIds ?? $userIds) + ->when($frozenIds === null && $frozen, fn (Builder $query) => $query->newPartnerInByActiveDate($month, $year)) + ->when($frozenIds === null && ! $frozen, fn (Builder $query) => $query->newPartnerIn($month, $year)) ->orderBy('partner_paid_at') + ->orderBy('active_date') ->get(); $sponsors = User::query() @@ -274,6 +280,34 @@ class BackofficeDrilldownService return array_values(array_unique($ids)); } + /** + * Neupartner einer Linie zum Monatsabschluss; null, wenn der Snapshot die IDs + * noch nicht kennt (gebaut vor dem 18.09.2026) — dann gilt die damalige Regel. + * + * @param array> $frozen + * @return int[]|null + */ + private function frozenNewPartnerIds(array $frozen, int $line): ?array + { + $ids = []; + + foreach ($frozen as $index => $row) { + if ($line !== 0 && (int) ($row['line'] ?? $index) !== $line) { + continue; + } + + if (! array_key_exists('new_partner_ids', $row)) { + return null; + } + + foreach ($row['new_partner_ids'] as $id) { + $ids[] = (int) $id; + } + } + + return array_values(array_unique($ids)); + } + /** * Abos einer Linie zum Monatsabschluss; Linie 0 = das ganze Team. Gibt es zu der * Kennzahl keine eingefrorene Liste, wird null zurueckgegeben (Live-Auswertung). diff --git a/app/Services/Payment.php b/app/Services/Payment.php index 110e378f..26b7c930 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -227,7 +227,13 @@ class Payment $user->wizard = 100; // Erste Kontozahlung = Zeitpunkt, ab dem der Partner in der // Neupartner-Statistik zaehlt. Verlaengerungen aendern ihn nicht. - UserUtil::markPartnerPaid($user); + // Nur bei bestaetigter Zahlung (Audit 18.09., F1): der Rechnungskauf + // ruft diesen Pfad mit $paid = false auf und aktiviert das Konto + // vorab — bezahlt ist er erst, wenn die Rechnung beglichen wird + // (ManualPaymentService, dann $paid = true). + if ($paid) { + UserUtil::markPartnerPaid($user); + } // only date is > now and acount is deactive. if ($date > \Carbon::now()) { if ($user->active === 0) { diff --git a/app/Services/UserUtil.php b/app/Services/UserUtil.php index b617532e..7eefb5a6 100644 --- a/app/Services/UserUtil.php +++ b/app/Services/UserUtil.php @@ -301,8 +301,13 @@ class UserUtil * 1. die Punktebuchung der ersten bezahlten Registrierungsbestellung * (`payment_for = 1`) — sie entsteht im Moment der Zahlung; * 2. ersatzweise das Aenderungsdatum dieser Bestellung; - * 3. ohne Registrierungsbestellung (Konto vom Admin freigeschaltet) das - * Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht. + * 3. ohne jede Registrierungsbestellung (Konto vom Admin freigeschaltet) das + * Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht. Gibt es + * eine Registrierungsbestellung, die nur noch nicht bezahlt ist (offene + * Rechnung), bleibt der Wert leer: er wird bei der Zahlung gesetzt + * (Audit 18.09., F1). + * + * Schreibt ohne Events und ohne `updated_at` anzufassen (Audit 18.09., F5). * * Absichtlich NICHT `payment_account` minus ein Jahr: bei Verlaengerungen * wird das Konto vom alten Ablaufdatum aus verlaengert, das Ergebnis waere der @@ -320,15 +325,20 @@ class UserUtil ->orderBy('id') ->chunkById(200, function ($users) use (&$set): void { foreach ($users as $user) { - $paidAt = self::firstRegistrationPaidAt((int) $user->id) - ?? ($user->active_date ? Carbon::parse($user->active_date) : null); + $paidAt = self::firstRegistrationPaidAt((int) $user->id); + + if ($paidAt === null && ! self::hasRegistrationOrder((int) $user->id)) { + $paidAt = $user->active_date ? Carbon::parse($user->active_date) : null; + } if ($paidAt === null) { continue; } $user->partner_paid_at = $paidAt; + $user->timestamps = false; $user->saveQuietly(); + $user->timestamps = true; $set++; } }); @@ -336,6 +346,14 @@ class UserUtil return $set; } + private static function hasRegistrationOrder(int $userId): bool + { + return ShoppingOrder::query() + ->where('auth_user_id', $userId) + ->where('payment_for', 1) + ->exists(); + } + private static function firstRegistrationPaidAt(int $userId): ?Carbon { $order = ShoppingOrder::query() diff --git a/app/User.php b/app/User.php index a9cb9f8b..17b9d46e 100644 --- a/app/User.php +++ b/app/User.php @@ -274,6 +274,22 @@ class User extends Authenticatable ->whereBetween('partner_paid_at', [$startDate, $endDate]); } + /** + * Neupartner-Regel bis 16.09.2026 (Registrierungsdatum statt Zahlungsdatum). + * Nur noch fuer eingefrorene Snapshots ohne gespeicherte Neupartner-IDs, damit + * die verlinkte Liste dieselben Partner zeigt wie die damals gezaehlte Kachel + * (Audit 18.09., F2). + */ + public function scopeNewPartnerInByActiveDate($query, int $month, int $year) + { + $startDate = Carbon::create($year, $month, 1)->startOfMonth(); + + return $query->whereNotNull('m_level') + ->whereNotNull('payment_account') + ->where('payment_account', '>', self::newPartnerReferenceDate($month, $year)) + ->whereBetween('active_date', [$startDate, $startDate->copy()->endOfMonth()]); + } + /** * PHP-seitiges Gegenstück zu {@see scopeNewPartnerIn()} für bereits geladene * User-Collections (Linien-Auswertung, Recruiter-Ranking) — damit die diff --git a/dev/_tickets/2026-09-18-audit-commits-16-09-2026.md b/dev/_tickets/2026-09-18-audit-commits-16-09-2026.md new file mode 100644 index 00000000..61e2597f --- /dev/null +++ b/dev/_tickets/2026-09-18-audit-commits-16-09-2026.md @@ -0,0 +1,223 @@ +# Audit der vier Commits vom 16.09.2026 + +**Prüfdatum:** 18.09.2026 +**Geprüfter Stand:** `d5ad1ba50b8b4442d6b6ccffebb0226f90c04894` +**Vergleichsbasis:** `203999a` (`HEAD~4` zum Prüfzeitpunkt) +**Umfang:** Code, Ticketanforderungen, Migrationen/Backfills, Aufrufer, Views, Übersetzungen und automatisierte Tests. + +## Bewertung + +Die Umsetzung hat eine gute Grundlage: Datumsberechnung und Sperrregeln sind zentralisiert, Monatsenden werden berücksichtigt, und die Statusanzeige verwendet einen gemeinsamen Explainer. Die neuen Tests decken zahlreiche reguläre Abläufe ab. **Den Gesamtstand würde ich wegen des falsch gesetzten Zahlungsdatums und der inkonsistenten historischen Statistik noch nicht als abgeschlossen abnehmen.** + +Es gibt fünf konkrete Befunde: einen mit hoher, drei mit mittlerer und einen mit niedriger Priorität. Hinzu kommen gezielte Verbesserungen bei Bestätigung, Datenqualität, Performance und Testabdeckung. Die vorhandene Testsuite ist schon vor diesen Änderungen nicht vollständig grün; durch die vier Commits kommt genau ein weiterer fehlgeschlagener Test hinzu. + +| Commit | Umsetzung | Einschätzung | +| --- | --- | --- | +| `c0535be` | A4: sichtbarer Bereich für Pause/Kündigung, Folgen-Hinweis und Bestätigungs-Checkbox | UI sinnvoll ergänzt; Mindestlaufzeit und Sperre bleiben serverseitig abgesichert. Checkbox selbst wird nur im Browser geprüft. | +| `ec3ecad` | A3: Statusgrund, Pausenende, Zahlungsfehler und Backfill | Gemeinsame Darstellung ist sinnvoll; Pausenende kann nach Terminänderungen falsch sein, Backfill verändert entgegen seiner Beschreibung Zeitstempel. | +| `66c6ae0` | A1/A2: Liefertage 1–31, Monatsende, Sperre am Ausführungstag | Datums- und Kalendertagslogik im geprüften Umfang schlüssig; Zusammenspiel mit der neuen Pauseninformation muss ergänzt werden. | +| `d5ad1ba` | Neupartner im Zahlungsmonat | Zentrale Definition sinnvoll, aber Rechnungskauf und bestehende Snapshots sind unvollständig berücksichtigt; ein Test wurde ohne die zugehörige Controller-Änderung committet. | + +## Prüfverfahren und Grenzen + +Im Arbeitsverzeichnis lagen bereits umfangreiche uncommittete Änderungen. Deshalb wurden beide Git-Stände mit `git archive` in separate temporäre Verzeichnisse extrahiert und mit Kopien der vorhandenen PHP-Abhängigkeiten getestet. Die lokale Anwendungs-Konfiguration mit echten Zugangsdaten wurde nicht übernommen; verwendet wurden SQLite im Speicher und Testkonfiguration. Die bereits vorhandenen Änderungen wurden nicht bearbeitet. + +Ticketgrundlage waren insbesondere [Entwicklungsplan September](entwicklungsplan-protokoll-september-2026.md), Abschnitte A1–A4 und Antworten R1/R2, sowie [Neupartner im Zahlungsmonat](../2026-07-22/neupartner-zahlungsmonat.md). Fundstellen und Zeilennummern beziehen sich auf den geprüften Commitstand; uncommittete Änderungen können die Zeilennummern im Arbeitsverzeichnis verschieben. + +Die Prüfung umfasst keine Browserinteraktion, keinen produktiven Datenabgleich und keinen MySQL-Parallelitätstest. Es wurden keine fachlichen Backfills oder Migrationen gegen die bestehende Anwendungsdatenbank ausgeführt. Im Repository wurde ausschließlich dieser Bericht neu angelegt; zusätzliche Reproduktionstests liegen nur in der temporären Prüfkopie. + +## Konkrete Befunde + +### F1 – Hohe Priorität: Unbezahlte Rechnung setzt bereits das erste Zahlungsdatum + +**Commit:** `d5ad1ba` +**Fundstellen:** [Payment.php](../../app/Services/Payment.php), Zeilen 224–230; [UserUtil.php](../../app/Services/UserUtil.php), Zeilen 285–293. Relevanter Aufrufer: [CheckoutController.php](../../app/Http/Controllers/Web/CheckoutController.php), `directPaymentStatus()`, im Commitstand Zeilen 760–773. + +`Payment::paymentStatusPaidAction()` wird beim Rechnungskauf ausdrücklich mit `$paid = false` aufgerufen. Der neue Aufruf von `markPartnerPaid()` prüft dieses Argument nicht. Dadurch wird schon bei einer offenen Rechnung ein Zahlungszeitpunkt gespeichert. Bei der späteren tatsächlichen Zahlung bleibt dieser Wert wegen des gewollten „nur einmal setzen“-Guards bestehen. + +**Reproduziert:** Konto-Produkt auf Rechnung am 31.08.2026 → Bestellung bleibt `paid = false`, aber `partner_paid_at = 31.08.2026`. Zahlung am 01.09.2026 → `partner_paid_at` bleibt August; `isNewPartnerIn(9, 2026)` liefert `false`. + +**Auswirkung:** Die zentrale Ticketanforderung „Monat der Zahlung“ ist für diesen Pfad nicht erfüllt. Kachel, Recruiter-Auswertung und Neupartner-Liste können den falschen Monat verwenden. Auch nie bezahlte Rechnungskäufe erhalten bereits einen Zahlungszeitpunkt. + +**Empfehlung:** Im Zahlungsservice das Feld nur bei bestätigter Zahlung setzen. Die ausdrücklich erlaubte manuelle Freischaltung im Admin bleibt ein eigener Fall. Zusätzlich im Backfill unterscheiden, ob ein Konto tatsächlich manuell freigeschaltet wurde oder lediglich eine offene Rechnung vorliegt: `payment_account != null` und fehlende bezahlte Registrierungsbestellung beweisen keine Admin-Freischaltung. Bereits falsch gesetzte Werte müssen bei einer Korrektur gezielt identifiziert werden; ein normaler erneuter Zahlungslauf repariert sie nicht. + +**Fehlender Regressionstest:** Offene Rechnung Ende August, Zahlung Anfang September; vor Zahlung kein `partner_paid_at`, danach September. Außerdem offener Rechnungskauf ohne Zahlung im Backfill. + +### F2 – Mittlere Priorität: Historische Kachel und verlinkte Neupartner-Liste verwenden unterschiedliche Regeln + +**Commit:** `d5ad1ba` +**Fundstellen:** [User.php](../../app/User.php), `scopeNewPartnerIn()`, Zeilen 264–274; [BackofficeDrilldownService.php](../../app/Services/Backoffice/BackofficeDrilldownService.php), Zeilen 36–52 und 152–159. + +Abgeschlossene Monate zeigen weiterhin die gespeicherten Snapshot-Zahlen nach der bisherigen Definition. Die Detailansicht übernimmt aus dem Snapshot nur die Teammitglieder und filtert diese anschließend mit dem geänderten `newPartnerIn()` nach `partner_paid_at`. Die eingefrorene Neupartner-Mitgliedschaft wird nicht übernommen. + +**Reproduziert:** Ein August-Snapshot enthält einen Neupartner nach Registrierungsdatum. Nach Hinterlegung des tatsächlichen Zahlungsdatums im September zeigt die August-Kachel weiterhin **1**, die verlinkte August-Liste aber **0** Zeilen. Der CSV-Export verwendet ebenfalls diese Detaildaten. + +Die Deploy-Dokumentation beschreibt bereits, dass alte Snapshots bewusst nicht automatisch neu aufgebaut werden. Das erklärt die unveränderte Kachel, löst aber nicht die neu entstehende Abweichung innerhalb derselben Ansicht. Auch die dokumentierten wiederhergestellten Partner mit deutlich früherer Erstzahlung können hiervon betroffen sein; die produktive Anzahl wurde im Audit nicht ermittelt. + +**Empfehlung:** Snapshot und Drilldown müssen dieselbe Definitionsversion verwenden. Für künftige Snapshots auch die Neupartner-IDs speichern. Für bestehende Snapshots eine explizite Strategie vorsehen: alte Definition konsistent weiter anzeigen oder gezielt abgestimmt migrieren. Ein pauschales `--force` ist keine unbedenkliche Reparatur, weil dadurch laut Projektdokumentation auch historische Teamzuordnungen verändert werden können. + +**Fehlender Regressionstest:** Snapshot vor Definitionswechsel erzeugen, Zahlungsdatum abweichend nachtragen, danach Kachel, Liste und CSV auf gleiche Mitgliedschaft prüfen. + +### F3 – Mittlere Priorität: Pausenende bleibt nach Änderung des Liefertermins auf dem alten Datum + +**Commits:** Zusammenspiel von `ec3ecad` und `66c6ae0` +**Fundstellen:** [AboRepository.php](../../app/Repositories/AboRepository.php), Zeilen 99–105 und 243–249; [UserAbo.php](../../app/Models/UserAbo.php), Zeilen 135–149 und 171–175. + +Eine Pause setzt `paused_until` auf den neuen Liefertermin. Eine nachfolgende zulässige Änderung des Liefertags schreibt jedoch nur `abo_interval` und `next_date`. Der Explainer und der Admin-Filter vertrauen weiterhin auf das alte `paused_until`. + +**Reproduziert:** Nächste Lieferung 05.10.2026; Pause verschiebt sie auf 05.11.2026. Danach wird der Liefertag auf den 20. geändert. Ergebnis: `next_date = 20.11.2026`, aber `paused_until = 05.11.2026`. + +**Auswirkung:** Kunden und Mitarbeitende erhalten einen falschen Wiederaufnahmetermin. Ab dem 06.11. verschwindet das Abo aus dem Pausenfilter, obwohl die verschobene Lieferung erst am 20.11. ansteht. Bei einer Vorverlegung kann die Anzeige umgekehrt eine längere Pause versprechen als tatsächlich vorgesehen. + +**Empfehlung:** Bei Terminänderungen während einer laufenden Pause das Pausenende konsistent mitführen oder die laufende Pause aus einer eindeutigen Quelle ableiten. Die Regel für Selfservice und Admin zentral umsetzen. Beim Abschluss einer Pause auch `status_reason` bereinigen: Der aktuelle Hook löscht bei geändertem `last_date` zwar `paused_until`, aber einen bestehenden `paused_by_*`-Grund bei unverändertem Status 2 nicht. + +**Fehlender Regressionstest:** Pause → Liefertermin nach hinten/vorn ändern → Erklärung und Admin-Filter prüfen → erfolgreiche Ausführung → Pausenfelder und Pausengrund entfernt. + +### F4 – Mittlere Priorität: Neuer Test erwartet eine nicht mitcommittete Controller-Erweiterung + +**Commit:** `d5ad1ba` +**Fundstellen:** [TeamFirstlinesPageTest.php](../../tests/Feature/TeamFirstlinesPageTest.php), Zeilen 69–72; [TeamController.php](../../app/Http/Controllers/User/TeamController.php), Rückgabedaten in `firstlines()`, im Commitstand ab Zeile 752. + +Der Test erwartet neu `expiredCount`. Der Controller liefert diesen Schlüssel im Git-Stand nicht. Das Ergebnis ist reproduzierbar `Undefined array key "expiredCount"`. + +**Besonders relevant:** Die passende Controller-Erweiterung existiert im aktuellen Arbeitsverzeichnis bereits als uncommittete Änderung. Damit kann ein Test im gemischten Arbeitsverzeichnis einen vollständigeren Stand prüfen, als später aus den vier Commits ausgeliefert wird. + +**Empfehlung:** Die fachlich zusammengehörende Controller-/View-/Test-Änderung vollständig und fokussiert versionieren oder die sachfremde Test-Erweiterung aus diesem Paket herauslösen. Die Abnahme anschließend gegen einen sauberen Checkout durchführen. + +**Nachweis:** Dies ist der einzige zusätzliche fehlgeschlagene Test im vollständigen Vergleich mit `203999a`. + +### F5 – Niedrige Priorität: Backfills verändern entgegen der Dokumentation `updated_at` + +**Commits:** `ec3ecad`, zusätzlich entsprechendes Verhalten in `d5ad1ba` +**Fundstellen:** [AboBackfillStatusReasons.php](../../app/Console/Commands/AboBackfillStatusReasons.php), Zeilen 42–43 und 65–69; [UserUtil.php](../../app/Services/UserUtil.php), Zeilen 330–331. + +Der Abo-Backfill behauptet, ohne Änderung von `updated_at` zu schreiben. Tatsächlich verwendet er Eloquent-Builder-Updates; diese ergänzen den Aktualisierungszeitstempel. Auch `saveQuietly()` im Partner-Backfill unterdrückt Events, aber keine Zeitstempel. + +**Reproduziert:** Ein bestehendes Abo mit `updated_at = 01.08.2026 12:00:00` erhält beim Status-Backfill den Testzeitpunkt `16.09.2026 10:00:00`. + +**Auswirkung:** Die ursprüngliche Änderungszeit geht für betroffene Datensätze verloren. Sortierungen oder spätere Analysen anhand dieses Feldes sehen eine Massenänderung zum Deploy. Eine darüber hinausgehende konkrete Störung wurde nicht nachgewiesen. + +**Empfehlung:** Bewusst ohne automatische Zeitstempel schreiben, beispielsweise mit geeignetem Query-Builder-Update oder `withoutTimestamps()`. Den Erhalt der Zeitstempel testen und die Deploy-Dokumentation mit der tatsächlichen Implementierung abgleichen. + +## Weitere Verbesserungen und Optimierungen + +### Kündigungsbestätigung auch serverseitig prüfen + +Das neue Formular sendet `abo_cancel_confirm`, aber kein PHP-Pfad wertet das Feld aus. Ein Request mit `abo_cancel=true` ohne Bestätigung kündigt weiterhin erfolgreich; dies wurde im Repository-Test reproduziert. Mindestlaufzeit und Sperrfrist werden dabei weiterhin geprüft – es handelt sich also nicht um deren Umgehung. + +Wenn die Checkbox eine verbindliche Bestätigung der angezeigten Folgen sein soll, sollte der Selfservice-Pfad eine `accepted`-Validierung erhalten. Dazu Tests für fehlende, falsche und gültige Bestätigung ergänzen. Der normale Browserablauf wird aktuell lediglich durch den deaktivierten Button gesteuert. + +### Zusätzliche Einzelabfragen im Status-Explainer reduzieren + +[AboStatusExplainer.php](../../app/Services/Abo/AboStatusExplainer.php), `latestHistory()` ab Zeile 199, fragt die Historie bei jedem Aufruf erneut ab. Ein deaktiviertes Abo benötigt mindestens eine zusätzliche Abfrage für das Datum; ohne gespeicherten Akteur entstehen zwei Abfragen derselben Historie für Akteur und Datum. Das neue Partial wird auch pro Tabellenzeile gerendert. + +Empfehlung: Die jeweils benötigten letzten Historieneinträge gesammelt laden, geladene Beziehungen tatsächlich verwenden und den Eintrag innerhalb einer Erklärung wiederverwenden. Ein Query-Count-Test mit beispielsweise 50 Abos ist hier aussagekräftiger als weitere reine Textvergleiche. Der Admin lädt die Abo-Bestellungen bereits vor; diese vorhandene Optimierung ist positiv. + +### Herkunft und Genauigkeit rückgefüllter Zahlungsdaten sichtbar halten + +`firstRegistrationPaidAt()` verwendet ohne Punktebuchung `shopping_orders.updated_at`, ohne passende Bestellung `active_date`. Beide Ersatzwerte können fachlich vom ersten Zahlungseingang abweichen: `updated_at` kann durch spätere Bestellbearbeitung verschoben werden, `active_date` wird unter anderem bei Wiederherstellung neu gesetzt. Diese Fallbacks sind dokumentiert, erscheinen in der Oberfläche aber uneingeschränkt als „Bezahlt am“. + +Empfehlung: Eine belastbare Zahlungstransaktion bevorzugen und Ersatzwerte mit Herkunft beziehungsweise Unsicherheit kennzeichnen. Ein Dry-Run mit Zählung je Datenquelle verbessert die Kontrolle vor der Migration. Zudem sollte „erste Zahlung“ bei mehreren bezahlten Registrierungsbestellungen nach Zahlungszeitpunkt bestimmt werden; die kleinste Bestell-ID allein garantiert diese Reihenfolge nicht. Das tatsächliche Ausmaß dieser Randfälle wurde nicht gegen Bestandsdaten geprüft. + +### Backfill von Schemaänderung entkoppeln und Datenzugriffe bündeln + +Die Migration ruft einen veränderlichen Anwendungsservice auf, der pro Partner eine Bestellung, gegebenenfalls eine Punktebuchung und ein Update ausführt. Chunking begrenzt den Speicher, aber nicht die Zahl dieser Einzelabfragen. Ein eigenständiges, wiederaufnehmbares Backfill-Kommando mit Dry-Run, Statistik je Quelle und gebündelten Datenzugriffen wäre besser kontrollierbar. Den erstmaligen Schreibvorgang von `partner_paid_at` bei parallelen Zahlungsprozessen außerdem atomar absichern; der aktuelle Null-Check schützt nur die einzelne geladene Modelinstanz. Ein tatsächlicher Parallelitätsfehler wurde hier nicht reproduziert. + +### Änderungen und Tests stärker nach Fachthema trennen + +Der Statistik-Commit enthält zusätzlich Änderungen am Löschpräfix von E-Mail-Adressen (`buildDeletedEmail()` / `stripDeletePrefix()`). Dazu passende Tests liegen im Arbeitsverzeichnis untracked, sind jedoch nicht Teil des Commits. Diese Änderungen sollten mit eigener Beschreibung und Regressionstests gemeinsam versioniert werden. Dasselbe gilt für die Firstline-Erweiterung aus F4. + +Für A1/A2 zusätzlich einen Integrationstest des tatsächlichen Ausführungscommands am 30. eines Monats mit Liefertag 31 ergänzen. Die neuen Tests belegen die Hilfsfunktion, aber nicht allein den gesamten Ablauf Auswahl → Bestellung → nächster Termin. Für A4 fehlt eine Browserprüfung der Checkbox, des Modal-Resets und des Buttons „Lieber pausieren“. + +## Validierung + +### Ergebnisse + +| Prüfung | Ergebnis | +| --- | --- | +| PHP-Syntax aller 47 geänderten PHP-Dateien ohne Blade-Templates | Alle erfolgreich | +| Gezielte Tests zu Abo-Tickets, Zahlungsdatum, Prognose und Statistik | 163 Tests: **161 bestanden, 2 fehlgeschlagen**, 850 Assertions | +| Vollständige Suite auf `203999a` | **1.055 bestanden, 96 fehlgeschlagen, 1 übersprungen**, 4.019 Assertions | +| Vollständige Suite auf `d5ad1ba` | **1.095 bestanden, 97 fehlgeschlagen, 1 übersprungen**, 4.248 Assertions | +| Vergleich der vollständigen JUnit-Ergebnisse | Genau **ein neuer Fehlschlag**, keine zuvor fehlgeschlagene Prüfung behoben | +| Zusätzliche temporäre Reproduktionstests | **5 bestanden**, 15 Assertions; bestätigen bewusst die fehlerhaften Ist-Zustände | + +Die zwei Fehlschläge der gezielten Auswahl sind `HomeMonthlyStatsTest` (`payline_is_career_fallback`, bereits in der Basis fehlgeschlagen) und `TeamFirstlinesPageTest` (`expiredCount`, neu durch `d5ad1ba`). Die 96 gemeinsamen Fehlschläge wurden nicht als durch diese Tickets verursacht bewertet. Die Zahlen gelten für die identische isolierte Testumgebung; sie sind keine Aussage über die Laufzeitfehlerzahl der Produktion. Zusätzlich meldet PHPUnit das bereits vorhandene veraltete XML-Konfigurationsschema. + +Die fünf temporären Reproduktionen prüfen: unbezahlte Rechnung und spätere Zahlung, historische Snapshot-/Listenabweichung, veraltetes Pausenende nach Terminänderung, verändertes `updated_at` beim Status-Backfill und Kündigung ohne Checkbox-Bestätigung. + +### Wiederholbare Testbefehle + +In einem sauberen Checkout mit gültigem reinem Test-`APP_KEY`, Test-Zahlungskonfiguration und den vorhandenen Dependencies: + +```bash +php artisan test --compact --filter='AboDeliveryDayTest|AboLockDaysSettingTest|AboPauseCancelSectionTest|AboPauseTest|AboRepositoryDateChangeTest|AboStatusExplainTest|PartnerPaidAtTest|PaylineForecastServiceTest|TeamFirstlinesPageTest|TopResultsServiceTest|BackofficeStatisticsDrilldownTest|BackofficeStatisticsListsTest|BackofficeSnapshotFreezeTest|HomeMonthlyStatsTest|StatisticsConsistencyTest|BackofficeDashboardServiceTest' +php artisan test --compact --log-junit /tmp/audit-results.xml +``` + +Beim Audit wurden für den Artisan-Boot zusätzlich `APP_ENV=testing`, `DB_CONNECTION=sqlite`, `DB_DATABASE=:memory:` und der in `phpunit.xml` hinterlegte Testwert für `PAYONE_KEY_HASH` gesetzt. Die vollständige HEAD-Prüfung schloss die separat markierte temporäre Testgruppe `audit` aus. Es wurden die installierten Dependencies verwendet, keine Paketaktualisierungen vorgenommen. + +## Empfohlene Reihenfolge + +1. F1 korrigieren und den Rechnungskauf über einen Monatswechsel absichern. +2. F2 mit einer konsistenten Strategie für bestehende Snapshots lösen; keine ungeprüfte historische Neuberechnung. +3. F3 korrigieren und die Kombination Pause/Terminänderung/Ausführung testen. +4. F4 durch einen vollständigen, fokussierten Commit beheben und den sauberen Checkout erneut testen. +5. F5 vor dem Backfill bereinigen; anschließend Bestätigungsprüfung und Performance-Verbesserungen umsetzen. + +Die bereits dokumentierten Deploy-Schritte bleiben relevant: Schema-Migrationen, Abo-Status-Backfill zunächst als Dry-Run, beide Sperrfrist-Settings ausdrücklich auf `0` und Blade-Views leeren. Ob diese Schritte auf einer Zielumgebung bereits erfolgt sind, war nicht Gegenstand dieses Audits. + +--- + +## Stellungnahme und Umsetzung (18.09.2026) + +Jeder Befund wurde gegen den Code geprüft. Ergebnis: **alle fünf Befunde und die Kündigungsbestätigung sind +zutreffend** und wurden umgesetzt (Commit „Audit 18.09.“ auf `payment/phase-r-payone-refactoring`). Die +Zahlen der Validierung wurden nicht nachgerechnet; der eine neue Fehlschlag (F4) ist reproduzierbar und +behoben. + +| Befund | Bewertung | Umsetzung | +| --- | --- | --- | +| F1 Rechnungskauf setzt Zahlungsdatum | **zutreffend** — `paymentStatusPaidAction($order, false)` beim Rechnungskauf, `markPartnerPaid()` prüfte `$paid` nicht | `markPartnerPaid()` nur noch bei `$paid = true`; die spätere Rechnungszahlung (`ManualPaymentService`, `$paid = true`) setzt den Wert. Backfill: Aktivierungsdatum nur noch, wenn **gar keine** Registrierungsbestellung existiert — eine offene Rechnung bleibt leer. Tests: offene Rechnung Ende August, Zahlung Anfang September → September; Backfill mit offener Rechnung → leer. | +| F2 Kachel und Liste mit unterschiedlichen Regeln | **zutreffend** — Snapshot zählte nach damaliger Regel, Drilldown filterte die eingefrorenen Mitglieder mit der neuen Regel | Neue Snapshots speichern je Linie `new_partner_ids`; die Liste nimmt exakt diese. Alte Snapshots ohne IDs: Liste rechnet mit der damaligen Regel (`scopeNewPartnerInByActiveDate`, Registrierungsmonat) über die eingefrorenen Mitglieder — Kachel und Liste stimmen wieder überein, ohne historische Neuberechnung. Kein Bump der Payload-Version (alte Snapshots bleiben gültig). Tests: nachgetragenes Zahlungsdatum, alter Snapshot ohne IDs. | +| F3 Pausenende bleibt bei Terminänderung stehen | **zutreffend** | Liefertag-/Termin-Änderung (Selfservice und Admin) zieht `paused_until` auf den neuen Termin, solange eine Pause läuft. Ausführung (`last_date` ändert sich) löscht jetzt auch den `paused_by_*`-Grund. Test: Pause → 5.11. auf 20.11. → Admin auf 10.12. → Ausführung → Felder leer. | +| F4 Test ohne Controller-Änderung committet | **zutreffend** — `expiredCount` lag nur im Arbeitsverzeichnis | Controller-Hunk, View und die vier `team.php`-Keys zusammen versioniert. | +| F5 Backfills schreiben `updated_at` | **zutreffend** — Eloquent-Builder-`update()` ergänzt den Zeitstempel, `saveQuietly()` ebenfalls | Abo-Backfill über `toBase()->update()`, Partner-Backfill mit `timestamps = false`. Tests prüfen den erhaltenen Zeitstempel. | +| Kündigungsbestätigung nur im Browser | **zutreffend** | `abo_cancel_confirm` wird im Selfservice serverseitig verlangt (nach Mindestlaufzeit und Sperre), sonst `abo.cancel_confirm_required`. Admin unverändert. Tests: fehlend, `0`, `1`. | +| Explainer-Einzelabfragen | **zutreffend** | Historie je Abo und Aktion nur einmal geladen, vorgeladene Relation wird genutzt. Kein Query-Count-Test; Listen laden `status_histories` weiterhin nicht vor (offen, siehe unten). | + +**Nicht umgesetzt, bewusst:** + +- *Herkunft der rückgefüllten Zahlungsdaten kennzeichnen* und *Backfill von der Migration entkoppeln*: Die + Migration `2026_09_16_090000` mit Backfill ist Teil von `d5ad1ba`; ob sie auf Produktion schon gelaufen ist, + war hier nicht feststellbar. Eine Kennzeichnung der Quelle bräuchte eine weitere Spalte — Aufwand ohne + fachliche Anfrage. Für die Erstzahlung bei mehreren bezahlten Registrierungsbestellungen gilt weiter die + kleinste Bestell-ID. +- *Atomares Erstsetzen von `partner_paid_at`*: Der Paid-Pfad läuft in beiden Webhooks bereits in einer + Transaktion mit `lockForUpdate` auf der Bestellung (C2); ein zweiter Zahlungsprozess desselben Users im + selben Moment ist nicht reproduziert worden. +- *Integrationstest des Ausführungscommands am 30. mit Liefertag 31* und *Browserprüfung A4*: offen. +- *Löschpräfix-Änderung (`buildDeletedEmail`) im Statistik-Commit*: gehört zum DSGVO-Thema, das noch + uncommittet im Arbeitsbaum liegt; wird mit diesem Thema versioniert. + +**Deploy-Folgen:** + +- Keine neue Migration. `php artisan view:clear`. +- Falls `d5ad1ba` bereits auf Produktion lief: Rechnungskäufe seit diesem Deploy haben ein falsches + `partner_paid_at` (Datum des Rechnungskaufs statt der Zahlung). Prüfen und leeren: + + ```sql + -- Kandidaten: Rechnungskauf offen, keine bezahlte Registrierungsbestellung + SELECT u.id, u.email, u.partner_paid_at + FROM users u + WHERE u.partner_paid_at IS NOT NULL + AND EXISTS (SELECT 1 FROM shopping_orders o WHERE o.auth_user_id = u.id AND o.payment_for = 1 AND o.paid = 0) + AND NOT EXISTS (SELECT 1 FROM shopping_orders o WHERE o.auth_user_id = u.id AND o.payment_for = 1 AND o.paid = 1); + -- danach fuer diese IDs: UPDATE users SET partner_paid_at = NULL WHERE id IN (...); + ``` + + Der Wert wird bei der Rechnungszahlung neu gesetzt. Die Backfill-Logik nimmt für diese Fälle nicht mehr + das Aktivierungsdatum. +- Snapshots müssen nicht neu gebaut werden; ab dem nächsten Monatsabschluss enthalten sie die + Neupartner-IDs. Ein `--force`-Neuaufbau bleibt eine gesonderte Entscheidung (siehe + `neupartner-zahlungsmonat.md`). diff --git a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md index 4d27c746..f8d28d6b 100644 --- a/dev/_tickets/entwicklungsplan-protokoll-september-2026.md +++ b/dev/_tickets/entwicklungsplan-protokoll-september-2026.md @@ -1,6 +1,6 @@ # Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026 -**Stand:** 16.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett +**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort. **Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3) **Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig) diff --git a/resources/lang/de/abo.php b/resources/lang/de/abo.php index 2a2bec91..b331fcc3 100644 --- a/resources/lang/de/abo.php +++ b/resources/lang/de/abo.php @@ -386,4 +386,5 @@ return [ 'pause_cancel_locked_hint_zero' => 'Die Lieferung am :date wird heute ausgeführt. Pausieren oder Kündigen ist danach wieder möglich – bis 23:59 Uhr des Vortags einer Lieferung.', 'filter_next_from' => 'von', 'filter_next_to' => 'bis', + 'cancel_confirm_required' => 'Bitte bestätige die Folgen der Kündigung über das Kästchen.', ]; diff --git a/resources/lang/de/team.php b/resources/lang/de/team.php index 60c66c38..d27b9bdc 100644 --- a/resources/lang/de/team.php +++ b/resources/lang/de/team.php @@ -165,6 +165,7 @@ return [ 'annual_fee_group_due_3_months' => 'Fällig in ≤ 3 Monaten', 'firstlines' => 'Firstlines', 'firstlines_count_split' => ':active Berater · :total insgesamt', + 'firstlines_count_expired' => ':count mit abgelaufener Jahresgebühr', 'firstlines_copy' => 'Deine direkten Teampartner mit Umsatz und Kontaktdaten - sortiert nach Punkten des gewählten Monats.', 'firstlines_none' => 'Du hast noch keine direkten Teampartner.', 'firstlines_points' => 'Umsatz (Punkte)', diff --git a/resources/lang/en/abo.php b/resources/lang/en/abo.php index ab3614d5..c34e6022 100644 --- a/resources/lang/en/abo.php +++ b/resources/lang/en/abo.php @@ -383,4 +383,5 @@ return [ 'pause_cancel_locked_hint_zero' => 'The delivery on :date is being executed today. Pausing or cancelling is possible again afterwards – until 11:59 pm the day before a delivery.', 'filter_next_from' => 'from', 'filter_next_to' => 'to', + 'cancel_confirm_required' => 'Please confirm the consequences of the cancellation via the checkbox.', ]; diff --git a/resources/lang/en/team.php b/resources/lang/en/team.php index 26ac69a3..d973a76d 100644 --- a/resources/lang/en/team.php +++ b/resources/lang/en/team.php @@ -165,6 +165,7 @@ return [ 'annual_fee_group_due_3_months' => 'Due in ≤ 3 months', 'firstlines' => 'Firstlines', 'firstlines_count_split' => ':active consultants · :total in total', + 'firstlines_count_expired' => ':count with an expired annual fee', 'firstlines_copy' => 'Your direct team partners with turnover and contact details - sorted by points of the selected month.', 'firstlines_none' => 'You do not have any direct team partners yet.', 'firstlines_points' => 'Turnover (points)', diff --git a/resources/lang/es/abo.php b/resources/lang/es/abo.php index 6e66dd5d..09f4879e 100644 --- a/resources/lang/es/abo.php +++ b/resources/lang/es/abo.php @@ -383,4 +383,5 @@ return [ 'pause_cancel_locked_hint_zero' => 'La entrega del :date se ejecuta hoy. Pausar o cancelar volverá a ser posible después, hasta las 23:59 del día anterior a una entrega.', 'filter_next_from' => 'desde', 'filter_next_to' => 'hasta', + 'cancel_confirm_required' => 'Confirma las consecuencias de la cancelación mediante la casilla.', ]; diff --git a/resources/lang/es/team.php b/resources/lang/es/team.php index 81652820..78e58485 100644 --- a/resources/lang/es/team.php +++ b/resources/lang/es/team.php @@ -165,6 +165,7 @@ return [ 'annual_fee_group_due_3_months' => 'Vence en ≤ 3 meses', 'firstlines' => 'Firstlines', 'firstlines_count_split' => ':active asesores · :total en total', + 'firstlines_count_expired' => ':count con la cuota anual vencida', 'firstlines_copy' => 'Tus socios directos con volumen y datos de contacto - ordenados por puntos del mes seleccionado.', 'firstlines_none' => 'Aún no tienes socios directos.', 'firstlines_points' => 'Volumen (puntos)', diff --git a/resources/lang/fr/abo.php b/resources/lang/fr/abo.php index a37bf342..42f3ec5a 100644 --- a/resources/lang/fr/abo.php +++ b/resources/lang/fr/abo.php @@ -383,4 +383,5 @@ return [ 'pause_cancel_locked_hint_zero' => 'La livraison du :date est exécutée aujourd’hui. Suspendre ou résilier sera de nouveau possible ensuite – jusqu’à 23 h 59 la veille d’une livraison.', 'filter_next_from' => 'du', 'filter_next_to' => 'au', + 'cancel_confirm_required' => 'Merci de confirmer les conséquences de la résiliation via la case à cocher.', ]; diff --git a/resources/lang/fr/team.php b/resources/lang/fr/team.php index e757796b..f1c09fae 100644 --- a/resources/lang/fr/team.php +++ b/resources/lang/fr/team.php @@ -144,6 +144,7 @@ return [ 'annual_fee_group_due_3_months' => 'Échéance dans ≤ 3 mois', 'firstlines' => 'Firstlines', 'firstlines_count_split' => ':active conseillers · :total au total', + 'firstlines_count_expired' => ':count avec cotisation annuelle échue', 'firstlines_copy' => 'Tes partenaires directs avec chiffre d’affaires et coordonnées - triés par points du mois sélectionné.', 'firstlines_none' => 'Tu n’as pas encore de partenaires directs.', 'firstlines_points' => 'Chiffre d’affaires (points)', diff --git a/resources/views/user/team/firstlines.blade.php b/resources/views/user/team/firstlines.blade.php index 52e52936..1ecb7772 100644 --- a/resources/views/user/team/firstlines.blade.php +++ b/resources/views/user/team/firstlines.blade.php @@ -10,7 +10,7 @@
{{ __('team.firstlines') }} - ({{ __('team.firstlines_count_split', ['active' => $activeCount, 'total' => count($rows)]) }}) + ({{ __('team.firstlines_count_split', ['active' => $activeCount, 'total' => count($rows)]) }}@if ($expiredCount > 0), {{ __('team.firstlines_count_expired', ['count' => $expiredCount]) }}@endif)
{{ __('team.firstlines_copy') }}
diff --git a/tests/Feature/AboPauseTest.php b/tests/Feature/AboPauseTest.php index 9b3e1297..436b047d 100644 --- a/tests/Feature/AboPauseTest.php +++ b/tests/Feature/AboPauseTest.php @@ -183,6 +183,7 @@ it('protokolliert die Kuendigung in der Status-Historie', function (): void { 'view' => 'portal', 'abo_interval' => $abo->abo_interval, 'abo_cancel' => 'true', + 'abo_cancel_confirm' => '1', ]); expect((int) $abo->fresh()->status)->toBe(4); diff --git a/tests/Feature/AboStatusExplainTest.php b/tests/Feature/AboStatusExplainTest.php index 1f9389eb..c591df35 100644 --- a/tests/Feature/AboStatusExplainTest.php +++ b/tests/Feature/AboStatusExplainTest.php @@ -165,7 +165,7 @@ it('haelt bei der Kuendigung fest, wer gekuendigt hat', function () { $repository->setModel($abo); $repository->update([ 'action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', - 'abo_interval' => 5, 'abo_cancel' => 'true', + 'abo_interval' => 5, 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1', ]); $abo = $abo->fresh(); @@ -244,6 +244,75 @@ it('rendert das Partial mit Badge, Zustand und Erklaerung', function () { ->toContain(__('abo.state_text_payment_failed', ['date' => '10.09.2026'])); }); +it('zieht das Pausenende mit, wenn der Liefertermin waehrend der Pause geaendert wird (Audit F3)', function () { + $user = aseUser(); + $abo = aseAbo($user); + $this->actingAs($user); + + $repository = new AboRepository; + $repository->setModel($abo); + $repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_pause' => 'true']); + + expect($abo->fresh()->paused_until->format('Y-m-d'))->toBe('2026-11-05'); + + // Liefertag im Pausenmonat nach hinten: 5.11. -> 20.11. + $repository->setModel($abo->fresh()); + expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 20, 'abo_is_active' => 1]))->toBeTrue(); + + $abo = $abo->fresh(); + expect($abo->getRawOriginal('next_date'))->toBe('2026-11-20') + ->and($abo->paused_until->format('Y-m-d'))->toBe('2026-11-20') + ->and($abo->status_reason)->toBe('paused_by_consultant') + ->and(aseExplain($abo)->text)->toContain('20.11.2026'); + + // Admin-Termin: gleiche Regel. + $repository->setModel($abo); + expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'admin', 'abo_interval' => 10, 'abo_next_month' => '2026-12', 'abo_is_active' => 'true']))->toBeTrue() + ->and($abo->fresh()->paused_until->format('Y-m-d'))->toBe('2026-12-10'); + + // Ausfuehrung gelaufen: Pausenende UND Pausengrund weg. + $abo->fresh()->update(['last_date' => '2026-12-10', 'next_date' => '2027-01-10']); + + expect($abo->fresh()->paused_until)->toBeNull() + ->and($abo->fresh()->status_reason)->toBeNull() + ->and(aseExplain($abo)->state)->toBe(AboStatusInfo::STATE_ACTIVE); + + // Ohne Pause bleibt paused_until bei einer Terminaenderung leer. + $plain = aseAbo(aseUser(), ['next_date' => '2026-10-05']); + $repository->setModel($plain); + $repository->update(['action' => 'abo_update_settings', 'id' => $plain->id, 'view' => 'me', 'abo_interval' => 20, 'abo_is_active' => 1]); + expect($plain->fresh()->paused_until)->toBeNull(); +}); + +it('kuendigt im Selfservice nur mit bestaetigten Folgen (Audit)', function () { + $user = aseUser(); + $abo = aseAbo($user); + $this->actingAs($user); + + $repository = new AboRepository; + $repository->setModel($abo); + + expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true']))->toBeFalse() + ->and(session('alert-error'))->toBe(__('abo.cancel_confirm_required')) + ->and((int) $abo->fresh()->status)->toBe(2); + + expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true', 'abo_cancel_confirm' => '0']))->toBeFalse() + ->and((int) $abo->fresh()->status)->toBe(2); + + expect($repository->update(['action' => 'abo_update_settings', 'id' => $abo->id, 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1']))->toBeTrue() + ->and((int) $abo->fresh()->status)->toBe(4); +}); + +it('laesst der Backfill updated_at unangetastet (Audit F5)', function () { + $abo = aseAbo(aseUser(), ['status' => 3]); + UserAbo::whereKey($abo->id)->toBase()->update(['status_reason' => null, 'updated_at' => '2026-08-01 12:00:00']); + + $this->artisan('abo:backfill-status-reasons')->assertSuccessful(); + + expect($abo->fresh()->status_reason)->toBe('payment_failed') + ->and($abo->fresh()->updated_at->format('Y-m-d H:i:s'))->toBe('2026-08-01 12:00:00'); +}); + it('traegt Grund und Pausenende fuer Bestandsdaten nach', function () { $failed = aseAbo(aseUser(), ['status' => 3]); $cancelled = aseAbo(aseUser(), ['status' => 4, 'active' => false]); diff --git a/tests/Feature/AboStatusGuardTest.php b/tests/Feature/AboStatusGuardTest.php index 17866653..1a4cd56b 100644 --- a/tests/Feature/AboStatusGuardTest.php +++ b/tests/Feature/AboStatusGuardTest.php @@ -144,7 +144,7 @@ describe('A16: Statushistorie über alle Schreibpfade', function () { $abo = makeStatusGuardAbo(['next_date' => '2026-04-20']); $this->actingAs($abo->user); - statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true']); + statusGuardUpdate($abo, ['view' => 'me', 'abo_cancel' => 'true', 'abo_cancel_confirm' => '1']); $entry = $abo->status_histories()->sole(); diff --git a/tests/Feature/AboUpdateAuthorizationTest.php b/tests/Feature/AboUpdateAuthorizationTest.php index 24f5c515..34dfb70b 100644 --- a/tests/Feature/AboUpdateAuthorizationTest.php +++ b/tests/Feature/AboUpdateAuthorizationTest.php @@ -152,6 +152,7 @@ it('kündigt das eigene Abo nach erfüllter Mindestlaufzeit weiterhin regulär', 'view' => 'me', 'abo_interval' => 5, 'abo_cancel' => 'true', + 'abo_cancel_confirm' => '1', ]); expect((int) $abo->fresh()->status)->toBe(4) diff --git a/tests/Feature/BackofficeSnapshotFreezeTest.php b/tests/Feature/BackofficeSnapshotFreezeTest.php index e0e6782e..ef00bf15 100644 --- a/tests/Feature/BackofficeSnapshotFreezeTest.php +++ b/tests/Feature/BackofficeSnapshotFreezeTest.php @@ -149,6 +149,55 @@ it('haelt Kachel und verlinkte Liste der neuen Abos zusammen', function () { ->and($details['summary']['abo_count'])->toBe(1); }); +it('haelt Neupartner-Kachel und verlinkte Liste zusammen, auch nach spaeter nachgetragenem Zahlungsdatum (Audit F2)', function () { + $root = freezeUser(); + // Registriert und bezahlt im August: Neupartner August. + $anna = User::forceCreate([ + 'email' => 'freeze-anna-'.uniqid('', true).'@example.test', 'password' => bcrypt('secret'), 'lang' => 'de', + 'm_level' => 1, 'm_sponsor' => $root->id, 'active_date' => '2026-08-28', 'partner_paid_at' => '2026-08-28', 'payment_account' => '2027-08-28', + ]); + + app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + // Nachtraeglich korrigiert: Zahlung war erst im September. + User::whereKey($anna->id)->toBase()->update(['partner_paid_at' => '2026-09-01 06:49:00']); + + $overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026); + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 8, 2026); + + expect((int) $overview['totals']['new_partners'])->toBe(1) + ->and($details['rows'])->toHaveCount(1) + ->and($details['rows'][0]['user_id'])->toBe($anna->id); +}); + +it('rechnet fuer Snapshots ohne Neupartner-IDs mit der damaligen Regel (Registrierungsmonat)', function () { + $root = freezeUser(); + // Vor dem 16.09. gezaehlt: registriert im August, bezahlt erst im September. + $anna = User::forceCreate([ + 'email' => 'freeze-anna-'.uniqid('', true).'@example.test', 'password' => bcrypt('secret'), 'lang' => 'de', + 'm_level' => 1, 'm_sponsor' => $root->id, 'active_date' => '2026-08-28', 'partner_paid_at' => '2026-09-01 06:49:00', 'payment_account' => '2027-09-01', + ]); + + $snapshot = app(BackofficeDashboardService::class)->storeSnapshot($root, 8, 2026); + + // Alten Snapshot nachstellen: Kachel nach alter Regel = 1, keine gespeicherten IDs. + $payload = $snapshot->payload; + foreach ($payload['lines'] as &$line) { + unset($line['new_partner_ids']); + $line['new_partners'] = 1; + } + unset($line); + $payload['totals']['new_partners'] = 1; + $snapshot->update(['payload' => $payload]); + + $overview = app(BackofficeDashboardService::class)->overview($root, 8, 2026); + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 8, 2026); + + expect((int) $overview['totals']['new_partners'])->toBe(1) + ->and($details['rows'])->toHaveCount(1) + ->and($details['rows'][0]['user_id'])->toBe($anna->id); +}); + it('rechnet den laufenden Monat weiterhin live', function () { $root = freezeUser(); $anna = freezeUser($root->id); diff --git a/tests/Feature/PartnerPaidAtTest.php b/tests/Feature/PartnerPaidAtTest.php index 2b157a96..0090b141 100644 --- a/tests/Feature/PartnerPaidAtTest.php +++ b/tests/Feature/PartnerPaidAtTest.php @@ -106,7 +106,55 @@ describe('partner_paid_at: erste Kontozahlung', function () { }); }); +describe('partner_paid_at: Rechnungskauf (Audit 18.09., F1)', function () { + it('setzt den Wert erst bei der Zahlung der Rechnung, nicht bei der offenen Rechnung', function () { + Carbon::setTestNow(Carbon::parse('2026-08-31 18:00:00')); + $user = ppaMakeUser(); + $product = Product::forceCreate([ + 'name' => 'Starterpaket', 'title' => 'Starterpaket', 'price' => 89.0, 'tax' => 19.0, 'active' => true, + 'pos' => 1, 'weight' => 0, 'points' => 0, 'slug' => 'starter-'.uniqid(), 'action' => [0], + ]); + $order = ppaRegistrationOrder($user, false); + ShoppingOrderItem::forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 89.0]); + + // Rechnungskauf: Konto wird vorab freigeschaltet, bezahlt ist noch nichts. + Payment::paymentStatusPaidAction($order->fresh(), false); + + $user->refresh(); + expect($user->payment_account)->not->toBeNull() + ->and($user->partner_paid_at)->toBeNull() + ->and($user->isNewPartnerIn(8, 2026))->toBeFalse(); + + // Zahlungseingang im September: jetzt zaehlt er, und zwar im September. + Carbon::setTestNow(Carbon::parse('2026-09-01 09:00:00')); + Payment::paymentStatusPaidAction($order->fresh(), true); + + $user->refresh(); + expect($user->partner_paid_at?->toDateString())->toBe('2026-09-01') + ->and($user->isNewPartnerIn(9, 2026))->toBeTrue() + ->and($user->isNewPartnerIn(8, 2026))->toBeFalse(); + }); +}); + describe('partner_paid_at: Rueckfuellung fuer bestehende Partner', function () { + it('laesst eine offene Rechnung ohne bezahlte Bestellung leer statt das Aktivierungsdatum zu nehmen (Audit F1)', function () { + $user = ppaMakeUser(['payment_account' => '2027-08-28 00:00:00']); + ppaRegistrationOrder($user, false); + + expect(UserUtil::backfillPartnerPaidAt())->toBe(0) + ->and($user->fresh()->partner_paid_at)->toBeNull(); + }); + + it('laesst updated_at beim Rueckfuellen unangetastet (Audit F5)', function () { + $user = ppaMakeUser(['payment_account' => '2027-08-28 00:00:00']); + User::whereKey($user->id)->toBase()->update(['updated_at' => '2026-05-05 05:05:05']); + + UserUtil::backfillPartnerPaidAt(); + + expect($user->fresh()->partner_paid_at)->not->toBeNull() + ->and($user->fresh()->updated_at->format('Y-m-d H:i:s'))->toBe('2026-05-05 05:05:05'); + }); + it('nimmt die Punktebuchung der ersten bezahlten Registrierungsbestellung', function () { $user = ppaMakeUser(['payment_account' => '2027-09-01 06:49:35']); $order = ppaRegistrationOrder($user);