mivita/tests/Feature/HomeMonthlyStatsTest.php
Kevin b7e8bbd951 Statistik: Neupartner zaehlen im Monat der Zahlung, nicht der Registrierung
Wer Ende August registriert und am 1. September bezahlt hat, tauchte in keiner
Neupartner-Zaehlung auf: Der August-Snapshot wurde um 04:45 eingefroren, da
fehlte noch das Abrechnungskonto; im September lag active_date im falschen
Monat. Betroffen sind 2026 acht Partner, auch im Recruiting ihrer Sponsoren.

Kundenentscheidung 16.09.2026: der Zeitpunkt der Zahlung ist massgeblich.

Neue Spalte users.partner_paid_at, gesetzt bei der ersten Kontozahlung
(Payment::paymentStatusPaidAction) und bei manueller Freischaltung durch den
Admin — nur, solange sie leer ist; Verlaengerung und Wiederherstellung aendern
sie nicht. Bestehende Partner werden per Migration aus der ersten bezahlten
Registrierungsbestellung rueckgefuellt (Zeitstempel der Punktebuchung, ersatzweise
Bestelldatum, ohne Bestellung das Aktivierungsdatum). Bewusst nicht
payment_account minus ein Jahr: das ergaebe bei Verlaengerern den Jahrestag der
Erstzahlung.

User::scopeNewPartnerIn() und isNewPartnerIn() lesen jetzt partner_paid_at —
damit Kachel, Recruiter-Ranking, Top 3 und Neupartner-Liste gleichermassen.
Die Liste und der CSV-Export zeigen "Bezahlt am" neben "Registriert am".

Unveraendert: die Teamstruktur haengt weiter an active_date (auch im
Provisionsbaum). Wer ist neu dazugekommen und wer gehoert zur Linie sind zwei
verschiedene Fragen.

Die eingefrorenen Monate Januar bis August enthalten sieben dieser Partner
nicht; ob sie per --force neu gebaut werden, ist eine Entscheidung — siehe
dev/2026-07-22/neupartner-zahlungsmonat.md.

Mit drin: die handgebaute SQLite-Tabelle in BackofficeDashboardServiceTest
kannte status_reason aus d6efe56 noch nicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:48:01 +00:00

198 lines
7.2 KiB
PHP

<?php
use App\Http\Controllers\HomeController;
use App\Models\UserAbo;
use App\Models\UserLevel;
use App\Services\Abo\PaylineForecastService;
use App\Services\BusinessPlan\LiveCommissionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-08-15'));
Cache::flush();
});
afterEach(function () {
Carbon::setTestNow();
});
function hmsMakeUser(?int $sponsorId = null, ?string $paymentAccount = 'future'): User
{
return User::forceCreate([
'email' => 'hms-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'active_date' => '2026-01-10',
'partner_paid_at' => '2026-01-10',
'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount,
]);
}
function hmsMonthlyStats(User $user, int $month = 8, int $year = 2026): array
{
Request::merge(['stats_month' => $month, 'stats_year' => $year]);
$method = (new ReflectionClass(HomeController::class))->getMethod('buildMonthlyStats');
$method->setAccessible(true);
return $method->invoke(new HomeController, $user);
}
it('zaehlt in der Firstlines-Kachel nur aktive Berater, konsistent zu "Teampartner gesamt"', function (): void {
$root = hmsMakeUser();
hmsMakeUser($root->id);
hmsMakeUser($root->id);
// Abgelaufenes, aber gesetztes Abrechnungskonto: zaehlt wie in der
// "Berater"-Spalte weiterhin als Berater (BackofficeDashboardService::isActiveConsultant).
hmsMakeUser($root->id, now()->subMonth()->toDateString());
// Gar kein Abrechnungskonto: kein Berater
hmsMakeUser($root->id, null);
$stats = hmsMonthlyStats($root);
// Beide Kacheln stehen im selben Cluster und muessen dieselbe Basis nutzen.
expect($stats['firstline_count'])->toBe(3)
->and($stats['team_partner_count'])->toBe(3);
});
it('zaehlt Neupartner weiterhin ueber alle Firstlines, nicht nur ueber die Kachel-Menge', function (): void {
$root = hmsMakeUser();
// Im laufenden Monat aktiv geworden, Konto gueltig -> Neupartner
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', 'partner_paid_at' => '2026-08-06'])->save();
// Ohne Abrechnungskonto: weder Neupartner noch Berater
hmsMakeUser($root->id, null)->forceFill(['active_date' => '2026-08-07', 'partner_paid_at' => '2026-08-07'])->save();
$stats = hmsMonthlyStats($root);
expect($stats['direct_new_partners'])->toBe(1)
->and($stats['firstline_count'])->toBe(2);
});
/**
* Legt ein Karriere-Level an und haengt den User daran. Ohne Monatsqualifikation
* ist genau das die Ebenenzahl, mit der die beiden Payline-Kacheln rechnen.
*/
function hmsMakeLevel(int $pos, int $paylines, string $name = 'Testlevel'): UserLevel
{
return UserLevel::forceCreate([
'name' => $name.' '.$pos,
'qual_kp' => 150,
'qual_pp' => 0,
'paylines' => $paylines,
'pos' => $pos,
'active' => true,
]);
}
it('zeigt die Payline-Kacheln am Monatsanfang nach dem Karriere-Level, statt auf 0 zu fallen', function (): void {
// Ohne Monatsquali liefert der Live-Lauf paylines = 0. Die beiden
// Struktur-Kacheln muessen dann auf die Ebenen des Karriere-Levels
// zurueckfallen — die Teamstruktur aendert sich zum Monatswechsel nicht.
$level = hmsMakeLevel(pos: 3, paylines: 2);
$root = hmsMakeUser();
$root->forceFill(['m_level' => $level->id])->save();
$line1 = hmsMakeUser($root->id);
$line2 = hmsMakeUser($line1->id);
// Ebene 3 liegt ausserhalb der zwei bezahlten Ebenen des Karriere-Levels.
hmsMakeUser($line2->id);
$stats = hmsMonthlyStats($root->fresh());
expect($stats['payline_is_career_fallback'])->toBeTrue()
->and($stats['payline_member_count'])->toBe(2)
->and($stats['team_partner_count'])->toBe(3);
});
it('haelt eine leere Payline nicht im 6-h-Cache fest', function (): void {
// Ohne Karriere-Level bleibt die Tiefe 0. Das darf NICHT gecacht werden:
// der Zustand loest sich im Monatsverlauf von selbst auf.
$root = hmsMakeUser();
hmsMakeUser($root->id);
$service = app(PaylineForecastService::class);
$context = $service->paylineContext($root, 8, 2026);
expect($context['paylines'])->toBe(0)
->and($context['member_ids'])->toBe([])
->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeFalse();
});
it('cacht den Payline-Kontext, sobald eine Ebenenzahl feststeht', function (): void {
$level = hmsMakeLevel(pos: 3, paylines: 2);
$root = hmsMakeUser();
$root->forceFill(['m_level' => $level->id])->save();
hmsMakeUser($root->id);
$service = app(PaylineForecastService::class);
$context = $service->paylineContext($root->fresh(), 8, 2026);
expect($context['paylines'])->toBe(2)
->and(Cache::has("payline_context_{$root->id}_8_2026"))->toBeTrue();
});
it('blendet den Level-Block ohne Monatsqualifikation nicht aus, sondern zeigt die fehlenden Punkte', function (): void {
// setFirstQualLevel() liefert das Level auf pos 1 als next_level mit —
// daraus muss "dir fehlen noch X Punkte persoenliches Volumen" entstehen.
hmsMakeLevel(pos: 1, paylines: 3, name: 'Junior Berater');
$root = hmsMakeUser();
$root->forceFill(['m_level' => UserLevel::where('pos', 1)->value('id')])->save();
$level = app(LiveCommissionService::class)
->levelProgress($root->fresh(), 8, 2026);
expect($level)->not->toBeNull()
->and($level['is_qualified'])->toBeFalse()
->and($level['name'])->toBe('')
->and($level['next_name'])->toBe('Junior Berater 1')
->and($level['missing_personal'])->toBe(150.0);
});
it('rechnet Bestand und "+N neu" der Payline-Kachel nach derselben Regel', function (): void {
// Regression: der Bestand kam aus der Monatsend-Rueckrechnung, das Badge aus
// activeAbo(). In derselben Kachel standen damit zwei Zaehlweisen.
$level = hmsMakeLevel(pos: 3, paylines: 2);
$root = hmsMakeUser();
$root->forceFill(['m_level' => $level->id])->save();
$anna = hmsMakeUser($root->id);
$abo = UserAbo::create([
'user_id' => $anna->id,
'member_id' => $anna->id,
'shopping_user_id' => 0,
'email' => 'hms-abo@example.com',
'is_for' => 'ot',
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => false,
'status' => 2,
'abo_interval' => 5,
'start_date' => '2026-07-10',
'next_date' => '2026-08-25',
]);
// Erst im August beendet -> zaehlt im Juli sowohl im Bestand als auch als "neu".
$abo->update(['cancel_date' => '2026-08-05']);
$stats = hmsMonthlyStats($root->fresh(), month: 7, year: 2026);
expect($stats['payline_abo_count'])->toBe(1)
->and($stats['payline_abos_new'])->toBe(1);
});