mivita/tests/Feature/AnnualFeeDueServiceTest.php
Kevin 867dc21b57 Statistiken: Kundenfeedback 2026-08-19 komplett (N1-N8) + Review-Fixes + VP-/Incentive-Repair gehärtet
Statistik-Änderungen 2026-08-19 (dev/2026-07-22/entwicklungsplan-2026-08-19.md, alle 8 Pakete gemäß beantworteten F-Fragen):
- N1 Monatsstatistik: Team-Cluster (Firstlines klickbar -> neue Seite user_team_firstlines, Teampartner gesamt/Payline [nur aktive], Abos Payline mit +N) + Level-Kachel (Quali-Stufe, fehlende Payline-/persönliche Punkte, Karriere-Level); LiveCommissionService-Snapshot v3 reicht Level-/Quali-Daten durch
- N2 Abo-Kacheln auf Home monatsbezogen (aboStockCount-Rückrechnung über cancel/deleted/Status-Historie; VIP-Statistik bleibt tagesaktuell)
- N3 VIP-Statistik 'Recruiter des Monats' (Neupartner-Definition wie KPI, rekursives Ketten-Volumen als Gesamtpunkte inkl. Starterpaket) + Drilldown/CSV-Spalte 'Punkte gesamt'
- N4 Prognose-Block 'Level & Provision laut Prognose (Stand 25.)' mit unverbindlich-Kennzeichnung (ebenengenaue offene Abo-Punkte + TP-Laufstände)
- N5 Checkout-Herkunft: Dropdown raus, Pflicht-Freitext (lokalisierte Fehlermeldung)
- N6 Berater-Profilfoto (user_accounts.profile_image, Einwilligung als Sprachkey-Platzhalter, DSGVO-Löschung) + teaminterne Top-3-Karte (TopResultsService, Scope-Schalter)
- N7 USt-Optionen: 'nicht umsatzsteuerpflichtig' zuerst + '§ 19 UStG'-Textfix
- N8 Jahresgebühr-Nachfassliste je Berater (AnnualFeeDueService, überfällig/1-3 Monate, Sidenav)

Kontroll-Review (8 Perspektiven) angewendet: next-Level-Fallback im Steady-State (resolveNextLevel — sonst 'Höchstes Level erreicht' für Normalfälle), _calculated-Punktebasis bei mehr Paylines, keine Wiederbelebung deaktivierter Abos in der Monatsrückrechnung, Payline-Abo-Kachel monatskonsistent, Top-3-Nachrücker; Performance: BackofficeDashboardService als Singleton mit lineBuckets-Memo (+flushLineBucketsMemo), level_forecast 6h-Cache; Reuse: User-Scopes firstlinesOf/newPartnerIn, SalesVolumePoints::totalPointsByUser, Slim::storeCroppedImage (3 Upload-Kopien vereinigt), activeAbo-Scope statt Inline-Prädikate, Form::open-Konvention, Return-Types

Repair-Härtung (Kundenentscheide): VP-Nachtrepair dedupliziert über shopping_order_id (Live-Pfad schreibt sie jetzt mit), zählende Alt-Zeilen-Erkennung, Vormonats-Scan für Monatswechsel, Firmenaccount-Buchungen (config mivita.company_user_id) werden automatisch auf den Berater umgebucht (in place bzw. per Gegenbuchung bei geschlossenem Monat; Echtfall Anna Schall Order 58797 -> Christina Schuder), Berater-zu-Berater nur Warnung; Incentive-Repair: m_sponsor=NULL löscht nicht mehr, FK-Repair vor Stale-Bereinigung, gebatchte Lookups, zentrales Incentive::qualificationWindow(); .env aus dem Git-Tracking entfernt (Pflege per SFTP)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:44:44 +00:00

151 lines
5.7 KiB
PHP

<?php
use App\Models\UserAccount;
use App\Models\UserLevel;
use App\Services\Team\AnnualFeeDueService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function afdMakeLevel(string $name): UserLevel
{
return UserLevel::forceCreate([
'name' => $name,
'pos' => 1,
'active' => 1,
'default' => 0,
]);
}
function afdMakeUser(?int $sponsorId, ?string $paymentAccount, ?int $levelId = null, array $accountAttributes = []): User
{
$account = UserAccount::create(array_merge([
'first_name' => 'Vorname',
'last_name' => 'Nachname',
'phone' => '',
'mobil' => '',
], $accountAttributes));
return User::forceCreate([
'email' => 'afd-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'account_id' => $account->id,
'm_level' => $levelId ?? 1,
'm_sponsor' => $sponsorId,
'payment_account' => $paymentAccount,
]);
}
it('gruppiert Teampartner nach Jahresgebühr-Dringlichkeit mit korrekter Linie und Sortierung', function (): void {
$reference = Carbon::parse('2026-08-15 12:00:00');
$level = afdMakeLevel('Berater');
$root = afdMakeUser(null, '2027-05-01', $level->id);
// Linie 1
$overdueLater = afdMakeUser($root->id, '2026-08-10', $level->id, [
'first_name' => 'Anna', 'last_name' => 'Alt', 'phone' => '030 123', 'mobil' => '0171 111',
]);
$overdueEarlier = afdMakeUser($root->id, '2026-06-20', $level->id, [
'first_name' => 'Bernd', 'last_name' => 'Blum',
]);
$dueOneMonth = afdMakeUser($root->id, '2026-09-10', $level->id, [
'first_name' => 'Clara', 'last_name' => 'Cato',
]);
$outsideWindow = afdMakeUser($root->id, '2026-12-15', $level->id); // +4 Monate: nicht anzeigen
$withoutPaymentAccount = afdMakeUser($root->id, null, $level->id); // ohne payment_account: weglassen
// Linie 2 (unter Anna)
$dueTwoMonths = afdMakeUser($overdueLater->id, '2026-10-10', $level->id, [
'first_name' => 'Doris', 'last_name' => 'Dahl',
]);
$dueThreeMonths = afdMakeUser($overdueLater->id, '2026-11-10', $level->id, [
'first_name' => 'Emil', 'last_name' => 'Eck',
]);
$groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference);
// Gruppen-Reihenfolge: überfällig zuerst
expect(array_keys($groups))->toBe([
AnnualFeeDueService::GROUP_OVERDUE,
AnnualFeeDueService::GROUP_DUE_1_MONTH,
AnnualFeeDueService::GROUP_DUE_2_MONTHS,
AnnualFeeDueService::GROUP_DUE_3_MONTHS,
]);
// Überfällig: beide User, sortiert nach Fälligkeitsdatum aufsteigend (ältestes zuerst)
$overdue = $groups[AnnualFeeDueService::GROUP_OVERDUE];
expect($overdue)->toHaveCount(2)
->and($overdue[0]['user_id'])->toBe($overdueEarlier->id)
->and($overdue[1]['user_id'])->toBe($overdueLater->id)
->and($overdue[1]['first_name'])->toBe('Anna')
->and($overdue[1]['last_name'])->toBe('Alt')
->and($overdue[1]['email'])->toBe($overdueLater->email)
->and($overdue[1]['phone'])->toBe('030 123')
->and($overdue[1]['mobil'])->toBe('0171 111')
->and($overdue[1]['line'])->toBe(1)
->and($overdue[1]['level_name'])->toBe('Berater')
->and($overdue[1]['due_date']->format('Y-m-d'))->toBe('2026-08-10');
// ≤ 1 Monat
$dueOne = $groups[AnnualFeeDueService::GROUP_DUE_1_MONTH];
expect($dueOne)->toHaveCount(1)
->and($dueOne[0]['user_id'])->toBe($dueOneMonth->id)
->and($dueOne[0]['line'])->toBe(1);
// ≤ 2 Monate (Linie 2)
$dueTwo = $groups[AnnualFeeDueService::GROUP_DUE_2_MONTHS];
expect($dueTwo)->toHaveCount(1)
->and($dueTwo[0]['user_id'])->toBe($dueTwoMonths->id)
->and($dueTwo[0]['line'])->toBe(2);
// ≤ 3 Monate (Linie 2)
$dueThree = $groups[AnnualFeeDueService::GROUP_DUE_3_MONTHS];
expect($dueThree)->toHaveCount(1)
->and($dueThree[0]['user_id'])->toBe($dueThreeMonths->id)
->and($dueThree[0]['line'])->toBe(2);
// +4 Monate und ohne payment_account tauchen nirgends auf
$allUserIds = collect($groups)->flatten(1)->pluck('user_id');
expect($allUserIds)->not->toContain($outsideWindow->id)
->and($allUserIds)->not->toContain($withoutPaymentAccount->id)
->and($allUserIds)->not->toContain($root->id);
});
it('liefert leere Gruppen für ein Team ohne fällige Jahresgebühren', function (): void {
$reference = Carbon::parse('2026-08-15 12:00:00');
$root = afdMakeUser(null, '2027-05-01');
afdMakeUser($root->id, '2027-04-01');
$groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference);
expect(collect($groups)->flatten(1))->toBeEmpty()
->and(array_keys($groups))->toBe(AnnualFeeDueService::GROUP_ORDER);
});
it('rendert die Jahresgebühr-Seite mit Gruppen und Hinweistext', function (): void {
$reference = Carbon::parse('2026-08-15 12:00:00');
$level = afdMakeLevel('Berater');
$root = afdMakeUser(null, '2027-05-01', $level->id);
afdMakeUser($root->id, '2026-08-10', $level->id, [
'first_name' => 'Anna', 'last_name' => 'Alt', 'phone' => '030 123',
]);
$groups = app(AnnualFeeDueService::class)->dueGroups($root->id, $reference);
$html = view('user.team._annual_fee_groups', [
'groups' => $groups,
'totalCount' => array_sum(array_map('count', $groups)),
])->render();
expect($html)->toContain(__('team.annual_fee_title'))
->toContain(__('team.annual_fee_hint'))
->toContain(__('team.annual_fee_group_overdue'))
->toContain('Anna')
->toContain('Alt')
->toContain('030 123');
});