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>
433 lines
16 KiB
PHP
433 lines
16 KiB
PHP
<?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);
|
|
});
|