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

@ -96,6 +96,7 @@ class IncentiveCalculate extends Command
'with_abos' => 0,
'tracking_partner_added' => 0,
'tracking_partner_removed' => 0,
'tracking_partner_cancelled' => 0,
'tracking_abo_added' => 0,
'tracking_abo_removed' => 0,
'repair_partner_fk' => 0,
@ -131,6 +132,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_partner_cancelled'] += $repairService->markCancelledPartners($participant);
$stats['tracking_abo_removed'] += $repairService->removeExcludedAboTrackings($participant);
$stats['tracking_abo_added'] += $repairService->syncMissingTrackingAbos($participant);
$stats['sv_logs_added'] += $repairService->syncMissingSalesVolumeLogs($participant);
@ -187,6 +189,7 @@ class IncentiveCalculate extends Command
if (! $force) {
$tableRows[] = ['Neupartner-Trackings nachgezogen', (string) $stats['tracking_partner_added']];
$tableRows[] = ['Neupartner-Trackings entfernt (Sponsorwechsel)', (string) $stats['tracking_partner_removed']];
$tableRows[] = ['Neupartner storniert (Starterpaket storniert)', (string) $stats['tracking_partner_cancelled']];
$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']];

View file

@ -325,15 +325,19 @@ class IncentiveController extends Controller
]);
}
// Storno: Partner bleibt sichtbar, zaehlt aber nicht (Kundenentscheid 25.09.2026)
$cancelled = $np->isCancelled();
return [
'id' => $np->id,
'label' => $np->user ? ($np->user->getFullName() ?: $np->user->email ?: 'User #'.$np->user_id) : 'User #'.$np->user_id,
'month' => $np->registered_at->month,
'year' => $np->registered_at->year,
'onetime' => $incentive->points_partner_onetime,
'monthly' => $monthly,
'total' => $incentive->points_partner_onetime + array_sum($monthly),
'transactions' => $transactions,
'onetime' => $cancelled ? 0 : $incentive->points_partner_onetime,
'monthly' => $cancelled ? array_fill(0, count($monthly), 0) : $monthly,
'total' => $cancelled ? 0 : $incentive->points_partner_onetime + array_sum($monthly),
'transactions' => $cancelled ? [] : $transactions,
'cancelled_at' => $np->cancelled_at?->format('d.m.Y'),
];
});

View file

@ -44,8 +44,13 @@ class IncentiveController extends Controller
->where('user_id', $user->id)
->first();
// Datenschutz (Kundenentscheid 25.09.2026): Unbestaetigte Teilnehmer sehen nur VIP-Konten;
// der eigene Eintrag bleibt fuer den Berater selbst sichtbar.
$ranking = IncentiveParticipant::where('incentive_id', $incentive->id)
->withRankingActivity()
->when(! $user->isVIP(), fn ($query) => $query->where(
fn ($visible) => $visible->whereNotNull('accepted_terms_at')->orWhere('user_id', $user->id)
))
->with('user', 'user.account')
->orderByIncentiveLeaderboard()
->paginate(100);

View file

@ -4,6 +4,8 @@ namespace App\Models;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -14,11 +16,12 @@ use Illuminate\Database\Eloquent\Model;
* @property int $participant_id
* @property int $user_id
* @property Carbon $registered_at
* @property Carbon|null $cancelled_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read IncentiveParticipant $participant
* @property-read User $user
* @property-read \Illuminate\Database\Eloquent\Collection<int, IncentivePointsLog> $pointsLogs
* @property-read Collection<int, IncentivePointsLog> $pointsLogs
*
* @mixin \Eloquent
*/
@ -32,14 +35,29 @@ class IncentiveNewPartner extends Model
'participant_id' => 'int',
'user_id' => 'int',
'registered_at' => 'datetime',
'cancelled_at' => 'datetime',
];
protected $fillable = [
'participant_id',
'user_id',
'registered_at',
'cancelled_at',
];
/**
* Nur Neupartner, die zaehlen (Starterpaket-Bestellung nicht storniert).
*/
public function scopeCounting(Builder $query): Builder
{
return $query->whereNull($query->getModel()->qualifyColumn('cancelled_at'));
}
public function isCancelled(): bool
{
return $this->cancelled_at !== null;
}
public function participant()
{
return $this->belongsTo(IncentiveParticipant::class, 'participant_id');

View file

@ -2,6 +2,7 @@
namespace App\Models;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Carbon\Carbon;
@ -240,7 +241,7 @@ class IncentiveParticipant extends Model
*/
public function recalculateFromTrackingTables(): self
{
$this->qualified_partners = $this->newPartners()->count();
$this->qualified_partners = $this->newPartners()->counting()->count();
$this->qualified_abos = $this->newAbos()->count();
$this->total_points = (int) $this->pointsLog()
@ -301,6 +302,9 @@ class IncentiveParticipant extends Model
]);
}
// Stornierte Starterpakete: Neupartner bleibt sichtbar, zaehlt aber nicht
app(IncentivePointsLogRepairService::class)->markCancelledPartners($this);
// B. Kundenabos (ot) + Berater-Eigenabos (me): status=2
[$qualStart, $qualEnd] = $incentive->qualificationWindow();

View file

@ -437,6 +437,16 @@ class ShoppingOrder extends Model
});
}
/**
* Bestellung nicht storniert (Stornorechnung setzt txaction = cancelled, paid bleibt 1).
*/
public function scopeWhereNotCancelled(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->whereNull('txaction')->orWhere('txaction', '!=', 'cancelled');
});
}
/**
* Erfüllt diese Bestellung die Voraussetzung für Incentive-Neupartner-Tracking (Starterpaket)?
*/

View file

@ -13,11 +13,14 @@ use Illuminate\Support\Collection;
* Rang-Bonus, wenn der Teilnehmer qualifiziert ist und innerhalb der Gewinnerplaetze
* liegt. Grundlage ist der gespeicherte Stand des Teilnehmers (qualified_partners, rank),
* also derselbe Stand wie in der Rangliste.
*
* Gerechnet wird fuer alle Teilnehmer; ausgezahlt wird nur an Teilnehmer mit bestaetigter
* Teilnahme (Kundenentscheid 25.09.2026). Unbestaetigte haben keinen Rang, also keinen Bonus.
*/
class IncentivePayoutService
{
/**
* @return array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}
* @return array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float, confirmed: bool}
*/
public function forParticipant(Incentive $incentive, IncentiveParticipant $participant): array
{
@ -32,13 +35,14 @@ class IncentivePayoutService
'partner_amount' => $partnerAmount,
'rank_bonus' => $rankBonus,
'total' => round($partnerAmount + $rankBonus, 2),
'confirmed' => $participant->accepted_terms_at !== null,
];
}
/**
* Auszahlungszeilen: alle Teilnehmer mit Praemie > 0, nach Rang.
* Auszahlungszeilen: Teilnehmer mit bestaetigter Teilnahme und Praemie > 0, nach Rang.
*
* @return Collection<int, array{participant: IncentiveParticipant, payout: array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}}>
* @return Collection<int, array{participant: IncentiveParticipant, payout: array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float, confirmed: bool}}>
*/
public function rows(Incentive $incentive): Collection
{
@ -52,14 +56,14 @@ class IncentivePayoutService
'participant' => $participant,
'payout' => $this->forParticipant($incentive, $participant),
])
->filter(fn (array $row) => $row['payout']['total'] > 0)
->filter(fn (array $row) => $row['payout']['confirmed'] && $row['payout']['total'] > 0)
->values();
}
/**
* Summen ueber alle Auszahlungszeilen.
*
* @param Collection<int, array{participant: IncentiveParticipant, payout: array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}}> $rows
* @param Collection<int, array{participant: IncentiveParticipant, payout: array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float, confirmed: bool}}> $rows
* @return array{recipients: int, partners: int, partner_amount: float, rank_bonus: float, total: float}
*/
public function totals(Collection $rows): array

View file

@ -9,8 +9,11 @@ use App\Models\IncentivePointsLog;
use App\Models\ShoppingOrder;
use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserInvoice;
use App\Models\UserSalesVolume;
use App\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class IncentivePointsLogRepairService
@ -107,6 +110,64 @@ class IncentivePointsLogRepairService
return $added;
}
/**
* Storno (Kundenentscheid 25.09.2026): Ist jede bezahlte Starterpaket-Bestellung eines
* getrackten Neupartners storniert, zaehlt er nicht mehr. Die Tracking-Zeile bleibt mit
* cancelled_at stehen (Anzeige in "Meine Berechnung"), eine Storno-Logzeile nimmt die
* Einmalpunkte zurueck.
*
* @return int Anzahl neu als storniert markierter Neupartner
*/
public function markCancelledPartners(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
return 0;
}
$marked = 0;
foreach ($participant->newPartners()->counting()->with('user')->get() as $tracking) {
$orders = ShoppingOrder::query()
->where('auth_user_id', $tracking->user_id)
->wherePaidRegistrationIncludesStarterKit()
->get(['id', 'txaction', 'updated_at']);
if ($orders->isEmpty() || $orders->contains(fn (ShoppingOrder $order) => $order->txaction !== 'cancelled')) {
continue;
}
$cancelledAt = UserInvoice::query()
->whereIn('shopping_order_id', $orders->pluck('id'))
->where('cancellation', true)
->whereNull('cancellation_id')
->min('created_at');
$cancelledAt = Carbon::parse($cancelledAt ?? $orders->max('updated_at') ?? now());
DB::transaction(function () use ($participant, $incentive, $tracking, $cancelledAt) {
$tracking->update(['cancelled_at' => $cancelledAt]);
IncentivePointsLog::create([
'participant_id' => $participant->id,
'type' => 'partner',
'source_type' => User::class,
'source_id' => $tracking->user_id,
'source_label' => 'Storno: '.($tracking->user?->getFullName() ?: 'User #'.$tracking->user_id),
'month' => $cancelledAt->month,
'year' => $cancelledAt->year,
'points_onetime' => -$incentive->points_partner_onetime,
'points_accumulated' => 0,
'is_storno' => true,
'incentive_new_partner_id' => $tracking->id,
]);
});
$marked++;
}
return $marked;
}
/**
* Entfernt Neupartner-Trackings, deren Partner inzwischen einen anderen Sponsor hat
* (z. B. nach Umhaengen in der Teamstruktur). Die zugehoerigen Log-Zeilen (Einmalpunkte

View file

@ -338,22 +338,25 @@ class IncentiveTracker
* Der Rang entspricht der Anzeigereihenfolge des Leaderboards: Qualifizierte zuerst (1..n),
* dahinter Nicht-Qualifizierte, jeweils nach Punkten absteigend.
*
* Bei Punktgleichstand: Teilnahme bestaetigt vor anonym, danach liegt vorn, wer den Stand
* zuerst erreicht hat (Teilnahmebedingungen Recruiting-Challenge, Punkt 6), zuletzt die
* Einen Rang bekommen nur Teilnehmer mit bestaetigter Teilnahme. Bei Punktgleichstand
* liegt vorn, wer den Stand zuerst erreicht hat (Teilnahmebedingungen Recruiting-Challenge, Punkt 6), zuletzt die
* Teilnehmer-ID, damit die Reihenfolge nie von der Datenbank abhaengt.
*/
public static function updateRanking(Incentive $incentive): void
{
// Nur Teilnehmer mit Punkten bekommen einen Rang
// Nur Teilnehmer mit Punkten und bestaetigter Teilnahme bekommen einen Rang
// (Kundenentscheid 25.09.2026: Unbestaetigte zaehlen im Hintergrund mit, stehen
// aber nicht in der Rangliste und belegen keinen Gewinnerplatz).
$with_points = IncentiveParticipant::where('incentive_id', $incentive->id)
->where('total_points', '>', 0)
->whereNotNull('accepted_terms_at')
->get();
$reached_at = self::pointsReachedAtByParticipant($incentive, $with_points->pluck('id')->all());
$with_points = $with_points->sort(function (IncentiveParticipant $a, IncentiveParticipant $b) use ($reached_at): int {
return [(int) $b->is_qualified, (int) $b->total_points, (int) $b->hasAcceptedTerms(), $reached_at[$a->id] ?? PHP_INT_MAX, $a->id]
<=> [(int) $a->is_qualified, (int) $a->total_points, (int) $a->hasAcceptedTerms(), $reached_at[$b->id] ?? PHP_INT_MAX, $b->id];
return [(int) $b->is_qualified, (int) $b->total_points, $reached_at[$a->id] ?? PHP_INT_MAX, $a->id]
<=> [(int) $a->is_qualified, (int) $a->total_points, $reached_at[$b->id] ?? PHP_INT_MAX, $b->id];
});
$rank = 1;
@ -363,9 +366,9 @@ class IncentiveTracker
$rank++;
}
// Teilnehmer ohne Punkte: Rang entfernen
// Teilnehmer ohne Punkte oder ohne Bestaetigung: Rang entfernen
IncentiveParticipant::where('incentive_id', $incentive->id)
->where('total_points', '<=', 0)
->where(fn ($query) => $query->where('total_points', '<=', 0)->orWhereNull('accepted_terms_at'))
->whereNotNull('rank')
->update(['rank' => null]);
}
@ -398,6 +401,7 @@ class IncentiveTracker
$latest = $model::query()
->whereIn('participant_id', $participantIds)
->when($model === IncentiveNewPartner::class, fn ($query) => $query->whereNull('cancelled_at'))
->groupBy('participant_id')
->selectRaw("participant_id, MAX({$column}) as reached_at")
->pluck('reached_at', 'participant_id');

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Storno eines Neupartners (Kundenentscheid 25.09.2026): Wird die Starterpaket-Bestellung
* storniert, bleibt die Tracking-Zeile stehen, zaehlt aber nicht mehr. So sieht der
* Berater in "Meine Berechnung", welcher Partner storniert wurde und warum er fehlt.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('incentive_new_partners', function (Blueprint $table) {
if (! Schema::hasColumn('incentive_new_partners', 'cancelled_at')) {
$table->dateTime('cancelled_at')->nullable()->after('registered_at')
->comment('Starterpaket-Bestellung storniert: zaehlt nicht mehr');
}
});
}
public function down(): void
{
Schema::table('incentive_new_partners', function (Blueprint $table) {
if (Schema::hasColumn('incentive_new_partners', 'cancelled_at')) {
$table->dropColumn('cancelled_at');
}
});
}
};

View file

@ -104,12 +104,12 @@ HTML;
return <<<'HTML'
<h5>Teilnahmebedingungen: Recruiting Challenge 2026/27</h5>
<p><strong>1. Worum es geht.</strong> mivita veranstaltet die Recruiting Challenge 2026/27. Für jeden neuen Partner, den ein Berater im Aktionszeitraum gewinnt, zahlt mivita eine Prämie. Die drei Berater mit den meisten neuen Partnern erhalten zusätzlich einen Bonus.</p>
<p><strong>2. Wer mitmachen kann.</strong> Alle Berater mit aktivem Beraterkonto. Die Teilnahme wird einmal im Backoffice bestätigt. Neue Partner werden ab dem 9. Oktober 2026 gezählt, egal wann die Bestätigung erfolgt. Ohne Bestätigung erscheint der Berater in der Rangliste anonym und erhält keine Auszahlung.</p>
<p><strong>2. Wer mitmachen kann.</strong> Alle Berater mit aktivem Beraterkonto. Die Teilnahme wird einmal im Backoffice bestätigt. Neue Partner werden ab dem 9. Oktober 2026 gezählt, egal wann die Bestätigung erfolgt. Ohne Bestätigung erscheint der Berater nicht in der Rangliste und erhält keine Auszahlung.</p>
<p><strong>3. Aktionszeitraum.</strong> 9. Oktober 2026 bis 31. Januar 2027. Es zählt das Datum der bezahlten Registrierungsbestellung des neuen Partners.</p>
<p><strong>4. Was zählt.</strong> Ein neuer Partner ist eine Person, die sich im Aktionszeitraum mit dem Teilnehmer als Sponsor als Berater registriert und ein Starterpaket bezahlt hat. Eine Registrierung nur mit Mitgliedschaft zählt nicht. Jeder neue Partner wird einmal gezählt. Die Rangliste ergibt sich aus der Anzahl neuer Partner.</p>
<p><strong>5. Prämie.</strong> 50 € je neuem Partner, ohne Obergrenze. Die Berater auf den Plätzen 1 bis 3 erhalten zusätzlich 500 €, 300 € und 200 €.</p>
<p><strong>6. Gleichstand.</strong> Haben mehrere Berater die gleiche Anzahl neuer Partner, liegt vorn, wer diese Anzahl zuerst erreicht hat.</p>
<p><strong>7. Widerruf und Storno.</strong> Widerruft ein neuer Partner seine Registrierung oder wird die Bestellung storniert, entfällt die Wertung für diesen Partner. Stichtag ist die Auszahlung im Februar 2027, danach wird nicht mehr zurückgerechnet.</p>
<p><strong>7. Widerruf und Storno.</strong> Widerruft ein neuer Partner seine Registrierung oder wird die Bestellung storniert, entfällt die Wertung für diesen Partner. In der eigenen Berechnung steht er dann als storniert. Stichtag ist die Auszahlung im Februar 2027, danach wird nicht mehr zurückgerechnet.</p>
<p><strong>8. Auszahlung.</strong> Die Prämien werden nach Ende der Challenge im Februar 2027 gesammelt ausgezahlt. Die Prämie ist eine Einnahme aus der Beratertätigkeit. Für die Versteuerung ist der Berater selbst verantwortlich.</p>
<p><strong>9. Ausschluss.</strong> Bei Missbrauch, zum Beispiel Scheinregistrierungen oder Mehrfachkonten, kann mivita einzelne Wertungen streichen oder den Teilnehmer ausschließen.</p>
<p><strong>10. Änderungen.</strong> mivita kann die Bedingungen aus sachlichem Grund anpassen. Änderungen werden im Backoffice bekannt gegeben.</p>
@ -155,12 +155,12 @@ HTML;
return <<<'HTML'
<h5>Terms of participation: Recruiting Challenge 2026/27</h5>
<p><strong>1. What it is about.</strong> mivita is running the Recruiting Challenge 2026/27. For every new partner a consultant wins during the promotion period, mivita pays a reward. The three consultants with the most new partners also receive a bonus.</p>
<p><strong>2. Who can take part.</strong> All consultants with an active consultant account. Participation is confirmed once in the back office. New partners are counted from 9 October 2026, regardless of when the confirmation is given. Without confirmation, the consultant appears anonymously in the ranking and receives no payout.</p>
<p><strong>2. Who can take part.</strong> All consultants with an active consultant account. Participation is confirmed once in the back office. New partners are counted from 9 October 2026, regardless of when the confirmation is given. Without confirmation, the consultant does not appear in the ranking and receives no payout.</p>
<p><strong>3. Promotion period.</strong> 9 October 2026 to 31 January 2027. The date on which the new partner paid the registration order counts.</p>
<p><strong>4. What counts.</strong> A new partner is a person who registers as a consultant during the promotion period with the participant as sponsor and has paid for a starter kit. A registration with membership only does not count. Each new partner is counted once. The ranking is based on the number of new partners.</p>
<p><strong>5. Reward.</strong> €50 per new partner, with no upper limit. The consultants in places 1 to 3 additionally receive €500, €300 and €200.</p>
<p><strong>6. Tie.</strong> If several consultants have the same number of new partners, whoever reached that number first ranks ahead.</p>
<p><strong>7. Withdrawal and cancellation.</strong> If a new partner withdraws their registration or the order is cancelled, that partner no longer counts. The cut-off is the payout in February 2027; no recalculation takes place after that.</p>
<p><strong>7. Withdrawal and cancellation.</strong> If a new partner withdraws their registration or the order is cancelled, that partner no longer counts and is marked as cancelled in the consultant's own calculation. The cut-off is the payout in February 2027; no recalculation takes place after that.</p>
<p><strong>8. Payout.</strong> The rewards are paid out together after the end of the challenge in February 2027. The reward is income from the consultant activity. The consultant is responsible for any taxes.</p>
<p><strong>9. Exclusion.</strong> In case of abuse, for example fake registrations or multiple accounts, mivita may remove individual results or exclude the participant.</p>
<p><strong>10. Changes.</strong> mivita may adjust these terms for objective reasons. Changes are announced in the back office.</p>
@ -173,12 +173,12 @@ HTML;
return <<<'HTML'
<h5>Condiciones de participación: Recruiting Challenge 2026/27</h5>
<p><strong>1. De qué se trata.</strong> mivita organiza la Recruiting Challenge 2026/27. Por cada nuevo socio que un asesor consiga durante el periodo de la promoción, mivita paga una prima. Los tres asesores con más socios nuevos reciben además un bonus.</p>
<p><strong>2. Quién puede participar.</strong> Todos los asesores con una cuenta de asesor activa. La participación se confirma una vez en el backoffice. Los nuevos socios se cuentan a partir del 9 de octubre de 2026, independientemente de cuándo se confirme. Sin confirmación, el asesor aparece de forma anónima en la clasificación y no recibe ningún pago.</p>
<p><strong>2. Quién puede participar.</strong> Todos los asesores con una cuenta de asesor activa. La participación se confirma una vez en el backoffice. Los nuevos socios se cuentan a partir del 9 de octubre de 2026, independientemente de cuándo se confirme. Sin confirmación, el asesor no aparece en la clasificación y no recibe ningún pago.</p>
<p><strong>3. Periodo de la promoción.</strong> Del 9 de octubre de 2026 al 31 de enero de 2027. Cuenta la fecha en que el nuevo socio pagó el pedido de registro.</p>
<p><strong>4. Qué cuenta.</strong> Un nuevo socio es una persona que, durante el periodo de la promoción, se registra como asesor con el participante como patrocinador y ha pagado un kit de inicio. Un registro solo con membresía no cuenta. Cada nuevo socio se cuenta una vez. La clasificación se basa en el número de socios nuevos.</p>
<p><strong>5. Prima.</strong> 50 € por cada nuevo socio, sin límite. Los asesores en los puestos 1 a 3 reciben además 500 €, 300 € y 200 €.</p>
<p><strong>6. Empate.</strong> Si varios asesores tienen el mismo número de socios nuevos, queda por delante quien alcanzó ese número primero.</p>
<p><strong>7. Desistimiento y anulación.</strong> Si un nuevo socio revoca su registro o se anula el pedido, ese socio deja de contar. La fecha límite es el pago en febrero de 2027; después ya no se recalcula.</p>
<p><strong>7. Desistimiento y anulación.</strong> Si un nuevo socio revoca su registro o se anula el pedido, ese socio deja de contar y aparece como anulado en el cálculo propio del asesor. La fecha límite es el pago en febrero de 2027; después ya no se recalcula.</p>
<p><strong>8. Pago.</strong> Las primas se pagan conjuntamente tras el final de la challenge en febrero de 2027. La prima es un ingreso de la actividad como asesor. El asesor es responsable de su tributación.</p>
<p><strong>9. Exclusión.</strong> En caso de abuso, por ejemplo registros ficticios o cuentas múltiples, mivita puede anular resultados concretos o excluir al participante.</p>
<p><strong>10. Cambios.</strong> mivita puede adaptar las condiciones por motivos justificados. Los cambios se anuncian en el backoffice.</p>
@ -191,12 +191,12 @@ HTML;
return <<<'HTML'
<h5>Conditions de participation : Recruiting Challenge 2026/27</h5>
<p><strong>1. De quoi s’agit-il.</strong> mivita organise la Recruiting Challenge 2026/27. Pour chaque nouveau partenaire qu’un conseiller recrute pendant la période de l’opération, mivita verse une prime. Les trois conseillers ayant le plus de nouveaux partenaires reçoivent en plus un bonus.</p>
<p><strong>2. Qui peut participer.</strong> Tous les conseillers disposant d’un compte conseiller actif. La participation est confirmée une fois dans le back-office. Les nouveaux partenaires sont comptés à partir du 9 octobre 2026, quelle que soit la date de confirmation. Sans confirmation, le conseiller apparaît de manière anonyme dans le classement et ne reçoit aucun versement.</p>
<p><strong>2. Qui peut participer.</strong> Tous les conseillers disposant d’un compte conseiller actif. La participation est confirmée une fois dans le back-office. Les nouveaux partenaires sont comptés à partir du 9 octobre 2026, quelle que soit la date de confirmation. Sans confirmation, le conseiller n’apparaît pas dans le classement et ne reçoit aucun versement.</p>
<p><strong>3. Période de l’opération.</strong> Du 9 octobre 2026 au 31 janvier 2027. C’est la date à laquelle le nouveau partenaire a payé la commande d’inscription qui compte.</p>
<p><strong>4. Ce qui compte.</strong> Un nouveau partenaire est une personne qui s’inscrit comme conseiller pendant la période de l’opération avec le participant comme parrain et qui a payé un kit de démarrage. Une inscription avec adhésion seule ne compte pas. Chaque nouveau partenaire est compté une fois. Le classement se base sur le nombre de nouveaux partenaires.</p>
<p><strong>5. Prime.</strong> 50 € par nouveau partenaire, sans plafond. Les conseillers classés aux places 1 à 3 reçoivent en plus 500 €, 300 € et 200 €.</p>
<p><strong>6. Égalité.</strong> Si plusieurs conseillers ont le même nombre de nouveaux partenaires, celui qui a atteint ce nombre en premier est classé devant.</p>
<p><strong>7. Rétractation et annulation.</strong> Si un nouveau partenaire se rétracte ou si la commande est annulée, ce partenaire n’est plus pris en compte. La date limite est le versement en février 2027 ; aucun recalcul n’a lieu ensuite.</p>
<p><strong>7. Rétractation et annulation.</strong> Si un nouveau partenaire se rétracte ou si la commande est annulée, ce partenaire n’est plus pris en compte et apparaît comme annulé dans le calcul personnel du conseiller. La date limite est le versement en février 2027 ; aucun recalcul n’a lieu ensuite.</p>
<p><strong>8. Versement.</strong> Les primes sont versées ensemble après la fin de la challenge, en février 2027. La prime constitue un revenu de l’activité de conseiller. Le conseiller est responsable de sa déclaration fiscale.</p>
<p><strong>9. Exclusion.</strong> En cas d’abus, par exemple des inscriptions fictives ou des comptes multiples, mivita peut annuler certains résultats ou exclure le participant.</p>
<p><strong>10. Modifications.</strong> mivita peut adapter les conditions pour un motif objectif. Les modifications sont annoncées dans le back-office.</p>

View file

@ -67,7 +67,7 @@ return [
'vip_view_notice' => 'VIP-Ansicht: Klarnamen aller Teilnehmer werden angezeigt.',
'vip_terms_accepted' => 'Teilnahmebedingungen akzeptiert',
'vip_terms_pending' => 'Teilnahmebedingungen noch nicht akzeptiert',
'ranking_anonymous_hint' => 'Namen erscheinen erst, wenn die Teilnahme am Incentive bestätigt wurde.',
'ranking_anonymous_hint' => 'Die Rangliste zeigt nur Berater, die ihre Teilnahme bestätigt haben.',
'ranking_extended_hint' => 'Die Liste zeigt alle Berater mit mehr als 0 Punkten. Die besten :n qualifizierten Berater (hervorgehoben) gewinnen; die Plätze danach zeigen, wer noch nachlegen kann.',
'calculation_details' => 'Berechnungsdetails',
'close' => 'Schliessen',
@ -143,7 +143,7 @@ return [
'dash_notice_unregistered_title' => 'Noch nicht angemeldet',
'dash_notice_unregistered_body' => 'Du nimmst am Incentive noch nicht offiziell teil. Ohne Bestätigung werden deine Punkte nicht gewertet und du erscheinst nicht in der Rangliste.',
'dash_notice_unconfirmed_title' => 'Teilnahme noch nicht bestätigt',
'dash_notice_unconfirmed_body' => 'Deine Punkte laufen bereits mit – aber ohne Bestätigung der Teilnahmebedingungen wirst du in der Rangliste anonym angezeigt und kannst nicht gewinnen.',
'dash_notice_unconfirmed_body' => 'Deine Punkte laufen bereits mit – aber ohne Bestätigung der Teilnahmebedingungen erscheinst du nicht in der Rangliste und bekommst keine Auszahlung.',
'dash_notice_btn' => 'Jetzt Teilnahme bestätigen',
'dash_modal_title' => 'Teilnahme bestätigen',
'dash_modal_intro' => 'Bitte lies die Informationen und Teilnahmebedingungen sorgfältig durch und bestätige anschließend deine Teilnahme.',
@ -200,6 +200,9 @@ return [
'payout_column' => 'Prämie',
'payout_incl_bonus' => 'inkl. :amount Rang-Bonus',
'payout_so_far' => 'Deine Prämie bisher',
'partner_cancelled' => 'Storniert am :date, zählt nicht',
'payout_unconfirmed' => 'Teilnahme nicht bestätigt, keine Auszahlung',
'payout_after_confirmation' => 'Deine Prämie nach Bestätigung',
'payout_tile_title' => 'Deine Prämie',
'payout_per_partner_label' => 'je neuem Teampartner mit Starterpaket',
'payout_per_partner_text' => 'Prämie: :amount je direkt gesponsertem Neupartner mit Starterpaket.',

View file

@ -67,7 +67,7 @@ return [
'vip_view_notice' => 'VIP view: Real names of all participants are shown.',
'vip_terms_accepted' => 'Terms accepted',
'vip_terms_pending' => 'Terms not yet accepted',
'ranking_anonymous_hint' => 'Names appear only after participation in the incentive has been confirmed.',
'ranking_anonymous_hint' => 'The ranking only shows consultants who have confirmed their participation.',
'ranking_extended_hint' => 'The list shows all consultants with more than 0 points. The best :n qualified consultants (highlighted) win; the ranks below show who can still push ahead.',
'calculation_details' => 'Calculation Details',
'close' => 'Close',
@ -143,7 +143,7 @@ return [
'dash_notice_unregistered_title' => 'Not yet registered',
'dash_notice_unregistered_body' => 'You are not yet officially participating. Without confirmation, your points won\'t count and you won\'t appear in the ranking.',
'dash_notice_unconfirmed_title' => 'Participation not yet confirmed',
'dash_notice_unconfirmed_body' => 'Your points are already tracked – but without accepting the terms you will appear anonymously in the ranking and cannot win.',
'dash_notice_unconfirmed_body' => 'Your points are already tracked – but without accepting the terms you will not appear in the ranking and will not receive a payout.',
'dash_notice_btn' => 'Confirm participation now',
'dash_modal_title' => 'Confirm participation',
'dash_modal_intro' => 'Please read the information and terms carefully, then confirm your participation.',
@ -200,6 +200,9 @@ return [
'payout_column' => 'Reward',
'payout_incl_bonus' => 'incl. :amount rank bonus',
'payout_so_far' => 'Your reward so far',
'partner_cancelled' => 'Cancelled on :date, does not count',
'payout_unconfirmed' => 'Participation not confirmed, no payout',
'payout_after_confirmation' => 'Your reward after confirmation',
'payout_tile_title' => 'Your reward',
'payout_per_partner_label' => 'per new team partner with a starter kit',
'payout_per_partner_text' => 'Reward: :amount per directly sponsored new partner with a starter kit.',

View file

@ -67,7 +67,7 @@ return [
'vip_view_notice' => 'Vista VIP: Se muestran los nombres reales de todos los participantes.',
'vip_terms_accepted' => 'Condiciones aceptadas',
'vip_terms_pending' => 'Condiciones aun no aceptadas',
'ranking_anonymous_hint' => 'Los nombres solo se muestran despues de confirmar la participacion en el incentivo.',
'ranking_anonymous_hint' => 'La clasificación solo muestra a los asesores que han confirmado su participación.',
'ranking_extended_hint' => 'La lista muestra todos los consultores con mas de 0 puntos. Los mejores :n consultores calificados (marcados) ganan; los puestos siguientes muestran quien aun puede reforzar.',
'calculation_details' => 'Detalles del calculo',
'close' => 'Cerrar',
@ -143,7 +143,7 @@ return [
'dash_notice_unregistered_title' => 'Aun no registrado',
'dash_notice_unregistered_body' => 'Aun no participas oficialmente. Sin confirmacion, tus puntos no contaran y no aparecereas en el ranking.',
'dash_notice_unconfirmed_title' => 'Participacion aun no confirmada',
'dash_notice_unconfirmed_body' => 'Tus puntos ya se estan registrando, pero sin aceptar las condiciones aparecereas de forma anonima en el ranking y no podras ganar.',
'dash_notice_unconfirmed_body' => 'Tus puntos ya se están registrando, pero sin aceptar las condiciones no aparecerás en la clasificación ni recibirás ningún pago.',
'dash_notice_btn' => 'Confirmar participacion ahora',
'dash_modal_title' => 'Confirmar participacion',
'dash_modal_intro' => 'Por favor lee la informacion y las condiciones con atencion y confirma tu participacion.',
@ -200,6 +200,9 @@ return [
'payout_column' => 'Prima',
'payout_incl_bonus' => 'incl. :amount bono por puesto',
'payout_so_far' => 'Tu prima hasta ahora',
'partner_cancelled' => 'Anulado el :date, no cuenta',
'payout_unconfirmed' => 'Participación no confirmada, sin pago',
'payout_after_confirmation' => 'Tu prima tras la confirmación',
'payout_tile_title' => 'Tu prima',
'payout_per_partner_label' => 'por nuevo socio de equipo con paquete de inicio',
'payout_per_partner_text' => 'Prima: :amount por nuevo socio patrocinado directamente con paquete de inicio.',

View file

@ -58,7 +58,7 @@ return [
'vip_view_notice' => 'Vue VIP : les noms réels de tous les participants sont affichés.',
'vip_terms_accepted' => 'Conditions de participation acceptées',
'vip_terms_pending' => 'Conditions de participation pas encore acceptées',
'ranking_anonymous_hint' => 'Les noms n’apparaissent qu’après confirmation de la participation à l’incentive.',
'ranking_anonymous_hint' => 'Le classement n’affiche que les conseillers ayant confirmé leur participation.',
'ranking_extended_hint' => 'La liste affiche tous les conseillers avec plus de 0 point. Les :n meilleurs conseillers qualifiés (mis en évidence) gagnent ; les places suivantes montrent qui peut encore progresser.',
'calculation_details' => 'Détails du calcul',
'close' => 'Fermer',
@ -124,7 +124,7 @@ return [
'dash_notice_unregistered_title' => 'Pas encore inscrit',
'dash_notice_unregistered_body' => 'Vous ne participez pas encore officiellement à l’incentive. Sans confirmation, vos points ne seront pas pris en compte et vous n’apparaîtrez pas dans le classement.',
'dash_notice_unconfirmed_title' => 'Participation pas encore confirmée',
'dash_notice_unconfirmed_body' => 'Vos points sont déjà comptabilisés, mais sans confirmation des conditions de participation, vous serez affiché anonymement dans le classement et ne pourrez pas gagner.',
'dash_notice_unconfirmed_body' => 'Vos points sont déjà comptabilisés, mais sans confirmation des conditions de participation, vous n’apparaîtrez pas dans le classement et ne recevrez aucun versement.',
'dash_notice_btn' => 'Confirmer la participation maintenant',
'dash_modal_title' => 'Confirmer la participation',
'dash_modal_intro' => 'Veuillez lire attentivement les informations et conditions de participation, puis confirmez votre participation.',
@ -175,6 +175,9 @@ return [
'payout_column' => 'Prime',
'payout_incl_bonus' => 'dont :amount de bonus de classement',
'payout_so_far' => 'Ta prime jusqu’ici',
'partner_cancelled' => 'Annulé le :date, ne compte pas',
'payout_unconfirmed' => 'Participation non confirmée, aucun versement',
'payout_after_confirmation' => 'Ta prime après confirmation',
'payout_tile_title' => 'Ta prime',
'payout_per_partner_label' => 'par nouveau partenaire d’équipe avec kit de démarrage',
'payout_per_partner_text' => 'Prime : :amount par nouveau partenaire parrainé directement avec kit de démarrage.',

View file

@ -166,7 +166,10 @@
@if ($incentive->hasCashPayout())
@php $payout = $payoutByParticipantId[$p->id] ?? null; @endphp
<td class="text-right">
@if ($payout && $payout['total'] > 0)
@if ($payout && $payout['total'] > 0 && ! $payout['confirmed'])
<span class="text-muted">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payout['total']) }}</span>
<small class="text-muted d-block">{{ __('incentive.payout_unconfirmed') }}</small>
@elseif ($payout && $payout['total'] > 0)
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payout['total']) }}
@if ($payout['rank_bonus'] > 0)
<small class="text-muted d-block">{{ __('incentive.payout_incl_bonus', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount($payout['rank_bonus'])]) }}</small>

View file

@ -69,7 +69,7 @@
@if ($dashPayout)
<div class="inc-dash-stat">
<span class="inc-dash-stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($dashPayout['total']) }}</span>
<span class="inc-dash-stat-label">{{ __('incentive.payout_so_far') }}</span>
<span class="inc-dash-stat-label">{{ $dashPayout['confirmed'] ? __('incentive.payout_so_far') : __('incentive.payout_after_confirmation') }}</span>
</div>
@endif
<div class="inc-dash-stat">

View file

@ -23,7 +23,12 @@
@if(count($source['transactions']) > 0)
<i class="fa fa-chevron-right fa-xs text-muted mr-1 toggle-icon" id="icon-{{ $type }}-{{ $source['id'] }}"></i>
@endif
@if (! empty($source['cancelled_at']))
<span class="text-muted"><del>{{ $source['label'] }}</del></span>
<span class="badge badge-secondary ml-1">{{ __('incentive.partner_cancelled', ['date' => $source['cancelled_at']]) }}</span>
@else
{{ $source['label'] }}
@endif
</td>
<td>{{ str_pad($source['month'], 2, '0', STR_PAD_LEFT) }}/{{ $source['year'] }}</td>
<td class="text-right">{{ number_format($source['onetime'], 0, ',', '.') }}</td>

View file

@ -1236,7 +1236,7 @@
</div>
@if ($participantPayout)
<div class="stat-box">
<div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
<div class="stat-label">{{ $participantPayout['confirmed'] ? __('incentive.payout_so_far') : __('incentive.payout_after_confirmation') }}</div>
<div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
</div>
@endif

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');
});