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:
parent
42d2a9c0ab
commit
c4edbf97e4
23 changed files with 318 additions and 45 deletions
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue