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>
228 lines
10 KiB
PHP
228 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Models\Incentive;
|
|
use App\Models\IncentiveParticipant;
|
|
use App\Models\UserAccount;
|
|
use App\Services\Incentive\IncentivePayoutService;
|
|
use App\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\URL;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
/**
|
|
* Recruiting-Challenge (Entwicklungsplan September 2026, E1): Geldpraemie je Neupartner
|
|
* plus Rang-Bonus fuer die Gewinnerplaetze, mehrere parallel aktive Incentives.
|
|
*/
|
|
function payoutUser(string $firstName = 'Anna'): User
|
|
{
|
|
$account = UserAccount::create(['first_name' => $firstName, 'last_name' => 'Berater']);
|
|
|
|
return User::forceCreate([
|
|
'email' => 'payout-'.uniqid('', true).'@example.com',
|
|
'password' => bcrypt('secret'),
|
|
'lang' => 'de',
|
|
'account_id' => $account->id,
|
|
'payment_account' => now()->addYear(),
|
|
]);
|
|
}
|
|
|
|
function recruitingChallenge(array $overrides = []): Incentive
|
|
{
|
|
return Incentive::factory()->create(array_merge([
|
|
'name' => 'Recruiting-Challenge',
|
|
'qualification_start' => '2026-10-01',
|
|
'qualification_end' => '2027-01-31',
|
|
'calculation_end' => '2027-02-28',
|
|
'points_partner_onetime' => 1,
|
|
'points_abo_onetime' => 0,
|
|
'min_direct_partners' => 0,
|
|
'min_customer_abos' => 0,
|
|
'max_winners' => 3,
|
|
'amount_per_partner' => 50,
|
|
'bonus_rank_1' => 300,
|
|
'bonus_rank_2' => 200,
|
|
'bonus_rank_3' => 100,
|
|
'include_sales_points' => false,
|
|
], $overrides));
|
|
}
|
|
|
|
describe('Incentive-Modell: Praemienfelder', function () {
|
|
it('erkennt eine Geldpraemie und die Challenge-Wertung nach Neupartnern', function () {
|
|
$challenge = recruitingChallenge();
|
|
$montenegro = Incentive::factory()->create();
|
|
|
|
expect($challenge->hasCashPayout())->toBeTrue()
|
|
->and($challenge->rankBonuses())->toBe([1 => 300.0, 2 => 200.0, 3 => 100.0])
|
|
->and($challenge->includesSalesPoints())->toBeFalse()
|
|
->and($challenge->hasMinimumRequirements())->toBeFalse()
|
|
->and($challenge->usesAbos())->toBeFalse()
|
|
->and($challenge->ranksByPartnerCount())->toBeTrue()
|
|
->and($montenegro->hasCashPayout())->toBeFalse()
|
|
->and($montenegro->includesSalesPoints())->toBeTrue()
|
|
->and($montenegro->ranksByPartnerCount())->toBeFalse();
|
|
});
|
|
|
|
it('gibt den Rang-Bonus nur innerhalb der Gewinnerplaetze', function () {
|
|
$challenge = recruitingChallenge(['max_winners' => 2]);
|
|
|
|
expect($challenge->rankBonus(1))->toBe(300.0)
|
|
->and($challenge->rankBonus(2))->toBe(200.0)
|
|
->and($challenge->rankBonus(3))->toBe(0.0)
|
|
->and($challenge->rankBonus(null))->toBe(0.0);
|
|
});
|
|
|
|
it('liefert aktive Incentives fuer die Navigation in Reihenfolge des Starts', function () {
|
|
$later = recruitingChallenge();
|
|
$earlier = Incentive::factory()->create(['qualification_start' => '2026-04-01']);
|
|
Incentive::factory()->closed()->create();
|
|
Incentive::factory()->draft()->create();
|
|
|
|
expect(Incentive::activeForNavigation()->pluck('id')->all())->toBe([$earlier->id, $later->id]);
|
|
});
|
|
});
|
|
|
|
describe('IncentivePayoutService', function () {
|
|
it('rechnet Neupartner mal Betrag plus Rang-Bonus fuer qualifizierte Gewinner', function () {
|
|
$challenge = recruitingChallenge();
|
|
$service = new IncentivePayoutService;
|
|
|
|
$first = IncentiveParticipant::factory()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
|
'qualified_partners' => 4, 'total_points' => 4, 'is_qualified' => true, 'rank' => 1,
|
|
]);
|
|
$fourth = IncentiveParticipant::factory()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
|
'qualified_partners' => 1, 'total_points' => 1, 'is_qualified' => true, 'rank' => 4,
|
|
]);
|
|
$notQualified = IncentiveParticipant::factory()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
|
'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => false, 'rank' => 2,
|
|
]);
|
|
|
|
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]);
|
|
});
|
|
|
|
it('listet nur Teilnehmer mit Praemie, nach Rang, und summiert', function () {
|
|
$challenge = recruitingChallenge();
|
|
$service = new IncentivePayoutService;
|
|
|
|
$second = IncentiveParticipant::factory()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Bea')->id,
|
|
'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => true, 'rank' => 2,
|
|
]);
|
|
$first = IncentiveParticipant::factory()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Anna')->id,
|
|
'qualified_partners' => 3, 'total_points' => 3, 'is_qualified' => true, 'rank' => 1,
|
|
]);
|
|
IncentiveParticipant::factory()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser('Null')->id,
|
|
'qualified_partners' => 0, 'total_points' => 0, 'is_qualified' => true, 'rank' => null,
|
|
]);
|
|
|
|
$rows = $service->rows($challenge);
|
|
|
|
expect($rows->pluck('participant.id')->all())->toBe([$first->id, $second->id])
|
|
->and($service->totals($rows))->toBe([
|
|
'recipients' => 2, 'partners' => 5, 'partner_amount' => 250.0, 'rank_bonus' => 500.0, 'total' => 750.0,
|
|
]);
|
|
|
|
$export = $service->exportRows($challenge);
|
|
expect($export)->toHaveCount(2)
|
|
->and($export[0])->toHaveCount(count($service->exportHeaders()))
|
|
->and($export[0][0])->toBe(1)
|
|
->and($export[0][1])->toBe($first->user_id)
|
|
->and($export[0][2])->toBe('Anna Berater')
|
|
->and($export[0][5])->toBe(3)
|
|
->and($export[0][6])->toBe(50.0)
|
|
->and($export[0][7])->toBe(150.0)
|
|
->and($export[0][8])->toBe(300.0)
|
|
->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([
|
|
'incentive_id' => $challenge->id, 'user_id' => payoutUser()->id,
|
|
'qualified_partners' => 5, 'total_points' => 5, 'is_qualified' => true, 'rank' => 3,
|
|
]);
|
|
|
|
expect($challenge->hasCashPayout())->toBeTrue()
|
|
->and((new IncentivePayoutService)->forParticipant($challenge, $participant))->toMatchArray([
|
|
'partner_amount' => 0.0, 'rank_bonus' => 100.0, 'total' => 100.0,
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('Ansichten', function () {
|
|
beforeEach(function () {
|
|
URL::resolveMissingNamedRoutesUsing(fn (string $name, array $parameters) => '/'.$name.'/'.implode('/', array_map('strval', $parameters)));
|
|
});
|
|
|
|
it('zeigt die Praemien-Kachel mit Betrag und Boni', function () {
|
|
$html = view('user.incentive._payout_tile', ['incentive' => recruitingChallenge()])->render();
|
|
|
|
expect($html)->toContain('id="incentive-payout-tile"')
|
|
->toContain('50,00 €')
|
|
->toContain(__('incentive.payout_rank_bonus_label', ['n' => 1]))
|
|
->toContain('300,00 €');
|
|
});
|
|
|
|
it('rendert im Dashboard je aktivem Incentive eine Karte mit eigenen Modal-IDs', function () {
|
|
$montenegro = Incentive::factory()->create(['name' => 'Montenegro', 'qualification_start' => '2026-04-01']);
|
|
$challenge = recruitingChallenge();
|
|
$user = payoutUser();
|
|
// Noch unbestaetigt: dann erscheint auch fuer die Challenge das Teilnahme-Modal.
|
|
$participant = IncentiveParticipant::factory()->unconfirmed()->create([
|
|
'incentive_id' => $challenge->id, 'user_id' => $user->id,
|
|
'qualified_partners' => 2, 'total_points' => 2, 'is_qualified' => true, 'rank' => 1,
|
|
]);
|
|
|
|
$html = view('dashboard._incentive', [
|
|
'activeIncentives' => Incentive::activeForNavigation(),
|
|
'incentiveParticipants' => collect([$challenge->id => $participant]),
|
|
])->render();
|
|
|
|
expect($html)->toContain('Montenegro')
|
|
->toContain('Recruiting-Challenge')
|
|
->toContain('id="incParticipateModal-'.$montenegro->id.'"')
|
|
->toContain('id="incParticipateModal-'.$challenge->id.'"')
|
|
->toContain(__('incentive.badge_challenge'))
|
|
// 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);
|
|
});
|
|
|
|
it('rendert nichts, wenn kein Incentive aktiv ist', function () {
|
|
$html = view('dashboard._incentive', [
|
|
'activeIncentives' => collect(),
|
|
'incentiveParticipants' => collect(),
|
|
])->render();
|
|
|
|
expect(trim($html))->toBe('');
|
|
});
|
|
});
|