diff --git a/app/Http/Controllers/AdminUserController.php b/app/Http/Controllers/AdminUserController.php index 991800c0..adcd903c 100644 --- a/app/Http/Controllers/AdminUserController.php +++ b/app/Http/Controllers/AdminUserController.php @@ -162,6 +162,9 @@ class AdminUserController extends Controller $payment_account = Carbon::parse(str_replace('- ', '', $data['payment_account'])); $user->payment_account = $payment_account; if ($payment_account > Carbon::now()) { + // Manuelle Freischaltung ohne Bestellung: der Partner zaehlt ab jetzt + // als bezahlt (nur beim ersten Mal, siehe markPartnerPaid). + UserUtil::markPartnerPaid($user); if ($user->active === 0) { $user->active = true; UserUtil::reactiveUserResetChilds($user->id, 'on save-account AdminUserController'); @@ -299,7 +302,7 @@ class AdminUserController extends Controller ->addColumn('active', function (User $user) { if ($user->trashed()) { $date = Carbon::parse(now())->addDays(10)->format('d.m.Y H:i'); - $email = str_replace('delete-', '', $user->email); + $email = UserUtil::stripDeletePrefix($user->email); $link = ''; return $link.' Account reaktivieren'; diff --git a/app/Http/Controllers/User/BackofficeStatisticsController.php b/app/Http/Controllers/User/BackofficeStatisticsController.php index 65811ef4..ca394cf8 100644 --- a/app/Http/Controllers/User/BackofficeStatisticsController.php +++ b/app/Http/Controllers/User/BackofficeStatisticsController.php @@ -288,7 +288,7 @@ class BackofficeStatisticsController extends Controller } if ($metric === 'new_partners') { - return ['Name', 'E-Mail', 'Telefon', 'Karriere-Level', 'Sponsor', 'Punkte seiner Kunden', 'Gesamt inkl. Eigenbestellung', 'Aktiv seit', 'Account gueltig bis']; + return ['Name', 'E-Mail', 'Telefon', 'Karriere-Level', 'Sponsor', 'Punkte seiner Kunden', 'Gesamt inkl. Eigenbestellung', 'Bezahlt am', 'Registriert am', 'Account gueltig bis']; } return ['Name', 'E-Mail', 'Karriere-Level', 'Aktiv seit', 'Account gueltig bis', 'Account Status']; @@ -348,6 +348,7 @@ class BackofficeStatisticsController extends Controller $row['sponsor'] ?? '', $row['customer_points'] ?? 0, $row['total_points'] ?? 0, + $row['paid_at'] ?? '', $row['active_date'] ?? '', $row['payment_account'] ?? '', ]; @@ -385,10 +386,10 @@ class BackofficeStatisticsController extends Controller } if ($metric === 'new_partners') { - // Neun Werte fuer neun Spalten: die Telefonspalte kam nachtraeglich dazu, - // wodurch die Summen um eine Spalte verrutscht waren (Kundenpunkte - // standen unter "Sponsor"). - return ['Summe', $summary['count'].' Eintraege', '', '', '', $summary['customer_points'], $summary['total_points'], '', '']; + // Zehn Werte fuer zehn Spalten — die Breitenpruefung im Test faengt ein + // Verrutschen der Summen ab, wie es beim Nachruesten der Telefonspalte + // passiert war (Kundenpunkte standen unter "Sponsor"). + return ['Summe', $summary['count'].' Eintraege', '', '', '', $summary['customer_points'], $summary['total_points'], '', '', '']; } return ['Summe', $summary['count'].' Eintraege', '', '', '', '']; diff --git a/app/Services/Backoffice/BackofficeDrilldownService.php b/app/Services/Backoffice/BackofficeDrilldownService.php index 845015a6..37fa76c0 100644 --- a/app/Services/Backoffice/BackofficeDrilldownService.php +++ b/app/Services/Backoffice/BackofficeDrilldownService.php @@ -155,7 +155,7 @@ class BackofficeDrilldownService ->with(['account', 'user_level']) ->whereIn('id', $userIds) ->newPartnerIn($month, $year) - ->orderBy('active_date') + ->orderBy('partner_paid_at') ->get(); $sponsors = User::query() @@ -179,6 +179,9 @@ class BackofficeDrilldownService 'sponsor' => $user->m_sponsor && $sponsors->has((int) $user->m_sponsor) ? $this->userName($sponsors->get((int) $user->m_sponsor)) : '-', 'customer_points' => (float) ($monthlyPoints->get((int) $user->id)['external_points'] ?? 0.0), 'total_points' => (float) ($monthlyPoints->get((int) $user->id)['total_points'] ?? 0.0), + // Massgeblich fuer die Zugehoerigkeit zum Monat (User::scopeNewPartnerIn()). + 'paid_at' => $this->formatDate($user->partner_paid_at), + 'paid_at_sort' => $this->sortDate($user->partner_paid_at), 'active_date' => $this->formatDate($user->active_date), 'active_date_sort' => $this->sortDate($user->active_date), 'payment_account' => $this->formatDate($user->payment_account), diff --git a/app/Services/Payment.php b/app/Services/Payment.php index 7b1a962d..110e378f 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -225,6 +225,9 @@ class Payment $user->payment_order_id = $shopping_order_item->product->id; $user->payment_account = $date; $user->wizard = 100; + // Erste Kontozahlung = Zeitpunkt, ab dem der Partner in der + // Neupartner-Statistik zaehlt. Verlaengerungen aendern ihn nicht. + 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 483b979d..b617532e 100644 --- a/app/Services/UserUtil.php +++ b/app/Services/UserUtil.php @@ -2,11 +2,14 @@ namespace App\Services; +use App\Models\ShoppingOrder; use App\Models\ShoppingUser; use App\Models\ShoppingUserMemberLog; use App\Models\UserCleanUpLog; +use App\Models\UserSalesVolume; use App\Models\UserShop; use App\User; +use Carbon\Carbon; class UserUtil { @@ -166,6 +169,27 @@ class UserUtil $user->save(); } + /** + * Praefix fuer pseudonymisierte E-Mail-Adressen geloeschter Benutzer. + * Die User-ID gehoert zwingend dazu: ohne sie kollidiert die zweite + * Loeschung derselben Adresse mit dem Unique-Index auf users.email. + */ + public static function buildDeletedEmail(User $user): string + { + $prefix = 'delete-'.$user->id.'-'; + + return $prefix.mb_substr(self::stripDeletePrefix((string) $user->email), 0, 255 - mb_strlen($prefix)); + } + + /** + * Entfernt das Loesch-Praefix inklusive der optionalen User-ID. + * Beruecksichtigt bewusst auch das alte Format ohne ID. + */ + public static function stripDeletePrefix(?string $email): string + { + return (string) preg_replace('/^delete-(\d+-)?/', '', (string) $email); + } + public static function deleteUser(User $user, $complete = false) { // shop wird gelöscht @@ -177,7 +201,7 @@ class UserUtil } // user soll nicht komplett gelöscht werden - $user->email = 'delete-'.$user->email; + $user->email = self::buildDeletedEmail($user); // password wird gelöscht $user->password = 'delete'.time(); $user->confirmed = 0; @@ -194,7 +218,7 @@ class UserUtil $user->pre_deleted_at = now(); // user soll komplett gelöscht werden if ($complete) { - $user->email = 'delete-'.time().'-'.rand(1000, 9999); + $user->email = 'delete-'.$user->id.'-'.time().'-'.rand(1000, 9999); if ($user->account) { $user->account->delete(); } @@ -208,7 +232,7 @@ class UserUtil public static function checkEmailExists($user) { - $email = str_replace('delete-', '', $user->email); + $email = self::stripDeletePrefix($user->email); $user = User::where('email', $email)->first(); if ($user) { @@ -226,7 +250,7 @@ class UserUtil $user->pre_sponsor = null; } - $user->email = str_replace('delete-', '', $user->email); + $user->email = self::stripDeletePrefix($user->email); $user->confirmed = 1; $user->confirmation_date = now(); $user->confirmation_code = null; @@ -251,6 +275,89 @@ class UserUtil } } + /** + * Haelt fest, wann der Partner zum bezahlten Partner wurde — nur beim ersten + * Mal. Verlaengerungen und Wiederherstellungen lassen den Wert stehen, sonst + * wuerde ein alter Partner in der Neupartner-Statistik wieder auftauchen. + * + * @return bool true, wenn der Wert gerade gesetzt wurde + */ + public static function markPartnerPaid(User $user, ?Carbon $paidAt = null): bool + { + if ($user->partner_paid_at !== null) { + return false; + } + + $user->partner_paid_at = $paidAt ?? now(); + + return true; + } + + /** + * Rueckfuellung von `users.partner_paid_at` fuer bestehende Partner, nur wo + * die Spalte leer ist — mehrfach ausfuehrbar. + * + * Quelle in dieser Reihenfolge: + * 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. + * + * Absichtlich NICHT `payment_account` minus ein Jahr: bei Verlaengerungen + * wird das Konto vom alten Ablaufdatum aus verlaengert, das Ergebnis waere der + * Jahrestag der Erstzahlung und jeder Verlaengerer ein „Neupartner". + * + * @return int Anzahl gesetzter Zeilen + */ + public static function backfillPartnerPaidAt(): int + { + $set = 0; + + User::withTrashed() + ->whereNull('partner_paid_at') + ->whereNotNull('payment_account') + ->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); + + if ($paidAt === null) { + continue; + } + + $user->partner_paid_at = $paidAt; + $user->saveQuietly(); + $set++; + } + }); + + return $set; + } + + private static function firstRegistrationPaidAt(int $userId): ?Carbon + { + $order = ShoppingOrder::query() + ->where('auth_user_id', $userId) + ->where('payment_for', 1) + ->where('paid', 1) + ->orderBy('id') + ->first(['id', 'updated_at']); + + if ($order === null) { + return null; + } + + $booking = UserSalesVolume::query() + ->where('user_id', $userId) + ->where('shopping_order_id', $order->id) + ->orderBy('id') + ->value('created_at'); + + return Carbon::parse($booking ?? $order->updated_at); + } + public static function reactiveUserResetChilds($user_id, $info = '') { diff --git a/app/User.php b/app/User.php index 125488b6..a9cb9f8b 100644 --- a/app/User.php +++ b/app/User.php @@ -153,6 +153,7 @@ use Laravel\Passport\Token; * @method static \Illuminate\Database\Eloquent\Builder|User wherePreSponsor($value) * * @property \Illuminate\Support\Carbon|null $pre_deleted_at + * @property \Illuminate\Support\Carbon|null $partner_paid_at * * @method static \Illuminate\Database\Eloquent\Builder|User wherePreDeletedAt($value) * @@ -185,7 +186,7 @@ class User extends Authenticatable 'settings' => 'array', 'payment_methods' => 'array', 'pre_deleted_at' => 'datetime', - + 'partner_paid_at' => 'datetime', ]; /** @@ -248,7 +249,16 @@ class User extends Authenticatable /** * Neupartner-Definition der Statistik (eine Regel für KPI, Recruiter-Ranking * und Top-3-Karte): Mitgliedsebene gesetzt, Abrechnungskonto zum Stichtag - * gültig ({@see newPartnerReferenceDate()}), im Monat aktiv geworden. + * gültig ({@see newPartnerReferenceDate()}), **im Monat bezahlt**. + * + * Massgeblich ist `partner_paid_at`, nicht `active_date` (Kundenentscheidung + * 16.09.2026): Wer Ende August registriert und am 1. September bezahlt, + * ist ein September-Neupartner. Mit `active_date` fiel dieser Fall durch — + * im August noch ohne Abrechnungskonto, im September mit dem falschen + * Aktivierungsmonat — und fehlte damit in jeder Zählung, auch im Recruiting. + * + * Die Teamstruktur (wer gehört zum Monatsende zur Linie) hängt weiterhin an + * `active_date`; das ist eine andere Frage als „wer ist neu dazugekommen". * * @param Builder $query * @return Builder @@ -261,7 +271,7 @@ class User extends Authenticatable return $query->whereNotNull('m_level') ->whereNotNull('payment_account') ->where('payment_account', '>', self::newPartnerReferenceDate($month, $year)) - ->whereBetween('active_date', [$startDate, $endDate]); + ->whereBetween('partner_paid_at', [$startDate, $endDate]); } /** @@ -271,7 +281,7 @@ class User extends Authenticatable */ public function isNewPartnerIn(int $month, int $year): bool { - if ($this->m_level === null || $this->active_date === null || $this->payment_account === null) { + if ($this->m_level === null || $this->partner_paid_at === null || $this->payment_account === null) { return false; } @@ -281,7 +291,7 @@ class User extends Authenticatable $startDate = Carbon::create($year, $month, 1)->startOfMonth(); - return Carbon::parse($this->active_date)->betweenIncluded($startDate, $startDate->copy()->endOfMonth()); + return Carbon::parse($this->partner_paid_at)->betweenIncluded($startDate, $startDate->copy()->endOfMonth()); } public function user_pre_sponsor() diff --git a/database/migrations/2026_09_16_090000_add_partner_paid_at_to_users.php b/database/migrations/2026_09_16_090000_add_partner_paid_at_to_users.php new file mode 100644 index 00000000..7428350e --- /dev/null +++ b/database/migrations/2026_09_16_090000_add_partner_paid_at_to_users.php @@ -0,0 +1,46 @@ +dateTime('partner_paid_at')->nullable()->after('active_date'); + } + }); + + UserUtil::backfillPartnerPaidAt(); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'partner_paid_at')) { + $table->dropColumn('partner_paid_at'); + } + }); + } +}; diff --git a/dev/2026-07-22/README.md b/dev/2026-07-22/README.md index aeecc32b..debf4d99 100644 --- a/dev/2026-07-22/README.md +++ b/dev/2026-07-22/README.md @@ -22,6 +22,7 @@ Plan und Tickets: [`dev/_tickets/entwicklungsplan-protokoll-september-2026.md`]( | [monatsabschluss-eingefrorene-statistik.md](monatsabschluss-eingefrorene-statistik.md) | Warum ein abgerechneter Monat vollständig eingefroren wird — Statistik, Team-Struktur, Detaillisten. Grenzen und Betriebsbefehle. | | [audit-statistiken-2026-09-09-bewertung.md, Abschnitt 8](audit-statistiken-2026-09-09-bewertung.md) | Stornoregel: ein Storno mindert nur den Monat der Ursprungsbestellung. Settings-Schalter für den Ausnahmefall. | | [audit-statistiken-2026-09-09-bewertung.md, Abschnitt 14](audit-statistiken-2026-09-09-bewertung.md) | Abo-Zustand: Berater wechseln ihn nur über Pause und Kündigung, nie über ein Aktiv-Feld. Jeder Wechsel wird zentral protokolliert. | +| [neupartner-zahlungsmonat.md](neupartner-zahlungsmonat.md) | Neupartner zählen im Monat der **Zahlung**, nicht der Registrierung (`partner_paid_at`). Gilt für Kachel, Recruiting, Top 3 und Liste. | ## Prüfungen vom September 2026 diff --git a/dev/2026-07-22/deploy-2026-09-10.md b/dev/2026-07-22/deploy-2026-09-10.md index caff82ba..0c1c08c1 100644 --- a/dev/2026-07-22/deploy-2026-09-10.md +++ b/dev/2026-07-22/deploy-2026-09-10.md @@ -143,3 +143,25 @@ Die Monatsspalte erscheint nur, solange kein anderer Monat gefiltert ist; für e wäre sie irreführend. **Deployment:** nur Dateien, keine Migration, kein Skript. Danach `php artisan view:clear`. + +--- + +## Nachtrag 16.09.2026 — Neupartner zählen im Monat der Zahlung + +Regeländerung nach Kundenentscheidung, Einzelheiten in +[neupartner-zahlungsmonat.md](neupartner-zahlungsmonat.md). Auslöser: Partner 1623, Ende August +registriert, am 1. September bezahlt, in keinem Monat gezählt. + +**Deployment:** + +```bash +php artisan migrate # neue Spalte users.partner_paid_at + Rueckfuellung (ca. 10 s) +php artisan view:clear +``` + +Optional und bewusst zu entscheiden — die eingefrorenen Monate Januar bis August enthalten sieben +Partner nicht, die unter der neuen Regel dorthin gehören: + +```bash +php artisan backoffice:store-statistics-snapshots --force +``` diff --git a/dev/2026-07-22/neupartner-zahlungsmonat.md b/dev/2026-07-22/neupartner-zahlungsmonat.md new file mode 100644 index 00000000..f5329948 --- /dev/null +++ b/dev/2026-07-22/neupartner-zahlungsmonat.md @@ -0,0 +1,86 @@ +# Neupartner zählen im Monat der Zahlung + +**Entscheidung der Kundin, 16.09.2026.** Ein neuer Vertriebspartner gehört in den Monat, in dem +er **bezahlt** hat — nicht in den Monat seiner Registrierung. Das gilt für jede Stelle, die +Neupartner zählt: Kachel, Recruiter-Ranking, Top 3 „Recruiter des Monats", Neupartner-Liste, +CSV-Export. + +## Der Fall, der es ausgelöst hat + +Partner 1623 (Sponsor 454): registriert am 28.08., erster Zahlungslink abgelaufen, zweiter am +**01.09. um 06:49** bezahlt. Der Snapshot für August wurde am 01.09. um 04:45 eingefroren — da +hatte er noch kein Abrechnungskonto und fiel im August raus. Im September lag sein +Aktivierungsdatum im falschen Monat. **Er tauchte nirgends auf**, auch nicht beim Recruiting +seines Sponsors. + +Das ist kein Einzelfall: 2026 betrifft es **8 Partner**, jeweils Ende des Monats registriert und +Anfang des Folgemonats bezahlt — 2 im Januar, 1 im April, 1 im Juni, 3 im Juli, 1 im September. + +## Was sich geändert hat + +Die Neupartner-Regel hing an `users.active_date` (Abschluss der Registrierung). Neu ist die Spalte +**`users.partner_paid_at`**: der Zeitpunkt der ersten Kontozahlung. + +| | vorher | jetzt | +| --- | --- | --- | +| Neupartner im Monat X | `active_date` in X | `partner_paid_at` in X | +| Abrechnungskonto gültig am Stichtag | ja | ja, unverändert | +| Mitgliedsebene gesetzt | ja | ja, unverändert | + +Die Regel steht an **einer** Stelle — `User::scopeNewPartnerIn()` und das PHP-Gegenstück +`User::isNewPartnerIn()` — und alle Zählungen gehen darüber. + +**Gesetzt wird `partner_paid_at`** bei der ersten Kontozahlung (`Payment::paymentStatusPaidAction`, +Produktaktion `payment_for_account`) und bei manueller Kontofreischaltung durch den Admin. Beides +über `UserUtil::markPartnerPaid()`, das den Wert **nur setzt, solange er leer ist**. Eine +Verlängerung oder eine Wiederherstellung ändert ihn nicht — sonst würde ein alter Partner in der +Neupartner-Statistik wieder auftauchen. + +**Rückgefüllt** wurde per Migration aus der ersten bezahlten Registrierungsbestellung +(`shopping_orders.payment_for = 1`): die Punktebuchung dazu entsteht im Moment der Zahlung, deren +Zeitstempel ist der Wert. Ohne Buchung das Änderungsdatum der Bestellung; ohne Bestellung (Konto +vom Admin freigeschaltet, 14 Fälle) das Aktivierungsdatum. **Nicht** verwendet wurde +`payment_account` minus ein Jahr: bei Verlängerungen wird vom alten Ablaufdatum aus verlängert, das +Ergebnis wäre der Jahrestag der Erstzahlung und jeder Verlängerer ein „Neupartner". + +## Was sich nicht geändert hat + +**Die Teamstruktur.** Ob jemand zum Monatsende zur Linie gehört, hängt weiterhin an `active_date` — +so auch im Provisionsbaum. Das ist eine andere Frage als „wer ist neu dazugekommen": Partner 1623 +steht im September in der Struktur seines Sponsors, und er ist der September-Neupartner. Ein Partner, +der am 28.09. registriert und am 02.10. bezahlt, steht im September schon in der Struktur, zählt aber +erst im Oktober als neu. + +**Provisionen, Gutschriften, Marketingplan.** Unberührt. Die Migration fügt eine leere Spalte hinzu +und füllt sie; keine vorhandene Zeile wird sonst geändert. + +## Wiederhergestellte Partner + +Vier Partner haben ein `active_date` Jahre **nach** ihrer Registrierungszahlung (414, 522, 610, +663) — sie wurden offenbar wiederhergestellt, und `restoreUser()` setzt `active_date` neu. Unter der +alten Regel zählten sie als Neupartner im Monat der Wiederherstellung. Unter der neuen Regel zählen +sie im Monat ihrer ursprünglichen Zahlung (2021–2023), also **nicht** als Neupartner 2026. Das ist +fachlich richtig, wenn eine Wiederherstellung keine Neuwerbung ist. Falls die Kundin das anders +sieht, müsste `restoreUser()` den Wert zurücksetzen — bewusst nicht gemacht. + +## Die eingefrorenen Monate + +Die Snapshots Januar bis August wurden mit der **alten** Regel gebaut. Sieben der acht +Partner von oben fehlen darin vollständig — in beiden Monaten. Der Snapshot friert bewusst ein +(siehe [monatsabschluss-eingefrorene-statistik.md](monatsabschluss-eingefrorene-statistik.md)), ein +Neuaufbau ist deshalb eine Entscheidung, keine Routine: + +```bash +php artisan backoffice:store-statistics-snapshots --force +``` + +Damit landen die sieben in ihren Zahlungsmonaten. Wie am 10.09. gilt: der Neuaufbau friert die +Teamstruktur des **Ausführungstags** ein, nicht die des damaligen Monatsendes. Wer seit dem 10.09. +verschoben wurde oder dazukam, steht danach an seiner heutigen Stelle in den historischen Zahlen. + +## Sichtprüfung nach dem Deploy + +- Backoffice → September → Neupartner-Liste: Partner 1623 steht drin, Spalte „Bezahlt am" = + 01.09.2026, „Registriert am" = 28.08.2026. +- Recruiter-Ranking September: Sponsor 454 hat ihn angerechnet. +- Backoffice → August (eingefroren): unverändert, solange kein Neuaufbau läuft. diff --git a/resources/views/user/backoffice/statistics/details.blade.php b/resources/views/user/backoffice/statistics/details.blade.php index 57cb2761..3d4bf407 100644 --- a/resources/views/user/backoffice/statistics/details.blade.php +++ b/resources/views/user/backoffice/statistics/details.blade.php @@ -113,7 +113,8 @@ Sponsor Punkte seiner Kunden Gesamt (inkl. Eigenbestellung) - Aktiv seit + Bezahlt am + Registriert am Account gültig bis @else Aktiv seit @@ -187,6 +188,7 @@ {{ $row['sponsor'] ?? '-' }} {{ \App\Services\Util::formatNumber($row['customer_points'] ?? 0) }} {{ \App\Services\Util::formatNumber($row['total_points'] ?? 0) }} + {{ $row['paid_at'] ?? '-' }} {{ $row['active_date'] ?? '-' }} {{ $row['payment_account'] ?? '-' }} @else @@ -233,6 +235,7 @@ {{ \App\Services\Util::formatNumber($details['summary']['total_points'] ?? 0) }} + @else diff --git a/tests/Feature/BackofficeSnapshotFreezeTest.php b/tests/Feature/BackofficeSnapshotFreezeTest.php index e77220e7..e0e6782e 100644 --- a/tests/Feature/BackofficeSnapshotFreezeTest.php +++ b/tests/Feature/BackofficeSnapshotFreezeTest.php @@ -27,6 +27,7 @@ function freezeUser(?int $sponsor = null): User 'm_level' => 1, 'm_sponsor' => $sponsor, 'active_date' => '2026-01-01', + 'partner_paid_at' => '2026-01-01', 'payment_account' => '2027-01-01', ]); } diff --git a/tests/Feature/BackofficeStatisticsDrilldownTest.php b/tests/Feature/BackofficeStatisticsDrilldownTest.php index cb4136b6..b985caf6 100644 --- a/tests/Feature/BackofficeStatisticsDrilldownTest.php +++ b/tests/Feature/BackofficeStatisticsDrilldownTest.php @@ -35,6 +35,8 @@ function boMakeUser(?int $sponsorId = null, ?string $activeDate = null, ?string // Ohne active_date steht ein Berater nicht im Provisionsbaum (K11) — wie // im TreeCalcBot. Der Standard liegt vor allen Testmonaten. 'active_date' => $activeDate ?? '2026-01-15', + // Regelfall: am Tag der Registrierung bezahlt (User::scopeNewPartnerIn()). + 'partner_paid_at' => $activeDate ?? '2026-01-15', 'payment_account' => $paymentAccount ?? now()->addYear(), ]); } @@ -371,3 +373,40 @@ it('rendert die Uebersicht mit dem Neu-Badge samt Baum-Parameter (Regression: Cl ->toContain('only_new=1') ->toContain('tree=commission'); }); + +it('zaehlt einen Neupartner im Monat der Zahlung, nicht im Monat der Registrierung', function (): void { + // Kundenentscheidung 16.09.2026: Ende August registriert, am 1. September + // bezahlt -> September-Neupartner. Mit active_date fiel er komplett durch. + $root = boMakeUser(); + $latePayer = boMakeUser($root->id, '2026-08-28'); + $latePayer->forceFill(['partner_paid_at' => '2026-09-01 06:49:35'])->save(); + + $service = app(BackofficeDashboardService::class); + $teamIds = $service->teamUserIds($root->id); + + expect($service->newPartnersCount($teamIds, 8, 2026))->toBe(0) + ->and($service->newPartnersCount($teamIds, 9, 2026))->toBe(1) + ->and($latePayer->fresh()->isNewPartnerIn(8, 2026))->toBeFalse() + ->and($latePayer->fresh()->isNewPartnerIn(9, 2026))->toBeTrue(); + + // Und im Neupartner-Drilldown steht er mit dem Zahlungsdatum. + $rows = collect($service->overview($root, 9, 2026)['recruiter_ranking'] ?? []); + $details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 9, 2026); + $row = collect($details['rows'])->firstWhere('user_id', $latePayer->id); + + expect($row)->not->toBeNull() + ->and($row['paid_at_sort'])->toBe('2026-09-01') + ->and($row['active_date_sort'])->toBe('2026-08-28'); +}); + +it('zaehlt einen registrierten, aber nie bezahlten Partner in keinem Monat', function (): void { + $root = boMakeUser(); + $unpaid = boMakeUser($root->id, '2026-08-19'); + $unpaid->forceFill(['partner_paid_at' => null, 'payment_account' => null])->save(); + + $service = app(BackofficeDashboardService::class); + $teamIds = $service->teamUserIds($root->id); + + expect($service->newPartnersCount($teamIds, 8, 2026))->toBe(0) + ->and($service->newPartnersCount($teamIds, 9, 2026))->toBe(0); +}); diff --git a/tests/Feature/BackofficeStatisticsListsTest.php b/tests/Feature/BackofficeStatisticsListsTest.php index a7a91848..2e4145f7 100644 --- a/tests/Feature/BackofficeStatisticsListsTest.php +++ b/tests/Feature/BackofficeStatisticsListsTest.php @@ -42,6 +42,7 @@ function bslMakeUser(?int $sponsorId = null, ?int $levelId = null, ?string $acti 'm_level' => $levelId ?? 1, 'm_sponsor' => $sponsorId, 'active_date' => $activeDate ?? '2026-01-15', + 'partner_paid_at' => $activeDate ?? '2026-01-15', 'payment_account' => now()->addYear(), ]); } diff --git a/tests/Feature/HomeMonthlyStatsTest.php b/tests/Feature/HomeMonthlyStatsTest.php index b2057d59..7bfd8d12 100644 --- a/tests/Feature/HomeMonthlyStatsTest.php +++ b/tests/Feature/HomeMonthlyStatsTest.php @@ -31,6 +31,7 @@ function hmsMakeUser(?int $sponsorId = null, ?string $paymentAccount = 'future') 'm_level' => 1, 'm_sponsor' => $sponsorId, 'active_date' => '2026-01-10', + 'partner_paid_at' => '2026-01-10', 'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount, ]); } @@ -66,13 +67,13 @@ it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kache $root = hmsMakeUser(); // Im laufenden Monat aktiv geworden, Konto gueltig -> Neupartner - hmsMakeUser($root->id)->forceFill(['active_date' => '2026-08-05'])->save(); + hmsMakeUser($root->id)->forceFill(['active_date' => '2026-08-05', 'partner_paid_at' => '2026-08-05'])->save(); // Im laufenden Monat aktiv geworden, Konto abgelaufen -> kein Neupartner, // aber weiterhin Berater in der Firstlines-Kachel hmsMakeUser($root->id, now()->subDay()->toDateString()) - ->forceFill(['active_date' => '2026-08-06'])->save(); + ->forceFill(['active_date' => '2026-08-06', 'partner_paid_at' => '2026-08-06'])->save(); // Ohne Abrechnungskonto: weder Neupartner noch Berater - hmsMakeUser($root->id, null)->forceFill(['active_date' => '2026-08-07'])->save(); + hmsMakeUser($root->id, null)->forceFill(['active_date' => '2026-08-07', 'partner_paid_at' => '2026-08-07'])->save(); $stats = hmsMonthlyStats($root); diff --git a/tests/Feature/PartnerPaidAtTest.php b/tests/Feature/PartnerPaidAtTest.php new file mode 100644 index 00000000..2b157a96 --- /dev/null +++ b/tests/Feature/PartnerPaidAtTest.php @@ -0,0 +1,168 @@ +hasColumn('products', 'slug')) { + Schema::connection('sqlite')->table('products', function ($table) { + $table->string('slug')->nullable(); + }); + } +}); + +afterEach(fn () => Carbon::setTestNow()); + +function ppaMakeUser(array $overrides = []): User +{ + return User::forceCreate(array_merge([ + 'email' => 'ppa-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + 'm_level' => 1, + 'active_date' => '2026-08-28 16:13:11', + 'payment_account' => null, + 'partner_paid_at' => null, + ], $overrides)); +} + +function ppaRegistrationOrder(User $user, bool $paid = true): ShoppingOrder +{ + return ShoppingOrder::forceCreate([ + 'shopping_user_id' => 0, + 'auth_user_id' => $user->id, + 'country_id' => 0, + 'user_shop_id' => 0, + 'payment_for' => 1, + 'paid' => $paid, + 'created_at' => '2026-08-28 16:14:00', + 'updated_at' => '2026-09-01 08:54:00', + ]); +} + +/** + * Wer Ende August registriert und am 1. September bezahlt, ist ein + * September-Neupartner. Massgeblich ist `partner_paid_at`, gesetzt beim ersten + * Zahlungseingang und danach nie wieder. + */ +describe('partner_paid_at: erste Kontozahlung', function () { + it('setzt den Wert nur beim ersten Mal', function () { + $user = ppaMakeUser(); + + expect(UserUtil::markPartnerPaid($user))->toBeTrue() + ->and($user->partner_paid_at->toDateTimeString())->toBe('2026-09-01 06:49:35'); + + Carbon::setTestNow(Carbon::parse('2027-09-01 10:00:00')); + + // Verlaengerung ein Jahr spaeter: der Wert bleibt stehen. + expect(UserUtil::markPartnerPaid($user))->toBeFalse() + ->and($user->partner_paid_at->toDateTimeString())->toBe('2026-09-01 06:49:35'); + }); + + it('wird von der Kontozahlung gesetzt und macht den Partner zum Neupartner des Zahlungsmonats', function () { + $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], // payment_for_account + ]); + + $order = ppaRegistrationOrder($user, false); + ShoppingOrderItem::forceCreate([ + 'shopping_order_id' => $order->id, + 'product_id' => $product->id, + 'qty' => 1, + 'price' => 89.0, + ]); + + Payment::paymentStatusPaidAction($order->fresh(), true); + + $user->refresh(); + + expect($user->partner_paid_at?->toDateString())->toBe('2026-09-01') + ->and($user->payment_account)->not->toBeNull() + ->and($user->isNewPartnerIn(8, 2026))->toBeFalse() + ->and($user->isNewPartnerIn(9, 2026))->toBeTrue(); + }); +}); + +describe('partner_paid_at: Rueckfuellung fuer bestehende Partner', function () { + it('nimmt die Punktebuchung der ersten bezahlten Registrierungsbestellung', function () { + $user = ppaMakeUser(['payment_account' => '2027-09-01 06:49:35']); + $order = ppaRegistrationOrder($user); + UserSalesVolume::forceCreate([ + 'user_id' => $user->id, + 'shopping_order_id' => $order->id, + 'month' => 9, + 'year' => 2026, + 'points' => 200, + 'status' => 1, + 'created_at' => '2026-09-01 06:49:35', + ]); + + expect(UserUtil::backfillPartnerPaidAt())->toBe(1) + ->and($user->fresh()->partner_paid_at->toDateTimeString())->toBe('2026-09-01 06:49:35'); + }); + + it('faellt ohne Buchung auf das Aenderungsdatum der Bestellung zurueck', function () { + $user = ppaMakeUser(['payment_account' => '2027-09-01 06:49:35']); + ppaRegistrationOrder($user); + + UserUtil::backfillPartnerPaidAt(); + + expect($user->fresh()->partner_paid_at->toDateTimeString())->toBe('2026-09-01 08:54:00'); + }); + + it('nimmt ohne Registrierungsbestellung das Aktivierungsdatum (Admin-Freischaltung)', function () { + $user = ppaMakeUser(['payment_account' => '2027-08-28 00:00:00']); + + UserUtil::backfillPartnerPaidAt(); + + expect($user->fresh()->partner_paid_at->toDateTimeString())->toBe('2026-08-28 16:13:11'); + }); + + it('laesst bereits gesetzte Werte und unbezahlte Partner unangetastet', function () { + $set = ppaMakeUser(['payment_account' => '2027-01-01', 'partner_paid_at' => '2025-03-03 12:00:00']); + $unpaid = ppaMakeUser(); + + expect(UserUtil::backfillPartnerPaidAt())->toBe(0) + ->and($set->fresh()->partner_paid_at->toDateTimeString())->toBe('2025-03-03 12:00:00') + ->and($unpaid->fresh()->partner_paid_at)->toBeNull(); + }); + + it('ignoriert unbezahlte Registrierungsbestellungen', function () { + // Abgelaufener Zahlungslink, danach ein zweiter erfolgreicher: nur die + // bezahlte Bestellung zaehlt. + $user = ppaMakeUser(['payment_account' => '2027-09-01 06:49:35']); + ppaRegistrationOrder($user, false); + $paid = ppaRegistrationOrder($user); + UserSalesVolume::forceCreate([ + 'user_id' => $user->id, 'shopping_order_id' => $paid->id, 'month' => 9, 'year' => 2026, + 'points' => 200, 'status' => 1, 'created_at' => '2026-09-01 06:49:35', + ]); + + UserUtil::backfillPartnerPaidAt(); + + expect($user->fresh()->partner_paid_at->toDateString())->toBe('2026-09-01'); + }); +}); diff --git a/tests/Feature/PaylineForecastServiceTest.php b/tests/Feature/PaylineForecastServiceTest.php index ea064cb1..d76f5fe2 100644 --- a/tests/Feature/PaylineForecastServiceTest.php +++ b/tests/Feature/PaylineForecastServiceTest.php @@ -44,6 +44,7 @@ function pflMakeUser(?int $sponsorId = null, ?string $activeDate = '2026-01-10') 'm_sponsor' => $sponsorId, 'payment_account' => now()->addYear(), 'active_date' => $activeDate, + 'partner_paid_at' => $activeDate, ]); } diff --git a/tests/Feature/StatisticsConsistencyTest.php b/tests/Feature/StatisticsConsistencyTest.php index 1b247361..d4848fc6 100644 --- a/tests/Feature/StatisticsConsistencyTest.php +++ b/tests/Feature/StatisticsConsistencyTest.php @@ -27,6 +27,7 @@ function scUser(?int $sponsor = null): User 'm_level' => 1, 'm_sponsor' => $sponsor, 'active_date' => '2026-01-01', + 'partner_paid_at' => '2026-01-01', 'payment_account' => '2027-01-01', ]); } diff --git a/tests/Feature/TeamFirstlinesPageTest.php b/tests/Feature/TeamFirstlinesPageTest.php index fc246da7..6034220f 100644 --- a/tests/Feature/TeamFirstlinesPageTest.php +++ b/tests/Feature/TeamFirstlinesPageTest.php @@ -28,6 +28,7 @@ function tflMakeUser(?int $sponsorId = null, array $accountData = []): User 'm_sponsor' => $sponsorId, 'account_id' => $account->id, 'active_date' => '2026-01-15', + 'partner_paid_at' => '2026-01-15', 'payment_account' => now()->addYear(), ]); } @@ -65,6 +66,10 @@ it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere a expect($rows)->toHaveCount(4) ->and($view->getData()['activeCount'])->toBe(3) // Kachel-Zahl: nur aktive Berater + // Dritte Zahl im Kopf: die Zeilenmarkierung beantwortet eine andere Frage + // (abgelaufene Jahresgebuehr) als "Berater" — ohne sie liesse sich die + // Kopfzahl nicht mit den unmarkierten Zeilen abgleichen. + ->and($view->getData()['expiredCount'])->toBe(2) ->and($rows[0]['user_id'])->toBe($ben->id) // 400 Punkte zuerst ->and($rows[0]['total_points'])->toBe(400.0) ->and($rows[1]['user_id'])->toBe($anna->id) diff --git a/tests/Feature/TopResultsServiceTest.php b/tests/Feature/TopResultsServiceTest.php index d634e84f..0647defa 100644 --- a/tests/Feature/TopResultsServiceTest.php +++ b/tests/Feature/TopResultsServiceTest.php @@ -42,6 +42,7 @@ function trsMakeUser( 'm_sponsor' => $sponsorId, 'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount, 'active_date' => $activeDate, + 'partner_paid_at' => $activeDate, 'account_id' => $account->id, ]); } @@ -322,3 +323,21 @@ it('haelt "Top 3 neue Abos" fuer abgeschlossene Monate stabil, auch wenn Abos sp ->and($result['top_new_abos'][0]['first_name'])->toBe('Anna') ->and($result['top_new_abos'][0]['value'])->toBe(2); }); + +it('rechnet dem Recruiter den Neupartner im Monat der Zahlung an', function (): void { + // Registriert am 28.08., bezahlt am 01.09. -> September-Recruit, nicht August. + Carbon::setTestNow(Carbon::parse('2026-09-15')); + + $root = trsMakeUser(null, 'Root', 'Viewer'); + $sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor'); + trsMakeUser($sponsor->id, 'Spaet', 'Zahler', '2026-08-28') + ->forceFill(['partner_paid_at' => '2026-09-01 06:49:35'])->save(); + + Cache::flush(); + $august = app(TopResultsService::class)->monthlyTop3($root, 8, 2026); + Cache::flush(); + $september = app(TopResultsService::class)->monthlyTop3($root, 9, 2026); + + expect(collect($august['top_recruiters'])->firstWhere('user_id', $sponsor->id))->toBeNull() + ->and(collect($september['top_recruiters'])->firstWhere('user_id', $sponsor->id))->toMatchArray(['value' => 1]); +}); diff --git a/tests/Unit/Services/BackofficeDashboardServiceTest.php b/tests/Unit/Services/BackofficeDashboardServiceTest.php index aaee52a6..3d496438 100644 --- a/tests/Unit/Services/BackofficeDashboardServiceTest.php +++ b/tests/Unit/Services/BackofficeDashboardServiceTest.php @@ -53,6 +53,7 @@ beforeEach(function () { $table->unsignedInteger('m_sponsor')->nullable(); $table->boolean('active')->default(false); $table->timestamp('active_date')->nullable(); + $table->timestamp('partner_paid_at')->nullable(); $table->unsignedTinyInteger('admin')->default(0); $table->unsignedTinyInteger('wizard')->default(0); $table->unsignedTinyInteger('blocked')->default(0); @@ -69,6 +70,7 @@ beforeEach(function () { $table->char('is_for', 2)->nullable(); $table->boolean('active')->default(true); $table->unsignedTinyInteger('status')->default(2); + $table->string('status_reason')->nullable(); $table->date('start_date')->nullable(); $table->date('next_date')->nullable(); $table->timestamps(); @@ -216,6 +218,7 @@ it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function 'm_sponsor' => $root->id, 'm_level' => 1, 'active_date' => '2026-05-03 00:00:00', + 'partner_paid_at' => '2026-05-03 00:00:00', 'payment_account' => '2030-01-01 00:00:00', ]); @@ -226,6 +229,7 @@ it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function 'm_sponsor' => $lineOne->id, 'm_level' => 1, 'active_date' => '2026-04-03 00:00:00', + 'partner_paid_at' => '2026-04-03 00:00:00', 'payment_account' => '2030-01-01 00:00:00', ]); @@ -236,6 +240,7 @@ it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function 'm_sponsor' => $root->id, 'm_level' => 1, 'active_date' => '2026-01-03 00:00:00', + 'partner_paid_at' => '2026-01-03 00:00:00', 'payment_account' => '2020-01-01 00:00:00', ]); @@ -249,6 +254,7 @@ it('aggregiert Linien, Abos und Punkte fuer die Backoffice-Statistik', function 'm_sponsor' => $sponsor->id, 'm_level' => 1, 'active_date' => '2026-04-03 00:00:00', + 'partner_paid_at' => '2026-04-03 00:00:00', 'payment_account' => '2030-01-01 00:00:00', ]); }