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>
This commit is contained in:
Kevin 2026-08-21 10:44:44 +00:00
parent 517d010d58
commit d65922adfe
88 changed files with 4480 additions and 425 deletions

View file

@ -0,0 +1,151 @@
<?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');
});

View file

@ -382,13 +382,16 @@ it('erfasst die Herkunft bei Kundenbestellungen im Shop-Checkout', function () {
$checkoutRepository = file_get_contents(app_path('Repositories/CheckoutRepository.php'));
$orderDetail = file_get_contents(resource_path('views/admin/sales/_detail.blade.php'));
expect($checkoutView)->toContain('customer_order_source');
// Kundenentscheid F6: nur noch Freitext-Pflichtfeld, kein Dropdown mehr
expect($checkoutView)->toContain('customer_order_source_comment');
expect($checkoutView)->toContain('Wie bist du auf uns aufmerksam geworden?');
expect($checkoutView)->not->toContain('name="customer_order_source"');
expect($checkoutController)->toContain("Request::get('is_from') === 'shopping'");
expect($checkoutController)->toContain('customer_order_source');
expect($checkoutController)->toContain("\$rules['customer_order_source_comment'] = 'required|string|max:500'");
expect($checkoutRepository)->toContain('$shopping_user->is_from === \'shopping\'');
expect($checkoutRepository)->toContain('customer_order_source_comment');
expect($orderDetail)->toContain('getCustomerOrderSourceLabel');
expect($orderDetail)->toContain('customer_order_source_comment');
$shoppingOrder = (new ShoppingOrder)->forceFill([
'customer_order_source' => 'social_media',

View file

@ -223,6 +223,7 @@ it('zaehlt den Berater selbst bei den Dashboard-KPIs zum 1000-Punkte-Shop mit',
$partner = boMakeUser($root->id);
boMakeSalesVolume($partner->id, 6, 2026, 1300, 2);
app(BackofficeDashboardService::class)->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert
$kpis = app(TeamAboOverviewService::class)->teamKpis($root->fresh(), 6, 2026);
expect($kpis['shop_1000'])->toBe(2);
});

View file

@ -7,6 +7,7 @@ use App\Models\UserAboItem;
use App\Models\UserLevel;
use App\Models\UserSalesVolume;
use App\Services\Backoffice\BackofficeDashboardService;
use App\Services\Backoffice\BackofficeDrilldownService;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -31,7 +32,7 @@ function bslMakeLevel(string $name, int $pos): UserLevel
]);
}
function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User
function bslMakeUser(?int $sponsorId = null, ?int $levelId = null, ?string $activeDate = null): User
{
return User::forceCreate([
'email' => 'bsl-'.uniqid('', true).'@example.com',
@ -39,7 +40,7 @@ function bslMakeUser(?int $sponsorId = null, ?int $levelId = null): User
'lang' => 'de',
'm_level' => $levelId ?? 1,
'm_sponsor' => $sponsorId,
'active_date' => '2026-01-15',
'active_date' => $activeDate ?? '2026-01-15',
'payment_account' => now()->addYear(),
]);
}
@ -288,6 +289,8 @@ it('ergaenzt beim Snapshot-Overlay Linien, die erst nach dem Snapshot entstanden
bslMakeAbo($ben->id, 'me', '2026-07-01');
bslMakeAbo($ben->id, 'ot', '2026-07-02');
$service->flushLineBucketsMemo(); // Struktur hat sich im selben Prozess geändert
$overview = $service->overview($root, 5, 2026);
expect($overview['_meta']['source'])->toBe('snapshot')
@ -433,3 +436,103 @@ it('ignoriert Abo-Punkte geloeschter Produkte im Soll der Top-10-Umsatzliste', f
->and($byPoints[0]['user_id'])->toBe($anna->id)
->and($byPoints[0]['points_target'])->toBe(40.0);
});
it('rankt Recruiter des Monats nach gesponserten Neupartnern inklusive ich', function (): void {
$root = bslMakeUser(); // active_date Januar — selbst kein Neupartner
$anna = bslMakeUser($root->id, null, '2026-06-05');
$ben = bslMakeUser($root->id, null, '2026-06-10');
$carla = bslMakeUser($anna->id, null, '2026-06-12'); // von Anna im selben Monat geworben
bslMakeUser($anna->id, null, '2026-05-12'); // Mai, zaehlt nicht
bslMakeUser($root->id); // Januar-Bestand, zaehlt nicht
// abgelaufener Account: zaehlt nicht als Neupartner
$expired = bslMakeUser($root->id, null, '2026-06-20');
$expired->forceFill(['payment_account' => now()->subDay()])->save();
$ranking = app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'];
expect($ranking)->toHaveCount(2)
->and($ranking[0]['user_id'])->toBe($root->id)
->and($ranking[0]['is_self'])->toBeTrue()
->and($ranking[0]['line'])->toBe(0)
->and($ranking[0]['new_partner_count'])->toBe(2)
->and(collect($ranking[0]['partners'])->pluck('user_id')->sort()->values()->all())
->toBe(collect([$anna->id, $ben->id])->sort()->values()->all())
->and($ranking[1]['user_id'])->toBe($anna->id)
->and($ranking[1]['is_self'])->toBeFalse()
->and($ranking[1]['line'])->toBe(1)
->and($ranking[1]['new_partner_count'])->toBe(1)
->and($ranking[1]['partners'][0]['user_id'])->toBe($carla->id);
// Carla erscheint in Roots Detailliste eingerueckt unter Anna
$annaEntry = collect($ranking[0]['partners'])->firstWhere('user_id', $anna->id);
expect($annaEntry['new_partner_count'])->toBe(1)
->and($annaEntry['children'][0]['user_id'])->toBe($carla->id);
});
it('summiert das Recruiter-Volumen aus Eigen- und Kundenpunkten inklusive Kettenvolumen', function (): void {
$root = bslMakeUser();
$anna = bslMakeUser($root->id, null, '2026-06-05'); // Neupartner von Root
$ben = bslMakeUser($anna->id, null, '2026-06-15'); // Neupartner von Anna (Kette)
// Anna: Starterpaket (Status 1, Eigenpunkte) + Kundenumsatz (Status 2)
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'month' => 6,
'year' => 2026,
'points' => 400,
'status' => 1,
]);
UserSalesVolume::forceCreate([
'user_id' => $anna->id,
'month' => 6,
'year' => 2026,
'points' => 100,
'status' => 2,
]);
// Ben: nur Starterpaket
UserSalesVolume::forceCreate([
'user_id' => $ben->id,
'month' => 6,
'year' => 2026,
'points' => 200,
'status' => 1,
]);
$ranking = collect(app(BackofficeDashboardService::class)->overview($root, 6, 2026)['recruiter_ranking'])->keyBy('user_id');
// Root: Annas 500 (400 eigene + 100 Kunden) + rekursiv Bens 200
expect($ranking->get($root->id)['volume_points'])->toBe(700.0)
->and($ranking->get($root->id)['partners'][0]['total_points'])->toBe(500.0)
->and($ranking->get($root->id)['partners'][0]['children'][0]['total_points'])->toBe(200.0)
// Anna selbst: nur Bens Punkte
->and($ranking->get($anna->id)['volume_points'])->toBe(200.0);
});
it('liefert im Neupartner-Drilldown zusaetzlich die Spalte Punkte gesamt', function (): void {
$root = bslMakeUser();
$partner = bslMakeUser($root->id, null, '2026-06-15');
UserSalesVolume::forceCreate([
'user_id' => $partner->id,
'month' => 6,
'year' => 2026,
'points' => 100,
'status' => 1, // Starterpaket / Eigenpunkte
]);
UserSalesVolume::forceCreate([
'user_id' => $partner->id,
'month' => 6,
'year' => 2026,
'points' => 250,
'status' => 2, // Kundenpunkte
]);
$details = app(BackofficeDrilldownService::class)->details($root, 0, 'new_partners', 6, 2026);
expect($details['rows'])->toHaveCount(1)
->and($details['rows'][0]['customer_points'])->toBe(250.0)
->and($details['rows'][0]['total_points'])->toBe(350.0)
->and($details['summary']['customer_points'])->toBe(250.0)
->and($details['summary']['total_points'])->toBe(350.0);
});

View file

@ -0,0 +1,72 @@
<?php
use App\Http\Controllers\Web\CheckoutController;
use Illuminate\Validation\Validator;
use Tests\TestCase;
uses(TestCase::class);
/**
* Ruft die private Checkout-Validierung mit dem gegebenen Request-Input auf.
* (route() ist wegen Domain-Routing im Test nicht verfügbar, daher direkt.)
*/
function cosvValidateCheckout(array $input): Validator
{
request()->replace($input);
$method = new ReflectionMethod(CheckoutController::class, 'validateCheckoutData');
return $method->invoke(app(CheckoutController::class));
}
/**
* @return array<string, string>
*/
function cosvBaseCheckoutInput(array $overrides = []): array
{
return array_merge([
'is_from' => 'shopping',
'billing_salutation' => 'Frau',
'billing_firstname' => 'Erika',
'billing_lastname' => 'Muster',
'billing_email' => 'erika@example.com',
'billing_address' => 'Musterweg 1',
'billing_zipcode' => '12345',
'billing_city' => 'Berlin',
'accepted_data_checkbox' => '1',
], $overrides);
}
it('verlangt den Herkunfts-Freitext bei Shop-Kundenbestellungen', function () {
$validator = cosvValidateCheckout(cosvBaseCheckoutInput());
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue();
expect($validator->errors()->first('customer_order_source_comment'))->toContain('aufmerksam geworden');
});
it('akzeptiert den Checkout ohne das frühere Herkunfts-Dropdown', function () {
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
'customer_order_source_comment' => 'Empfehlung von Maria Mustermann',
]));
expect($validator->fails())->toBeFalse();
expect($validator->errors()->has('customer_order_source'))->toBeFalse();
});
it('begrenzt den Herkunfts-Freitext auf 500 Zeichen', function () {
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
'customer_order_source_comment' => str_repeat('a', 501),
]));
expect($validator->fails())->toBeTrue();
expect($validator->errors()->has('customer_order_source_comment'))->toBeTrue();
});
it('verlangt keinen Herkunfts-Freitext außerhalb des Shop-Checkouts', function () {
$validator = cosvValidateCheckout(cosvBaseCheckoutInput([
'is_from' => 'user',
]));
expect($validator->fails())->toBeFalse();
});

View file

@ -11,6 +11,7 @@ use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserSalesVolume;
use App\Models\UserShop;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
@ -188,6 +189,25 @@ it('trackNewPartner zaehlt Bestellungen am letzten Tag des Qualifikationszeitrau
expect(IncentiveNewPartner::where('user_id', $partner->id)->exists())->toBeTrue();
});
it('syncMissingTrackingPartners zaehlt Bestellungen am letzten Tag des Qualifikationszeitraums (Randtag-Fall)', function () {
Carbon::setTestNow(Carbon::parse('2026-08-01 04:00:00'));
$incentive = repairTestCreateIncentive();
$sponsor = repairTestCreateUser();
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $sponsor->id,
]);
$partner = repairTestCreateUser($sponsor->id);
repairTestCreateStarterKitOrder($partner, $sponsor, '2026-07-31 23:15:00');
$added = app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant);
expect($added)->toBe(1)
->and(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue();
});
it('removeStaleSponsorTrackings verschiebt Partner nach Sponsorwechsel zum aktuellen Sponsor', function () {
$incentive = repairTestCreateIncentive();
@ -246,6 +266,145 @@ it('removeStaleSponsorTrackings verschiebt Partner nach Sponsorwechsel zum aktue
expect((int) $participantNew->fresh()->qualified_partners)->toBe(1);
});
it('removeStaleSponsorTrackings behaelt Tracking und Punkte-Logs bei leerem Sponsor (m_sponsor = NULL)', function () {
$incentive = repairTestCreateIncentive();
$sponsor = repairTestCreateUser();
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $sponsor->id,
]);
$partner = repairTestCreateUser($sponsor->id);
$tracking = IncentiveNewPartner::create([
'participant_id' => $participant->id,
'user_id' => $partner->id,
'registered_at' => Carbon::parse('2026-05-10 09:00:00'),
]);
IncentivePointsLog::create([
'participant_id' => $participant->id,
'type' => 'partner',
'source_type' => User::class,
'source_id' => $partner->id,
'source_label' => 'Partner',
'month' => 5,
'year' => 2026,
'points_onetime' => 600,
'points_accumulated' => 0,
'incentive_new_partner_id' => $tracking->id,
]);
// Admin-Umbau in Arbeit: Sponsor temporaer entfernt — kein Sponsorwechsel
$partner->m_sponsor = null;
$partner->save();
$removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant);
expect($removed)->toBe(0)
->and(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue()
->and(IncentivePointsLog::where('participant_id', $participant->id)->count())->toBe(1);
$participant->recalculateFromTrackingTables()->save();
expect((int) $participant->fresh()->total_points)->toBe(600);
});
it('removeStaleSponsorTrackings behaelt Trackings geloeschter User (wie bisher)', function () {
$incentive = repairTestCreateIncentive();
$sponsor = repairTestCreateUser();
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $sponsor->id,
]);
$partner = repairTestCreateUser($sponsor->id);
IncentiveNewPartner::create([
'participant_id' => $participant->id,
'user_id' => $partner->id,
'registered_at' => now(),
]);
$partner->delete();
$removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant);
expect($removed)->toBe(0)
->and(IncentiveNewPartner::where('participant_id', $participant->id)->exists())->toBeTrue();
});
it('incentive:calculate repariert FKs vor der Stale-Bereinigung — keine Waisen-Logs beim alten Sponsor nach Umhaengen', function () {
$incentive = repairTestCreateIncentive();
$oldSponsor = repairTestCreateUser();
$newSponsor = repairTestCreateUser();
$participantOld = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $oldSponsor->id,
]);
$participantNew = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $newSponsor->id,
]);
// Partner wurde unter dem alten Sponsor getrackt, haengt inzwischen unter dem neuen
$partner = repairTestCreateUser($newSponsor->id);
repairTestCreateStarterKitOrder($partner, $newSponsor, '2026-05-10 09:00:00');
$staleTracking = IncentiveNewPartner::create([
'participant_id' => $participantOld->id,
'user_id' => $partner->id,
'registered_at' => Carbon::parse('2026-05-10 09:00:00'),
]);
IncentivePointsLog::create([
'participant_id' => $participantOld->id,
'type' => 'partner',
'source_type' => User::class,
'source_id' => $partner->id,
'source_label' => 'Partner',
'month' => 5,
'year' => 2026,
'points_onetime' => 600,
'points_accumulated' => 0,
'incentive_new_partner_id' => $staleTracking->id,
]);
// Umsatz-Log OHNE FK (Altbestand): ueberlebte frueher die Stale-Bereinigung als Waise
$sv = UserSalesVolume::create([
'user_id' => $partner->id,
'month' => 5,
'year' => 2026,
'date' => '12.05.2026',
'points' => 25,
'total_net' => 90,
'status_points' => 1,
'status' => 2,
'message' => 'SV 5/2026',
]);
IncentivePointsLog::create([
'participant_id' => $participantOld->id,
'type' => 'partner',
'source_type' => UserSalesVolume::class,
'source_id' => $sv->id,
'source_label' => 'SV 5/2026',
'month' => 5,
'year' => 2026,
'points_onetime' => 0,
'points_accumulated' => 25,
'user_sales_volume_id' => $sv->id,
'incentive_new_partner_id' => null,
]);
$this->artisan('incentive:calculate', ['incentive_id' => $incentive->id])->assertExitCode(0);
// Alter Sponsor: Tracking + ALLE Logs weg (auch der Waisen-Log ohne FK), Punkte 0
expect(IncentiveNewPartner::where('participant_id', $participantOld->id)->exists())->toBeFalse()
->and(IncentivePointsLog::where('participant_id', $participantOld->id)->count())->toBe(0)
->and((int) $participantOld->fresh()->total_points)->toBe(0);
// Neuer Sponsor: Partner nachgezogen inkl. Einmal- und Umsatzpunkten (keine Doppelzaehlung)
expect((int) $participantNew->fresh()->qualified_partners)->toBe(1)
->and((int) $participantNew->fresh()->total_points)->toBe(625);
});
it('removeStaleSponsorTrackings laesst korrekte Trackings unangetastet', function () {
$incentive = repairTestCreateIncentive();
$sponsor = repairTestCreateUser();

View file

@ -62,7 +62,7 @@ it('berechnet den Live-Snapshot ueber den gemeinsamen Lauf und cached ihn', func
->and($snapshot['failed'])->toBeFalse()
->and($snapshot['payline_points'])->toBe(0.0)
->and($snapshot['paylines'])->toBe(0)
->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeTrue();
->and(Cache::has("live_business_v3_{$user->id}_7_2026"))->toBeTrue();
});
it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached den Fehler nicht', function (): void {
@ -75,14 +75,14 @@ it('faellt bei fehlschlagender Live-Berechnung auf Null-Werte zurueck und cached
expect($commission['total'])->toBe(0.0)
->and($commission['is_live'])->toBeTrue()
->and(Cache::has("live_business_{$user->id}_7_2026"))->toBeFalse();
->and(Cache::has("live_business_v3_{$user->id}_7_2026"))->toBeFalse();
});
it('cached die Live-Provision fuer Folgeaufrufe', function (): void {
$user = lcsMakeUser();
Cache::put(
"live_business_{$user->id}_7_2026",
"live_business_v3_{$user->id}_7_2026",
['payline_points' => 12.5, 'paylines' => 2, 'pp' => 99.0, 'shop' => 0.0, 'growth' => 0.0, 'is_live' => true, 'failed' => false],
now()->addHour()
);
@ -149,3 +149,35 @@ it('rendert die Provisions-Kachel auf der Monatsstatistik-Card', function (): vo
->toContain(__('home.commission_today'))
->toContain('160,75 €');
});
it('reicht Level- und Quali-Daten im Snapshot durch (N1/N4)', 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();
});
}
}
$user = lcsMakeUser();
UserBusiness::forceCreate([
'user_id' => $user->id,
'month' => 6,
'year' => 2026,
'version' => 2,
'payline_points' => 900.0,
'payline_points_qual_kp' => 950.0,
'sales_volume_points_KP_sum' => 300.0,
'qual_user_level' => ['name' => 'Gold', 'paylines' => 3],
'qual_user_level_next' => ['name' => 'Diamant', 'qual_pp' => 2000, 'qual_kp' => 600],
]);
$snapshot = app(LiveCommissionService::class)->snapshot($user, 6, 2026);
expect($snapshot['qual_level']['name'])->toBe('Gold')
->and($snapshot['next_level']['name'])->toBe('Diamant')
->and($snapshot['payline_points_qual_kp'])->toBe(950.0)
->and($snapshot['personal_points'])->toBe(300.0)
->and($snapshot['paylines'])->toBe(3);
});

View file

@ -252,3 +252,78 @@ it('rendert die Payline-Prognose auf der Monatsstatistik-Card', function (): voi
->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);
});

View file

@ -0,0 +1,79 @@
<?php
use App\Http\Controllers\ProfileImageController;
use App\Models\UserAccount;
use App\Services\Slim;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function piMakeUser(): User
{
$account = UserAccount::forceCreate([
'first_name' => 'Pia',
'last_name' => 'Probe',
]);
return User::forceCreate([
'email' => 'pi-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'account_id' => $account->id,
'payment_account' => now()->addYear(),
]);
}
it('verweigert den Foto-Upload ohne Einwilligung', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
Storage::fake('public');
$user = piMakeUser();
$this->actingAs($user);
Request::merge(['profile_image_consent' => null]);
app(ProfileImageController::class)->uploadImage();
expect(session('alert-danger'))->toBe(__('account.profile_image_consent_required'))
->and($user->account->fresh()->profile_image)->toBeNull();
});
it('speichert ein Slim-Upload nur fuer jpg/png und liefert die Metadaten', function (): void {
Storage::fake('public');
$image = [
'input' => ['size' => 1234],
'output' => ['data' => 'BINARYDATA', 'name' => 'Mein Foto!.jpg', 'type' => 'image/jpeg'],
];
$stored = Slim::storeCroppedImage($image, 'images/profile');
expect($stored)->not->toBeNull()
->and($stored['ext'])->toBe('jpg')
->and($stored['mime'])->toBe('image/jpeg')
->and($stored['size'])->toBe(1234);
Storage::disk('public')->assertExists('images/profile/'.$stored['filename']);
$gif = ['input' => [], 'output' => ['data' => 'X', 'name' => 'a.gif', 'type' => 'image/gif']];
expect(Slim::storeCroppedImage($gif, 'images/profile'))->toBeNull();
});
it('loescht das Profilfoto samt Datei', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
Storage::fake('public');
$user = piMakeUser();
Storage::disk('public')->put('images/profile/test_pic.jpg', 'BINARY');
$user->account->forceFill(['profile_image' => 'test_pic.jpg'])->save();
$this->actingAs($user);
app(ProfileImageController::class)->deleteImage();
expect($user->account->fresh()->profile_image)->toBeNull();
Storage::disk('public')->assertMissing('images/profile/test_pic.jpg');
});

View file

@ -0,0 +1,433 @@
<?php
use App\Models\Country;
use App\Models\Product;
use App\Models\Shipping;
use App\Models\ShippingCountry;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserSalesVolume;
use App\Models\UserShop;
use App\Services\Payment;
use App\Services\SponsorBuyingPointsRepairService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-08-12 12:00:00'));
});
afterEach(function () {
Carbon::setTestNow();
});
function vpRepairCreateUser(?int $sponsorId = null, string $firstName = 'Test', string $lastName = 'User'): User
{
$user = User::forceCreate([
'email' => 'vp-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_sponsor' => $sponsorId,
]);
$accountId = DB::table('user_accounts')->insertGetId([
'first_name' => $firstName,
'last_name' => $lastName,
'created_at' => now(),
'updated_at' => now(),
]);
$user->account_id = $accountId;
$user->save();
return $user->fresh();
}
function vpRepairCreateVpProduct(int $vpPoints = 200): Product
{
$id = DB::table('products')->insertGetId([
'name' => 'Starterkit '.uniqid(),
'title' => 'T',
'is_membership_only' => false,
'sponsor_buying_points' => true,
'sponsor_buying_points_amount' => $vpPoints,
'active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
return Product::findOrFail($id);
}
function vpRepairCreatePaidOrder(User $buyer, User $shopOwner, Product $product, bool $paid = true, ?string $createdAt = null): ShoppingOrder
{
$country = Country::firstOrCreate(['code' => 'DE'], [
'phone' => '49',
'en' => 'Germany',
'de' => 'Deutschland',
'es' => 'Alemania',
'fr' => 'Allemagne',
'it' => 'Germania',
'ru' => 'Германия',
]);
$shipping = Shipping::create(['name' => 'S', 'active' => true]);
$shippingCountry = ShippingCountry::create(['shipping_id' => $shipping->id, 'country_id' => $country->id]);
$userShop = UserShop::create([
'user_id' => $shopOwner->id,
'name' => 'T'.substr(md5(uniqid('', true)), 0, 10),
'slug' => 't-'.substr(md5(uniqid('', true)), 0, 10),
'active' => true,
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $buyer->id,
'member_id' => $shopOwner->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => 'su-'.uniqid('', true).'@example.com',
'is_from' => 'wizard',
]);
$order = ShoppingOrder::create([
'shopping_user_id' => $shoppingUser->id,
'auth_user_id' => $buyer->id,
'country_id' => $shippingCountry->id,
'user_shop_id' => $userShop->id,
'payment_for' => 1,
'paid' => $paid,
'txaction' => $paid ? 'paid' : 'appointed',
'mode' => 'test',
'total' => 100,
'subtotal' => 90,
]);
ShoppingOrderItem::create([
'shopping_order_id' => $order->id,
'product_id' => $product->id,
'qty' => 1,
'price' => 50,
]);
if ($createdAt) {
$order->created_at = Carbon::parse($createdAt);
$order->save();
}
return $order->fresh();
}
it('bucht fehlende VP-Punkte des Sponsors für bezahlte Bestellungen des Monats nach', function () {
$sponsor = vpRepairCreateUser(null, 'Jasmin', 'Grund');
$buyer = vpRepairCreateUser($sponsor->id, 'Madeleine', 'Schrade');
$product = vpRepairCreateVpProduct(200);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(1);
$row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect($row)->not->toBeNull();
expect((int) $row->points)->toBe(200);
expect((int) $row->status_points)->toBe(2);
expect((int) $row->month)->toBe(8);
expect((int) $row->year)->toBe(2026);
expect($row->shopping_order_id)->toBe($order->id);
expect($row->info)->toBe('VP: Madeleine Schrade | '.$product->name);
});
it('ist idempotent - zweiter Lauf bucht nichts doppelt', function () {
$sponsor = vpRepairCreateUser();
$buyer = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct());
$service = app(SponsorBuyingPointsRepairService::class);
$service->repair(8, 2026);
$second = $service->repair(8, 2026);
expect($second['created'])->toHaveCount(0);
expect($second['skipped_existing'])->toBe(1);
expect(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1);
});
it('erkennt vorhandene Live-Buchungen (ohne shopping_order_id) und bucht nicht doppelt', function () {
$sponsor = vpRepairCreateUser(null, 'Steffi', 'Karrer');
$buyer = vpRepairCreateUser($sponsor->id, 'Dino', 'Portaro');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Live-Zeile wie Payment::addSponsorBuyingPoints (shopping_order_id = null)
UserSalesVolume::create([
'user_id' => $sponsor->id,
'shopping_order_id' => null,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'total_net' => 0,
'info' => 'VP: Dino Portaro | '.$product->name,
'status' => 5,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0);
expect($result['skipped_existing'])->toBe(1);
expect(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1);
});
it('ignoriert unbezahlte Bestellungen und Käufer ohne Sponsor', function () {
$sponsor = vpRepairCreateUser();
$product = vpRepairCreateVpProduct();
$buyerUnpaid = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyerUnpaid, $sponsor, $product, paid: false);
$buyerNoSponsor = vpRepairCreateUser(null);
vpRepairCreatePaidOrder($buyerNoSponsor, $sponsor, $product);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0);
expect(UserSalesVolume::where('status', 5)->count())->toBe(0);
});
it('lässt Bestellungen aus anderen Monaten unangetastet', function () {
$sponsor = vpRepairCreateUser();
$buyer = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct(), createdAt: '2026-06-19 09:32:05');
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['checked'])->toBe(0);
expect(UserSalesVolume::where('status', 5)->count())->toBe(0);
});
it('bucht im dry-run nichts, listet aber die Kandidaten', function () {
$sponsor = vpRepairCreateUser();
$buyer = vpRepairCreateUser($sponsor->id);
vpRepairCreatePaidOrder($buyer, $sponsor, vpRepairCreateVpProduct());
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026, dryRun: true);
expect($result['created'])->toHaveCount(1);
expect(UserSalesVolume::where('status', 5)->count())->toBe(0);
});
it('verweigert Buchungen in fremde Monate ohne --force', function () {
$this->artisan('sales:repair-sponsor-vp', ['--month' => 6, '--year' => 2026])
->assertFailed();
$this->artisan('sales:repair-sponsor-vp', ['--month' => 6, '--year' => 2026, '--dry-run' => true])
->assertSuccessful();
});
it('heilt den Monatswechsel: Vormonats-Bestellung ohne Buchung wird in den Zielmonat nachgebucht', function () {
$sponsor = vpRepairCreateUser(null, 'Sina', 'Sponsor');
$buyer = vpRepairCreateUser($sponsor->id, 'Britta', 'Boundary');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product, true, '2026-07-31 23:50:00');
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(1);
$row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect((int) $row->month)->toBe(8)->and((int) $row->year)->toBe(2026);
});
it('erkennt Alt-Zeilen zaehlend: zwei gleiche Bestellungen, eine Alt-Buchung - die zweite wird nachgebucht', function () {
$sponsor = vpRepairCreateUser(null, 'Sonja', 'Sponsor');
$buyer = vpRepairCreateUser($sponsor->id, 'Doppel', 'Besteller');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product);
vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Alt-Zeile aus dem frueheren Live-Pfad: ohne Order-Referenz, gleicher info-Text
UserSalesVolume::forceCreate([
'user_id' => $sponsor->id,
'shopping_order_id' => null,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Doppel Besteller | '.$product->name,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(1)
->and($result['skipped_existing'])->toBe(1)
->and(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(2);
});
it('ist gegen Umbenennungen immun, wenn die Live-Buchung eine Order-Referenz traegt', function () {
$sponsor = vpRepairCreateUser(null, 'Rita', 'Sponsor');
$buyer = vpRepairCreateUser($sponsor->id, 'Madelaine', 'Schrade');
$product = vpRepairCreateVpProduct(200);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Live-Buchung MIT Order-Referenz, info mit altem (Tippfehler-)Namen
UserSalesVolume::forceCreate([
'user_id' => $sponsor->id,
'shopping_order_id' => $order->id,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Madelaine Schrade | '.$product->name,
]);
// Name wird korrigiert - der info-Text passt nicht mehr, die Order-Referenz schon
$buyer->account->forceFill(['first_name' => 'Madeleine'])->save();
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0)
->and($result['skipped_existing'])->toBe(1)
->and(UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->count())->toBe(1);
});
it('schreibt im Live-Pfad die Order-Referenz mit', function () {
// Test-Schema hinkt der Produktion hinterher: status_turnover ist dort nullable
Schema::table('user_sales_volumes', function ($table) {
$table->integer('status_turnover')->nullable()->change();
});
$sponsor = vpRepairCreateUser(null, 'Lena', 'Live');
$buyer = vpRepairCreateUser($sponsor->id, 'Nora', 'Neu');
$product = vpRepairCreateVpProduct(400);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
Payment::addSponsorBuyingPoints($buyer->fresh(), $product, $order);
$row = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect($row)->not->toBeNull()
->and($row->shopping_order_id)->toBe($order->id)
->and((int) $row->points)->toBe(400);
// Repair danach: nichts doppelt
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0);
});
it('bucht nach Umhaengung nicht doppelt, sondern meldet den anderen Sponsor', function () {
$oldSponsor = vpRepairCreateUser(null, 'Alte', 'Firma');
$newSponsor = vpRepairCreateUser(null, 'Christina', 'Schuder');
$buyer = vpRepairCreateUser($oldSponsor->id, 'Anna', 'Schall');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $oldSponsor, $product);
// Live-Buchung lief beim ALTEN Sponsor (ohne Order-Referenz, Vormonat)
UserSalesVolume::forceCreate([
'user_id' => $oldSponsor->id,
'shopping_order_id' => null,
'month' => 7,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Anna Schall | '.$product->name,
]);
// Danach wurde Anna umgehaengt
$buyer->forceFill(['m_sponsor' => $newSponsor->id])->save();
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['created'])->toHaveCount(0)
->and($result['skipped_existing'])->toBe(1)
->and($result['skipped_other_sponsor'])->toHaveCount(1)
->and($result['skipped_other_sponsor'][0]['booked_sponsor_id'])->toBe($oldSponsor->id)
->and($result['skipped_other_sponsor'][0]['current_sponsor_id'])->toBe($newSponsor->id)
->and(UserSalesVolume::where('status', 5)->count())->toBe(1);
});
it('bucht VP-Punkte vom Firmenaccount automatisch auf den Berater um (Zielmonat: in place)', function () {
config(['mivita.company_user_id' => 999999]);
$company = vpRepairCreateUser(null, 'mivita', 'care gmbh');
$company->forceFill(['id' => 999999])->save();
$company = User::find(999999);
$sponsor = vpRepairCreateUser(null, 'Christina', 'Schuder');
$buyer = vpRepairCreateUser($sponsor->id, 'Anna', 'Schall');
$product = vpRepairCreateVpProduct(200);
$order = vpRepairCreatePaidOrder($buyer, $sponsor, $product);
// Live-Buchung landete beim Firmenaccount (gleicher Monat, ohne Order-Referenz)
UserSalesVolume::forceCreate([
'user_id' => $company->id,
'shopping_order_id' => null,
'month' => 8,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Anna Schall | '.$product->name,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['rebooked'])->toHaveCount(1)
->and($result['created'])->toHaveCount(0)
->and($result['skipped_other_sponsor'])->toHaveCount(0);
// Genau EINE Zeile, jetzt beim Berater und mit Order-Referenz
expect(UserSalesVolume::where('status', 5)->count())->toBe(1);
$row = UserSalesVolume::where('status', 5)->first();
expect($row->user_id)->toBe($sponsor->id)
->and($row->shopping_order_id)->toBe($order->id)
->and((int) $row->points)->toBe(200);
});
it('bucht aus abgeschlossenem Monat per Gegenbuchung um, ohne den alten Monat anzufassen', function () {
config(['mivita.company_user_id' => 999999]);
$company = vpRepairCreateUser(null, 'mivita', 'care gmbh');
$company->forceFill(['id' => 999999])->save();
$company = User::find(999999);
$sponsor = vpRepairCreateUser(null, 'Christina', 'Schuder');
$buyer = vpRepairCreateUser($sponsor->id, 'Anna', 'Schall');
$product = vpRepairCreateVpProduct(200);
vpRepairCreatePaidOrder($buyer, $sponsor, $product, true, '2026-07-31 23:50:00');
// Live-Buchung beim Firmenaccount im (abgeschlossenen) Juli
$julyRow = UserSalesVolume::forceCreate([
'user_id' => $company->id,
'shopping_order_id' => null,
'month' => 7,
'year' => 2026,
'points' => 200,
'status_points' => 2,
'status' => 5,
'info' => 'VP: Anna Schall | '.$product->name,
]);
$result = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($result['rebooked'])->toHaveCount(1);
// Juli-Zeile unangetastet
expect($julyRow->fresh()->user_id)->toBe($company->id)
->and((int) $julyRow->fresh()->points)->toBe(200)
->and((int) $julyRow->fresh()->month)->toBe(7);
// August: +200 beim Berater (mit Order-Referenz), -200 beim Firmenaccount
$sponsorRow = UserSalesVolume::where('user_id', $sponsor->id)->where('status', 5)->first();
expect($sponsorRow)->not->toBeNull()
->and((int) $sponsorRow->points)->toBe(200)
->and((int) $sponsorRow->month)->toBe(8);
$stornoRow = UserSalesVolume::where('user_id', $company->id)->where('month', 8)->where('status', 5)->first();
expect($stornoRow)->not->toBeNull()
->and((int) $stornoRow->points)->toBe(-200)
->and($stornoRow->info)->toStartWith('VP-Storno:');
// Zweiter Lauf: idempotent, nichts weiter
$second = app(SponsorBuyingPointsRepairService::class)->repair(8, 2026);
expect($second['rebooked'])->toHaveCount(0)
->and($second['created'])->toHaveCount(0);
});

View file

@ -5,6 +5,7 @@ use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboItem;
use App\Models\UserAboOrder;
use App\Models\UserAboStatusHistory;
use App\Models\UserSalesVolume;
use App\Services\Abo\TeamAboOverviewService;
use App\Services\HTMLHelper;
@ -196,6 +197,61 @@ it('liefert je Ebene abgerechnete Abo-Punkte und die Prognose bis zum letzten Li
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('liefert pro Mitglied das Eigen-Abo-Flag und Punkte', function (): void {
$root = makeUser();
$anna = makeUser($root->id);

View file

@ -0,0 +1,81 @@
<?php
use App\Http\Controllers\User\TeamController;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function tflMakeUser(?int $sponsorId = null, array $accountData = []): User
{
$account = UserAccount::forceCreate(array_merge([
'first_name' => 'Vor'.uniqid(),
'last_name' => 'Nach'.uniqid(),
'phone' => '0123456789',
], $accountData));
return User::forceCreate([
'email' => 'tfl-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'account_id' => $account->id,
'active_date' => '2026-01-15',
'payment_account' => now()->addYear(),
]);
}
it('listet die Firstlines mit Monats-Gesamtpunkten sortiert und laesst Tiefere aus', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$root = tflMakeUser();
$anna = tflMakeUser($root->id, ['first_name' => 'Anna', 'last_name' => 'Alpha']);
$ben = tflMakeUser($root->id, ['first_name' => 'Ben', 'last_name' => 'Beta']);
tflMakeUser($anna->id); // Ebene 2: keine Firstline
// Eigene (Status 1) + Kundenpunkte (Status 2) zaehlen zusammen
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 100, 'status' => 1]);
UserSalesVolume::forceCreate(['user_id' => $anna->id, 'month' => 6, 'year' => 2026, 'points' => 50, 'status' => 2]);
UserSalesVolume::forceCreate(['user_id' => $ben->id, 'month' => 6, 'year' => 2026, 'points' => 400, 'status' => 2]);
UserSalesVolume::forceCreate(['user_id' => $ben->id, 'month' => 5, 'year' => 2026, 'points' => 900, 'status' => 2]); // anderer Monat
$this->actingAs($root);
Request::merge(['month' => 6, 'year' => 2026]);
Request::instance()->setLaravelSession(app('session')->driver());
$view = app(TeamController::class)->firstlines();
$rows = $view->getData()['rows'];
expect($rows)->toHaveCount(2)
->and($rows[0]['user_id'])->toBe($ben->id) // 400 Punkte zuerst
->and($rows[0]['total_points'])->toBe(400.0)
->and($rows[1]['user_id'])->toBe($anna->id)
->and($rows[1]['total_points'])->toBe(150.0)
->and($rows[1]['name'])->toBe('Anna Alpha')
->and($rows[1]['phone'])->toBe('0123456789')
->and($rows[1]['email'])->toBe($anna->email);
// View rendert ohne Fehler (Route-Fallback aktiv)
expect($view->render())
->toContain('Anna Alpha')
->toContain('Ben Beta');
});
it('zeigt eine leere Firstlines-Liste ohne direkte Teampartner', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$root = tflMakeUser();
$this->actingAs($root);
Request::instance()->setLaravelSession(app('session')->driver());
$view = app(TeamController::class)->firstlines();
expect($view->getData()['rows'])->toBe([])
->and($view->render())->toContain(e(__('team.firstlines_none')));
});

View file

@ -0,0 +1,219 @@
<?php
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserSalesVolume;
use App\Services\Team\TopResultsService;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\URL;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-08-15'));
Cache::flush();
});
afterEach(function () {
Carbon::setTestNow();
});
function trsMakeUser(
?int $sponsorId = null,
string $firstName = 'Team',
string $lastName = 'Partner',
?string $activeDate = null,
?string $paymentAccount = 'future',
): User {
$account = UserAccount::forceCreate([
'first_name' => $firstName,
'last_name' => $lastName,
]);
return User::forceCreate([
'email' => 'trs-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'm_level' => 1,
'm_sponsor' => $sponsorId,
'payment_account' => $paymentAccount === 'future' ? now()->addYear() : $paymentAccount,
'active_date' => $activeDate,
'account_id' => $account->id,
]);
}
function trsAddVolume(int $userId, int $points, int $status, int $month = 8, int $year = 2026): void
{
UserSalesVolume::forceCreate([
'user_id' => $userId,
'month' => $month,
'year' => $year,
'points' => $points,
'status' => $status,
]);
}
function trsMakeAbo(int $ownerId, string $isFor, string $startDate, bool $active = true, int $status = 2): UserAbo
{
return UserAbo::create([
'user_id' => $ownerId,
'member_id' => $ownerId,
'shopping_user_id' => 0,
'email' => 'trs-abo-'.uniqid('', true).'@example.com',
'is_for' => $isFor,
'payone_userid' => 0,
'clearingtype' => 'cc',
'amount' => 5000,
'active' => $active,
'status' => $status,
'abo_interval' => 5,
'start_date' => $startDate,
'next_date' => '2026-09-05',
]);
}
function trsTop3(User $viewer): array
{
Cache::flush();
return app(TopResultsService::class)->monthlyTop3($viewer, 8, 2026);
}
it('rankt das persoenliche Volumen inkl. Status-1- und Status-2-Punkten, ohne Betrachter und Fremde', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
$ben = trsMakeUser($root->id, 'Ben', 'Beta');
$fremd = trsMakeUser(null, 'Frank', 'Fremd');
trsAddVolume($anna->id, 100, 1); // Eigenpunkte
trsAddVolume($anna->id, 50, 2); // Kundenpunkte
trsAddVolume($anna->id, 999, 3); // pending zaehlt nicht
trsAddVolume($ben->id, 40, 1);
trsAddVolume($ben->id, 70, 1, 7); // anderer Monat zaehlt nicht
trsAddVolume($root->id, 500, 1); // Betrachter zaehlt nicht mit
trsAddVolume($fremd->id, 800, 1); // nicht im Team
$result = trsTop3($root);
expect($result['top_volume'])->toHaveCount(2)
->and($result['top_volume'][0])->toMatchArray(['user_id' => $anna->id, 'first_name' => 'Anna', 'last_name' => 'Alpha', 'value' => 150.0])
->and($result['top_volume'][0]['profile_image_url'])->toBeNull()
->and($result['top_volume'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 40.0]);
});
it('begrenzt auf Top 3 und sortiert Gleichstaende nach Name', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$carla = trsMakeUser($root->id, 'Carla', 'Chef');
$zoe = trsMakeUser($root->id, 'Zoe', 'Zett');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
$doris = trsMakeUser($root->id, 'Doris', 'Klein');
trsAddVolume($carla->id, 200, 1);
trsAddVolume($zoe->id, 100, 1);
trsAddVolume($anna->id, 100, 1);
trsAddVolume($doris->id, 50, 1); // Platz 4, faellt raus
$result = trsTop3($root);
expect($result['top_volume'])->toHaveCount(3)
->and(array_column($result['top_volume'], 'user_id'))
->toBe([$carla->id, $anna->id, $zoe->id]);
});
it('zaehlt Neupartner je Sponsor nach der Dashboard-Definition', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$sponsor = trsMakeUser($root->id, 'Sandra', 'Sponsor');
$other = trsMakeUser($root->id, 'Olaf', 'Other');
trsMakeUser($sponsor->id, 'Neu', 'Eins', '2026-08-10');
trsMakeUser($sponsor->id, 'Neu', 'Zwei', '2026-08-20');
trsMakeUser($sponsor->id, 'Alt', 'Juli', '2026-07-10'); // anderer Monat
trsMakeUser($sponsor->id, 'Neu', 'Inaktiv', '2026-08-12', null); // kein aktives Abrechnungskonto
trsMakeUser($other->id, 'Neu', 'Drei', '2026-08-05');
trsMakeUser($root->id, 'Neu', 'BeimViewer', '2026-08-08'); // Sponsor = Betrachter, zaehlt nicht
$result = trsTop3($root);
expect($result['top_recruiters'])->toHaveCount(2)
->and($result['top_recruiters'][0])->toMatchArray(['user_id' => $sponsor->id, 'value' => 2])
->and($result['top_recruiters'][1])->toMatchArray(['user_id' => $other->id, 'value' => 1]);
});
it('zaehlt neue Abos als Kundenabos plus Eigenabo je Berater', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
$ben = trsMakeUser($root->id, 'Ben', 'Beta');
$fremd = trsMakeUser(null, 'Frank', 'Fremd');
trsMakeAbo($anna->id, 'ot', '2026-08-05');
trsMakeAbo($anna->id, 'ot', '2026-08-20');
trsMakeAbo($anna->id, 'me', '2026-08-03');
trsMakeAbo($anna->id, 'ot', '2026-07-05'); // anderer Monat
trsMakeAbo($anna->id, 'me', '2026-08-10', true, 4); // gekuendigt
trsMakeAbo($anna->id, 'ot', '2026-08-11', false); // inaktiv
trsMakeAbo($ben->id, 'me', '2026-08-15');
trsMakeAbo($root->id, 'ot', '2026-08-09'); // Betrachter zaehlt nicht mit
trsMakeAbo($fremd->id, 'ot', '2026-08-09'); // nicht im Team
$result = trsTop3($root);
expect($result['top_new_abos'])->toHaveCount(2)
->and($result['top_new_abos'][0])->toMatchArray(['user_id' => $anna->id, 'value' => 3])
->and($result['top_new_abos'][1])->toMatchArray(['user_id' => $ben->id, 'value' => 1]);
});
it('cacht das Ergebnis fuer 6 Stunden', function (): void {
$root = trsMakeUser(null, 'Root', 'Viewer');
$anna = trsMakeUser($root->id, 'Anna', 'Alpha');
trsAddVolume($anna->id, 100, 1);
$first = app(TopResultsService::class)->monthlyTop3($root, 8, 2026);
trsAddVolume($anna->id, 900, 1);
$second = app(TopResultsService::class)->monthlyTop3($root, 8, 2026);
expect($first['top_volume'][0]['value'])->toBe(100.0)
->and($second)->toBe($first);
});
it('rendert die Top-Ergebnisse-Karte nur mit Eintraegen', function (): void {
URL::resolveMissingNamedRoutesUsing(fn (string $name, $parameters = []) => '/'.$name);
$row = fn (string $first, string $last, $value) => [
'user_id' => 1, 'first_name' => $first, 'last_name' => $last,
'profile_image_url' => null, 'value' => $value,
];
$html = view('dashboard._top_results', [
'monthlyStats' => [
'month' => 8,
'year' => 2026,
'top_results' => [
'top_volume' => [$row('Anna', 'Alpha', 150.0)],
'top_recruiters' => [],
'top_new_abos' => [$row('Ben', 'Beta', 3)],
],
],
])->render();
expect($html)
->toContain(e(__('home.top_results_title')))
->toContain(__('home.top_results_volume'))
->toContain('Anna Alpha')
->toContain('Ben Beta')
->toContain(__('home.top_results_empty')); // leere Recruiter-Spalte
$emptyHtml = view('dashboard._top_results', [
'monthlyStats' => [
'month' => 8,
'year' => 2026,
'top_results' => ['top_volume' => [], 'top_recruiters' => [], 'top_new_abos' => []],
],
])->render();
expect($emptyHtml)->not->toContain(e(__('home.top_results_title')));
});

View file

@ -7,12 +7,14 @@ use App\Models\ShippingCountry;
use App\Models\UserAbo;
use App\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(Tests\TestCase::class)->in('Feature/Incentive');
uses(Tests\TestCase::class)->in('Feature/Sys');
uses(Tests\TestCase::class)->in('Unit/Incentive');
uses(Tests\TestCase::class)->in('Unit/Dhl');
uses(Tests\TestCase::class)->in('Feature/PaymentDashboard');
uses(TestCase::class)->in('Feature/Incentive');
uses(TestCase::class)->in('Feature/Sys');
uses(TestCase::class)->in('Unit/Incentive');
uses(TestCase::class)->in('Unit/Dhl');
uses(TestCase::class)->in('Feature/PaymentDashboard');
uses(TestCase::class)->in('Feature/SalesVolume');
if (! function_exists('makeShopEnv')) {
function makeShopEnv(): void

View file

@ -60,6 +60,34 @@ it('returns correct calculation months', function () {
->and($months[4])->toBe(['month' => 8, 'year' => 2026]);
});
it('qualificationWindow liefert Fensterstart 00:00:00 und Fensterende 23:59:59', function () {
$incentive = new Incentive([
'qualification_start' => '2026-04-01',
'qualification_end' => '2026-07-31',
'calculation_end' => '2026-08-31',
]);
[$windowStart, $windowEnd] = $incentive->qualificationWindow();
expect($windowStart->format('Y-m-d H:i:s'))->toBe('2026-04-01 00:00:00')
->and($windowEnd->format('Y-m-d H:i:s'))->toBe('2026-07-31 23:59:59');
});
it('qualificationWindowContains zaehlt Zeitstempel am letzten Fenstertag (Randtag-Fall)', function () {
$incentive = new Incentive([
'qualification_start' => '2026-04-01',
'qualification_end' => '2026-07-31',
'calculation_end' => '2026-08-31',
]);
expect($incentive->qualificationWindowContains(Carbon::parse('2026-07-31 11:05:52')))->toBeTrue()
->and($incentive->qualificationWindowContains(Carbon::parse('2026-07-31 23:59:59')))->toBeTrue()
->and($incentive->qualificationWindowContains(Carbon::parse('2026-04-01 00:00:00')))->toBeTrue()
->and($incentive->qualificationWindowContains(Carbon::parse('2026-05-15 12:00:00')))->toBeTrue()
->and($incentive->qualificationWindowContains(Carbon::parse('2026-03-31 23:59:59')))->toBeFalse()
->and($incentive->qualificationWindowContains(Carbon::parse('2026-08-01 00:00:00')))->toBeFalse();
});
// === Incentive: Status ===
it('detects status types correctly', function () {