hasColumn('products', 'slug')) { Schema::connection('sqlite')->table('products', function ($table) { $table->string('slug')->nullable(); }); } }); afterEach(function () { Carbon::setTestNow(); }); function pflMakeUser(?int $sponsorId = null, ?string $activeDate = '2026-01-10'): User { // active_date ist gesetzt wie in der Produktion: der Provisionsbaum nimmt nur // Teampartner auf, die bis zum Monatsende aktiv geworden sind // (BusinessUserItemOptimized::readParentsBusinessUsers()). return User::forceCreate([ 'email' => 'pfl-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_level' => 1, 'm_sponsor' => $sponsorId, 'payment_account' => now()->addYear(), 'active_date' => $activeDate, 'partner_paid_at' => $activeDate, ]); } function pflMakeAbo(int $userId, string $isFor, string $nextDate, int $points, int $status = 2): UserAbo { $abo = UserAbo::create([ 'user_id' => $userId, 'member_id' => $userId, 'shopping_user_id' => 0, 'email' => 'pfl-abo-'.uniqid('', true).'@example.com', 'is_for' => $isFor, 'payone_userid' => 0, 'clearingtype' => 'cc', 'amount' => 5000, 'active' => $status === 2, 'status' => $status, 'abo_interval' => (int) Carbon::parse($nextDate)->day, 'start_date' => '2026-01-05', 'last_date' => '2026-06-05', 'next_date' => $nextDate, ]); $product = Product::forceCreate([ 'name' => 'PFL-'.uniqid('', true), 'title' => 'Test', 'price' => 10.0, 'tax' => 19.0, 'active' => true, 'pos' => 1, 'weight' => 100, 'points' => $points, 'slug' => 'pfl-'.uniqid('', true), ]); UserAboItem::create([ 'user_abo_id' => $abo->id, 'product_id' => $product->id, 'comp' => 0, 'qty' => 1, 'status' => 1, ]); return $abo->fresh(); } function pflBookAboPoints(int $userId, int $orderId, float $points, string $createdAt, int $month = 7, int $year = 2026): UserSalesVolume { return UserSalesVolume::forceCreate([ 'user_id' => $userId, 'shopping_order_id' => $orderId, 'month' => $month, 'year' => $year, 'points' => $points, 'status' => 2, 'created_at' => $createdAt, ]); } function pflSeedContext(User $user, array $memberIds, int $paylines = 2, ?int $month = null, ?int $year = null): void { $month ??= (int) now()->month; $year ??= (int) now()->year; Cache::put( "payline_context_{$user->id}_{$month}_{$year}", ['paylines' => $paylines, 'member_ids' => $memberIds, 'is_career_fallback' => false], now()->addHour() ); } it('berechnet die Prognose kumuliert je Ausfuehrungstermin inklusive eigener Abos', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); $fremd = pflMakeUser(); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-05', 20); // Payline-Kundenabo zum 5. pflMakeAbo($root->id, 'me', '2026-07-10', 30); // eigenes Abo (F12: zaehlt mit) pflMakeAbo($anna->id, 'me', '2026-07-25', 10); // Payline-Beraterabo zum 25. pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline pflMakeAbo($anna->id, 'ot', '2026-08-05', 50); // naechster Monat, zaehlt nicht pflMakeAbo($anna->id, 'ot', '2026-07-20', 40, 3); // angehalten, zaehlt nicht $forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0); expect($forecast['base'])->toBe(100.0) ->and($forecast['days'])->toHaveCount(5) ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false, 'is_month_end' => false]) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0, 'total' => 150.0]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 50.0, 'total' => 150.0]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 60.0, 'total' => 160.0]) ->and($forecast['days'][4])->toMatchArray(['day' => 31, 'upcoming' => 60.0, 'total' => 160.0, 'is_month_end' => true]); }); it('nimmt Abos nach dem 25. in die Monatsende-Kachel auf (A1: freie Liefertage)', function (): void { Carbon::setTestNow(Carbon::parse('2026-07-02')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-25', 20); pflMakeAbo($anna->id, 'ot', '2026-07-28', 30); pflMakeAbo($anna->id, 'ot', '2026-07-31', 40); $forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0); expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0]) ->and($forecast['days'][4])->toMatchArray(['day' => 31, 'date' => '31.07.2026', 'upcoming' => 90.0, 'total' => 90.0, 'is_month_end' => true]); }); it('markiert vergangene Termine und zaehlt nur noch offene Ausfuehrungen', function (): void { Carbon::setTestNow(Carbon::parse('2026-07-22')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-25', 20); // noch offen pflMakeAbo($anna->id, 'me', '2026-08-05', 30); // bereits fortgeschrieben (naechster Monat) $forecast = app(PaylineForecastService::class)->forecastDays($root, 100.0); expect($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'is_past' => true]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 0.0, 'is_past' => true]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 20.0, 'total' => 120.0, 'is_past' => false]); }); it('zaehlt eine heute faellige, noch nicht ausgefuehrte Lieferung mit (A13)', function (): void { // 10.07., 00:01 Uhr: der Ausfuehrungsjob laeuft erst um 04:00. Bis dahin ist // das Abo weder im Ist gebucht noch — bisher — in der Prognose enthalten. Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-10', 50); $forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0); expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 50.0]); }); it('zaehlt eine heute bereits ausgefuehrte Lieferung nicht doppelt (A13)', function (): void { Carbon::setTestNow(Carbon::parse('2026-07-10 06:00:00')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id]); // Der Job hat die Ausfuehrung angelegt; die Punkte stecken damit im Ist. $abo = pflMakeAbo($anna->id, 'ot', '2026-07-10', 50); $abo->user_abo_orders()->create(['shopping_order_id' => 0, 'status' => 1, 'paid' => true]); $forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0); expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 0.0]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]); }); it('zaehlt eine gestern faellige Lieferung nicht mehr mit (A13)', function (): void { // Nur der heutige Liefertag ist offen; ein liegengebliebener Vortag gehoert // in die Nachbearbeitung, nicht in die Prognose. Carbon::setTestNow(Carbon::parse('2026-07-11 09:00:00')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'ot', '2026-07-10', 50); $forecast = app(PaylineForecastService::class)->forecastDays($root, 0.0); expect($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 0.0]); }); it('zaehlt den heutigen Liefertag auch in der persoenlichen Vorschau (A13)', function (): void { Carbon::setTestNow(Carbon::parse('2026-07-10 00:01:00')); $berater = pflMakeUser(); pflMakeAbo($berater->id, 'ot', '2026-07-10', 50); $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); expect($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 50.0]); }); it('liefert Soll und Ist des Payline-Abo-Umsatzes', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); $fremd = pflMakeUser(); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'me', '2026-07-10', 20); // Soll 20 pflMakeAbo($anna->id, 'ot', '2026-07-25', 30); // Soll 30 pflMakeAbo($anna->id, 'ot', '2026-07-25', 40, 4); // gekuendigt, kein Soll pflMakeAbo($fremd->id, 'me', '2026-07-10', 99); // ausserhalb der Payline $aboOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $anna->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); $shopOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $anna->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => false, 'paid' => true, ]); UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => 25, 'status' => 2]); UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $shopOrder->id, 'month' => 7, 'year' => 2026, 'points' => 50, 'status' => 2]); // kein Abo UserSalesVolume::forceCreate(['user_id' => $anna->id, 'shopping_order_id' => $aboOrder->id, 'month' => 6, 'year' => 2026, 'points' => 60, 'status' => 2]); // anderer Monat $turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026); expect($turnover['paylines'])->toBe(2) ->and($turnover['members'])->toBe(1) ->and($turnover['target'])->toBe(50.0) ->and($turnover['actual'])->toBe(25.0) ->and($turnover['team_abo_count'])->toBe(1) // nur Annas Eigenabo, nicht der Fremde ->and($turnover['team_customer_abo_count'])->toBe(1); // gekuendigtes Kundenabo zaehlt nicht }); it('liefert leere Kennzahlen ohne Payline, Prognose enthaelt aber eigene Abos', function (): void { $root = pflMakeUser(); pflSeedContext($root, [], 0); pflMakeAbo($root->id, 'me', '2026-07-10', 30); $turnover = app(PaylineForecastService::class)->aboTurnover($root, 7, 2026); $forecast = app(PaylineForecastService::class)->forecastDays($root, 10.0); expect($turnover['target'])->toBe(0.0) ->and($turnover['actual'])->toBe(0.0) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 40.0]); }); it('ermittelt den Payline-Kontext aus dem UserBusiness-Level und den Linien', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); // Linie 1 $ben = pflMakeUser($anna->id); // Linie 2 UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'qual_user_level' => ['paylines' => 1], ]); $context = app(PaylineForecastService::class)->paylineContext($root, 7, 2026); expect($context['paylines'])->toBe(1) ->and($context['member_ids'])->toBe([$anna->id]) ->and($context['member_ids'])->not->toContain($ben->id); }); it('nimmt die Payline-Mitglieder aus dem Provisionsbaum wie die Team-Statistik (A07)', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); // Linie 1, im Provisionsbaum $carl = pflMakeUser($root->id); // Linie 1, ohne Abrechnungskonto $carl->forceFill(['payment_account' => null])->save(); $dora = pflMakeUser($carl->id); // Linie 2 unter Carl: gekappt $emil = pflMakeUser($anna->id); // Linie 2 unter Anna: zaehlt UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'qual_user_level' => ['paylines' => 2], ]); pflMakeAbo($dora->id, 'me', '2026-07-20', 50); pflMakeAbo($emil->id, 'me', '2026-07-20', 30); $service = app(PaylineForecastService::class); $context = $service->paylineContext($root, 7, 2026); $turnover = $service->aboTurnover($root, 7, 2026); expect($context['member_ids'])->toEqualCanonicalizing([$anna->id, $emil->id]) ->and($context['member_ids'])->not->toContain($carl->id) ->and($context['member_ids'])->not->toContain($dora->id) ->and($turnover['team_abo_count'])->toBe(1) ->and($turnover['target'])->toBe(30.0); }); it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): void { URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name); $html = view('dashboard._statistics', [ 'monthlyStats' => [ 'month' => 7, 'year' => 2026, 'customer_points' => 100.0, 'team_payline_points' => 400.0, 'payline_is_live' => true, 'payline_forecast' => [ 'base' => 500.0, 'days' => [ ['day' => 5, 'date' => '05.07.2026', 'upcoming' => 0.0, 'total' => 500.0, 'is_past' => true], ['day' => 10, 'date' => '10.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false], ['day' => 20, 'date' => '20.07.2026', 'upcoming' => 30.0, 'total' => 530.0, 'is_past' => false], ['day' => 25, 'date' => '25.07.2026', 'upcoming' => 60.0, 'total' => 560.0, 'is_past' => false], ], ], 'direct_new_partners' => 0, 'team_total_abos' => 0, 'team_partner_abos' => 0, 'team_partner_abos_new' => 0, 'team_customer_abos' => 0, 'team_customer_abos_new' => 0, 'shop_1000' => 0, 'new_partners' => 0, 'own_abos' => 0, 'own_abos_new' => 0, 'own_customer_abos' => 0, 'own_customer_abos_new' => 0, ], ])->render(); expect($html) ->toContain(e(__('home.payline_forecast_title'))) ->toContain(__('home.forecast_day', ['date' => '25.07.2026'])) ->toContain('560') ->toContain(__('home.forecast_done')); }); it('zaehlt neue Abos der Payline-Mitglieder im Monat', function (): void { $root = pflMakeUser(); $anna = pflMakeUser($root->id); $fremd = pflMakeUser(); pflSeedContext($root, [$anna->id]); pflMakeAbo($anna->id, 'me', '2026-07-10', 20)->update(['start_date' => '2026-07-03']); // neu im Juli pflMakeAbo($anna->id, 'ot', '2026-07-25', 30)->update(['start_date' => '2026-07-05']); // neu im Juli pflMakeAbo($anna->id, 'ot', '2026-07-25', 40)->update(['start_date' => '2026-06-10']); // Bestand, nicht neu pflMakeAbo($fremd->id, 'me', '2026-07-10', 99)->update(['start_date' => '2026-07-04']); // ausserhalb Payline expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2); }); it('prognostiziert Level und Provision zum letzten Liefertag', function (): void { // Test-Schema hinkt der Produktions-Tabelle hinterher (bekannte Stolperfalle) foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); }); } } $root = pflMakeUser(); $anna = pflMakeUser($root->id); // Ebene 1 pflSeedContext($root, [$anna->id]); $levelBase = [ 'qual_kp' => 0, 'growth_bonus' => 0, 'pr_line_1' => 7, 'pr_line_2' => 5, 'pr_line_3' => 0, 'pr_line_4' => 0, 'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0, ]; UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'payline_points' => 800.0, 'payline_points_qual_kp' => 800.0, 'sales_volume_points_KP_sum' => 500.0, 'commission_pp_total' => 0, 'commission_shop_sales' => 0, 'commission_growth_total' => 0, 'qual_user_level' => array_merge($levelBase, ['name' => 'Gold', 'paylines' => 2, 'qual_pp' => 500]), 'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 2000, 'qual_kp' => 600]), ]); // Heutiger TP-Laufstand von Anna (Ebene 1): 800 Punkte UserSalesVolume::forceCreate([ 'user_id' => $anna->id, 'month' => 7, 'year' => 2026, 'points' => 800, 'status' => 1, 'month_TP_points' => 800, 'month_shop_points' => 0, ]); // Offene Ausfuehrungen bis zum 25.: Anna +100 (Ebene 1), eigenes Kundenabo +50 (persoenlich) pflMakeAbo($anna->id, 'ot', '2026-07-20', 100); pflMakeAbo($root->id, 'ot', '2026-07-10', 50); $forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026); // Persoenlich: 500 + 50 = 550 -> zu Diamant (600) fehlen 50 // Payline: Ebene 1 = 800 + 100 = 900, kein KP-Ueberschuss -> zu 2000 fehlen 1100 // Provision (Level Gold, da nicht erreicht): 900 x 7 % = 63 expect($forecast)->not->toBeNull() ->and($forecast['next_name'])->toBe('Diamant') ->and($forecast['reached'])->toBeFalse() ->and($forecast['missing_pp'])->toBe(1100.0) ->and($forecast['missing_personal'])->toBe(50.0) ->and($forecast['personal_forecast'])->toBe(550.0) ->and($forecast['estimated_level_name'])->toBe('Gold') ->and($forecast['estimated_commission'])->toBe(63.0); }); it('haelt das "+N neu"-Badge der Payline-Kachel fuer abgeschlossene Monate stabil', function (): void { // Regression: das Badge zaehlte ueber activeAbo() (heutiger Stand), der // Bestand daneben ueber die Monatsend-Rueckrechnung. Ein im Juli gestartetes // und im August beendetes Abo fiel damit aus der Juli-Zahl heraus. Carbon::setTestNow(Carbon::parse('2026-08-15')); $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflSeedContext($root, [$anna->id], month: 7, year: 2026); pflMakeAbo($anna->id, 'ot', '2026-08-25', 30) ->update(['start_date' => '2026-07-05', 'active' => false, 'cancel_date' => '2026-08-05']); pflMakeAbo($anna->id, 'me', '2026-08-10', 20)->update(['start_date' => '2026-07-03']); // Im August gestartet: gehoert nicht in die Juli-Zahl. pflMakeAbo($anna->id, 'ot', '2026-08-20', 15)->update(['start_date' => '2026-08-02']); expect(app(PaylineForecastService::class)->newPaylineAboCount($root, 7, 2026))->toBe(2); }); it('rechnet die Prognose ueber denselben Baum wie der Provisionslauf', function (): void { // lineBuckets() nimmt jeden Downline-User, der TreeCalcBot filtert und kappt // den Unterbaum darunter. Ohne denselben Filter zaehlte die Prognose Punkte // mit, die in den payline_points der Level-Kachel gar nicht stecken — // zwei verschiedene "fehlen noch X Punkte" auf einer Seite. foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); }); } } $root = pflMakeUser(); // Ebene 1 ohne Abrechnungskonto: steht nicht im Provisionsbaum ... $inaktiv = pflMakeUser($root->id); $inaktiv->forceFill(['payment_account' => null])->save(); // ... und damit auch nicht sein Teampartner auf Ebene 2. $unterInaktiv = pflMakeUser($inaktiv->id); // Ebene 1, erst nach dem Monatsende aktiv geworden: ebenfalls draussen. pflMakeUser($root->id, '2026-08-15'); $anna = pflMakeUser($root->id); // zaehlt $levelBase = [ 'qual_kp' => 0, 'growth_bonus' => 0, 'pr_line_1' => 10, 'pr_line_2' => 0, 'pr_line_3' => 0, 'pr_line_4' => 0, 'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0, ]; UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'payline_points' => 100.0, 'payline_points_qual_kp' => 100.0, 'sales_volume_points_KP_sum' => 0.0, 'commission_pp_total' => 0, 'commission_shop_sales' => 0, 'commission_growth_total' => 0, 'qual_user_level' => array_merge($levelBase, ['name' => 'Gold', 'paylines' => 2, 'qual_pp' => 0]), 'qual_user_level_next' => array_merge($levelBase, ['name' => 'Diamant', 'paylines' => 2, 'qual_pp' => 1000, 'qual_kp' => 0]), ]); foreach ([$inaktiv->id => 500, $unterInaktiv->id => 300, $anna->id => 100] as $userId => $points) { UserSalesVolume::forceCreate([ 'user_id' => $userId, 'month' => 7, 'year' => 2026, 'points' => $points, 'status' => 1, 'month_TP_points' => $points, 'month_shop_points' => 0, ]); } $forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026); // Nur Annas 100 Punkte zaehlen -> zu 1000 fehlen 900 (nicht 1000 - 900 = 100). expect($forecast['missing_pp'])->toBe(900.0) ->and($forecast['estimated_commission'])->toBe(10.0); }); it('prognostiziert die Liefertage ausschliesslich aus den eigenen Kundenabos', function (): void { // Kundenwunsch 2026-09-07: "wieviel Volumenpunkte habe ich am 5./10./20./25. // Nur mit meinen Kundenabos" — ohne eigenes Abo, ohne Team. $root = pflMakeUser(); $anna = pflMakeUser($root->id); pflMakeAbo($root->id, 'ot', '2026-07-10', 30); // eigenes Kundenabo pflMakeAbo($root->id, 'ot', '2026-07-25', 45); // eigenes Kundenabo pflMakeAbo($root->id, 'me', '2026-07-10', 99); // eigenes Abo zaehlt NICHT pflMakeAbo($anna->id, 'ot', '2026-07-05', 77); // Team zaehlt NICHT pflMakeAbo($root->id, 'ot', '2026-08-05', 50); // naechster Monat zaehlt nicht $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($root); expect($forecast['base'])->toBe(0.0) ->and($forecast['days'])->toHaveCount(5) ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'upcoming' => 0.0, 'total' => 0.0]) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'upcoming' => 30.0, 'total' => 30.0]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'upcoming' => 30.0, 'total' => 30.0]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'upcoming' => 75.0, 'total' => 75.0]); }); it('rechnet die bereits abgerechneten Kundenabo-Punkte taggenau in die persoenliche Vorschau ein', function (): void { // Die Kacheln beantworten "wieviel Volumenpunkte habe ich am 5./10./20./25.". // Jeder Termin zeigt den Stand AN DIESEM TAG, sonst enthaelt die Kachel zum 5. // auch schon die Lieferungen vom 10. und laesst sich nicht nachrechnen. Carbon::setTestNow(Carbon::parse('2026-07-11')); $berater = pflMakeUser(); $aboOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); $shopOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => false, 'paid' => true, ]); pflBookAboPoints($berater->id, $aboOrder->id, 40, '2026-07-05 04:10:00'); pflBookAboPoints($berater->id, $aboOrder->id, 60, '2026-07-10 04:10:00'); // Einzelbestellung eines Kunden: kein Abo, gehoert nicht in diese Kachel. pflBookAboPoints($berater->id, $shopOrder->id, 500, '2026-07-10 04:10:00'); // Anderer Monat. pflBookAboPoints($berater->id, $aboOrder->id, 900, '2026-06-10 04:10:00', 6, 2026); // Noch offen zum 20. pflMakeAbo($berater->id, 'ot', '2026-07-20', 30); $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); expect($forecast['base'])->toBe(100.0) ->and($forecast['days'][0])->toMatchArray(['day' => 5, 'billed' => 40.0, 'upcoming' => 0.0, 'total' => 40.0]) ->and($forecast['days'][1])->toMatchArray(['day' => 10, 'billed' => 100.0, 'upcoming' => 0.0, 'total' => 100.0]) ->and($forecast['days'][2])->toMatchArray(['day' => 20, 'billed' => 100.0, 'upcoming' => 30.0, 'total' => 130.0]) ->and($forecast['days'][3])->toMatchArray(['day' => 25, 'billed' => 100.0, 'upcoming' => 30.0, 'total' => 130.0]); }); it('haengt Buchungen nach dem letzten Liefertag an die letzte Kachel', function (): void { // Nachzuegler und manuelle Gutschriften duerfen nicht aus der Monatssumme // fallen — sonst zeigt die letzte Kachel weniger als den Monatsstand. Carbon::setTestNow(Carbon::parse('2026-07-29')); $berater = pflMakeUser(); $aboOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); pflBookAboPoints($berater->id, $aboOrder->id, 10, '2026-07-05 04:10:00'); pflBookAboPoints($berater->id, $aboOrder->id, 25, '2026-07-28 11:00:00'); $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); expect($forecast['days'][0]['total'])->toBe(10.0) ->and($forecast['days'][3]['total'])->toBe(10.0) ->and($forecast['days'][4]['total'])->toBe(35.0) ->and($forecast['base'])->toBe(35.0); }); it('zieht ein Storno von der Basis der persoenlichen Vorschau ab', function (): void { Carbon::setTestNow(Carbon::parse('2026-07-11')); $berater = pflMakeUser(); $aboOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); pflBookAboPoints($berater->id, $aboOrder->id, 100, '2026-07-05 04:10:00'); UserSalesVolume::forceCreate([ 'user_id' => $berater->id, 'shopping_order_id' => $aboOrder->id, 'month' => 7, 'year' => 2026, 'points' => -40, 'status' => 6, 'storno_origin_status' => 2, 'created_at' => '2026-07-08 09:00:00', ]); $forecast = app(PaylineForecastService::class)->personalCustomerAboForecast($berater); expect($forecast['base'])->toBe(60.0) ->and($forecast['days'][1]['billed'])->toBe(60.0); }); it('weist die abgerechneten Punkte je Abo aus, inklusive Einmal-Artikel', function (): void { // Die Liste unter der Vorschau muss auf dieselbe Summe kommen. Einmal-Artikel // werden nach der Ausfuehrung aus dem Abo entfernt, stehen also nicht mehr im // wiederkehrenden Aboinhalt — gebucht wurden sie trotzdem. Carbon::setTestNow(Carbon::parse('2026-07-11')); $berater = pflMakeUser(); $abo = pflMakeAbo($berater->id, 'ot', '2026-08-05', 37); $aboOrder = ShoppingOrder::forceCreate([ 'shopping_user_id' => 0, 'auth_user_id' => $berater->id, 'country_id' => 0, 'user_shop_id' => 0, 'is_abo' => true, 'paid' => true, ]); $abo->user_abo_orders()->create(['shopping_order_id' => $aboOrder->id, 'status' => 1, 'paid' => true]); // Geliefert wurden 80: 37 wiederkehrend + 43 einmalig. pflBookAboPoints($berater->id, $aboOrder->id, 80, '2026-07-05 04:10:00'); $svc = app(PaylineForecastService::class); $byAbo = $svc->billedCustomerAboPointsByAbo($berater, 7, 2026); expect($byAbo[$abo->id])->toBe(80.0) ->and($byAbo->sum())->toBe(80.0) ->and($svc->billedCustomerAboPoints($berater, 7, 2026))->toBe(80.0); }); it('prognostiziert auch ohne bereits erreichte Qualifikationsstufe', function (): void { // A08: Wer im Monat noch keine Stufe qualifiziert hat, kann die erste durch // kommende eigene Kundenabo-Punkte trotzdem erreichen. Vorher lieferte die // Prognose in diesem Zustand gar nichts. foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); }); } } $root = pflMakeUser(); pflSeedContext($root, []); UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'payline_points' => 0.0, 'payline_points_qual_kp' => 0.0, 'sales_volume_points_KP_sum' => 0.0, 'commission_pp_total' => 0, 'commission_shop_sales' => 0, 'commission_growth_total' => 0, 'qual_user_level' => null, // noch keine Stufe erreicht 'qual_user_level_next' => [ 'name' => 'Start', 'paylines' => 1, 'qual_pp' => 0, 'qual_kp' => 50, 'growth_bonus' => 0, 'pr_line_1' => 7, 'pr_line_2' => 0, 'pr_line_3' => 0, 'pr_line_4' => 0, 'pr_line_5' => 0, 'pr_line_6' => 0, 'pr_line_7' => 0, 'pr_line_8' => 0, ], ]); // Eigenes Kundenabo mit 50 Punkten, faellig vor Monatsende pflMakeAbo($root->id, 'ot', '2026-07-20', 50); $forecast = app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026); expect($forecast)->not->toBeNull() ->and($forecast['next_name'])->toBe('Start') ->and($forecast['personal_forecast'])->toBe(50.0) ->and($forecast['missing_personal'])->toBe(0.0) ->and($forecast['reached'])->toBeTrue() ->and($forecast['estimated_level_name'])->toBe('Start'); }); it('liefert ohne erreichte und ohne naechste Stufe weiterhin keine Prognose', function (): void { foreach (['sales_volume_points_KP_sum' => 'float', 'payline_points_qual_kp' => 'float', 'qual_user_level_next' => 'text'] as $column => $type) { if (! Schema::connection('sqlite')->hasColumn('user_businesses', $column)) { Schema::connection('sqlite')->table('user_businesses', function ($table) use ($column, $type) { $type === 'float' ? $table->float($column)->nullable() : $table->text($column)->nullable(); }); } } $root = pflMakeUser(); pflSeedContext($root, []); UserBusiness::forceCreate([ 'user_id' => $root->id, 'month' => 7, 'year' => 2026, 'version' => 1, 'payline_points' => 0.0, 'sales_volume_points_KP_sum' => 0.0, 'qual_user_level' => null, 'qual_user_level_next' => null, ]); expect(app(PaylineForecastService::class)->levelAndCommissionForecast($root, 7, 2026))->toBeNull(); });