Recruiting Challenge: Storno nimmt Neupartner raus, Rang und Auszahlung nur mit Bestaetigung

Kundenentscheid 25.09.2026:
- Storno: Ist jede bezahlte Starterpaket-Bestellung eines Neupartners
  storniert (txaction = cancelled), bekommt das Tracking cancelled_at,
  eine Storno-Logzeile nimmt die Einmalpunkte zurueck. Nachtlauf und
  Neuaufbau markieren; "Meine Berechnung" zeigt den Partner durchgestrichen
  mit "Storniert am ..., zaehlt nicht".
- Gezaehlt wird fuer alle, einen Rang bekommen nur bestaetigte Teilnehmer;
  Export und Summen nur fuer sie. Unbestaetigte sehen "Deine Praemie nach
  Bestaetigung", im Admin "Teilnahme nicht bestaetigt, keine Auszahlung".
- Datenschutz: andere Berater sehen Unbestaetigte in der Rangliste nicht
  mehr (auch nicht anonym), VIP-Konten schon, der eigene Eintrag bleibt.
- Teilnahmebedingungen Punkt 2 und 7 und Hinweistexte in de/en/es/fr.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-25 10:44:49 +00:00
parent 42d2a9c0ab
commit c4edbf97e4
23 changed files with 318 additions and 45 deletions

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\IncentiveController as AdminIncentiveController;
use App\Models\Country;
use App\Models\Incentive;
use App\Models\IncentiveNewPartner;
@ -466,3 +467,44 @@ it('zaehlt Neupartner im Monat der Zahlung, nicht der Bestellung (Kundenentschei
$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]);
});

View file

@ -102,6 +102,7 @@ describe('IncentivePayoutService', function () {
expect($service->forParticipant($challenge, $first))->toBe([
'partners' => 4, 'per_partner' => 50.0, 'partner_amount' => 200.0, 'rank_bonus' => 300.0, 'total' => 500.0,
'confirmed' => true,
])->and($service->forParticipant($challenge, $fourth)['total'])->toBe(50.0)
->and($service->forParticipant($challenge, $notQualified))->toMatchArray(['rank_bonus' => 0.0, 'total' => 100.0]);
});
@ -143,6 +144,25 @@ describe('IncentivePayoutService', function () {
->and($export[0][9])->toBe(450.0);
});
it('zahlt nur an Teilnehmer mit bestaetigter Teilnahme aus', function () {
// Kundenentscheid 25.09.2026: gerechnet wird fuer alle, ausgezahlt nur nach Bestaetigung.
$challenge = recruitingChallenge();
$service = new IncentivePayoutService;
$confirmed = IncentiveParticipant::factory()->create([
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Anna')->id,
'qualified_partners' => 1, 'total_points' => 1, 'is_qualified' => true, 'rank' => 1,
]);
$unconfirmed = IncentiveParticipant::factory()->unconfirmed()->create([
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Nina')->id,
'qualified_partners' => 5, 'total_points' => 5, 'is_qualified' => true, 'rank' => null,
]);
expect($service->forParticipant($challenge, $unconfirmed))->toMatchArray(['total' => 250.0, 'confirmed' => false])
->and($service->rows($challenge)->pluck('participant.id')->all())->toBe([$confirmed->id])
->and($service->exportRows($challenge))->toHaveCount(1);
});
it('zahlt ohne Betrag je Neupartner nur den Rang-Bonus', function () {
$challenge = recruitingChallenge(['amount_per_partner' => null]);
$participant = IncentiveParticipant::factory()->create([
@ -191,7 +211,8 @@ describe('Ansichten', function () {
->toContain('id="incParticipateModal-'.$montenegro->id.'"')
->toContain('id="incParticipateModal-'.$challenge->id.'"')
->toContain(__('incentive.badge_challenge'))
->toContain(__('incentive.payout_so_far'))
// Kundenentscheid 25.09.: vor der Bestaetigung "Deine Praemie nach Bestaetigung"
->toContain(__('incentive.payout_after_confirmation'))
->toContain('400,00 €')
->and(substr_count($html, '<style>'))->toBe(1);
});

View file

@ -76,13 +76,14 @@ it('macht Qualifizierte mit gespeichertem Rang innerhalb max_winners zu Gewinner
->and($notQualifiedTopPoints->refresh()->isWinner())->toBeFalse();
});
it('sortiert bei Punktgleichstand bestaetigte Teilnehmer vor anonymen', function () use ($makeIncentiveRankingUser) {
it('vergibt nur bestaetigten Teilnehmern einen Rang (Kundenentscheid 25.09.2026)', function () use ($makeIncentiveRankingUser) {
$incentive = Incentive::factory()->create();
$anonymous = IncentiveParticipant::factory()->unconfirmed()->create([
'incentive_id' => $incentive->id,
'user_id' => $makeIncentiveRankingUser()->id,
'total_points' => 500,
'total_points' => 900,
'rank' => 1,
]);
$confirmed = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
@ -94,7 +95,7 @@ it('sortiert bei Punktgleichstand bestaetigte Teilnehmer vor anonymen', function
IncentiveTracker::updateRanking($incentive);
expect($confirmed->refresh()->rank)->toBe(1)
->and($anonymous->refresh()->rank)->toBe(2);
->and($anonymous->refresh()->rank)->toBeNull();
});
it('stimmt mit der Leaderboard-Anzeigereihenfolge ueberein', function () use ($makeIncentiveRankingUser) {
@ -189,7 +190,7 @@ describe('Gleichstand: wer den Stand zuerst erreicht hat (Recruiting-Challenge,
->and($fewerButEarly->refresh()->rank)->toBe(2);
});
it('haelt bestaetigte Teilnahme weiterhin vor dem Zeitpunkt', function () use ($challengeWithTie) {
it('laesst unbestaetigte Teilnehmer trotz frueherem Zeitpunkt ohne Rang', function () use ($challengeWithTie) {
[$incentive, $participant] = $challengeWithTie();
$anonymousEarly = $participant(['2026-10-01 08:00:00']);
@ -199,7 +200,7 @@ describe('Gleichstand: wer den Stand zuerst erreicht hat (Recruiting-Challenge,
IncentiveTracker::updateRanking($incentive);
expect($confirmedLate->refresh()->rank)->toBe(1)
->and($anonymousEarly->refresh()->rank)->toBe(2);
->and($anonymousEarly->refresh()->rank)->toBeNull();
});
it('vergibt bei vollstaendigem Gleichstand stabil nach Teilnehmer-ID', function () use ($challengeWithTie) {

View file

@ -204,7 +204,7 @@ describe('Incentive-Seite: Teilnahme-Block oben, Rangliste unten', function () {
$this->get(route('user_incentive_teaser', [$incentive->slug]))
->assertOk()
->assertSee(__('incentive.join_confirm_title'))
->assertSee(__('incentive.payout_so_far'))
->assertSee(__('incentive.payout_after_confirmation'))
->assertSee('600,00 €');
});
@ -274,3 +274,47 @@ describe('Teaser-Seite der Challenge', function () {
}
});
});
/**
* Kundenentscheid 25.09.2026 (Datenschutz): Unbestaetigte zaehlen im Hintergrund mit,
* andere Berater sehen sie aber nicht, auch nicht anonym.
*/
function rankingPrivacyChallenge(): Incentive
{
$incentive = vipOnlyChallenge(['vip_only' => false]);
$confirmedUser = vipTestUser();
$confirmedUser->account->update(['first_name' => 'Anna', 'last_name' => 'Bestaetigt']);
$unconfirmedUser = vipTestUser();
$unconfirmedUser->account->update(['first_name' => 'Nina', 'last_name' => 'Offen']);
IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id, 'user_id' => $confirmedUser->id,
'qualified_partners' => 1, 'total_points' => 1, 'rank' => 1,
]);
IncentiveParticipant::factory()->unconfirmed()->create([
'incentive_id' => $incentive->id, 'user_id' => $unconfirmedUser->id,
'qualified_partners' => 3, 'total_points' => 3, 'rank' => null,
]);
return $incentive;
}
it('blendet unbestaetigte Teilnehmer in der Rangliste fuer normale Berater aus', function () {
$incentive = rankingPrivacyChallenge();
$this->actingAs(vipTestUser());
$this->get(route('user_incentive_teaser', [$incentive->slug]))
->assertOk()
->assertSee('Anna Bestaetigt')
->assertDontSee('Nina Offen')
->assertDontSee(__('incentive.anonymous_consultant'));
});
it('zeigt VIP-Konten auch die unbestaetigten Teilnehmer', function () {
$incentive = rankingPrivacyChallenge();
$this->actingAs(vipTestUser(admin: 1));
$this->get(route('user_incentive_teaser', [$incentive->slug]))
->assertOk()
->assertSee('Anna Bestaetigt')
->assertSee('Nina Offen');
});