'u-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'm_sponsor' => $sponsorId, ]); if ($createdAt) { $user->created_at = Carbon::parse($createdAt); $user->save(); } return $user->fresh(); } /** * Bezahlte Registrierung mit Starterpaket. Wie im Zahlungspfad bekommt der Partner * dabei seine erste Kontozahlung (`partner_paid_at`) — standardmaessig zum * Bestellzeitpunkt, mit `$paidAt` spaeter (Bestellung Ende Monat, Zahlung Anfang * Folgemonat). */ function repairTestCreateStarterKitOrder(User $registrant, User $shopOwner, ?string $orderCreatedAt = null, ?string $paidAt = 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'.bin2hex(random_bytes(4)), 'slug' => 't-'.uniqid(), 'active' => true, ]); $shoppingUser = ShoppingUser::create([ 'auth_user_id' => $registrant->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' => $registrant->id, 'country_id' => $shippingCountry->id, 'user_shop_id' => $userShop->id, 'payment_for' => 1, 'paid' => true, 'txaction' => 'paid', 'mode' => 'test', 'total' => 100, 'subtotal' => 90, ]); $productId = DB::table('products')->insertGetId([ 'name' => 'Starter '.uniqid(), 'title' => 'T', 'is_membership_only' => false, 'active' => true, 'created_at' => now(), 'updated_at' => now(), ]); ShoppingOrderItem::create([ 'shopping_order_id' => $order->id, 'product_id' => Product::findOrFail($productId)->id, 'qty' => 1, 'price' => 50, ]); if ($orderCreatedAt) { $order->created_at = Carbon::parse($orderCreatedAt); $order->save(); } if ($registrant->fresh()->partner_paid_at === null) { $registrant->forceFill(['partner_paid_at' => Carbon::parse($paidAt ?? $orderCreatedAt ?? now())])->save(); } return $order->fresh(['shopping_order_items.product']); } function repairTestCreateIncentive(): Incentive { return Incentive::factory()->create([ 'qualification_start' => '2026-04-01', 'qualification_end' => '2026-07-31', 'calculation_end' => '2026-08-31', 'points_partner_onetime' => 600, 'points_abo_onetime' => 400, 'status' => 1, ]); } it('syncMissingTrackingPartners zaehlt Bestandskunden, die im Zeitraum zum Berater upgraden (Zahlungsdatum zaehlt)', function () { $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); $participant = IncentiveParticipant::factory()->create([ 'incentive_id' => $incentive->id, 'user_id' => $sponsor->id, ]); // User lange vor dem Qualifikationszeitraum registriert, Starterpaket aber im Zeitraum bezahlt $partner = repairTestCreateUser($sponsor->id, '2024-10-28 16:48:05'); repairTestCreateStarterKitOrder($partner, $sponsor, '2026-05-10 11:05:52'); $added = app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant); expect($added)->toBe(1); expect(IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $partner->id)->exists())->toBeTrue(); $log = IncentivePointsLog::where('participant_id', $participant->id) ->where('type', 'partner') ->where('source_id', $partner->id) ->where('is_storno', false) ->first(); expect($log)->not->toBeNull(); expect((int) $log->points_onetime)->toBe(600); expect((int) $log->month)->toBe(5); }); it('syncMissingTrackingPartners ignoriert Starterpakete ausserhalb des Qualifikationszeitraums', function () { $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); $participant = IncentiveParticipant::factory()->create([ 'incentive_id' => $incentive->id, 'user_id' => $sponsor->id, ]); $partner = repairTestCreateUser($sponsor->id, '2026-05-01 10:00:00'); repairTestCreateStarterKitOrder($partner, $sponsor, '2026-03-15 11:00:00'); $added = app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant); expect($added)->toBe(0); expect(IncentiveNewPartner::where('user_id', $partner->id)->exists())->toBeFalse(); }); it('trackNewPartner zaehlt Bestellungen am letzten Tag des Qualifikationszeitraums (Datums-Randfall)', function () { Carbon::setTestNow(Carbon::parse('2026-07-31 11:05:52')); $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); IncentiveParticipant::factory()->create([ 'incentive_id' => $incentive->id, 'user_id' => $sponsor->id, ]); $partner = repairTestCreateUser($sponsor->id); $order = repairTestCreateStarterKitOrder($partner, $sponsor, '2026-07-31 11:05:52'); IncentiveTracker::trackNewPartner($order); 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(); $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 aber 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, ]); $participantOld->recalculateFromTrackingTables()->save(); expect((int) $participantOld->fresh()->total_points)->toBe(600); $repair = app(IncentivePointsLogRepairService::class); $removed = $repair->removeStaleSponsorTrackings($participantOld); expect($removed)->toBe(1); expect(IncentiveNewPartner::where('participant_id', $participantOld->id)->exists())->toBeFalse(); expect(IncentivePointsLog::where('participant_id', $participantOld->id)->exists())->toBeFalse(); $participantOld->recalculateFromTrackingTables()->save(); expect((int) $participantOld->fresh()->total_points)->toBe(0); // Beim aktuellen Sponsor zieht der Repair den Partner nach $added = $repair->syncMissingTrackingPartners($participantNew); expect($added)->toBe(1); $participantNew->recalculateFromTrackingTables()->save(); expect((int) $participantNew->fresh()->total_points)->toBe(600); 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(); $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(), ]); $removed = app(IncentivePointsLogRepairService::class)->removeStaleSponsorTrackings($participant); expect($removed)->toBe(0); expect(IncentiveNewPartner::where('participant_id', $participant->id)->exists())->toBeTrue(); }); it('zaehlt Neupartner im Monat der Zahlung, nicht der Bestellung (Kundenentscheid 16.09.2026)', function () { Carbon::setTestNow(Carbon::parse('2026-04-01 06:49:35')); $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); $participant = IncentiveParticipant::factory()->create([ 'incentive_id' => $incentive->id, 'user_id' => $sponsor->id, ]); // Bestellt vor dem Start, bezahlt am ersten Tag: zaehlt. $paidOnStart = repairTestCreateUser($sponsor->id); $order = repairTestCreateStarterKitOrder($paidOnStart, $sponsor, '2026-03-30 18:00:00', '2026-04-01 06:49:35'); IncentiveTracker::trackNewPartner($order); // Bestellt am letzten Tag, bezahlt erst danach: zaehlt nicht. $paidAfterEnd = repairTestCreateUser($sponsor->id); repairTestCreateStarterKitOrder($paidAfterEnd, $sponsor, '2026-07-31 20:00:00', '2026-08-01 09:00:00'); Carbon::setTestNow(Carbon::parse('2026-08-02 05:00:00')); app(IncentivePointsLogRepairService::class)->syncMissingTrackingPartners($participant); $tracked = IncentiveNewPartner::where('participant_id', $participant->id)->get(); expect($tracked->pluck('user_id')->all())->toBe([$paidOnStart->id]) ->and($tracked->first()->registered_at->toDateTimeString())->toBe('2026-04-01 06:49:35'); // Kompletter Neuaufbau kommt zum selben Ergebnis. $participant->fresh()->rebuildFromSourceTables()->save(); expect(IncentiveNewPartner::where('participant_id', $participant->id)->pluck('user_id')->all())->toBe([$paidOnStart->id]); }); describe('Storno der Starterpaket-Bestellung (Kundenentscheid 25.09.2026)', function () { it('nimmt einen stornierten Neupartner aus der Wertung und zeigt ihn als storniert', function (bool $force) { $incentive = repairTestCreateIncentive(); $sponsor = repairTestCreateUser(); $participant = IncentiveParticipant::factory()->create([ 'incentive_id' => $incentive->id, 'user_id' => $sponsor->id, ]); $kept = repairTestCreateUser($sponsor->id); repairTestCreateStarterKitOrder($kept, $sponsor, '2026-05-03 10:00:00'); $cancelled = repairTestCreateUser($sponsor->id); $order = repairTestCreateStarterKitOrder($cancelled, $sponsor, '2026-05-05 10:00:00'); $this->artisan('incentive:calculate', ['incentive_id' => (string) $incentive->id])->assertSuccessful(); expect($participant->fresh()->qualified_partners)->toBe(2); // Stornorechnung setzt txaction = cancelled, paid bleibt 1 $order->update(['txaction' => 'cancelled']); $this->artisan('incentive:calculate', array_filter([ 'incentive_id' => (string) $incentive->id, '--force' => $force, ]))->assertSuccessful(); $participant->refresh(); $tracking = IncentiveNewPartner::where('participant_id', $participant->id)->where('user_id', $cancelled->id)->first(); expect($participant->qualified_partners)->toBe(1) ->and($participant->total_points)->toBe(600) ->and($tracking)->not->toBeNull() ->and($tracking->cancelled_at)->not->toBeNull(); $sources = collect(AdminIncentiveController::buildParticipantDetailData($participant)['partner_sources'])->keyBy('id'); expect($sources[$tracking->id]['cancelled_at'])->not->toBeNull() ->and($sources[$tracking->id]['total'])->toBe(0); // Ein weiterer Lauf markiert nicht doppelt $this->artisan('incentive:calculate', ['incentive_id' => (string) $incentive->id])->assertSuccessful(); expect($participant->fresh()->total_points)->toBe(600); })->with(['nachtlauf' => false, 'neuaufbau' => true]); });