Incentive (E1): Recruiting-Challenge mit Geldpraemie, Auszahlungs-Export und parallelen Incentives

Neue Felder amount_per_partner, bonus_rank_1..3 und include_sales_points an
incentives. Mit ausgeschalteten Umsatzpunkten zaehlt die Wertung nur die
Einmalpunkte je Neupartner, die Rangliste entspricht damit der Anzahl
Neupartner mit Starterpaket. IncentivePayoutService rechnet Neupartner mal
Betrag plus Rang-Bonus fuer qualifizierte Gewinner; Admin-Detailseite bekommt
Praemienspalte, Summen und XLSX-Export fuer die Auszahlung.

Sidenav und Dashboard zeigen alle aktiven Incentives, damit Montenegro und
die Challenge parallel laufen koennen. Teaser, Rangliste und Details zeigen
bei Geldpraemie die Praemien-Kachel und "Deine Praemie bisher" und blenden
Abo-Bloecke aus, wenn Abos keine Rolle spielen. Montenegro bleibt unveraendert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-18 12:10:27 +00:00
parent 03416b0afe
commit e3efa2f5f0
27 changed files with 1418 additions and 129 deletions

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin;
use App\Exports\xExport;
use App\Http\Controllers\Controller;
use App\Models\Incentive;
use App\Models\IncentiveNewAbo;
@ -12,8 +13,11 @@ use App\Models\UserAbo;
use App\Models\UserAboOrder;
use App\Models\UserSalesVolume;
use App\Services\Incentive\IncentiveCalculationService;
use App\Services\Incentive\IncentivePayoutService;
use App\User;
use Maatwebsite\Excel\Facades\Excel;
use Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class IncentiveController extends Controller
{
@ -50,8 +54,14 @@ class IncentiveController extends Controller
'min_direct_partners' => 'required|integer|min:0',
'min_customer_abos' => 'required|integer|min:0',
'max_winners' => 'required|integer|min:1',
'amount_per_partner' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_1' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2',
]);
$data = $this->normalizePayoutFields($data);
$data = array_merge($data, $this->extractTranslations());
@ -70,12 +80,52 @@ class IncentiveController extends Controller
->orderByIncentiveLeaderboard()
->get();
$payoutService = new IncentivePayoutService;
$payoutByParticipantId = $incentive->hasCashPayout()
? $participants->mapWithKeys(fn (IncentiveParticipant $participant) => [
$participant->id => $payoutService->forParticipant($incentive, $participant),
])
: collect();
return view('admin.incentive.show', [
'incentive' => $incentive,
'participants' => $participants,
'payoutByParticipantId' => $payoutByParticipantId,
'payoutTotals' => $incentive->hasCashPayout()
? $payoutService->totals($payoutService->rows($incentive))
: null,
]);
}
/**
* Auszahlungsliste der Geldpraemie als XLSX (Februar-Auszahlung der Recruiting-Challenge).
*/
public function payoutExport($id): BinaryFileResponse
{
$incentive = Incentive::findOrFail($id);
$service = new IncentivePayoutService;
$filename = 'incentive-'.$incentive->slug.'-auszahlung-'.now()->format('Y-m-d').'.xlsx';
return Excel::download(new xExport($service->exportRows($incentive), $service->exportHeaders()), $filename);
}
/**
* Leere Betragsfelder als NULL speichern, Checkbox als bool.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function normalizePayoutFields(array $data): array
{
foreach (['amount_per_partner', 'bonus_rank_1', 'bonus_rank_2', 'bonus_rank_3'] as $field) {
$data[$field] = isset($data[$field]) && $data[$field] !== '' ? round((float) $data[$field], 2) : null;
}
$data['include_sales_points'] = filter_var($data['include_sales_points'] ?? false, FILTER_VALIDATE_BOOLEAN);
return $data;
}
public function edit($id)
{
$incentive = Incentive::findOrFail($id);
@ -102,8 +152,14 @@ class IncentiveController extends Controller
'min_direct_partners' => 'required|integer|min:0',
'min_customer_abos' => 'required|integer|min:0',
'max_winners' => 'required|integer|min:1',
'amount_per_partner' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_1' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_2' => 'nullable|numeric|min:0|max:999999',
'bonus_rank_3' => 'nullable|numeric|min:0|max:999999',
'include_sales_points' => 'nullable|boolean',
'status' => 'required|integer|in:0,1,2',
]);
$data = $this->normalizePayoutFields($data);
$data = array_merge($data, $this->extractTranslations());

View file

@ -57,18 +57,24 @@ class HomeController extends Controller
$user = Auth::user();
$activeIncentive = null;
$incentiveParticipant = null;
// Mehrere Incentives koennen parallel aktiv sein (E1): je eine Dashboard-Karte.
$activeIncentives = collect();
$incentiveParticipants = collect();
if ($user->isActiveAccount()) {
$activeIncentive = Incentive::active()->first();
if ($activeIncentive) {
$incentiveParticipant = IncentiveParticipant::where('incentive_id', $activeIncentive->id)
$activeIncentives = Incentive::activeForNavigation();
if ($activeIncentives->isNotEmpty()) {
$incentiveParticipants = IncentiveParticipant::query()
->whereIn('incentive_id', $activeIncentives->pluck('id'))
->where('user_id', $user->id)
->first();
->get()
->keyBy('incentive_id');
}
}
$activeIncentive = $activeIncentives->first();
$incentiveParticipant = $activeIncentive ? $incentiveParticipants->get($activeIncentive->id) : null;
// Eigenes Abo des Beraters für die Einmalprodukte-Dashboard-Card (nur wenn
// das Zeitfenster offen ist und das Feature für den User sichtbar ist).
$oneTimeAbo = null;
@ -96,6 +102,8 @@ class HomeController extends Controller
'dashboardNews' => DashboardNews::getActiveNews(),
'activeIncentive' => $activeIncentive,
'incentiveParticipant' => $incentiveParticipant,
'activeIncentives' => $activeIncentives,
'incentiveParticipants' => $incentiveParticipants,
'oneTimeAbo' => $oneTimeAbo,
'monthlyStats' => $monthlyStats,
];

View file

@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Models\UserAbo;
use App\Services\Incentive\IncentivePayoutService;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use App\User;
@ -68,6 +69,16 @@ class IncentiveController extends Controller
$participateHasTrackableAbos = $this->userHasTrackableAbosForIncentive($user, $incentive);
}
$payoutService = new IncentivePayoutService;
$participantPayout = $incentive->hasCashPayout() && $participant
? $payoutService->forParticipant($incentive, $participant)
: null;
$rankingPayouts = $incentive->hasCashPayout()
? $ranking->getCollection()->mapWithKeys(fn (IncentiveParticipant $row) => [
$row->id => $payoutService->forParticipant($incentive, $row),
])
: collect();
return view('user.incentive.show', [
'incentive' => $incentive,
'participant' => $participant,
@ -75,6 +86,8 @@ class IncentiveController extends Controller
'ranking' => $ranking,
'participateHasTrackableAbos' => $participateHasTrackableAbos,
'isVipView' => $user->isVIP(),
'participantPayout' => $participantPayout,
'rankingPayouts' => $rankingPayouts,
]);
}

View file

@ -31,6 +31,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int $min_direct_partners
* @property int $min_customer_abos
* @property int $max_winners
* @property float|null $amount_per_partner
* @property float|null $bonus_rank_1
* @property float|null $bonus_rank_2
* @property float|null $bonus_rank_3
* @property bool $include_sales_points
* @property int $status
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
@ -59,6 +64,11 @@ class Incentive extends Model
'min_direct_partners' => 'int',
'min_customer_abos' => 'int',
'max_winners' => 'int',
'amount_per_partner' => 'float',
'bonus_rank_1' => 'float',
'bonus_rank_2' => 'float',
'bonus_rank_3' => 'float',
'include_sales_points' => 'bool',
'status' => 'int',
'qualification_start' => 'date',
'qualification_end' => 'date',
@ -83,9 +93,19 @@ class Incentive extends Model
'min_direct_partners',
'min_customer_abos',
'max_winners',
'amount_per_partner',
'bonus_rank_1',
'bonus_rank_2',
'bonus_rank_3',
'include_sales_points',
'status',
];
/**
* Anzahl der Plaetze, fuer die ein Rang-Bonus hinterlegt werden kann.
*/
public const RANK_BONUS_SLOTS = 3;
public static $statusTypes = [
0 => 'draft',
1 => 'active',
@ -121,6 +141,14 @@ class Incentive extends Model
return $query->where('status', 1);
}
/**
* Incentives, bei denen Umsatzpunkte der Neupartner/Neuabos mitzaehlen.
*/
public function scopeIncludingSalesPoints($query)
{
return $query->where('include_sales_points', true);
}
public function scopeInQualificationPeriod($query, ?Carbon $date = null)
{
$date = $date ?: Carbon::now();
@ -149,6 +177,20 @@ class Incentive extends Model
// Helpers
/**
* Aktive Incentives fuer Sidenav und Dashboard: mehrere koennen parallel laufen
* (z. B. Montenegro bis Oktober, Recruiting-Challenge ab Oktober), aelteste zuerst.
*
* @return Collection<int, Incentive>
*/
public static function activeForNavigation(): Collection
{
return static::active()
->orderBy('qualification_start')
->orderBy('id')
->get();
}
public function isActive(): bool
{
return $this->status === 1;
@ -198,6 +240,76 @@ class Incentive extends Model
return self::$statusColors[$this->status] ?? 'default';
}
/**
* Wird eine Geldpraemie je Neupartner ausgezahlt (Recruiting-Challenge)?
*/
public function hasCashPayout(): bool
{
return (float) $this->amount_per_partner > 0 || $this->rankBonuses() !== [];
}
/**
* Rang-Boni je Platz (nur gesetzte Betraege > 0, Platz => Betrag).
*
* @return array<int, float>
*/
public function rankBonuses(): array
{
$bonuses = [];
for ($rank = 1; $rank <= self::RANK_BONUS_SLOTS; $rank++) {
$amount = (float) $this->{'bonus_rank_'.$rank};
if ($amount > 0) {
$bonuses[$rank] = $amount;
}
}
return $bonuses;
}
/**
* Rang-Bonus fuer einen Platz, 0 wenn keiner hinterlegt oder der Platz ausserhalb der Gewinnerplaetze liegt.
*/
public function rankBonus(?int $rank): float
{
if ($rank === null || $rank < 1 || $rank > $this->max_winners) {
return 0.0;
}
return $this->rankBonuses()[$rank] ?? 0.0;
}
/**
* Zaehlen Umsatzpunkte der Neupartner/Neuabos mit? Aus = Wertung nur nach Einmalpunkten.
*/
public function includesSalesPoints(): bool
{
return $this->include_sales_points === null || (bool) $this->include_sales_points;
}
/**
* Gibt es eine Mindestqualifikation (direkte Partner oder Kundenabos)?
*/
public function hasMinimumRequirements(): bool
{
return $this->min_direct_partners > 0 || $this->min_customer_abos > 0;
}
/**
* Spielen Abos in diesem Incentive eine Rolle (Punkte oder Mindestanzahl)?
*/
public function usesAbos(): bool
{
return $this->points_abo_onetime > 0 || $this->min_customer_abos > 0;
}
/**
* Rangliste nach Anzahl Neupartner: Einmalpunkte je Partner, keine Abo- und Umsatzpunkte.
*/
public function ranksByPartnerCount(): bool
{
return ! $this->includesSalesPoints() && ! $this->usesAbos() && $this->points_partner_onetime > 0;
}
/**
* Get specific translation for a field and locale.
*/

View file

@ -365,6 +365,11 @@ class IncentiveParticipant extends Model
}
// C. Akkumulierte Punkte NUR von Neupartnern und Neuabos
// (entfaellt bei Incentives ohne Umsatzpunkte, z. B. Recruiting-Challenge)
if (! $incentive->includesSalesPoints()) {
return $this->recalculateFromTrackingTables();
}
$calculation_months = $incentive->getCalculationMonths();
$new_partner_user_ids = $this->newPartners()->pluck('user_id')->toArray();
$abo_shopping_user_ids = UserAbo::whereIn('id', $this->newAbos()->pluck('user_abo_id'))

View file

@ -0,0 +1,137 @@
<?php
namespace App\Services\Incentive;
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use Illuminate\Support\Collection;
/**
* Geldpraemie eines Incentives (Recruiting-Challenge, Entwicklungsplan E1).
*
* Praemie = Anzahl Neupartner mit Starterpaket x Betrag je Neupartner, dazu der
* 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.
*/
class IncentivePayoutService
{
/**
* @return array{partners: int, per_partner: float, partner_amount: float, rank_bonus: float, total: float}
*/
public function forParticipant(Incentive $incentive, IncentiveParticipant $participant): array
{
$partners = (int) $participant->qualified_partners;
$perPartner = (float) $incentive->amount_per_partner;
$partnerAmount = round($partners * $perPartner, 2);
$rankBonus = $participant->is_qualified ? $incentive->rankBonus($participant->rank) : 0.0;
return [
'partners' => $partners,
'per_partner' => $perPartner,
'partner_amount' => $partnerAmount,
'rank_bonus' => $rankBonus,
'total' => round($partnerAmount + $rankBonus, 2),
];
}
/**
* Auszahlungszeilen: alle Teilnehmer mit 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}}>
*/
public function rows(Incentive $incentive): Collection
{
return IncentiveParticipant::query()
->where('incentive_id', $incentive->id)
->with('user', 'user.account')
->orderByRankNullsLast()
->orderByDesc('qualified_partners')
->get()
->map(fn (IncentiveParticipant $participant) => [
'participant' => $participant,
'payout' => $this->forParticipant($incentive, $participant),
])
->filter(fn (array $row) => $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
* @return array{recipients: int, partners: int, partner_amount: float, rank_bonus: float, total: float}
*/
public function totals(Collection $rows): array
{
return [
'recipients' => $rows->count(),
'partners' => (int) $rows->sum(fn (array $row) => $row['payout']['partners']),
'partner_amount' => round((float) $rows->sum(fn (array $row) => $row['payout']['partner_amount']), 2),
'rank_bonus' => round((float) $rows->sum(fn (array $row) => $row['payout']['rank_bonus']), 2),
'total' => round((float) $rows->sum(fn (array $row) => $row['payout']['total']), 2),
];
}
/**
* Spaltenueberschriften des XLSX-Exports.
*
* @return list<string>
*/
public function exportHeaders(): array
{
return [
__('incentive.rank'),
__('incentive.export_user_id'),
__('incentive.consultant'),
'E-Mail',
__('incentive.admin_terms_accepted'),
__('incentive.partners'),
__('incentive.amount_per_partner'),
__('incentive.payout_partner_amount'),
__('incentive.payout_rank_bonus'),
__('incentive.payout_total'),
__('incentive.total_points'),
__('incentive.qualified'),
];
}
/**
* Zeilen des XLSX-Exports (Betraege als Zahlen, damit Excel summieren kann).
*
* @return list<array<int, string|int|float>>
*/
public function exportRows(Incentive $incentive): array
{
return $this->rows($incentive)->map(function (array $row) {
$participant = $row['participant'];
$payout = $row['payout'];
$user = $participant->user;
$name = $user?->account
? trim($user->account->first_name.' '.$user->account->last_name)
: ($user?->getFullName() ?: '');
return [
$participant->rank ?? '',
(int) $participant->user_id,
$name,
(string) ($user?->email ?? ''),
$participant->accepted_terms_at
? $participant->accepted_terms_at->format('d.m.Y H:i')
: __('incentive.admin_terms_pending'),
$payout['partners'],
$payout['per_partner'],
$payout['partner_amount'],
$payout['rank_bonus'],
$payout['total'],
(int) $participant->total_points,
$participant->is_qualified ? __('incentive.yes') : __('incentive.no'),
];
})->all();
}
public static function formatAmount(float $amount): string
{
return number_format($amount, 2, ',', '.').' €';
}
}

View file

@ -350,7 +350,7 @@ class IncentivePointsLogRepairService
public function syncMissingSalesVolumeLogs(IncentiveParticipant $participant): int
{
$incentive = $participant->incentive;
if (! $incentive) {
if (! $incentive || ! $incentive->includesSalesPoints()) {
return 0;
}

View file

@ -136,7 +136,8 @@ class IncentiveTracker
/**
* Track accumulated sales volume points.
* Punkte werden NUR gezaehlt wenn der Umsatz von einem gettrackten
* Neupartner oder Neuabo stammt.
* Neupartner oder Neuabo stammt. Incentives mit `include_sales_points = 0`
* (Recruiting-Challenge) bekommen keine Umsatzpunkte.
*/
public static function trackSalesVolume(UserSalesVolume $user_sales_volume): void
{
@ -155,7 +156,7 @@ class IncentiveTracker
// A. Pruefen ob der User ein gettrackter Neupartner ist
$partner_trackings = IncentiveNewPartner::where('user_id', $user_sales_volume->user_id)
->whereHas('participant.incentive', fn ($q) => $q->active())
->whereHas('participant.incentive', fn ($q) => $q->active()->includingSalesPoints())
->with('participant.incentive')
->get();
@ -210,7 +211,7 @@ class IncentiveTracker
$abo_trackings = $userAboFromOrder
? IncentiveNewAbo::query()
->where('user_abo_id', $userAboFromOrder->id)
->whereHas('participant.incentive', fn ($q) => $q->active())
->whereHas('participant.incentive', fn ($q) => $q->active()->includingSalesPoints())
->with('participant.incentive')
->get()
: collect();

View file

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Recruiting-Challenge (Entwicklungsplan September 2026, E1).
*
* Ein Incentive kann neben Punkten eine Geldpraemie tragen: ein fester Betrag je
* Neupartner mit Starterpaket und ein Zusatz-Bonus fuer die ersten drei Plaetze.
* Beides ist optional (NULL = reines Punkte-Incentive wie Montenegro).
*
* `include_sales_points` steuert, ob die Umsatzpunkte der Neupartner und Neuabos
* in die Wertung einfliessen. Fuer eine Recruiting-Challenge steht der Schalter
* auf 0: die Punkte entsprechen dann der Anzahl Neupartner (Einmalpunkte 1).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('incentives', function (Blueprint $table) {
if (! Schema::hasColumn('incentives', 'amount_per_partner')) {
$table->decimal('amount_per_partner', 8, 2)->nullable()->after('max_winners')
->comment('Geldpraemie je Neupartner in EUR (NULL = keine)');
}
if (! Schema::hasColumn('incentives', 'bonus_rank_1')) {
$table->decimal('bonus_rank_1', 8, 2)->nullable()->after('amount_per_partner')->comment('Zusatz-Bonus Platz 1 in EUR');
$table->decimal('bonus_rank_2', 8, 2)->nullable()->after('bonus_rank_1')->comment('Zusatz-Bonus Platz 2 in EUR');
$table->decimal('bonus_rank_3', 8, 2)->nullable()->after('bonus_rank_2')->comment('Zusatz-Bonus Platz 3 in EUR');
}
if (! Schema::hasColumn('incentives', 'include_sales_points')) {
$table->boolean('include_sales_points')->default(true)->after('bonus_rank_3')
->comment('Umsatzpunkte der Neupartner/Neuabos mitzaehlen (0 = nur Einmalpunkte)');
}
});
}
public function down(): void
{
Schema::table('incentives', function (Blueprint $table) {
$columns = array_values(array_filter(
['amount_per_partner', 'bonus_rank_1', 'bonus_rank_2', 'bonus_rank_3', 'include_sales_points'],
fn (string $column) => Schema::hasColumn('incentives', $column)
));
if ($columns !== []) {
$table->dropColumn($columns);
}
});
}
};

View file

@ -1,6 +1,6 @@
# Entwicklungsplan – Protokoll Zoom-Meeting 08.09.2026
**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort.
**Stand:** 18.09.2026 · **Status:** C1/C2 live seit 14.09., A4, A3 und A1+A2 umgesetzt (committet, Deploy offen); Abo-Paket damit bis auf A5 komplett. Audit vom 18.09. ([2026-09-18-audit-commits-16-09-2026.md](2026-09-18-audit-commits-16-09-2026.md)) geprüft, alle Befunde umgesetzt — Stellungnahme und Deploy-Folgen dort. **E1 (Recruiting-Challenge) umgesetzt 18.09., Deploy offen** — Konfiguration im Admin, Details unten.
**Quellen:** `2026 09 Protokoll September-komprimiert.pdf` (Protokoll), `2026-09-14.md` (Ticket-Entwurf Abos #1–#3)
**Verwandte Pläne:** `dev/2026-07-22/entwicklungsplan-2026-09-07.md` (K11–K14 behandeln dieselben Themen aus dem Kundenbrief; die dortigen Rückfragen F4–F7 sind weiterhin offen), `dev/2026-09-10-business-plan/` (Ledger-Plan, löst A07/K11 langfristig)
@ -21,7 +21,7 @@
| **C2** | Laufstand-Race: Provision liest zu niedrigen Monatsstand bei zeitgleichen Buchungen – **Stufe 1+2 live seit 14.09.**, Stufe 3 offen | Befund aus C1 | 0,5 + 1 PT | hoch (Provision) | – (keine Nachzahlung, Entscheid 14.09.) |
| **D1** | Neuer Reiter „Events“ mit Foto-Galerien (wie News-Archiv) | Protokoll | 2–3 PT | niedrig | Fotos liegen vor |
| **D2** | Top 3 firmenweit im Backoffice | Protokoll | 0,5 PT | rechtlich | Network-Anwalt (F3) |
| **E1** | Recruiting-Challenge Okt–Jan (50 €/Neupartner, Top 3) | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Details Dani/Alois |
| **E1** | Recruiting-Challenge Okt–Jan (50 €/Neupartner, Top 3) – **umgesetzt 18.09., Deploy offen (Migration + Anlage im Admin)** | Protokoll | 0,5 PT Konfiguration + 1–2 PT Erweiterung | niedrig | Bonushöhe/Texte Dani/Alois |
**Reihenfolge (festgelegt 14.09.):**
@ -427,7 +427,7 @@ Die Zeilen entstehen in [`Payment::paymentStatusPaidAction()`](../../app/Service
## 5. Aktionen
### E1 – Recruiting-Challenge Oktober–Januar
### E1 – Recruiting-Challenge Oktober–Januar — umgesetzt 18.09., Deploy offen
**Ziel (Protokoll):** Pro Neupartner mit Paket 50 € im Qualifikationszeitraum, Top 3 erhalten zusätzlich einen Bonus, Auszahlung gesammelt im Februar, wieder ein Ranking. Details klären Dani und Alois.
@ -441,6 +441,52 @@ Die Zeilen entstehen in [`Payment::paymentStatusPaidAction()`](../../app/Service
**Risiko:** niedrig. Offene Details (Bonushöhe Top 3, Anrechnung von Stornos/Rücktritten innerhalb des Zeitraums – `IncentivePointsLog.is_storno` existiert) mit Dani/Alois klären.
**Umsetzung (18.09.2026)**
Das Incentive-Modul trägt jetzt optional eine Geldprämie; Montenegro bleibt unverändert (alle neuen Felder leer bzw. Schalter an).
- **Neue Felder** (`incentives`, Migration `2026_09_18_130000_add_payout_fields_to_incentives_table`): `amount_per_partner` (€ je Neupartner, NULL = keine Prämie), `bonus_rank_1/2/3` (Zusatz-Bonus je Platz), `include_sales_points` (Standard 1). Admin-Formular: neue Karte „Prämie / Auszahlung“.
- **Wertung nach Neupartnern:** Mit `include_sales_points = 0` schreibt der Tracker keine Umsatzpunkte der Neupartner/Neuabos mehr ([`IncentiveTracker::trackSalesVolume`](../../app/Services/Incentive/IncentiveTracker.php), Neuaufbau und Reparatur ebenso). Mit `points_partner_onetime = 1` und `points_abo_onetime = 0` entspricht der Punktestand damit der **Anzahl Neupartner mit Starterpaket**; die Rangliste bleibt die vorhandene (`updateRanking`). Neupartner = bezahlte Registrierung mit Starterpaket (`ShoppingOrder::wherePaidRegistrationIncludesStarterKit`), reine Mitgliedschaft zählt nicht.
- **Prämie:** [`IncentivePayoutService`](../../app/Services/Incentive/IncentivePayoutService.php): `qualified_partners × amount_per_partner` plus Rang-Bonus, letzterer nur für qualifizierte Teilnehmer auf Platz 1–`max_winners`. Grundlage ist der gespeicherte Stand des Teilnehmers (derselbe wie in der Rangliste).
- **Auszahlungs-Export:** Admin-Detailseite → Button „Auszahlungs-Export (XLSX)“ (`/admin/incentives/{id}/payout-export`): Rang, Berater-ID, Name, E-Mail, Teilnahme bestätigt, Neupartner, Betrag je Neupartner, Prämie Neupartner, Rang-Bonus, Prämie gesamt, Punkte, qualifiziert. Nur Zeilen mit Prämie > 0. Dazu Summenblock (Empfänger, Summen) und Prämienspalte in der Admin-Rangliste. **Gutschrift über `Credit` ist nicht gebaut** – die Februar-Auszahlung läuft über den Export in die Buchhaltung; Gutschriften ließen sich später aus derselben Liste erzeugen.
- **Mehrere aktive Incentives:** Sidenav listet alle aktiven Incentives (bei mehreren mit Namen), Dashboard zeigt je Incentive eine Karte mit eigenen Modal-IDs; `HomeController` liefert `activeIncentives`/`incentiveParticipants` (die alten Einzelvariablen bleiben befüllt). Montenegro kann so bis zum Abschluss parallel stehen bleiben.
- **Berater-Ansichten:** Teaser und Rangliste zeigen bei Geldprämie ohne Mindestqualifikation eine Prämien-Kachel statt „Dein Ticket“, blenden die Abo-Blöcke aus, wenn Abos keine Rolle spielen, und ergänzen die Rangliste um die Spalte „Prämie“; Header/Details zeigen „Deine Prämie bisher“. Der Punkte-Boost-Text erscheint nur, wenn Umsatzpunkte zählen.
- **Tests:** [`IncentivePayoutTest`](../../tests/Feature/Incentive/IncentivePayoutTest.php), [`IncentiveSalesPointsToggleTest`](../../tests/Feature/Incentive/IncentiveSalesPointsToggleTest.php), [`IncentiveAdminPayoutTest`](../../tests/Feature/Incentive/IncentiveAdminPayoutTest.php). Incentive-Suite: 48 grün, 1 vorbestehender Fehlschlag (`IncentiveTeaserPageTest`, Hero-Bild in der Galerie – unabhängig von E1).
**Konfiguration der Challenge (Admin → Incentives → Neu):**
| Feld | Wert |
|---|---|
| Qualifikationsbeginn / -ende | 01.10.2026 / 31.01.2027 |
| Berechnungsende | 31.01.2027 (ohne Umsatzpunkte gibt es keinen Nachlauf; 28.02. wäre nur nötig, wenn Umsätze zählen) |
| Einmalpunkte pro Partner / pro Abo | 1 / 0 |
| Mind. direkte Partner / Kundenabos | 0 / 0 |
| Max. Gewinner | 3 |
| Prämie je Neupartner | 50,00 |
| Bonus Platz 1 / 2 / 3 | **offen – Dani/Alois** |
| Umsatzpunkte mitzählen | **aus** |
| Status | Aktiv (ab 01.10.; früher anlegen ist unschädlich, getrackt wird nur im Zeitraum) |
| Bild, Untertitel, Beschreibung, Teilnahmebedingungen | **Dani/Alois** – ohne Beschreibung erscheint der Montenegro-Fallbacktext („Adria“), also Beschreibung unbedingt füllen |
**Nachtragen nach dem 01.10.:** `php artisan incentive:calculate {id}` zieht Neupartner ab Qualifikationsbeginn nach (läuft ohnehin täglich 05:00 für alle aktiven Incentives).
**Offen / bewusst nicht gebaut**
- Bonushöhe Top 3, Beschreibungstexte, Bild (Dani/Alois).
- **Punktgleichstand** in den Top 3: Rangfolge ist dann bestätigte Teilnahme vor anonym, sonst Datenbankreihenfolge. Falls es auf die Boni ankommt, Regel festlegen (z. B. wer die Anzahl zuerst erreicht hat) – Umsetzung 0,5 PT.
- **Stornos/Rücktritte:** Ein einmal getrackter Neupartner bleibt gezählt, auch wenn die Registrierung später storniert wird (Verhalten wie Montenegro). Vor der Auszahlung die Liste gegen stornierte Registrierungsbestellungen prüfen oder Regel mit Dani/Alois klären.
- Gutschrift-Erzeugung aus dem Export (siehe oben).
- Firmenweite Rangliste (D2) unverändert offen; die Challenge-Rangliste ist wie Montenegro für alle aktiven Berater sichtbar.
**Deploy**
```bash
php artisan migrate # incentives: amount_per_partner, bonus_rank_1..3, include_sales_points
php artisan view:clear && php artisan route:clear
```
Danach Challenge im Admin anlegen (Tabelle oben). Kein Neuaufbau, keine Datenänderung an Montenegro.
---
## 6. Rückfragen an die Kundin / Alois (vor Start des jeweiligen Pakets)

View file

@ -181,4 +181,32 @@ return [
// Galerie
'gallery_title' => 'Impressionen',
// Praemie / Auszahlung (Recruiting-Challenge, E1)
'payout_section' => 'Prämie / Auszahlung (Recruiting-Challenge)',
'payout_section_help' => 'Optional. Leer lassen für ein reines Punkte-Incentive. Die Prämie wird aus der Anzahl Neupartner mit Starterpaket berechnet, der Rang-Bonus nur für qualifizierte Teilnehmer innerhalb der Gewinnerplätze.',
'amount_per_partner' => 'Prämie je Neupartner (€)',
'amount_per_partner_help' => 'z. B. 50,00 – wird je direkt gesponsertem Neupartner mit Starterpaket ausgezahlt.',
'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).',
'payout_export' => 'Auszahlungs-Export (XLSX)',
'payout_recipients' => 'Empfänger',
'payout_partner_amount' => 'Prämie Neupartner',
'payout_rank_bonus' => 'Rang-Bonus',
'payout_total' => 'Prämie gesamt',
'payout_column' => 'Prämie',
'payout_incl_bonus' => 'inkl. :amount Rang-Bonus',
'payout_so_far' => 'Deine Prämie bisher',
'payout_tile_title' => 'Deine Prämie',
'payout_per_partner_label' => 'je direkt gesponsertem Neupartner mit Starterpaket',
'payout_per_partner_text' => 'Prämie: :amount je direkt gesponsertem Neupartner mit Starterpaket.',
'payout_rank_bonus_label' => 'Zusatz-Bonus für Platz :n',
'payout_top_bonus_title' => 'Zusatz-Bonus für die Top :n',
'payout_top_bonus_text' => 'Zusatz-Bonus für die Top :n: :list.',
'payout_rank_label' => 'Platz :n',
'payout_ranking_hint' => 'Die Rangliste zählt deine Neupartner mit Starterpaket im Qualifikationszeitraum. Die Auszahlung erfolgt gesammelt nach Ende des Zeitraums.',
'new_partners_short' => 'Neupartner',
'badge_challenge' => 'Challenge',
'export_user_id' => 'Berater-ID',
];

View file

@ -181,4 +181,32 @@ return [
// Gallery
'gallery_title' => 'Impressions',
// Praemie / Auszahlung (Recruiting-Challenge, E1)
'payout_section' => 'Reward / payout (recruiting challenge)',
'payout_section_help' => 'Optional. Leave empty for a points-only incentive. The reward is calculated from the number of new partners with a starter kit; the rank bonus only applies to qualified participants within the winner ranks.',
'amount_per_partner' => 'Reward per new partner (€)',
'amount_per_partner_help' => 'e.g. 50.00 – paid per directly sponsored new partner with a starter kit.',
'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).',
'payout_export' => 'Payout export (XLSX)',
'payout_recipients' => 'Recipients',
'payout_partner_amount' => 'New partner reward',
'payout_rank_bonus' => 'Rank bonus',
'payout_total' => 'Total reward',
'payout_column' => 'Reward',
'payout_incl_bonus' => 'incl. :amount rank bonus',
'payout_so_far' => 'Your reward so far',
'payout_tile_title' => 'Your reward',
'payout_per_partner_label' => 'per directly sponsored new partner with a starter kit',
'payout_per_partner_text' => 'Reward: :amount per directly sponsored new partner with a starter kit.',
'payout_rank_bonus_label' => 'Extra bonus for rank :n',
'payout_top_bonus_title' => 'Extra bonus for the top :n',
'payout_top_bonus_text' => 'Extra bonus for the top :n: :list.',
'payout_rank_label' => 'Rank :n',
'payout_ranking_hint' => 'The ranking counts your new partners with a starter kit during the qualification period. Payout is made collectively after the period ends.',
'new_partners_short' => 'New partners',
'badge_challenge' => 'Challenge',
'export_user_id' => 'Consultant ID',
];

View file

@ -181,4 +181,32 @@ return [
// Galería
'gallery_title' => 'Impresiones',
// Praemie / Auszahlung (Recruiting-Challenge, E1)
'payout_section' => 'Prima / pago (reto de reclutamiento)',
'payout_section_help' => 'Opcional. Déjalo vacío para un incentivo solo de puntos. La prima se calcula por el número de nuevos socios con paquete de inicio; el bono por puesto solo aplica a participantes cualificados dentro de los puestos ganadores.',
'amount_per_partner' => 'Prima por nuevo socio (€)',
'amount_per_partner_help' => 'p. ej. 50,00 – se paga por cada nuevo socio patrocinado directamente con paquete de inicio.',
'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).',
'payout_export' => 'Exportación de pagos (XLSX)',
'payout_recipients' => 'Destinatarios',
'payout_partner_amount' => 'Prima nuevos socios',
'payout_rank_bonus' => 'Bono por puesto',
'payout_total' => 'Prima total',
'payout_column' => 'Prima',
'payout_incl_bonus' => 'incl. :amount bono por puesto',
'payout_so_far' => 'Tu prima hasta ahora',
'payout_tile_title' => 'Tu prima',
'payout_per_partner_label' => 'por nuevo socio patrocinado directamente con paquete de inicio',
'payout_per_partner_text' => 'Prima: :amount por nuevo socio patrocinado directamente con paquete de inicio.',
'payout_rank_bonus_label' => 'Bono adicional para el puesto :n',
'payout_top_bonus_title' => 'Bono adicional para el top :n',
'payout_top_bonus_text' => 'Bono adicional para el top :n: :list.',
'payout_rank_label' => 'Puesto :n',
'payout_ranking_hint' => 'El ranking cuenta tus nuevos socios con paquete de inicio durante el periodo de cualificación. El pago se realiza de forma conjunta al finalizar el periodo.',
'new_partners_short' => 'Nuevos socios',
'badge_challenge' => 'Reto',
'export_user_id' => 'ID del asesor',
];

View file

@ -156,4 +156,32 @@ return [
'onetime_abo_activation' => 'Unique : activation abonnement',
'accumulated' => 'Chiffre d’affaires',
'gallery_title' => 'Impressions',
// Praemie / Auszahlung (Recruiting-Challenge, E1)
'payout_section' => 'Prime / versement (challenge de recrutement)',
'payout_section_help' => 'Facultatif. Laisser vide pour un incentive à points uniquement. La prime est calculée d’après le nombre de nouveaux partenaires avec kit de démarrage ; le bonus de classement ne s’applique qu’aux participants qualifiés dans les places gagnantes.',
'amount_per_partner' => 'Prime par nouveau partenaire (€)',
'amount_per_partner_help' => 'p. ex. 50,00 – versée par nouveau partenaire parrainé directement avec kit de démarrage.',
'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).',
'payout_export' => 'Export des versements (XLSX)',
'payout_recipients' => 'Bénéficiaires',
'payout_partner_amount' => 'Prime nouveaux partenaires',
'payout_rank_bonus' => 'Bonus de classement',
'payout_total' => 'Prime totale',
'payout_column' => 'Prime',
'payout_incl_bonus' => 'dont :amount de bonus de classement',
'payout_so_far' => 'Ta prime jusqu’ici',
'payout_tile_title' => 'Ta prime',
'payout_per_partner_label' => 'par nouveau partenaire parrainé directement avec kit de démarrage',
'payout_per_partner_text' => 'Prime : :amount par nouveau partenaire parrainé directement avec kit de démarrage.',
'payout_rank_bonus_label' => 'Bonus supplémentaire pour la place :n',
'payout_top_bonus_title' => 'Bonus supplémentaire pour le top :n',
'payout_top_bonus_text' => 'Bonus supplémentaire pour le top :n : :list.',
'payout_rank_label' => 'Place :n',
'payout_ranking_hint' => 'Le classement compte tes nouveaux partenaires avec kit de démarrage pendant la période de qualification. Le versement est effectué groupé après la fin de la période.',
'new_partners_short' => 'Nouveaux partenaires',
'badge_challenge' => 'Challenge',
'export_user_id' => 'ID du conseiller',
];

View file

@ -121,6 +121,43 @@
</div>
</div>
{{-- ===== PRAEMIE / AUSZAHLUNG (Recruiting-Challenge) ===== --}}
<div class="card mb-4">
<div class="card-header">
<i class="ion ion-md-cash mr-1"></i>
<strong>{{ __('incentive.payout_section') }}</strong>
</div>
<div class="card-body">
<p class="text-muted small">{{ __('incentive.payout_section_help') }}</p>
<div class="form-row">
<div class="form-group col-md-3">
<label for="amount_per_partner">{{ __('incentive.amount_per_partner') }}</label>
<input type="number" step="0.01" min="0" class="form-control" id="amount_per_partner"
name="amount_per_partner"
value="{{ old('amount_per_partner', isset($incentive->amount_per_partner) ? number_format((float) $incentive->amount_per_partner, 2, '.', '') : '') }}">
<small class="form-text text-muted">{{ __('incentive.amount_per_partner_help') }}</small>
</div>
@for ($bonusRank = 1; $bonusRank <= \App\Models\Incentive::RANK_BONUS_SLOTS; $bonusRank++)
<div class="form-group col-md-3">
<label for="bonus_rank_{{ $bonusRank }}">{{ __('incentive.bonus_rank', ['n' => $bonusRank]) }}</label>
<input type="number" step="0.01" min="0" class="form-control" id="bonus_rank_{{ $bonusRank }}"
name="bonus_rank_{{ $bonusRank }}"
value="{{ old('bonus_rank_' . $bonusRank, isset($incentive) && $incentive->{'bonus_rank_' . $bonusRank} !== null ? number_format((float) $incentive->{'bonus_rank_' . $bonusRank}, 2, '.', '') : '') }}">
</div>
@endfor
</div>
<div class="custom-control custom-checkbox">
<input type="hidden" name="include_sales_points" value="0">
<input type="checkbox" class="custom-control-input" id="include_sales_points" name="include_sales_points"
value="1" @if (old('include_sales_points', isset($incentive) ? (int) $incentive->includesSalesPoints() : 1)) checked @endif>
<label class="custom-control-label" for="include_sales_points">{{ __('incentive.include_sales_points') }}</label>
</div>
<small class="form-text text-muted">{{ __('incentive.include_sales_points_help') }}</small>
</div>
</div>
{{-- ===== INHALTE: DEUTSCH (Standard) ===== --}}
<div class="card mb-4">
<div class="card-header bg-primary text-white">

View file

@ -8,6 +8,12 @@
<a href="{{ route('admin_incentive_edit', [$incentive->id]) }}" class="btn btn-sm btn-warning">
<span class="fa fa-edit"></span> {{ __('incentive.edit') }}
</a>
@if ($incentive->hasCashPayout())
<a href="{{ route('admin_incentive_payout_export', [$incentive->id]) }}" class="btn btn-sm btn-success"
id="incentive-payout-export">
<span class="fa fa-file-excel"></span> {{ __('incentive.payout_export') }}
</a>
@endif
{{-- <form action="{{ route('admin_incentive_recalculate', [$incentive->id]) }}" method="POST" class="d-inline">
@csrf
<button type="submit" class="btn btn-sm btn-info" onclick="return confirm('{{ __('incentive.recalculate_confirm') }}')">
@ -45,9 +51,37 @@
</div>
<div class="col-md-4">
<strong>{{ __('incentive.min_direct_partners') }}:</strong> {{ $incentive->min_direct_partners }}<br>
<strong>{{ __('incentive.min_customer_abos') }}:</strong> {{ $incentive->min_customer_abos }}
<strong>{{ __('incentive.min_customer_abos') }}:</strong> {{ $incentive->min_customer_abos }}<br>
<strong>{{ __('incentive.include_sales_points') }}:</strong>
{{ $incentive->includesSalesPoints() ? __('incentive.yes') : __('incentive.no') }}
</div>
</div>
@if ($incentive->hasCashPayout())
<hr>
<div class="row" id="incentive-payout-config">
<div class="col-md-4">
<strong>{{ __('incentive.amount_per_partner') }}:</strong>
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner) }}
</div>
<div class="col-md-4">
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
<strong>{{ __('incentive.bonus_rank', ['n' => $bonusRank]) }}:</strong>
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount) }}<br>
@endforeach
</div>
@if ($payoutTotals)
<div class="col-md-4">
<strong>{{ __('incentive.payout_recipients') }}:</strong> {{ $payoutTotals['recipients'] }}<br>
<strong>{{ __('incentive.payout_partner_amount') }}:</strong>
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payoutTotals['partner_amount']) }}<br>
<strong>{{ __('incentive.payout_rank_bonus') }}:</strong>
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payoutTotals['rank_bonus']) }}<br>
<strong>{{ __('incentive.payout_total') }}:</strong>
<span class="font-weight-bold">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($payoutTotals['total']) }}</span>
</div>
@endif
</div>
@endif
</div>
</div>
@ -68,6 +102,9 @@
<th>{{ __('incentive.partners') }}</th>
<th>{{ __('incentive.abos') }}</th>
<th>{{ __('incentive.qualified') }}</th>
@if ($incentive->hasCashPayout())
<th class="text-right">{{ __('incentive.payout_total') }}</th>
@endif
<th>{{ __('incentive.status') }}</th>
<th></th>
</tr>
@ -117,6 +154,19 @@
<span class="badge badge-secondary">{{ __('incentive.no') }}</span>
@endif
</td>
@if ($incentive->hasCashPayout())
@php $payout = $payoutByParticipantId[$p->id] ?? null; @endphp
<td class="text-right">
@if ($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>
@endif
@else
—
@endif
</td>
@endif
<td>
@if ($isWinner)
<span class="badge badge-warning">{{ __('incentive.winner') }}</span>
@ -136,7 +186,7 @@
</tr>
@empty
<tr>
<td colspan="10" class="text-center text-muted">{{ __('incentive.no_participants') }}</td>
<td colspan="{{ $incentive->hasCashPayout() ? 11 : 10 }}" class="text-center text-muted">{{ __('incentive.no_participants') }}</td>
</tr>
@endforelse
</tbody>

View file

@ -1,6 +1,19 @@
@if (isset($activeIncentive) && $activeIncentive)
@php
// Mehrere aktive Incentives parallel (E1): Fallback auf die alte Einzelvariable.
$dashIncentives = isset($activeIncentives) && $activeIncentives
? $activeIncentives
: collect(isset($activeIncentive) && $activeIncentive ? [$activeIncentive] : []);
@endphp
@foreach ($dashIncentives as $activeIncentive)
@php
$incentiveParticipant = isset($incentiveParticipants) && $incentiveParticipants
? $incentiveParticipants->get($activeIncentive->id)
: ($incentiveParticipant ?? null);
$hasConfirmedDash = $incentiveParticipant && $incentiveParticipant->accepted_terms_at !== null;
$dashSuffix = '-' . $activeIncentive->id;
$dashPayout = $activeIncentive->hasCashPayout() && $incentiveParticipant
? (new \App\Services\Incentive\IncentivePayoutService)->forParticipant($activeIncentive, $incentiveParticipant)
: null;
@endphp
<div class="d-flex col-xl-12 align-items-stretch">
<div class="w-100 mb-4 inc-dash-widget">
@ -12,7 +25,7 @@
<div class="inc-dash-hero-overlay"></div>
<div class="inc-dash-hero-content">
<span class="inc-dash-badge">
<i class="ion ion-md-trophy mr-1"></i> Incentive VIP
<i class="ion ion-md-trophy mr-1"></i> {{ $activeIncentive->hasCashPayout() ? __('incentive.badge_challenge') : 'Incentive VIP' }}
</span>
<h4 class="inc-dash-title">
{{ $activeIncentive->getLang('name') ?: $activeIncentive->name }}
@ -25,7 +38,7 @@
@else
<div class="inc-dash-header-plain">
<span class="inc-dash-badge">
<i class="ion ion-md-trophy mr-1"></i> Incentive VIP
<i class="ion ion-md-trophy mr-1"></i> {{ $activeIncentive->hasCashPayout() ? __('incentive.badge_challenge') : 'Incentive VIP' }}
</span>
<h4 class="font-weight-bold mb-1">
{{ $activeIncentive->getLang('name') ?: $activeIncentive->name }}
@ -51,8 +64,14 @@
<div class="inc-dash-stat">
<span
class="inc-dash-stat-value">{{ number_format($incentiveParticipant->total_points, 0, ',', '.') }}</span>
<span class="inc-dash-stat-label">{{ __('incentive.points_short') }}</span>
<span class="inc-dash-stat-label">{{ $activeIncentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.points_short') }}</span>
</div>
@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>
</div>
@endif
<div class="inc-dash-stat">
@if ($incentiveParticipant->is_qualified)
<span class="inc-dash-qual-yes">{{ __('incentive.qualified') }}</span>
@ -80,7 +99,7 @@
@endif
@if ($activeIncentive->isActive())
<button type="button" class="btn inc-dash-btn-notice" data-toggle="modal"
data-target="#incParticipateModal">
data-target="#incParticipateModal{{ $dashSuffix }}">
<i class="ion ion-md-checkmark-circle mr-1"></i>
{{ __('incentive.dash_notice_btn') }}
</button>
@ -145,12 +164,12 @@
{{-- Teilnahme-Modal --}}
@if (!$hasConfirmedDash && $activeIncentive->isActive())
<div class="modal fade" id="incParticipateModal" tabindex="-1" role="dialog"
aria-labelledby="incParticipateModalLabel" aria-hidden="true">
<div class="modal fade" id="incParticipateModal{{ $dashSuffix }}" tabindex="-1" role="dialog"
aria-labelledby="incParticipateModalLabel{{ $dashSuffix }}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document">
<div class="modal-content">
<div class="modal-header" style="background: linear-gradient(135deg, #6b7758, #4a5340);">
<h5 class="modal-title text-white" id="incParticipateModalLabel">
<h5 class="modal-title text-white" id="incParticipateModalLabel{{ $dashSuffix }}">
<i class="ion ion-md-trophy mr-2" style="color: #d7d700;"></i>
{{ __('incentive.dash_modal_title') }}
</h5>
@ -158,7 +177,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<form id="incDashParticipateForm"
<form id="incDashParticipateForm{{ $dashSuffix }}"
action="{{ route('user_incentive_participate', [$activeIncentive->slug]) }}" method="POST">
@csrf
<div class="modal-body">
@ -174,14 +193,14 @@
@if ($activeIncentive->getLang('terms'))
<div class="card mb-4" style="border: 1px solid #e0e0d8;">
<div class="card-header py-2 px-3" style="cursor: pointer; background: #f4f5f0;"
data-toggle="collapse" data-target="#dashTermsCollapse">
data-toggle="collapse" data-target="#dashTermsCollapse{{ $dashSuffix }}">
<div class="d-flex align-items-center">
<i class="ion ion-md-document mr-2" style="color: #6b7758;"></i>
<strong class="small">{{ __('incentive.terms') }}</strong>
<i class="ion ion-md-chevron-down ml-auto text-muted"></i>
</div>
</div>
<div id="dashTermsCollapse" class="collapse">
<div id="dashTermsCollapse{{ $dashSuffix }}" class="collapse">
<div class="card-body small"
style="max-height: 280px; overflow-y: auto; line-height: 1.6;">
{!! $activeIncentive->getLang('terms') !!}
@ -191,12 +210,12 @@
@endif
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="dashAcceptTerms"
<input type="checkbox" class="custom-control-input" id="dashAcceptTerms{{ $dashSuffix }}"
name="accept_terms" value="1" required>
<label class="custom-control-label" for="dashAcceptTerms">
<label class="custom-control-label" for="dashAcceptTerms{{ $dashSuffix }}">
{{ __('incentive.accept_terms') }}
@if ($activeIncentive->getLang('terms'))
(<a href="#dashTermsCollapse" data-toggle="collapse"
(<a href="#dashTermsCollapse{{ $dashSuffix }}" data-toggle="collapse"
class="text-muted">{{ __('incentive.show_terms') }}</a>)
@endif
</label>
@ -217,6 +236,9 @@
</div>
@endif
@endforeach
@if ($dashIncentives->isNotEmpty())
<style>
.inc-dash-notice {
background: rgba(215, 185, 0, 0.10);

View file

@ -290,15 +290,15 @@
<div>{{ __('navigation.downloadcenter') }}</div>
</a>
</li>
@php $activeIncentive = \App\Models\Incentive::active()->first(); @endphp
@if ($activeIncentive)
<li class="sidenav-item{{ Request::is('incentive/*') ? ' active' : '' }}">
<a href="{{ route('user_incentive_teaser', [$activeIncentive->slug]) }}"
@php $sidenavIncentives = \App\Models\Incentive::activeForNavigation(); @endphp
@foreach ($sidenavIncentives as $sidenavIncentive)
<li class="sidenav-item{{ Request::is('incentive/' . $sidenavIncentive->slug . '*') ? ' active' : '' }}">
<a href="{{ route('user_incentive_teaser', [$sidenavIncentive->slug]) }}"
class="sidenav-link"><i class="text-secondary sidenav-icon ion ion-md-trophy"></i>
<div>{{ __('navigation.incentive') }}</div>
<div>{{ $sidenavIncentives->count() > 1 ? ($sidenavIncentive->getLang('name') ?: $sidenavIncentive->name) : __('navigation.incentive') }}</div>
</a>
</li>
@endif
@endforeach
@endif
@endif

View file

@ -0,0 +1,7 @@
{{-- Praemien-Hinweise in der Punkte-Erklaerung (nur bei Geldpraemie). Erwartet $incentive. --}}
@if ((float) $incentive->amount_per_partner > 0)
<li>{{ __('incentive.payout_per_partner_text', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner)]) }}</li>
@endif
@if ($incentive->rankBonuses() !== [])
<li>{{ __('incentive.payout_top_bonus_text', ['n' => count($incentive->rankBonuses()), 'list' => collect($incentive->rankBonuses())->map(fn ($amount, $rank) => __('incentive.payout_rank_label', ['n' => $rank]).' '.\App\Services\Incentive\IncentivePayoutService::formatAmount($amount))->implode(', ')]) }}</li>
@endif

View file

@ -0,0 +1,31 @@
{{-- Praemien-Kachel einer Recruiting-Challenge (ersetzt die Mindestqualifikation, wenn keine gefordert ist). Erwartet $incentive. --}}
<div class="card inc-info-tile shadow-sm mb-4" id="incentive-payout-tile">
<div class="card-body">
<div class="d-flex align-items-start">
<div class="inc-tile-icon inc-tile-icon-plane mr-3">
<i class="ion ion-md-cash"></i>
</div>
<div>
<h6 class="font-weight-bold mb-2">{{ __('incentive.payout_tile_title') }}</h6>
<ul class="list-unstyled mb-2">
@if ((float) $incentive->amount_per_partner > 0)
<li class="mb-1">
<strong style="color: #6b7758;">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner) }}</strong>
{{ __('incentive.payout_per_partner_label') }}
</li>
@endif
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
<li>
<strong style="color: #6b7758;">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount) }}</strong>
{{ __('incentive.payout_rank_bonus_label', ['n' => $bonusRank]) }}
</li>
@endforeach
</ul>
<p class="text-muted small mb-0">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.payout_ranking_hint') }}
</p>
</div>
</div>
</div>
</div>

View file

@ -31,21 +31,36 @@
<div class="card-body">
<h5 class="card-title text-muted">{{ __('incentive.partners') }}</h5>
<h2 class="font-weight-bold {{ $participant->qualified_partners >= $incentive->min_direct_partners ? 'text-success' : '' }}">
{{ $participant->qualified_partners }}/{{ $incentive->min_direct_partners }}
{{ $participant->qualified_partners }}@if ($incentive->min_direct_partners > 0)/{{ $incentive->min_direct_partners }}@endif
</h2>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-muted">{{ __('incentive.abos') }}</h5>
<h2 class="font-weight-bold {{ $participant->qualified_abos >= $incentive->min_customer_abos ? 'text-success' : '' }}">
{{ $participant->qualified_abos }}/{{ $incentive->min_customer_abos }}
</h2>
@if ($incentive->hasCashPayout() && !$incentive->usesAbos())
@php $detailPayout = (new \App\Services\Incentive\IncentivePayoutService)->forParticipant($incentive, $participant); @endphp
<div class="col-md-3">
<div class="card text-center" id="incentive-payout-card">
<div class="card-body">
<h5 class="card-title text-muted">{{ __('incentive.payout_so_far') }}</h5>
<h2 class="font-weight-bold">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($detailPayout['total']) }}</h2>
@if ($detailPayout['rank_bonus'] > 0)
<small class="text-muted">{{ __('incentive.payout_incl_bonus', ['amount' => \App\Services\Incentive\IncentivePayoutService::formatAmount($detailPayout['rank_bonus'])]) }}</small>
@endif
</div>
</div>
</div>
</div>
@else
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<h5 class="card-title text-muted">{{ __('incentive.abos') }}</h5>
<h2 class="font-weight-bold {{ $participant->qualified_abos >= $incentive->min_customer_abos ? 'text-success' : '' }}">
{{ $participant->qualified_abos }}/{{ $incentive->min_customer_abos }}
</h2>
</div>
</div>
</div>
@endif
</div>
{{-- Sektion A: Neupartner-Punkte --}}
@ -64,21 +79,23 @@
</div>
</div>
{{-- Sektion B: Kundenabo-Punkte --}}
<div class="card mb-4">
<div class="card-header">
<strong>{{ __('incentive.section_abos') }}</strong>
{{-- Sektion B: Kundenabo-Punkte (entfaellt bei reiner Recruiting-Challenge) --}}
@if ($incentive->usesAbos() || $abo_sources->isNotEmpty())
<div class="card mb-4">
<div class="card-header">
<strong>{{ __('incentive.section_abos') }}</strong>
</div>
<div class="card-body p-0">
@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'),
])
</div>
</div>
<div class="card-body p-0">
@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'),
])
</div>
</div>
@endif
{{-- Gesamtpunkte --}}
<div class="card border-primary">

View file

@ -490,9 +490,15 @@
</div>
@endif
<div class="stat-box">
<div class="stat-label">{{ __('incentive.total_points') }}</div>
<div class="stat-label">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</div>
<div class="stat-value">{{ number_format($participant->total_points, 0, ',', '.') }}</div>
</div>
@if ($participantPayout)
<div class="stat-box">
<div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
<div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
</div>
@endif
<div class="mr-3">
@if ($participant->is_qualified)
<span class="badge-qual">{{ __('incentive.qualified') }}</span>
@ -560,6 +566,9 @@
</div>
</div>
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
@include('user.incentive._payout_tile', ['incentive' => $incentive])
@else
{{-- Mindestqualifikation --}}
<div class="card inc-info-tile shadow-sm mb-4">
<div class="card-body">
@ -580,14 +589,15 @@
{{ __('incentive.min_abos_label') }}
</li>
</ul>
<p class="text-muted small mb-0">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.min_qual_ranking_hint') }}
</p>
</div>
</div>
</div>
</div>
<p class="text-muted small mb-0">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.min_qual_ranking_hint') }}
</p>
</div>
</div>
</div>
</div>
@endif
{{-- Punkte-System --}}
<div class="inc-points-section mb-4">
@ -596,7 +606,7 @@
{{ __('incentive.section_points') }}
</h6>
<div class="row">
<div class="col-md-6 mb-3 mb-md-0">
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
<div class="inc-point-card inc-point-card-partner">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
<i class="ion ion-md-person-add mr-1"></i>
@ -609,10 +619,14 @@
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_starter_package_label') }}</li>
<li>{{ __('incentive.points_partner_boost') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_partner_boost') }}</li>
@endif
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
</ul>
</div>
</div>
@if ($incentive->usesAbos())
<div class="col-md-6">
<div class="inc-point-card inc-point-card-abo">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
@ -626,10 +640,13 @@
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_abo_direct') }}</li>
<li>{{ __('incentive.points_abo_boost') }}</li>
</ul>
</div>
</div>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_abo_boost') }}</li>
@endif
</ul>
</div>
</div>
@endif
</div>
</div>
@ -694,9 +711,15 @@
</div>
@endif
<div class="stat-box">
<div class="stat-label">{{ __('incentive.total_points') }}</div>
<div class="stat-label">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</div>
<div class="stat-value">{{ number_format($participant->total_points, 0, ',', '.') }}</div>
</div>
@if ($participantPayout)
<div class="stat-box">
<div class="stat-label">{{ __('incentive.payout_so_far') }}</div>
<div class="stat-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($participantPayout['total']) }}</div>
</div>
@endif
<div>
@if ($participant->is_qualified)
<span class="badge-qual">{{ __('incentive.qualified') }}</span>
@ -759,6 +782,9 @@
</div>
</div>
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
@include('user.incentive._payout_tile', ['incentive' => $incentive])
@else
{{-- Mindestqualifikation --}}
<div class="card inc-info-tile shadow-sm mb-4">
<div class="card-body">
@ -779,14 +805,15 @@
{{ __('incentive.min_abos_label') }}
</li>
</ul>
<p class="text-muted small mb-0">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.min_qual_ranking_hint') }}
</p>
</div>
</div>
</div>
</div>
<p class="text-muted small mb-0">
<i class="ion ion-md-information-circle mr-1"></i>
{{ __('incentive.min_qual_ranking_hint') }}
</p>
</div>
</div>
</div>
</div>
@endif
{{-- Punkte-System --}}
<div class="inc-points-section mb-4">
@ -795,7 +822,7 @@
{{ __('incentive.section_points') }}
</h6>
<div class="row">
<div class="col-md-6 mb-3 mb-md-0">
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
<div class="inc-point-card inc-point-card-partner">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
<i class="ion ion-md-person-add mr-1"></i>
@ -807,10 +834,15 @@
</div>
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_partner_boost') }}</li>
<li>{{ __('incentive.points_starter_package_label') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_partner_boost') }}</li>
@endif
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
</ul>
</div>
</div>
@if ($incentive->usesAbos())
<div class="col-md-6">
<div class="inc-point-card inc-point-card-abo">
<h6 class="font-weight-bold mb-1" style="font-size: .9rem;">
@ -823,10 +855,13 @@
</div>
<ul class="pl-3 mb-0">
<li>{{ __('incentive.points_onetime_label') }}</li>
<li>{{ __('incentive.points_abo_boost') }}</li>
</ul>
</div>
</div>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_abo_boost') }}</li>
@endif
</ul>
</div>
</div>
@endif
</div>
</div>
@ -929,9 +964,16 @@
<tr>
<th style="width: 60px;">{{ __('incentive.rank') }}</th>
<th>{{ __('incentive.consultant') }}</th>
<th class="text-right">{{ __('incentive.total_points') }}</th>
<th class="text-center">{{ __('incentive.partners') }}</th>
<th class="text-center">{{ __('incentive.abos') }}</th>
<th class="text-right">{{ $incentive->ranksByPartnerCount() ? __('incentive.new_partners_short') : __('incentive.total_points') }}</th>
@if (!$incentive->ranksByPartnerCount())
<th class="text-center">{{ __('incentive.partners') }}</th>
@endif
@if ($incentive->usesAbos())
<th class="text-center">{{ __('incentive.abos') }}</th>
@endif
@if ($incentive->hasCashPayout())
<th class="text-right">{{ __('incentive.payout_column') }}</th>
@endif
<th class="text-center">{{ __('incentive.status') }}</th>
</tr>
</thead>
@ -978,18 +1020,32 @@
<td class="text-right">
<strong>{{ number_format($p->total_points, 0, ',', '.') }}</strong>
</td>
<td class="text-center">
{{ $p->qualified_partners }}/{{ $incentive->min_direct_partners }}
@if ($p->qualified_partners >= $incentive->min_direct_partners)
<span class="check-ok">&#10003;</span>
@endif
</td>
<td class="text-center">
{{ $p->qualified_abos }}/{{ $incentive->min_customer_abos }}
@if ($p->qualified_abos >= $incentive->min_customer_abos)
<span class="check-ok">&#10003;</span>
@endif
</td>
@if (!$incentive->ranksByPartnerCount())
<td class="text-center">
{{ $p->qualified_partners }}@if ($incentive->min_direct_partners > 0)/{{ $incentive->min_direct_partners }}@endif
@if ($incentive->min_direct_partners > 0 && $p->qualified_partners >= $incentive->min_direct_partners)
<span class="check-ok">&#10003;</span>
@endif
</td>
@endif
@if ($incentive->usesAbos())
<td class="text-center">
{{ $p->qualified_abos }}@if ($incentive->min_customer_abos > 0)/{{ $incentive->min_customer_abos }}@endif
@if ($incentive->min_customer_abos > 0 && $p->qualified_abos >= $incentive->min_customer_abos)
<span class="check-ok">&#10003;</span>
@endif
</td>
@endif
@if ($incentive->hasCashPayout())
@php $rowPayout = $rankingPayouts[$p->id] ?? null; @endphp
<td class="text-right">
@if ($rowPayout && $rowPayout['total'] > 0)
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($rowPayout['total']) }}
@else
&mdash;
@endif
</td>
@endif
<td class="text-center">
@if ($isWinner)
<span class="badge-winner">{{ __('incentive.winner') }}</span>

View file

@ -515,25 +515,54 @@
</div>
</div>
{{-- Mindestqualifikation --}}
<div class="col-md-4 mb-3 mb-md-0">
<div class="card info-tile h-100 shadow-sm">
<div class="card-body text-center py-4">
<div class="tile-icon tile-icon-plane">
<i class="ion ion-md-airplane"></i>
</div>
<h5 class="font-weight-bold mb-3">{{ __('incentive.section_min_qual') }}</h5>
<div class="mb-3">
<div class="tile-value">{{ $incentive->min_direct_partners }}</div>
<span class="tile-label">{{ __('incentive.min_partners_label') }}</span>
</div>
<div>
<div class="tile-value">{{ $incentive->min_customer_abos }}</div>
<span class="tile-label">{{ __('incentive.min_abos_label') }}</span>
@if ($incentive->hasCashPayout() && !$incentive->hasMinimumRequirements())
{{-- Praemie (Recruiting-Challenge) --}}
<div class="col-md-4 mb-3 mb-md-0">
<div class="card info-tile h-100 shadow-sm" id="incentive-payout-tile">
<div class="card-body text-center py-4">
<div class="tile-icon tile-icon-plane">
<i class="ion ion-md-cash"></i>
</div>
<h5 class="font-weight-bold mb-3">{{ __('incentive.payout_tile_title') }}</h5>
@if ((float) $incentive->amount_per_partner > 0)
<div class="mb-3">
<div class="tile-value">{{ \App\Services\Incentive\IncentivePayoutService::formatAmount((float) $incentive->amount_per_partner) }}</div>
<span class="tile-label">{{ __('incentive.payout_per_partner_label') }}</span>
</div>
@endif
@if ($incentive->rankBonuses() !== [])
<div>
<span class="tile-label d-block mb-1">{{ __('incentive.payout_top_bonus_title', ['n' => count($incentive->rankBonuses())]) }}</span>
@foreach ($incentive->rankBonuses() as $bonusRank => $bonusAmount)
<div><strong>{{ __('incentive.payout_rank_label', ['n' => $bonusRank]) }}:</strong>
{{ \App\Services\Incentive\IncentivePayoutService::formatAmount($bonusAmount) }}</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
</div>
@else
{{-- Mindestqualifikation --}}
<div class="col-md-4 mb-3 mb-md-0">
<div class="card info-tile h-100 shadow-sm">
<div class="card-body text-center py-4">
<div class="tile-icon tile-icon-plane">
<i class="ion ion-md-airplane"></i>
</div>
<h5 class="font-weight-bold mb-3">{{ __('incentive.section_min_qual') }}</h5>
<div class="mb-3">
<div class="tile-value">{{ $incentive->min_direct_partners }}</div>
<span class="tile-label">{{ __('incentive.min_partners_label') }}</span>
</div>
<div>
<div class="tile-value">{{ $incentive->min_customer_abos }}</div>
<span class="tile-label">{{ __('incentive.min_abos_label') }}</span>
</div>
</div>
</div>
</div>
@endif
{{-- Punkte-System --}}
<div class="col-md-4">
@ -550,13 +579,15 @@
</span>
<span class="tile-label d-block">{{ __('incentive.points_partners_title') }}</span>
</div>
<div>
<span class="tile-value" style="color: #9a9a00;">
{{ number_format($incentive->points_abo_onetime, 0, ',', '.') }}
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
</span>
<span class="tile-label d-block">{{ __('incentive.points_abos_title') }}</span>
</div>
@if ($incentive->usesAbos())
<div>
<span class="tile-value" style="color: #9a9a00;">
{{ number_format($incentive->points_abo_onetime, 0, ',', '.') }}
<span class="small font-weight-normal">{{ __('incentive.points_short') }}</span>
</span>
<span class="tile-label d-block">{{ __('incentive.points_abos_title') }}</span>
</div>
@endif
</div>
</div>
</div>
@ -570,7 +601,7 @@
{{ __('incentive.section_points') }}
</h4>
<div class="row">
<div class="col-md-6 mb-3 mb-md-0">
<div class="{{ $incentive->usesAbos() ? 'col-md-6 mb-3 mb-md-0' : 'col-md-12' }}">
<div class="point-card point-card-partner">
<h6 class="font-weight-bold mb-2">
<i class="ion ion-md-person-add mr-1"></i>
@ -583,10 +614,14 @@
<ul>
<li>{{ __('incentive.teaser_partner_onetime_text') }}</li>
<li>{{ __('incentive.points_starter_package_label') }}</li>
<li>{{ __('incentive.points_partner_boost') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_partner_boost') }}</li>
@endif
@include('user.incentive._payout_bullets', ['incentive' => $incentive])
</ul>
</div>
</div>
@if ($incentive->usesAbos())
<div class="col-md-6">
<div class="point-card point-card-abo">
<h6 class="font-weight-bold mb-2">
@ -600,10 +635,13 @@
<ul>
<li>{{ __('incentive.teaser_abo_onetime_text') }}</li>
<li>{{ __('incentive.points_abo_direct') }}</li>
<li>{{ __('incentive.points_abo_boost') }}</li>
@if ($incentive->includesSalesPoints())
<li>{{ __('incentive.points_abo_boost') }}</li>
@endif
</ul>
</div>
</div>
@endif
</div>
</div>

View file

@ -394,6 +394,7 @@ Route::domain(config('app.pre_url_crm').config('app.domain').config('app.tld_car
Route::get('/admin/incentives/{id}/edit', 'Admin\IncentiveController@edit')->name('admin_incentive_edit');
Route::post('/admin/incentives/{id}/update', 'Admin\IncentiveController@update')->name('admin_incentive_update');
Route::post('/admin/incentives/{id}/recalculate', 'Admin\IncentiveController@recalculate')->name('admin_incentive_recalculate');
Route::get('/admin/incentives/{id}/payout-export', 'Admin\IncentiveController@payoutExport')->name('admin_incentive_payout_export');
Route::get('/admin/incentives/participant/{participant_id}/details', 'Admin\IncentiveController@participantDetails')->name('admin_incentive_participant_details');
// payments invoice // Rechnungen

View file

@ -0,0 +1,147 @@
<?php
use App\Models\Incentive;
use App\Models\IncentiveParticipant;
use App\Models\UserAccount;
use App\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Maatwebsite\Excel\Facades\Excel;
uses(RefreshDatabase::class);
/**
* E1: Admin legt die Recruiting-Challenge mit Praemienfeldern an und laedt die
* Auszahlungsliste als XLSX. Admin-Routen sind host-abhaengig (CRM-Host).
*/
beforeEach(function () {
$this->previousHttpHost = $_SERVER['HTTP_HOST'] ?? null;
$_SERVER['HTTP_HOST'] = config('domains.domains.crm.host');
$this->refreshApplication();
$this->artisan('migrate');
});
afterEach(function () {
if ($this->previousHttpHost === null) {
unset($_SERVER['HTTP_HOST']);
} else {
$_SERVER['HTTP_HOST'] = $this->previousHttpHost;
}
});
function payoutAdmin(): User
{
$account = UserAccount::create(['first_name' => 'Ad', 'last_name' => 'Min']);
return User::forceCreate([
'email' => 'admin-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'admin' => 2,
'account_id' => $account->id,
'payment_account' => now()->addYear(),
]);
}
function challengePayload(array $overrides = []): array
{
return array_merge([
'name' => 'Recruiting-Challenge Okt-Jan',
'qualification_start' => '01.10.2026',
'qualification_end' => '31.01.2027',
'calculation_end' => '28.02.2027',
'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' => '',
'include_sales_points' => '0',
'status' => 1,
], $overrides);
}
it('speichert Praemienfelder und den Umsatzpunkte-Schalter beim Anlegen', function () {
$this->actingAs(payoutAdmin());
$this->post(route('admin_incentive_store'), challengePayload())->assertRedirect(route('admin_incentives'));
$incentive = Incentive::where('name', 'Recruiting-Challenge Okt-Jan')->firstOrFail();
expect((float) $incentive->amount_per_partner)->toBe(50.0)
->and($incentive->rankBonuses())->toBe([1 => 300.0, 2 => 200.0])
->and($incentive->bonus_rank_3)->toBeNull()
->and($incentive->includesSalesPoints())->toBeFalse()
->and($incentive->hasCashPayout())->toBeTrue();
});
it('setzt leere Betragsfelder beim Bearbeiten auf NULL und den Schalter wieder an', function () {
$this->actingAs(payoutAdmin());
$incentive = Incentive::factory()->create([
'amount_per_partner' => 50, 'bonus_rank_1' => 300, 'include_sales_points' => false,
]);
$this->post(route('admin_incentive_update', [$incentive->id]), challengePayload([
'amount_per_partner' => '', 'bonus_rank_1' => '', 'bonus_rank_2' => '', 'include_sales_points' => '1',
]))->assertRedirect(route('admin_incentive_show', [$incentive->id]));
$incentive->refresh();
expect($incentive->amount_per_partner)->toBeNull()
->and($incentive->hasCashPayout())->toBeFalse()
->and($incentive->includesSalesPoints())->toBeTrue();
});
it('lehnt negative Betraege ab', function () {
$this->actingAs(payoutAdmin());
$this->from(route('admin_incentive_create'))
->post(route('admin_incentive_store'), challengePayload(['amount_per_partner' => '-5']))
->assertSessionHasErrors('amount_per_partner');
});
it('laedt die Auszahlungsliste als XLSX herunter', function () {
Excel::fake();
$this->actingAs(payoutAdmin());
$incentive = Incentive::factory()->create([
'slug' => 'recruiting-challenge',
'amount_per_partner' => 50, 'bonus_rank_1' => 300, 'max_winners' => 3, 'include_sales_points' => false,
]);
$account = UserAccount::create(['first_name' => 'Anna', 'last_name' => 'Berater']);
$winner = User::forceCreate([
'email' => 'winner-'.uniqid('', true).'@example.com', 'password' => bcrypt('secret'), 'lang' => 'de', 'account_id' => $account->id,
]);
IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id, 'user_id' => $winner->id,
'qualified_partners' => 3, 'total_points' => 3, 'is_qualified' => true, 'rank' => 1,
]);
$this->get(route('admin_incentive_payout_export', [$incentive->id]))->assertOk();
Excel::assertDownloaded('incentive-recruiting-challenge-auszahlung-'.now()->format('Y-m-d').'.xlsx', function ($export) {
$rows = $export->collection();
return $rows->count() === 1
&& $rows[0][2] === 'Anna Berater'
&& $rows[0][9] === 450.0;
});
});
it('zeigt auf der Admin-Detailseite Export-Button und Praemienspalte nur bei Geldpraemie', function () {
$this->actingAs(payoutAdmin());
$challenge = Incentive::factory()->create(['amount_per_partner' => 50, 'include_sales_points' => false]);
$montenegro = Incentive::factory()->create();
$this->get(route('admin_incentive_show', [$challenge->id]))
->assertOk()
->assertSee('id="incentive-payout-export"', false)
->assertSee(__('incentive.payout_total'));
$this->get(route('admin_incentive_show', [$montenegro->id]))
->assertOk()
->assertDontSee('id="incentive-payout-export"', false);
});

View file

@ -0,0 +1,207 @@
<?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,
])->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 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'))
->toContain(__('incentive.payout_so_far'))
->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('');
});
});

View file

@ -0,0 +1,135 @@
<?php
use App\Models\Incentive;
use App\Models\IncentiveNewPartner;
use App\Models\IncentiveParticipant;
use App\Models\IncentivePointsLog;
use App\Models\UserSalesVolume;
use App\Services\Incentive\IncentivePointsLogRepairService;
use App\Services\Incentive\IncentiveTracker;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* E1: Bei `include_sales_points = 0` (Recruiting-Challenge) zaehlen nur die Einmalpunkte
* je Neupartner; Umsaetze der Neupartner erzeugen keine Punkte.
*/
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-11-15 12:00:00'));
});
afterEach(fn () => Carbon::setTestNow());
function toggleUser(array $overrides = []): User
{
return User::forceCreate(array_merge([
'email' => 'toggle-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
], $overrides));
}
/**
* @return array{0: Incentive, 1: IncentiveParticipant, 2: User}
*/
function trackedNewPartner(bool $includeSalesPoints): array
{
$incentive = Incentive::factory()->create([
'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,
'include_sales_points' => $includeSalesPoints,
]);
$sponsor = toggleUser();
$partner = toggleUser(['m_sponsor' => $sponsor->id]);
$participant = IncentiveParticipant::factory()->create([
'incentive_id' => $incentive->id,
'user_id' => $sponsor->id,
]);
$tracking = IncentiveNewPartner::create([
'participant_id' => $participant->id,
'user_id' => $partner->id,
'registered_at' => '2026-10-20 10:00:00',
]);
IncentivePointsLog::create([
'participant_id' => $participant->id,
'type' => 'partner',
'source_type' => User::class,
'source_id' => $partner->id,
'source_label' => 'Neupartner',
'month' => 10,
'year' => 2026,
'points_onetime' => 1,
'points_accumulated' => 0,
'incentive_new_partner_id' => $tracking->id,
]);
$participant->recalculateFromTrackingTables()->save();
return [$incentive, $participant, $partner];
}
function partnerSalesVolume(User $partner, int $points = 120): UserSalesVolume
{
return UserSalesVolume::create([
'user_id' => $partner->id,
'month' => 11,
'year' => 2026,
'date' => '15.11.2026',
'points' => $points,
'total_net' => 150,
'status_points' => 1,
'status' => 1,
'message' => 'Eigenbestellung Neupartner',
]);
}
it('schreibt ohne Umsatzpunkte keinen Log fuer Umsaetze des Neupartners', function () {
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: false);
IncentiveTracker::trackSalesVolume(partnerSalesVolume($partner));
expect(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(0)
->and($participant->fresh()->total_points)->toBe(1)
->and($participant->fresh()->qualified_partners)->toBe(1);
});
it('schreibt mit Umsatzpunkten weiterhin den Log (Montenegro-Verhalten)', function () {
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: true);
IncentiveTracker::trackSalesVolume(partnerSalesVolume($partner));
expect(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(1)
->and($participant->fresh()->total_points)->toBe(121);
});
it('zieht ohne Umsatzpunkte auch bei der Reparatur keine Umsatz-Logs nach', function () {
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: false);
partnerSalesVolume($partner);
$synced = (new IncentivePointsLogRepairService)->syncMissingSalesVolumeLogs($participant->fresh());
expect($synced)->toBe(0)
->and(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(0);
});
it('haelt die Umsatzpunkte auch beim Umschalten heraus, wenn die Wertung neu berechnet wird', function () {
[$incentive, $participant, $partner] = trackedNewPartner(includeSalesPoints: true);
IncentiveTracker::trackSalesVolume(partnerSalesVolume($partner));
expect($participant->fresh()->total_points)->toBe(121);
// Schalter nachtraeglich aus: die bestehenden Umsatz-Logs bleiben, bis --force neu aufbaut;
// ein Neuaufbau ohne Umsatzpunkte endet nach A/B.
$incentive->update(['include_sales_points' => false]);
$rebuilt = $participant->fresh()->load('incentive')->rebuildFromSourceTables();
expect(IncentivePointsLog::where('participant_id', $participant->id)->whereNotNull('user_sales_volume_id')->count())->toBe(0)
->and($rebuilt->total_points)->toBe(0);
});