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>
198 lines
7.2 KiB
PHP
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);
|
|
});
|