From f6faad9ae78ec68f883a97931481fb75ad8025aa Mon Sep 17 00:00:00 2001 From: Kevin Date: Fri, 21 Aug 2026 12:06:29 +0000 Subject: [PATCH] =?UTF-8?q?Abo:=20extern-erledigt=20f=C3=BCr=20gel=C3=B6sc?= =?UTF-8?q?hte=20Abos=20+=20Lieferhistorie-Umzug=20(Ticket=20Kunz=20Mai-Pu?= =?UTF-8?q?nkte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AboExternalCompletionService: Bestellungen gelöschter/ersetzter Abos können nachträglich extern erledigt werden (Punkte + Status 8 + Versandstatus); das gelöschte Abo wird dabei nicht reaktiviert - IncentiveTracker::trackSalesVolume: Abo-Zuordnung inkl. gelöschter Abos (withTrashed) - nachgebuchte Umsätze bereits getrackter Abos zählen weiter - Neue Wartungs-Commands: abo:extern-done {shopping_order_id} und abo:move-orders {from_abo} {to_abo} (--dry-run, Guards: gleicher Kunde) - Latent datumsabhängigen Alt-Test in AboExternalCompletionTest repariert (Buchung nutzt date(), nicht Carbon::setTestNow) Fall Kunz (dev/2026-08-12-intensiv-tickets): Dannenhauers gelieferte, aber am 17.06. beim Nachtragen vergessene Mai-Order 53274 → abo:extern-done (+54 P); Bockhackers Lieferhistorie von gelöschtem Abo 197 → 496 umgehängt (Zähler 4). Bockhackers 24 P existieren bereits (Juni-Buchung der Mai-Lieferung). Co-Authored-By: Claude Fable 5 --- app/Console/Commands/AboExternDoneOrder.php | 45 +++++++ app/Console/Commands/AboMoveOrders.php | 80 ++++++++++++ app/Services/AboExternalCompletionService.php | 24 ++-- app/Services/Incentive/IncentiveTracker.php | 7 +- dev/2026-08-12-intensiv-tickets/README.md | 43 ++++++ tests/Feature/AboExternalCompletionTest.php | 41 ++++-- tests/Feature/AboMoveOrdersTest.php | 88 +++++++++++++ .../DeletedAboSalesVolumeIncentiveTest.php | 122 ++++++++++++++++++ 8 files changed, 433 insertions(+), 17 deletions(-) create mode 100644 app/Console/Commands/AboExternDoneOrder.php create mode 100644 app/Console/Commands/AboMoveOrders.php create mode 100644 tests/Feature/AboMoveOrdersTest.php create mode 100644 tests/Feature/Incentive/DeletedAboSalesVolumeIncentiveTest.php diff --git a/app/Console/Commands/AboExternDoneOrder.php b/app/Console/Commands/AboExternDoneOrder.php new file mode 100644 index 00000000..6abf3b91 --- /dev/null +++ b/app/Console/Commands/AboExternDoneOrder.php @@ -0,0 +1,45 @@ +argument('shopping_order_id')); + + if (! $order) { + $this->error('Bestellung nicht gefunden.'); + + return self::FAILURE; + } + + $result = $service->complete($order); + + if (! $result['success']) { + $this->error($result['message']); + + return self::FAILURE; + } + + $this->info("Bestellung {$order->id} extern erledigt: {$result['message']}"); + $this->info("Punkte: {$order->points} | Netto: {$order->subtotal} | Berater: ".($order->member_id ?? $order->auth_user_id)); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/AboMoveOrders.php b/app/Console/Commands/AboMoveOrders.php new file mode 100644 index 00000000..9149b9c2 --- /dev/null +++ b/app/Console/Commands/AboMoveOrders.php @@ -0,0 +1,80 @@ +find($this->argument('from_abo')); + $to = UserAbo::find($this->argument('to_abo')); + + if (! $from || ! $to) { + $this->error('Abo nicht gefunden (from: '.($from ? 'ok' : 'fehlt').', to: '.($to ? 'ok' : 'fehlt').').'); + + return self::FAILURE; + } + + if ($from->id === $to->id) { + $this->error('from_abo und to_abo sind identisch.'); + + return self::FAILURE; + } + + $sameCustomer = strcasecmp(trim((string) $from->email), trim((string) $to->email)) === 0 + && (int) $from->member_id === (int) $to->member_id + && $from->is_for === $to->is_for; + + if (! $sameCustomer) { + $this->error("Abos gehören nicht zum selben Kunden (from: {$from->email} / Berater {$from->member_id}, to: {$to->email} / Berater {$to->member_id}). Abbruch."); + + return self::FAILURE; + } + + $orders = UserAboOrder::where('user_abo_id', $from->id)->orderBy('id')->get(); + + if ($orders->isEmpty()) { + $this->info("Keine user_abo_orders bei Abo {$from->id} - nichts zu tun."); + + return self::SUCCESS; + } + + $this->info("Abo {$from->id} ({$from->email}".($from->trashed() ? ', gelöscht' : '').") → Abo {$to->id}: {$orders->count()} Lieferung(en)"); + $this->table( + ['user_abo_order', 'shopping_order', 'Status', 'bezahlt'], + $orders->map(fn (UserAboOrder $o) => [$o->id, $o->shopping_order_id, $o->status, $o->paid ? 'ja' : 'nein'])->all() + ); + + if ($this->option('dry-run')) { + $this->info('DRY-RUN - nichts geändert.'); + + return self::SUCCESS; + } + + UserAboOrder::where('user_abo_id', $from->id)->update(['user_abo_id' => $to->id]); + + $to->refresh(); + $this->info("Umgehängt. Abo {$to->id} zählt jetzt {$to->getCountPaidOrders()} ausgeführte Lieferung(en)."); + + return self::SUCCESS; + } +} diff --git a/app/Services/AboExternalCompletionService.php b/app/Services/AboExternalCompletionService.php index 26e7f973..bebfde69 100644 --- a/app/Services/AboExternalCompletionService.php +++ b/app/Services/AboExternalCompletionService.php @@ -37,7 +37,9 @@ class AboExternalCompletionService } $userAboOrder = UserAboOrder::where('shopping_order_id', $shoppingOrder->id)->first(); - $userAbo = $userAboOrder?->user_abo; + // Auch geloeschte Abos zulassen: eine ausstehende Lieferung eines ersetzten/ + // geloeschten Abos muss nachtraeglich erledigt werden koennen (Punkte + Zaehler). + $userAbo = $userAboOrder ? $userAboOrder->user_abo()->withTrashed()->first() : null; if (! $userAboOrder || ! $userAbo) { return ['success' => false, 'message' => __('abo.extern_done_no_order')]; @@ -65,18 +67,24 @@ class AboExternalCompletionService $shoppingOrder->save(); // Blockade aufheben: Abo zurueck in den regulaeren Zyklus. - $userAbo->update([ - 'status' => self::STATUS_ABO_OKAY, - 'active' => true, - 'last_date' => now(), - 'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval), - ]); + // Geloeschte (ersetzte) Abos bleiben unangetastet - dort werden nur + // Punkte und Lieferstatus nachgezogen, kein neuer Zyklus gestartet. + if (! $userAbo->trashed()) { + $userAbo->update([ + 'status' => self::STATUS_ABO_OKAY, + 'active' => true, + 'last_date' => now(), + 'next_date' => AboHelper::setNextDate(now(), $userAbo->abo_interval), + ]); + } }); // Wie bei einer erfolgreichen Ausfuehrung: Incentive tracken und // Einmal-Artikel entfernen. Beide Aufrufe sind idempotent. IncentiveTracker::trackAboActivated($shoppingOrder); - AboOneTimeService::purgeAfterExecution($userAbo); + if (! $userAbo->trashed()) { + AboOneTimeService::purgeAfterExecution($userAbo); + } \Log::channel('abo_order')->info('AboExternalCompletionService: Bestellung extern erledigt', [ 'abo_id' => $userAbo->id, diff --git a/app/Services/Incentive/IncentiveTracker.php b/app/Services/Incentive/IncentiveTracker.php index 3b638dc7..7c7be3de 100644 --- a/app/Services/Incentive/IncentiveTracker.php +++ b/app/Services/Incentive/IncentiveTracker.php @@ -9,6 +9,7 @@ use App\Models\IncentiveParticipant; use App\Models\IncentivePointsLog; use App\Models\ShoppingOrder; use App\Models\UserAbo; +use App\Models\UserAboOrder; use App\Models\UserSalesVolume; use App\User; use Carbon\Carbon; @@ -197,7 +198,11 @@ class IncentiveTracker $order = ShoppingOrder::find($user_sales_volume->shopping_order_id); if ($order) { - $userAboFromOrder = $order->getUserAbo(); + // Bewusst inkl. geloeschter Abos: ein bereits getracktes Abo kann + // geloescht/ersetzt worden sein, waehrend Umsaetze nachgebucht werden + // (z. B. extern erledigte Lieferungen) - die zaehlen weiterhin. + $userAboFromOrder = UserAboOrder::where('shopping_order_id', $order->id) + ->first()?->user_abo()->withTrashed()->first(); if (! $userAboFromOrder || ! in_array($userAboFromOrder->is_for, ['ot', 'me'], true)) { $userAboFromOrder = null; } diff --git a/dev/2026-08-12-intensiv-tickets/README.md b/dev/2026-08-12-intensiv-tickets/README.md index ca1017b8..2482f671 100644 --- a/dev/2026-08-12-intensiv-tickets/README.md +++ b/dev/2026-08-12-intensiv-tickets/README.md @@ -124,6 +124,49 @@ Umsetzung: Verifiziert auf der Live-Kopie (19.08.): 13 August-Bestellungen geprüft, 12 bereits gebucht, genau 1 nachgebucht (Order 59900, Jasmin Grund +200 P), Juni/Juli unberührt. +## Nachtrag 2026-08-21: Ticket Kunz Teil 2 – Mai-Punkte & Bockhacker-Lieferzähler + +Re-Verifikation mit DHL-Daten ergab ein anderes Bild als die erste Analyse: + +- Die Mai-Lieferungen **wurden versandt und zugestellt** (DHL 12.05.), aber nie bezahlt + (`txaction='prev'`). Am 17.06. hat ein Admin zwei Bestellungen per „extern erledigt" + (Status 8) nachgebucht: Bockhackers **Mai**-Order 53619 (24 P) und Dannenhauers + **Juni**-Order 55443 (54 P) – Buchungsmonat jeweils Juni (Systemregel: aktueller Monat). +- **Bockhacker:** Es fehlt nichts – die 24 P der Mai-Lieferung existieren (als Juni + gebucht). Kein Nachtrag, sonst Doppelbuchung. +- **Dannenhauer:** Ihre Mai-Order 53274 (geliefert!) wurde beim Nachtragen am 17.06. + **vergessen** → 54 P fehlten wirklich. Nachgebucht am 21.08. per neuem Command + `abo:extern-done 53274` (Buchung August, keine Retro-Buchung; +54 P auch im Incentive). + Hinweis: Abo 198 ist seit 06.07. gelöscht, Kundin hat kein aktives Abo mehr; die + Orders 54890/55441/55442/57268 wurden laut DHL nie versandt und bleiben offen. +- **Bockhacker Lieferzähler:** Abo 197 (09.04.) wurde am 06.07. gelöscht und durch + Abo 496 ersetzt → Zähler startete bei 0. Per neuem Command `abo:move-orders 197 496` + wurde die Lieferhistorie umgehängt; Abo 496 zählt jetzt **4 ausgeführte Lieferungen** + (09.04., 12.05. [extern erledigt], 10.07., 10.08.). Laut DHL war die Juli-Lieferung + die **3.** (nicht die 4. wie im Ticket) – es gab keine Juni-Sendung an Bockhacker; + die Juni-*Buchung* vom 17.06. gehörte zur Mai-Lieferung. + +Code-Änderungen dafür: + +- `AboExternalCompletionService`: funktioniert jetzt auch für Bestellungen + gelöschter/ersetzter Abos (Punkte + Status 8 + Versandstatus; das gelöschte Abo wird + **nicht** reaktiviert, kein neuer Zyklus). +- `IncentiveTracker::trackSalesVolume`: Abo-Zuordnung inkl. gelöschter Abos + (`withTrashed`) – nachgebuchte Umsätze bereits getrackter, inzwischen gelöschter + Abos zählen weiter im Incentive. +- Neue Wartungs-Commands: `abo:extern-done {shopping_order_id}` und + `abo:move-orders {from_abo} {to_abo} [--dry-run]` (Guards: gleicher Kunde/Berater). +- Fix eines latent datumsabhängigen Tests in `AboExternalCompletionTest` + (Erwartung nutzte `setTestNow`, Produktion bucht mit `date()`). + +**Live-Ausführung nach Deploy:** + +```bash +php artisan abo:extern-done 53274 # Dannenhauer Mai-Lieferung: +54 P an Kunz +php artisan abo:move-orders 197 496 # Bockhacker Lieferhistorie → Abo 496 +php artisan incentive:calculate 2 # optional sofort (sonst Nacht-Cron 05:00) +``` + ## Geänderte Dateien | Datei | Änderung | diff --git a/tests/Feature/AboExternalCompletionTest.php b/tests/Feature/AboExternalCompletionTest.php index 1d5ea819..402250df 100644 --- a/tests/Feature/AboExternalCompletionTest.php +++ b/tests/Feature/AboExternalCompletionTest.php @@ -3,6 +3,7 @@ use App\Models\Country; use App\Models\Incentive; use App\Models\IncentiveNewAbo; +use App\Models\IncentiveParticipant; use App\Models\ShoppingOrder; use App\Models\ShoppingUser; use App\Models\UserAbo; @@ -11,6 +12,7 @@ use App\Models\UserAccount; use App\Models\UserSalesVolume; use App\Services\AboExternalCompletionService; use App\User; +use Carbon\Carbon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -134,7 +136,7 @@ it('markiert eine angehaltene Abo-Bestellung als extern erledigt, schreibt Punkt // Blockade aufgehoben: zurueck auf okay, aktiv, next_date in der Zukunft expect((int) $userAbo->status)->toBe(2) ->and($userAbo->active)->toBeTrue(); - expect(\Carbon\Carbon::parse($userAbo->getRawOriginal('next_date'))->isFuture())->toBeTrue(); + expect(Carbon::parse($userAbo->getRawOriginal('next_date'))->isFuture())->toBeTrue(); // Abo-Lieferungs-Zaehler beruecksichtigt die extern erledigte Ausfuehrung. $fresh = UserAbo::find($userAbo->id); @@ -153,14 +155,14 @@ it('markiert eine angehaltene Abo-Bestellung als extern erledigt, schreibt Punkt }); it('trackt Incentive-Punkte mit dem Bestelldatum (Ausfuehrungsmonat), nicht mit heute', function () { - \Carbon\Carbon::setTestNow('2026-06-15'); + Carbon::setTestNow('2026-06-15'); [$userAbo, $order, , $user] = heldAboSeed(); // Die vorgesehene Ausfuehrung lag im April; das Incentive-Fenster deckt nur // April ab (nicht Juni/heute). Wuerde der Tracker "heute" verwenden, faende // er das Incentive nicht und legte keinen IncentiveNewAbo-Eintrag an. - $executionDate = \Carbon\Carbon::parse('2026-04-10'); + $executionDate = Carbon::parse('2026-04-10'); ShoppingOrder::where('id', $order->id)->update(['created_at' => $executionDate]); Incentive::factory()->active()->create([ @@ -177,15 +179,16 @@ it('trackt Incentive-Punkte mit dem Bestelldatum (Ausfuehrungsmonat), nicht mit ->and($newAbo->activated_at->toDateString())->toBe('2026-04-10'); // Incentive-Zaehler (qualified_abos) des Teilnehmers wurde erhoeht. - $participant = \App\Models\IncentiveParticipant::find($newAbo->participant_id); + $participant = IncentiveParticipant::find($newAbo->participant_id); expect((int) $participant->qualified_abos)->toBe(1); - // Reguläre Provisionspunkte hingegen mit heutigem Datum (Juni). + // Reguläre Provisionspunkte hingegen mit dem Buchungstag (SalesPointsVolume + // nutzt date(), nicht Carbon::setTestNow - daher gegen die echte Uhr pruefen). $volume = UserSalesVolume::where('shopping_order_id', $order->id)->first(); - expect((int) $volume->month)->toBe(6) - ->and((int) $volume->year)->toBe(2026); + expect((int) $volume->month)->toBe((int) date('m')) + ->and((int) $volume->year)->toBe((int) date('Y')); - \Carbon\Carbon::setTestNow(); + Carbon::setTestNow(); }); it('ist idempotent und schreibt Punkte nicht doppelt gut', function () { @@ -220,6 +223,28 @@ it('verweigert die Aktion fuer eine Nicht-Abo-Bestellung', function () { expect($result['success'])->toBeFalse(); }); +it('erledigt Bestellungen geloeschter (ersetzter) Abos: Punkte + Zaehler, ohne das Abo zu reaktivieren', function () { + [$userAbo, $order, $aboOrder, $user] = heldAboSeed(); + $userAbo->delete(); + + $result = app(AboExternalCompletionService::class)->complete($order->fresh()); + + expect($result['success'])->toBeTrue(); + expect((int) $aboOrder->fresh()->status)->toBe(8); + + // Abo bleibt geloescht und unveraendert - kein neuer Zyklus, keine Reaktivierung + $trashed = UserAbo::withTrashed()->find($userAbo->id); + expect($trashed->trashed())->toBeTrue() + ->and((int) $trashed->getRawOriginal('status'))->toBe(3) + ->and(Carbon::parse($trashed->getRawOriginal('next_date'))->isPast())->toBeTrue(); + + // Punkte trotzdem gutgeschrieben + $volume = UserSalesVolume::where('shopping_order_id', $order->id)->first(); + expect($volume)->not->toBeNull() + ->and((float) $volume->points)->toBe(25.0) + ->and((int) $volume->user_id)->toBe($user->id); +}); + it('erledigt ueber die Abo-Detailseite die juengste angehaltene Bestellung', function () { [$userAbo, $order] = heldAboSeed(); diff --git a/tests/Feature/AboMoveOrdersTest.php b/tests/Feature/AboMoveOrdersTest.php new file mode 100644 index 00000000..23f527ac --- /dev/null +++ b/tests/Feature/AboMoveOrdersTest.php @@ -0,0 +1,88 @@ + 'DE'], [ + 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', + 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $memberId, + 'member_id' => $memberId, + 'billing_email' => $email, + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'is_for' => 'ot', + ]); + + $abo = UserAbo::create([ + 'shopping_user_id' => $shoppingUser->id, + 'member_id' => $memberId, + 'is_for' => 'ot', + 'email' => $email, + 'payone_userid' => 1, + 'clearingtype' => 'cc', + 'active' => ! $deleted, + 'status' => 2, + 'abo_interval' => 5, + ]); + + if ($deleted) { + $abo->delete(); + } + + return $abo; +} + +it('haengt die Lieferhistorie eines geloeschten Abos auf das Nachfolge-Abo um', function () { + $old = moveOrdersSeedAbo('kundin@example.com', 42, deleted: true); + $new = moveOrdersSeedAbo('kundin@example.com', 42); + + UserAboOrder::create(['user_abo_id' => $old->id, 'shopping_order_id' => 1001, 'status' => 2, 'paid' => true]); + UserAboOrder::create(['user_abo_id' => $old->id, 'shopping_order_id' => 1002, 'status' => 8, 'paid' => false]); + UserAboOrder::create(['user_abo_id' => $new->id, 'shopping_order_id' => 1003, 'status' => 2, 'paid' => true]); + + $this->artisan('abo:move-orders', ['from_abo' => $old->id, 'to_abo' => $new->id]) + ->assertSuccessful(); + + expect(UserAboOrder::where('user_abo_id', $old->id)->count())->toBe(0) + ->and(UserAboOrder::where('user_abo_id', $new->id)->count())->toBe(3); + + // bezahlte + extern erledigte (Status 8) Lieferungen zaehlen fuer die Mindestlaufzeit + expect(UserAbo::find($new->id)->getCountPaidOrders())->toBe(3); +}); + +it('verweigert das Umhaengen zwischen Abos verschiedener Kunden', function () { + $old = moveOrdersSeedAbo('kundin-a@example.com', 42, deleted: true); + $new = moveOrdersSeedAbo('kundin-b@example.com', 42); + + UserAboOrder::create(['user_abo_id' => $old->id, 'shopping_order_id' => 1001, 'status' => 2, 'paid' => true]); + + $this->artisan('abo:move-orders', ['from_abo' => $old->id, 'to_abo' => $new->id]) + ->assertFailed(); + + expect(UserAboOrder::where('user_abo_id', $old->id)->count())->toBe(1); +}); + +it('aendert im dry-run nichts', function () { + $old = moveOrdersSeedAbo('kundin@example.com', 42, deleted: true); + $new = moveOrdersSeedAbo('kundin@example.com', 42); + + UserAboOrder::create(['user_abo_id' => $old->id, 'shopping_order_id' => 1001, 'status' => 2, 'paid' => true]); + + $this->artisan('abo:move-orders', ['from_abo' => $old->id, 'to_abo' => $new->id, '--dry-run' => true]) + ->assertSuccessful(); + + expect(UserAboOrder::where('user_abo_id', $old->id)->count())->toBe(1) + ->and(UserAboOrder::where('user_abo_id', $new->id)->count())->toBe(0); +}); diff --git a/tests/Feature/Incentive/DeletedAboSalesVolumeIncentiveTest.php b/tests/Feature/Incentive/DeletedAboSalesVolumeIncentiveTest.php new file mode 100644 index 00000000..b2fb5365 --- /dev/null +++ b/tests/Feature/Incentive/DeletedAboSalesVolumeIncentiveTest.php @@ -0,0 +1,122 @@ + 'berater-'.uniqid('', true).'@example.com', + 'password' => bcrypt('secret'), + 'lang' => 'de', + ]); + + $incentive = Incentive::factory()->create([ + 'qualification_start' => '2026-04-01', + 'qualification_end' => '2026-08-31', + 'calculation_end' => '2026-08-31', + 'status' => 1, + ]); + $participant = IncentiveParticipant::factory()->create([ + 'incentive_id' => $incentive->id, + 'user_id' => $consultant->id, + ]); + + $country = Country::firstOrCreate(['code' => 'DE'], [ + 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania', + 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия', + ]); + + $shoppingUser = ShoppingUser::create([ + 'auth_user_id' => $consultant->id, + 'member_id' => $consultant->id, + 'billing_email' => 'kundin-'.uniqid('', true).'@example.com', + 'billing_country_id' => $country->id, + 'shipping_country_id' => $country->id, + 'is_for' => 'ot', + ]); + + $abo = UserAbo::create([ + 'shopping_user_id' => $shoppingUser->id, + 'member_id' => $consultant->id, + 'is_for' => 'ot', + 'email' => 'kundin-'.uniqid('', true).'@example.com', + 'payone_userid' => 1, + 'clearingtype' => 'cc', + 'active' => true, + 'status' => 2, + 'abo_interval' => 5, + ]); + + IncentiveNewAbo::create([ + 'participant_id' => $participant->id, + 'user_abo_id' => $abo->id, + 'activated_at' => '2026-04-09', + ]); + + $order = ShoppingOrder::create([ + 'shopping_user_id' => $shoppingUser->id, + 'auth_user_id' => $consultant->id, + 'country_id' => $country->id, + 'user_shop_id' => 1, + 'payment_for' => 6, + 'total' => 92.60, + 'subtotal' => 82.79, + 'points' => 54, + 'paid' => false, + 'is_abo' => true, + 'mode' => 'live', + ]); + UserAboOrder::create([ + 'user_abo_id' => $abo->id, + 'shopping_order_id' => $order->id, + 'status' => 8, + 'paid' => false, + ]); + + // Abo wurde inzwischen geloescht (ersetzt) - wie im Fall Dannenhauer (#198) + $abo->delete(); + + $usv = UserSalesVolume::create([ + 'user_id' => $consultant->id, + 'shopping_order_id' => $order->id, + 'month' => 8, + 'year' => 2026, + 'points' => 54, + 'status_points' => 1, + 'total_net' => 82.79, + 'status' => 2, + ]); + + IncentiveTracker::trackSalesVolume($usv); + + $log = IncentivePointsLog::where('participant_id', $participant->id) + ->where('user_sales_volume_id', $usv->id) + ->where('is_storno', false) + ->first(); + + expect($log)->not->toBeNull() + ->and((int) $log->points_accumulated)->toBe(54) + ->and((int) $log->month)->toBe(8); +});