Incentive: Schalter "Abos beruecksichtigen", Recruiting Challenge ohne Abos

Die Recruiting Challenge erfasste Eigen- und Kundenabos mit 0 Punkten
(Live-Kopie: 175 Eigenabos). Sie tauchten in Details und Admin auf und
brachten Berater ohne Neupartner in die Rangliste.

- incentives.include_abos (Standard an); Migration schaltet Incentives
  ohne jede Abo-Wertung aus (0 Abo-Punkte, 0 Mindestabos, ohne Umsatzpunkte)
- aus: kein Abo-Tracking in Tracker, Neuaufbau und Reparatur;
  incentive:calculate raeumt vorhandene Abo-Zeilen ab
- Admin: Checkbox im Formular, Abo-Punkte/Mindestabos werden dann auf 0
  gesetzt; Abo-Spalte und -Bereiche in Admin, Details und Teilnahme-Hinweis
  entfallen
- Seeder der Recruiting Challenge setzt den Schalter aus

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-24 11:37:32 +00:00
parent 773d523bf7
commit 11afdb11d1
19 changed files with 306 additions and 34 deletions

View file

@ -97,6 +97,7 @@ class IncentiveCalculate extends Command
'tracking_partner_added' => 0,
'tracking_partner_removed' => 0,
'tracking_abo_added' => 0,
'tracking_abo_removed' => 0,
'repair_partner_fk' => 0,
'repair_abo_fk' => 0,
'repair_onetime_partner_fk' => 0,
@ -130,6 +131,7 @@ class IncentiveCalculate extends Command
$stats['repair_onetime_abo_fk'] += $r['onetime_abo_fk'];
$stats['tracking_partner_removed'] += $repairService->removeStaleSponsorTrackings($participant);
$stats['tracking_partner_added'] += $repairService->syncMissingTrackingPartners($participant);
$stats['tracking_abo_removed'] += $repairService->removeExcludedAboTrackings($participant);
$stats['tracking_abo_added'] += $repairService->syncMissingTrackingAbos($participant);
$stats['sv_logs_added'] += $repairService->syncMissingSalesVolumeLogs($participant);
}
@ -186,6 +188,7 @@ class IncentiveCalculate extends Command
$tableRows[] = ['Neupartner-Trackings nachgezogen', (string) $stats['tracking_partner_added']];
$tableRows[] = ['Neupartner-Trackings entfernt (Sponsorwechsel)', (string) $stats['tracking_partner_removed']];
$tableRows[] = ['Neuabo-Trackings nachgezogen', (string) $stats['tracking_abo_added']];
$tableRows[] = ['Abo-Trackings entfernt (Incentive ohne Abos)', (string) $stats['tracking_abo_removed']];
$tableRows[] = ['FK Partner (akkum.) repariert', (string) $stats['repair_partner_fk']];
$tableRows[] = ['FK Abo (akkum.) repariert', (string) $stats['repair_abo_fk']];
$tableRows[] = ['FK Partner (Einmal) repariert', (string) $stats['repair_onetime_partner_fk']];

View file

@ -59,6 +59,7 @@ class IncentiveController extends Controller
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean',
'include_abos' => 'nullable|boolean',
'vip_only' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2',
]);
@ -112,7 +113,9 @@ class IncentiveController extends Controller
}
/**
* Leere Betragsfelder als NULL speichern, Checkbox als bool.
* Leere Betragsfelder als NULL speichern, Checkboxen als bool. Ohne Abos werden
* Abo-Einmalpunkte und Mindestabos auf 0 gesetzt, sonst waere die Qualifikation
* unerreichbar.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
@ -124,6 +127,11 @@ class IncentiveController extends Controller
}
$data['include_sales_points'] = filter_var($data['include_sales_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
$data['vip_only'] = filter_var($data['vip_only'] ?? false, FILTER_VALIDATE_BOOLEAN);
$data['include_abos'] = filter_var($data['include_abos'] ?? false, FILTER_VALIDATE_BOOLEAN);
if (! $data['include_abos']) {
$data['points_abo_onetime'] = 0;
$data['min_customer_abos'] = 0;
}
return $data;
}
@ -159,6 +167,7 @@ class IncentiveController extends Controller
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean',
'include_abos' => 'nullable|boolean',
'vip_only' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2',
]);

View file

@ -51,7 +51,7 @@ class IncentiveController extends Controller
->paginate(100);
$participateHasTrackableAbos = false;
if (! $participant?->accepted_terms_at) {
if (! $participant?->accepted_terms_at && $incentive->includesAbos()) {
$participateHasTrackableAbos = $this->userHasTrackableAbosForIncentive($user, $incentive);
}

View file

@ -71,6 +71,7 @@ class Incentive extends Model
'bonus_rank_2' => 'float',
'bonus_rank_3' => 'float',
'include_sales_points' => 'bool',
'include_abos' => 'bool',
'status' => 'int',
'vip_only' => 'bool',
'qualification_start' => 'date',
@ -101,6 +102,7 @@ class Incentive extends Model
'bonus_rank_2',
'bonus_rank_3',
'include_sales_points',
'include_abos',
'status',
'vip_only',
];
@ -365,6 +367,15 @@ class Incentive extends Model
return $this->include_sales_points === null || (bool) $this->include_sales_points;
}
/**
* Werden Abos ueberhaupt erfasst? Aus = reine Recruiting-Challenge, weder Kunden-
* noch Eigenabos landen im Tracking.
*/
public function includesAbos(): bool
{
return $this->include_abos === null || (bool) $this->include_abos;
}
/**
* Gibt es eine Mindestqualifikation (direkte Partner oder Kundenabos)?
*/
@ -378,7 +389,7 @@ class Incentive extends Model
*/
public function usesAbos(): bool
{
return $this->points_abo_onetime > 0 || $this->min_customer_abos > 0;
return $this->includesAbos() && ($this->points_abo_onetime > 0 || $this->min_customer_abos > 0);
}
/**

View file

@ -325,7 +325,12 @@ class IncentiveParticipant extends Model
->where('created_at', '<', $qualStart)
->get();
foreach ($customerAbosInPeriod->concat($ownAbosInPeriod)->concat($ownAbosPreExisting) as $abo) {
// Incentive ohne Abos (reine Recruiting-Challenge): nichts erfassen
$abosToTrack = $incentive->includesAbos()
? $customerAbosInPeriod->concat($ownAbosInPeriod)->concat($ownAbosPreExisting)
: collect();
foreach ($abosToTrack as $abo) {
$activatedAt = $abo->created_at;
$logMonth = (int) $abo->created_at->month;
$logYear = (int) $abo->created_at->year;

View file

@ -171,6 +171,23 @@ class IncentivePointsLogRepairService
return $removed;
}
/**
* Incentive ohne Abos: bereits erfasste Abo-Trackings samt Log-Zeilen entfernen
* (z. B. nachdem der Schalter im Admin ausgeschaltet wurde).
*
* @return int Anzahl entfernter Abo-Trackings
*/
public function removeExcludedAboTrackings(IncentiveParticipant $participant): int
{
if (! $participant->incentive || $participant->incentive->includesAbos()) {
return 0;
}
$participant->pointsLog()->where('type', 'abo')->delete();
return $participant->newAbos()->delete();
}
/**
* Fehlende Abo-Tracking-Zeilen anlegen (Kundenabo ot / Eigenabo me, wie IncentiveTracker).
* Zuerst trackAboActivated ueber Erstbestellung; ohne UserAboOrder manuell wie Neuaufbau B.
@ -180,7 +197,7 @@ class IncentivePointsLogRepairService
public function syncMissingTrackingAbos(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
if (! $incentive || ! $incentive->includesAbos()) {
return 0;
}

View file

@ -121,7 +121,8 @@ class IncentiveTracker
$incentives = Incentive::query()
->active()
->qualificationWindowContains($activation_date)
->get();
->get()
->filter(fn (Incentive $incentive) => $incentive->includesAbos());
foreach ($incentives as $incentive) {
$participant = IncentiveParticipant::ensureForIncentiveUser($incentive, $consultant_id);

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Schalter "Abos beruecksichtigen": Aus = das Incentive erfasst weder Kunden- noch
* Eigenabos (keine Tracking-Zeilen, keine Abo-Sektion in Teaser/Details). Fuer eine
* reine Recruiting-Challenge, bei der nur Neupartner zaehlen.
*
* Bestehende Incentives ohne jede Abo-Wertung (0 Einmalpunkte, 0 Mindestabos, ohne
* Umsatzpunkte) werden direkt umgestellt; ihre bereits erfassten Abo-Zeilen raeumt der
* naechste Lauf von incentive:calculate ab.
*/
return new class extends Migration
{
public function up(): void
{
if (Schema::hasColumn('incentives', 'include_abos')) {
return;
}
Schema::table('incentives', function (Blueprint $table) {
$table->boolean('include_abos')->default(true)->after('include_sales_points')
->comment('0 = Abos werden nicht erfasst (reine Recruiting-Challenge)');
});
DB::table('incentives')
->where('points_abo_onetime', 0)
->where('min_customer_abos', 0)
->where('include_sales_points', false)
->update(['include_abos' => false]);
}
public function down(): void
{
Schema::table('incentives', function (Blueprint $table) {
if (Schema::hasColumn('incentives', 'include_abos')) {
$table->dropColumn('include_abos');
}
});
}
};

View file

@ -45,6 +45,7 @@ class RecruitingChallenge2026Seeder extends Seeder
'bonus_rank_2' => 300,
'bonus_rank_3' => 200,
'include_sales_points' => false,
'include_abos' => false,
'status' => 0,
'vip_only' => true,
'image' => 'incentive-recruiting-2026.jpg',

View file

@ -190,6 +190,8 @@ return [
'bonus_rank' => 'Bonus Platz :n (€)',
'include_sales_points' => 'Umsatzpunkte der Neupartner/Abos mitzählen',
'include_sales_points_help' => 'Aus: Die Wertung besteht nur aus den Einmalpunkten (z. B. 1 Punkt je Neupartner = Rangliste nach Anzahl Neupartner).',
'include_abos' => 'Abos berücksichtigen (Kunden- und Eigenabos)',
'include_abos_help' => 'Aus: Das Incentive erfasst keine Abos. Abo-Punkte, Mindestanzahl Abos und die Abo-Bereiche in Teaser und Details entfallen (z. B. reine Recruiting-Challenge).',
'payout_export' => 'Auszahlungs-Export (XLSX)',
'payout_recipients' => 'Empfänger',
'payout_partner_amount' => 'Prämie Neupartner',

View file

@ -190,6 +190,8 @@ return [
'bonus_rank' => 'Bonus rank :n (€)',
'include_sales_points' => 'Count sales points of new partners/subscriptions',
'include_sales_points_help' => 'Off: The score consists of one-time points only (e.g. 1 point per new partner = ranking by number of new partners).',
'include_abos' => 'Include subscriptions (customer and own subscriptions)',
'include_abos_help' => 'Off: The incentive does not track any subscriptions. Subscription points, minimum number of subscriptions and the subscription sections in teaser and details are omitted (e.g. pure recruiting challenge).',
'payout_export' => 'Payout export (XLSX)',
'payout_recipients' => 'Recipients',
'payout_partner_amount' => 'New partner reward',

View file

@ -190,6 +190,8 @@ return [
'bonus_rank' => 'Bono puesto :n (€)',
'include_sales_points' => 'Contar puntos de ventas de nuevos socios/suscripciones',
'include_sales_points_help' => 'Desactivado: la puntuación consta solo de los puntos únicos (p. ej. 1 punto por nuevo socio = ranking por número de nuevos socios).',
'include_abos' => 'Tener en cuenta suscripciones (de clientes y propias)',
'include_abos_help' => 'Desactivado: el incentivo no registra suscripciones. Se omiten los puntos por suscripción, el número mínimo de suscripciones y las secciones de suscripciones en el teaser y los detalles (p. ej. challenge de reclutamiento pura).',
'payout_export' => 'Exportación de pagos (XLSX)',
'payout_recipients' => 'Destinatarios',
'payout_partner_amount' => 'Prima nuevos socios',

View file

@ -165,6 +165,8 @@ return [
'bonus_rank' => 'Bonus place :n (€)',
'include_sales_points' => 'Compter les points de vente des nouveaux partenaires/abonnements',
'include_sales_points_help' => 'Désactivé : le score ne comprend que les points uniques (p. ex. 1 point par nouveau partenaire = classement par nombre de nouveaux partenaires).',
'include_abos' => 'Prendre en compte les abonnements (clients et personnels)',
'include_abos_help' => 'Désactivé : l’incentive n’enregistre aucun abonnement. Les points d’abonnement, le nombre minimum d’abonnements et les sections abonnements du teaser et des détails sont supprimés (p. ex. challenge de recrutement pur).',
'payout_export' => 'Export des versements (XLSX)',
'payout_recipients' => 'Bénéficiaires',
'payout_partner_amount' => 'Prime nouveaux partenaires',

View file

@ -107,6 +107,16 @@
</div>
</div>
<div class="form-group">
<div class="custom-control custom-checkbox">
<input type="hidden" name="include_abos" value="0">
<input type="checkbox" class="custom-control-input" id="include_abos" name="include_abos" value="1"
@if (old('include_abos', isset($incentive) ? (int) $incentive->includesAbos() : 1)) checked @endif>
<label class="custom-control-label" for="include_abos">{{ __('incentive.include_abos') }}</label>
</div>
<small class="form-text text-muted">{{ __('incentive.include_abos_help') }}</small>
</div>
<div class="form-row">
<div class="form-group col-md-6">
<label for="min_direct_partners">{{ __('incentive.min_direct_partners') }} *</label>

View file

@ -26,16 +26,18 @@
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center bg-light">
<div class="card-body py-2">
<small class="text-muted">{{ __('incentive.abos') }}</small>
<h4 class="mb-0 font-weight-bold {{ $participant->qualified_abos >= $incentive->min_customer_abos ? 'text-success' : 'text-danger' }}">
{{ $participant->qualified_abos }}/{{ $incentive->min_customer_abos }}
</h4>
@if ($incentive->includesAbos())
<div class="col-md-3">
<div class="card text-center bg-light">
<div class="card-body py-2">
<small class="text-muted">{{ __('incentive.abos') }}</small>
<h4 class="mb-0 font-weight-bold {{ $participant->qualified_abos >= $incentive->min_customer_abos ? 'text-success' : 'text-danger' }}">
{{ $participant->qualified_abos }}/{{ $incentive->min_customer_abos }}
</h4>
</div>
</div>
</div>
</div>
@endif
</div>
{{-- Sektion A: Neupartner-Punkte --}}
@ -50,12 +52,14 @@
])
</div>
{{-- Sektion B: Kundenabo-Punkte --}}
<h6 class="font-weight-bold">{{ __('incentive.section_abos') }}</h6>
@include('partials.incentive._source_table', [
'sources' => $abo_sources,
'type' => 'abo',
'label_header' => __('incentive.customer_abo'),
'date_header' => __('incentive.abo_date'),
'empty_message' => __('incentive.no_abos_yet'),
])
{{-- Sektion B: Kundenabo-Punkte (entfaellt bei Incentives ohne Abos) --}}
@if ($incentive->includesAbos())
<h6 class="font-weight-bold">{{ __('incentive.section_abos') }}</h6>
@include('partials.incentive._source_table', [
'sources' => $abo_sources,
'type' => 'abo',
'label_header' => __('incentive.customer_abo'),
'date_header' => __('incentive.abo_date'),
'empty_message' => __('incentive.no_abos_yet'),
])
@endif

View file

@ -56,7 +56,9 @@
<strong>{{ __('incentive.min_direct_partners') }}:</strong> {{ $incentive->min_direct_partners }}<br>
<strong>{{ __('incentive.min_customer_abos') }}:</strong> {{ $incentive->min_customer_abos }}<br>
<strong>{{ __('incentive.include_sales_points') }}:</strong>
{{ $incentive->includesSalesPoints() ? __('incentive.yes') : __('incentive.no') }}
{{ $incentive->includesSalesPoints() ? __('incentive.yes') : __('incentive.no') }}<br>
<strong>{{ __('incentive.include_abos') }}:</strong>
{{ $incentive->includesAbos() ? __('incentive.yes') : __('incentive.no') }}
</div>
</div>
@if ($incentive->hasCashPayout())
@ -103,7 +105,9 @@
<th>{{ __('incentive.admin_terms_accepted') }}</th>
<th>{{ __('incentive.total_points') }}</th>
<th>{{ __('incentive.partners') }}</th>
<th>{{ __('incentive.abos') }}</th>
@if ($incentive->includesAbos())
<th>{{ __('incentive.abos') }}</th>
@endif
<th>{{ __('incentive.qualified') }}</th>
@if ($incentive->hasCashPayout())
<th class="text-right">{{ __('incentive.payout_total') }}</th>
@ -144,12 +148,14 @@
<span class="text-success">&#10003;</span>
@endif
</td>
<td>
{{ $p->qualified_abos }}/{{ $incentive->min_customer_abos }}
@if ($p->qualified_abos >= $incentive->min_customer_abos)
<span class="text-success">&#10003;</span>
@endif
</td>
@if ($incentive->includesAbos())
<td>
{{ $p->qualified_abos }}/{{ $incentive->min_customer_abos }}
@if ($p->qualified_abos >= $incentive->min_customer_abos)
<span class="text-success">&#10003;</span>
@endif
</td>
@endif
<td>
@if ($p->is_qualified)
<span class="badge badge-success">{{ __('incentive.yes') }}</span>
@ -189,7 +195,7 @@
</tr>
@empty
<tr>
<td colspan="{{ $incentive->hasCashPayout() ? 11 : 10 }}" class="text-center text-muted">{{ __('incentive.no_participants') }}</td>
<td colspan="{{ ($incentive->hasCashPayout() ? 11 : 10) - ($incentive->includesAbos() ? 0 : 1) }}" class="text-center text-muted">{{ __('incentive.no_participants') }}</td>
</tr>
@endforelse
</tbody>

View file

@ -49,7 +49,7 @@
</div>
</div>
</div>
@else
@elseif ($incentive->includesAbos())
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
@ -80,7 +80,7 @@
</div>
{{-- Sektion B: Kundenabo-Punkte (entfaellt bei reiner Recruiting-Challenge) --}}
@if ($incentive->usesAbos() || $abo_sources->isNotEmpty())
@if ($incentive->includesAbos() && ($incentive->usesAbos() || $abo_sources->isNotEmpty()))
<div class="card mb-4">
<div class="card-header">
<strong>{{ __('incentive.section_abos') }}</strong>

View file

@ -145,3 +145,21 @@ it('zeigt auf der Admin-Detailseite Export-Button und Praemienspalte nur bei Gel
->assertOk()
->assertDontSee('id="incentive-payout-export"', false);
});
it('setzt Abo-Punkte und Mindestabos auf 0, wenn Abos nicht beruecksichtigt werden', function () {
$this->actingAs(payoutAdmin());
$this->post(route('admin_incentive_store'), challengePayload([
'include_abos' => '0', 'points_abo_onetime' => 400, 'min_customer_abos' => 6,
]))->assertRedirect(route('admin_incentives'));
$incentive = Incentive::where('name', 'Recruiting-Challenge Okt-Jan')->firstOrFail();
expect($incentive->includesAbos())->toBeFalse()
->and($incentive->points_abo_onetime)->toBe(0)
->and($incentive->min_customer_abos)->toBe(0);
$this->get(route('admin_incentive_show', [$incentive->id]))
->assertOk()
->assertDontSee('<th>'.__('incentive.abos').'</th>', false);
});

View file

@ -0,0 +1,134 @@
<?php
use App\Models\Country;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
uses(RefreshDatabase::class);
/**
* Kundenwunsch 24.09.2026: Bei der Recruiting-Challenge sollen keine Abos auftauchen.
* Schalter "Abos beruecksichtigen" im Incentive; aus = kein Abo-Tracking.
*/
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-11-15 10:00:00'));
});
afterEach(fn () => Carbon::setTestNow());
/**
* Berater mit Eigenabo, das schon vor dem Qualifikationsstart lief.
*
* @return array{0: User, 1: UserAbo}
*/
function includeAbosConsultant(): array
{
$country = Country::firstOrCreate(['code' => 'DE'], [
'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania',
'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
$consultant = User::forceCreate([
'email' => 'inc-abos-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
]);
$shoppingUser = ShoppingUser::create([
'auth_user_id' => $consultant->id,
'member_id' => $consultant->id,
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'billing_email' => 'me-'.uniqid('', true).'@example.com',
'is_from' => 'wizard',
]);
$userAbo = UserAbo::create([
'user_id' => $consultant->id,
'member_id' => null,
'shopping_user_id' => $shoppingUser->id,
'is_for' => 'me',
'email' => $shoppingUser->billing_email,
'payone_userid' => 999003,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 1,
]);
$userAbo->forceFill(['created_at' => '2026-06-15 12:00:00'])->save();
return [$consultant, $userAbo];
}
function includeAbosIncentive(bool $includeAbos): Incentive
{
return Incentive::factory()->create([
'qualification_start' => '2026-10-01',
'qualification_end' => '2027-01-31',
'calculation_end' => '2027-01-31',
'status' => 1,
'points_partner_onetime' => 1,
'points_abo_onetime' => 0,
'min_direct_partners' => 0,
'min_customer_abos' => 0,
'include_sales_points' => false,
'include_abos' => $includeAbos,
]);
}
it('erfasst bei ausgeschaltetem Schalter keine Abos', function () {
[$consultant] = includeAbosConsultant();
$incentive = includeAbosIncentive(false);
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $consultant->id,
]);
Artisan::call('incentive:calculate', ['incentive_id' => (string) $incentive->id]);
expect(IncentiveNewAbo::where('participant_id', $participant->id)->exists())->toBeFalse();
Artisan::call('incentive:calculate', ['incentive_id' => (string) $incentive->id, '--force' => true]);
expect(IncentiveNewAbo::where('participant_id', $participant->id)->exists())->toBeFalse()
->and($participant->fresh()->qualified_abos)->toBe(0)
->and($incentive->usesAbos())->toBeFalse();
});
it('erfasst das Eigenabo weiterhin, solange der Schalter an ist', function () {
[$consultant, $userAbo] = includeAbosConsultant();
$incentive = includeAbosIncentive(true);
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $consultant->id,
]);
Artisan::call('incentive:calculate', ['incentive_id' => (string) $incentive->id]);
expect(IncentiveNewAbo::where('participant_id', $participant->id)->where('user_abo_id', $userAbo->id)->exists())
->toBeTrue();
});
it('raeumt bereits erfasste Abos beim naechsten Lauf ab, wenn der Schalter aus ist', function () {
[$consultant, $userAbo] = includeAbosConsultant();
$incentive = includeAbosIncentive(true);
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $consultant->id,
]);
Artisan::call('incentive:calculate', ['incentive_id' => (string) $incentive->id]);
expect($participant->fresh()->qualified_abos)->toBe(1);
$incentive->update(['include_abos' => false]);
Artisan::call('incentive:calculate', ['incentive_id' => (string) $incentive->id]);
expect(IncentiveNewAbo::where('participant_id', $participant->id)->exists())->toBeFalse()
->and(IncentivePointsLog::where('participant_id', $participant->id)->where('type', 'abo')->exists())->toBeFalse()
->and($participant->fresh()->qualified_abos)->toBe(0);
});