'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); });