hasColumn('products', 'slug')) { Schema::connection('sqlite')->table('products', function ($table) { $table->string('slug')->nullable(); }); } }); function makeUser(?int $sponsorId = null): User { $user = User::forceCreate([ 'email' => 'u-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $sponsorId, 'payment_account' => now()->addYear(), ]); return $user; } function makeAbo(int $userId, int $memberId, string $isFor, string $startDate, int $points = 0): UserAbo { $abo = UserAbo::create([ 'user_id' => $userId, 'member_id' => $memberId, 'shopping_user_id' => 0, 'email' => 'a-'.uniqid('', true).'@example.com', 'is_for' => $isFor, 'payone_userid' => 0, 'clearingtype' => 'cc', 'amount' => 5000, 'active' => true, 'status' => 2, 'abo_interval' => 5, 'start_date' => $startDate, 'last_date' => $startDate, 'next_date' => '2026-12-10', ]); if ($points > 0) { $product = Product::forceCreate([ 'name' => 'P-'.uniqid('', true), 'title' => 'Test', 'price' => 10.0, 'tax' => 19.0, 'active' => true, 'pos' => 1, 'weight' => 100, 'points' => $points, 'slug' => 'p-'.uniqid('', true), ]); UserAboItem::create([ 'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1, ]); } return $abo->fresh(); } it('gruppiert Team-Abos nach Ebenen mit Eigen- und Kundenabos', function (): void { $root = makeUser(); // 1. Ebene: zwei direkte Partner $anna = makeUser($root->id); $lena = makeUser($root->id); // 2. Ebene: ein Partner unter Anna $lisa = makeUser($anna->id); // Anna hat ein Eigen-Abo (50 Punkte) und 2 Kundenabos makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50); makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20); makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30); // Lena hat kein Eigen-Abo, aber 1 Kundenabo makeAbo($lena->id, $lena->id, 'ot', '2026-06-12', 15); // Lisa (Ebene 2) hat ein Eigen-Abo makeAbo($lisa->id, $lisa->id, 'me', '2026-06-01', 40); $service = app(TeamAboOverviewService::class); $overview = $service->linesOverview($root, 6, 2026); expect($overview['lines'])->toHaveKey(1) ->and($overview['lines'])->toHaveKey(2); $line1 = $overview['lines'][1]; expect($line1['consultants'])->toBe(2) ->and($line1['members_with_own_abo'])->toBe(1) // nur Anna ->and($line1['own_abos'])->toBe(1) ->and($line1['customer_abos'])->toBe(3) // Anna 2 + Lena 1 ->and($line1['customer_abos_new'])->toBe(2) // im Juni: Anna 1 + Lena 1 (Mai zählt nicht) ->and($line1['own_abos_new'])->toBe(1); $line2 = $overview['lines'][2]; expect($line2['consultants'])->toBe(1) ->and($line2['own_abos'])->toBe(1) ->and($line2['customer_abos'])->toBe(0); $totals = $overview['totals']; expect($totals['own_abos'])->toBe(2) ->and($totals['customer_abos'])->toBe(3) ->and($totals['total_abos'])->toBe(5); }); it('zählt nur aktive Berater und blendet inaktive/fehlerhafte aus', function (): void { $root = makeUser(); // Aktiver Berater (m_level + gültiges payment_account) $anna = makeUser($root->id); // Inaktiv: kein payment_account $noPayment = makeUser($root->id); $noPayment->forceFill(['payment_account' => null])->save(); // Inaktiv: keine Mitgliedsebene $noLevel = makeUser($root->id); $noLevel->forceFill(['m_level' => null])->save(); // Abos der inaktiven Berater dürfen nicht in die Übersicht einfließen makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50); makeAbo($noPayment->id, $noPayment->id, 'me', '2026-06-05', 10); makeAbo($noLevel->id, $noLevel->id, 'ot', '2026-06-05', 10); $service = app(TeamAboOverviewService::class); $overview = $service->linesOverview($root, 6, 2026); $line1 = $overview['lines'][1]; expect($line1['consultants'])->toBe(1) ->and($line1['members'])->toHaveCount(1) ->and($line1['members'][0]['user_id'])->toBe($anna->id) ->and($line1['own_abos'])->toBe(1) ->and($line1['customer_abos'])->toBe(0) ->and($overview['totals']['consultants'])->toBe(1); }); it('liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Liefertag', function (): void { Carbon\Carbon::setTestNow('2026-06-10'); $root = makeUser(); $anna = makeUser($root->id); // Offene Ausfuehrung bis zum 25.: zaehlt in der Prognose makeAbo($anna->id, $anna->id, 'me', '2026-05-05', 50)->update(['next_date' => '2026-06-20']); // Naechste Ausfuehrung erst im Folgemonat: zaehlt nicht makeAbo($anna->id, $anna->id, 'ot', '2026-05-10', 30)->update(['next_date' => '2026-07-05']); // Bereits abgerechnete Abo-Punkte im Juni $order = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $anna->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); UserSalesVolume::forceCreate([ 'user_id' => $anna->id, 'shopping_order_id' => $order->id, 'month' => 6, 'year' => 2026, 'points' => 30, 'status' => 2, ]); $overview = app(TeamAboOverviewService::class)->linesOverview($root, 6, 2026); $line1 = $overview['lines'][1]; expect($line1['abo_points_billed'])->toBe(30.0) ->and($line1['abo_points_expected'])->toBe(80.0) // 30 abgerechnet + 50 offen bis 20.06. ->and($overview['totals']['abo_points_billed'])->toBe(30.0) ->and($overview['totals']['abo_points_expected'])->toBe(80.0); Carbon\Carbon::setTestNow(); }); it('liefert Abo-Bestaende monatsbezogen: vergangene Monate zeigen den Bestand zum Monatsende', function (): void { Carbon\Carbon::setTestNow('2026-08-15'); $root = makeUser(); $anna = makeUser($root->id); // Läuft seit Mai bis heute: zählt in Mai, Juli und im Live-Monat August makeAbo($anna->id, $anna->id, 'me', '2026-05-10'); // Im Juli gekündigt: zählt im Juni, nicht mehr im Juli-Bestand (Stichtag 31.07.) makeAbo($anna->id, $anna->id, 'ot', '2026-06-20') ->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']); // Im Juli soft-geloescht: zählt im Juni, nicht mehr im Juli $deleted = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01'); $deleted->delete(); UserAbo::withTrashed()->whereKey($deleted->id)->update(['deleted_at' => '2026-07-10']); // Heute inaktiv, aber laut Status-Historie erst im August deaktiviert: zählt im Juli noch $paused = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01'); $paused->update(['active' => false]); UserAboStatusHistory::forceCreate([ 'user_abo_id' => $paused->id, 'action' => UserAboStatusHistory::ACTION_DEACTIVATED, 'created_at' => '2026-08-02', ]); // Laut Historie schon im Juni deaktiviert, erst im August geloescht: darf // im Juni/Juli NICHT wiederauferstehen (spaeteres Ereignis heilt nichts) $deactivatedEarly = makeAbo($anna->id, $anna->id, 'ot', '2026-05-20'); $deactivatedEarly->update(['active' => false]); UserAboStatusHistory::forceCreate([ 'user_abo_id' => $deactivatedEarly->id, 'action' => UserAboStatusHistory::ACTION_DEACTIVATED, 'created_at' => '2026-06-10', ]); $deactivatedEarly->delete(); UserAbo::withTrashed()->whereKey($deactivatedEarly->id)->update(['deleted_at' => '2026-08-05']); $svc = app(TeamAboOverviewService::class); $august = $svc->teamKpis($root, 8, 2026); expect($august['team_partner_abos'])->toBe(1) ->and($august['team_customer_abos'])->toBe(0); // live: gekündigt/gelöscht/deaktiviert zählen nicht $juli = $svc->teamKpis($root, 7, 2026); expect($juli['team_partner_abos'])->toBe(1) ->and($juli['team_customer_abos'])->toBe(1); // nur das erst im August deaktivierte $juni = $svc->teamKpis($root, 6, 2026); expect($juni['team_customer_abos'])->toBe(3); // gekündigtes + gelöschtes + später deaktiviertes $mai = $svc->teamKpis($root, 5, 2026); expect($mai['team_partner_abos'])->toBe(1) ->and($mai['team_customer_abos'])->toBe(2); // Mai-Kundenabo (gelöscht 07/26) + das erst im Juni deaktivierte Carbon\Carbon::setTestNow(); }); it('zaehlt ein systemseitig reaktiviertes Abo wieder zum Bestand (A16)', function (): void { Carbon\Carbon::setTestNow('2026-09-15'); $root = makeUser(); $anna = makeUser($root->id); // Ende Juli deaktiviert, Anfang August durch einen Systemschreibpfad // (Recovery-Zahlung/Retry/externe Erledigung) wieder aktiviert. Ohne // protokollierte Reaktivierung fiele das Abo ab August dauerhaft aus dem Bestand. $abo = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01'); $abo->update(['status' => 6, 'active' => false]); $abo->status_histories()->latest('id')->first()->forceFill(['created_at' => '2026-07-28'])->save(); $abo->update(['status' => 2, 'active' => true]); $abo->status_histories()->latest('id')->first()->forceFill(['created_at' => '2026-08-03'])->save(); $svc = app(TeamAboOverviewService::class); expect($svc->teamKpis($root, 7, 2026)['team_customer_abos'])->toBe(0) ->and($svc->teamKpis($root, 8, 2026)['team_customer_abos'])->toBe(1); Carbon\Carbon::setTestNow(); }); it('wertet eine Reaktivierung in derselben Sekunde als spaeteres Ereignis (A16)', function (): void { Carbon\Carbon::setTestNow('2026-09-15'); $root = makeUser(); $anna = makeUser($root->id); // created_at ist sekundengenau: Deaktivierung und Reaktivierung koennen im // selben Request denselben Zeitstempel tragen. Dann entscheidet die Reihenfolge. $abo = makeAbo($anna->id, $anna->id, 'ot', '2026-06-01'); $abo->update(['status' => 6, 'active' => false]); $abo->update(['status' => 2, 'active' => true]); $abo->status_histories()->update(['created_at' => '2026-07-20 09:00:00']); $svc = app(TeamAboOverviewService::class); expect($abo->status_histories()->count())->toBe(2) ->and($svc->teamKpis($root, 7, 2026)['team_customer_abos'])->toBe(1); Carbon\Carbon::setTestNow(); }); it('zaehlt "+N neu" mit derselben Stichtags-Logik wie den Bestand', function (): void { Carbon\Carbon::setTestNow('2026-08-15'); $root = makeUser(); $anna = makeUser($root->id); // Im Juni gestartet, erst im Juli gekuendigt: gehoert in den Juni-Bestand // UND in die Juni-Zugaenge - frueher fiel es aus "+N neu" heraus, weil es // heute nicht mehr aktiv ist. makeAbo($anna->id, $anna->id, 'ot', '2026-06-20') ->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-07-15']); // Im Juni gestartet und noch im Juni gekuendigt: zaehlt in keiner Juni-Zahl. makeAbo($anna->id, $anna->id, 'ot', '2026-06-02') ->update(['status' => 4, 'active' => false, 'cancel_date' => '2026-06-20']); $svc = app(TeamAboOverviewService::class); $juni = $svc->teamKpis($root, 6, 2026); expect($juni['team_customer_abos'])->toBe(1) ->and($juni['team_customer_abos_new'])->toBe(1); // Wachstumstabelle nutzt dieselbe Zaehlung $growth = collect($svc->monthlyGrowth($root, 2026)['months'])->firstWhere('month', 6); expect($growth['customer_new'])->toBe(1); Carbon\Carbon::setTestNow(); }); it('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void { $root = makeUser(); $anna = makeUser($root->id); makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50); makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20); $service = app(TeamAboOverviewService::class); $overview = $service->linesOverview($root, 6, 2026); $member = collect($overview['lines'][1]['members'])->firstWhere('user_id', $anna->id); expect($member['has_own_abo'])->toBeTrue() ->and($member['own_abo_points'])->toBe(50.0) ->and($member['customer_abo_count'])->toBe(1) ->and($member['customer_abo_points'])->toBe(20.0) ->and($member['total_abos'])->toBe(2); }); it('rendert die Team-Abo-Linienansicht mit Ebenen und Summen', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', $parameters)); $root = makeUser(); $anna = makeUser($root->id); makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50); makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20); $this->actingAs($root); $service = app(TeamAboOverviewService::class); $overview = $service->linesOverview($root, 6, 2026); $growth = $service->monthlyGrowth($root, 2026); $html = view('user.team.abos', [ 'currentMonthName' => HTMLHelper::getTransMonths()[6], 'overview' => $overview, 'growth' => $growth, 'growthChart' => [ 'labels' => array_map(fn ($r) => $r['label'], $growth['months']), 'own' => array_map(fn ($r) => $r['own_new'], $growth['months']), 'customer' => array_map(fn ($r) => $r['customer_new'], $growth['months']), 'step' => 1, 'suggested_max' => 5, ], 'growthYear' => 2026, 'filterYears' => HTMLHelper::getYearRange(2022), ])->render(); expect($html) ->toContain(__('abo.team_by_level')) ->toContain(__('abo.team_total_abos')) ->toContain(__('abo.team_growth')) ->toContain('aboGrowthChart') ->toContain($anna->email); }); it('berechnet das monatliche Abo-Wachstum je Jahr', function (): void { $root = makeUser(); $anna = makeUser($root->id); $lena = makeUser($root->id); // Juni 2026: 1 Eigen-Abo + 2 Kundenabos makeAbo($anna->id, $anna->id, 'me', '2026-06-05'); makeAbo($anna->id, $anna->id, 'ot', '2026-06-10'); makeAbo($lena->id, $lena->id, 'ot', '2026-06-20'); // März 2026: 1 Kundenabo makeAbo($anna->id, $anna->id, 'ot', '2026-03-15'); // Anderes Jahr: zählt nicht makeAbo($anna->id, $anna->id, 'ot', '2025-06-01'); $growth = app(TeamAboOverviewService::class)->monthlyGrowth($root, 2026); expect($growth['year'])->toBe(2026) ->and($growth['months'])->toHaveCount(12); $june = collect($growth['months'])->firstWhere('month', 6); $march = collect($growth['months'])->firstWhere('month', 3); expect($june['own_new'])->toBe(1) ->and($june['customer_new'])->toBe(2) ->and($june['total_new'])->toBe(3) ->and($march['customer_new'])->toBe(1) ->and($march['own_new'])->toBe(0); expect($growth['totals']['own_new'])->toBe(1) ->and($growth['totals']['customer_new'])->toBe(3) ->and($growth['totals']['total_new'])->toBe(4); }); it('liefert leichtgewichtige Dashboard-KPIs inkl. 1000-Punkte-Shop', function (): void { $root = makeUser(); $anna = makeUser($root->id); $lena = makeUser($root->id); // Abos makeAbo($anna->id, $anna->id, 'me', '2026-06-05'); // 1 Eigen-Abo makeAbo($anna->id, $anna->id, 'ot', '2026-06-10'); // 1 Kundenabo makeAbo($lena->id, $lena->id, 'ot', '2026-06-12'); // 1 Kundenabo // Anna erreicht 1000 Punkte Kundenumsatz im Juni 2026 UserSalesVolume::create([ 'user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'month_KP_points' => 600, 'month_shop_points' => 500, 'points' => 1100, 'status' => 1, ]); $kpis = app(TeamAboOverviewService::class)->teamKpis($root, 6, 2026); expect($kpis['team_partner_abos'])->toBe(1) ->and($kpis['team_customer_abos'])->toBe(2) ->and($kpis['team_total_abos'])->toBe(3) ->and($kpis['shop_1000'])->toBe(1); }); it('liefert die Abo-Zusammenfassung eines Team-Mitglieds inkl. Ausführungen', function (): void { $root = makeUser(); $anna = makeUser($root->id); $ownAbo = makeAbo($anna->id, $anna->id, 'me', '2026-06-05', 50); makeAbo($anna->id, $anna->id, 'ot', '2026-06-10', 20); makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 30); // Zwei ausgeführte (bezahlte) Lieferungen für das Eigen-Abo UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]); UserAboOrder::create(['user_abo_id' => $ownAbo->id, 'shopping_order_id' => 0, 'status' => 2, 'paid' => true]); $summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $anna->id); expect($summary)->not->toBeNull() ->and($summary['email'])->toBe($anna->email) ->and($summary['own_abo'])->not->toBeNull() ->and($summary['own_abo']['points'])->toBe(50.0) ->and($summary['own_abo']['executions'])->toBe(2) ->and($summary['customer_abos']['count'])->toBe(2) ->and($summary['customer_abos']['total_points'])->toBe(50.0) ->and($summary['customer_abos']['items'])->toHaveCount(2); }); it('liefert null, wenn das Mitglied nicht im Team liegt', function (): void { $root = makeUser(); $stranger = makeUser(); // kein Sponsor → nicht in der Downline $summary = app(TeamAboOverviewService::class)->memberAboSummary($root, $stranger->id); expect($summary)->toBeNull(); }); it('prognostiziert am letzten Liefertag noch die offenen Ausfuehrungen (A13)', function (): void { // 25.07., morgens: der Ausfuehrungsjob laeuft um 04:00 — vorher steht die // Lieferung noch aus und gehoert in die Prognose, nicht auf null. Carbon\Carbon::setTestNow('2026-07-25 01:00:00'); $root = makeUser(); $anna = makeUser($root->id); $abo = makeAbo($anna->id, $anna->id, 'ot', '2026-05-01', 40); $abo->update(['next_date' => '2026-07-25']); $expectedAt25 = app(TeamAboOverviewService::class)->linesOverview($root, 7, 2026)['totals']['abo_points_expected']; // Einen Tag spaeter ist das Fenster zu, die Prognose faellt auf das Ist zurueck. Carbon\Carbon::setTestNow('2026-07-26 01:00:00'); $overview = app(TeamAboOverviewService::class)->linesOverview($root, 7, 2026); $billedAt26 = $overview['totals']['abo_points_billed']; expect($expectedAt25)->toBeGreaterThan($billedAt26) ->and($overview['totals']['abo_points_expected'])->toBe($billedAt26); Carbon\Carbon::setTestNow(); });