|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',
]);
}