Berater faellt aus (B2): pausieren nach 2 Tagen, Rueckgabe bei Zahlung, harter Cut am 28. Tag

Entwicklungsplan September, B2 (Protokoll 08.09., Rueckmeldung 14.09., R3):
- Ablauftag + 2 Kalendertage (Nachtlauf): Team, Kunden, Kundenabos und
  Portal-Logins gehen umkehrbar an den naechsten aktiven Sponsor, jede
  Umhaengung in consultant_suspension_transfers protokolliert. Kurzinfo
  an den Sponsor ohne Listen, keine Mail an das Firmenkonto.
- Zahlung bis Tag 27: Berater aktiv, Team und Kundendaten zurueck (nur
  was noch beim Sponsor liegt); Umsaetze der Zwischenzeit bleiben dort.
- Tag 28: was noch am Berater haengt geht endgueltig an den Sponsor,
  Berater wird dessen Kunde (ohne Duplikat) und geloescht; Listen-Mail
  mit Vorname, Nachname, Telefon, E-Mail an jeden haltenden Sponsor.
- Cron, Zahlungseingang und Admin-Ablaufdatum laufen ueber
  ConsultantSuspensionService; user:cleanup --dry-run listet beide Stufen.
- Mail 35 und Dashboard nennen Pausentag und Frist; Fristen in
  config/mivita.php (cleanup_suspend_days, cleanup_delete_days).
- Nebenbefunde: Kernel referenziert UserCleanUp, user:restore aus der
  User-Bereinigung laeuft nur noch einmal.
- Testschema: shopping_users.auth_user_id nullable wie auf Produktion
  (Migration aendert nur, wo die Spalte noch NOT NULL ist).
- Uebersicht fuer die Kundin: dev/_tickets/2026-09-23-mitgliedschaft-
  ablauf-uebersicht.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:44:45 +00:00
parent 6cd00a60ec
commit 0b8f8abeeb
29 changed files with 1528 additions and 193 deletions

View file

@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Mail\MailCustomMessage;
use App\Models\UserHistory;
use App\Models\UserMessage;
use App\Services\ConsultantSuspensionService;
use App\Services\Util;
use App\User;
use Carbon\Carbon;
@ -178,11 +179,16 @@ class CheckPaymentsAccount extends Command
return 0;
}
$days = abs($user->daysActiveAccount());
$datetime = $user->getPaymentAccountDateFormat();
$suspension = app(ConsultantSuspensionService::class);
$replace = [
'days' => abs($user->daysActiveAccount()),
'datetime' => $user->getPaymentAccountDateFormat(),
'suspend_date' => $suspension->suspensionDateFor($user)?->format('d.m.Y') ?? '',
'deadline' => $suspension->deadlineFor($user)?->format('d.m.Y') ?? '',
];
$message = __('reminder.copy_first_'.$status, ['days' => $days, 'datetime' => $datetime]);
$message_last = __('reminder.copy_last_'.$status, ['days' => $days, 'datetime' => $datetime]);
$message = __('reminder.copy_first_'.$status, $replace);
$message_last = __('reminder.copy_last_'.$status, $replace);
$button = __('reminder.button_'.$status);
$message = preg_replace("/[\n\r]/", '', $message);

View file

@ -2,202 +2,100 @@
namespace App\Console\Commands;
use App\Services\UserUtil;
use App\Services\ConsultantSuspensionService;
use App\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
/**
* Nächtlicher Ablauf für Berater mit abgelaufener Mitgliedschaft (Entwicklungsplan B2).
*
* Reihenfolge wie bisher: erst der harte Cut (Ablauf + `mivita.cleanup_delete_days`),
* dann das Pausieren (Ablauf + `mivita.cleanup_suspend_days`). Die Arbeit macht
* {@see ConsultantSuspensionService}; dieser Command wählt nur die Kandidaten aus.
*/
class UserCleanUp extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:cleanup';
protected $signature = 'user:cleanup
{--dry-run : Nur auflisten, was passieren würde, nichts ändern und keine Mails senden}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'User Clean Up inactive for Business Structure and UserDetails';
protected $description = 'Berater mit abgelaufener Mitgliedschaft pausieren (Team und Kunden an den Sponsor) bzw. nach der Frist endgültig entfernen';
private $timeStart;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
public function handle(ConsultantSuspensionService $suspension): int
{
parent::__construct();
}
$dryRun = (bool) $this->option('dry-run');
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('RUN Command user:cleanup');
\Log::channel('cleanup')->info('COMMAND [user:cleanup] started.');
$this->info('RUN Command user:cleanup'.($dryRun ? ' (dry-run)' : ''));
\Log::channel('cleanup')->info('COMMAND [user:cleanup] started.'.($dryRun ? ' dry-run' : ''));
$this->timeStart = microtime(true);
$this->runStage(
'Harter Cut (Ablauf vor mind. '.$suspension->deleteDays().' Tagen)',
$suspension->terminationCandidates(),
$suspension,
$dryRun,
fn (User $user) => $suspension->terminate($user),
);
// Schritt 1: User löschen, die länger als 2 Monate inaktiv sind
$this->deleteInactiveUsers();
// Schritt 2: Alle inaktiven User deaktivieren (länger als 2 Wochen inaktiv)
// Ihre Downline wird dem nächsten aktiven Berater (Sponsor) zugewiesen
$this->cleanUpInActiveUser();
$this->runStage(
'Pausieren (Ablauf vor mind. '.$suspension->suspendDays().' Tagen)',
$suspension->suspensionCandidates(),
$suspension,
$dryRun,
fn (User $user) => $suspension->suspend($user, 'user:cleanup'),
);
\Log::channel('cleanup')->info('COMMAND [user:cleanup] finished.');
return 0;
return self::SUCCESS;
}
/**
* Löscht User, die länger als 2 Monate inaktiv sind (payment_account < -2 month)
* - Weist deren Vertriebspartner-Kinder dem nächsten aktiven Sponsor zu
* - Überträgt deren Shopping-Kunden zum neuen Sponsor
* - Konvertiert den User zu einem Shopping-Kunden
* - Löscht den User (soft delete)
* @param iterable<User> $candidates
* @param callable(User): (array<string, int>|null) $action
*/
private function deleteInactiveUsers()
private function runStage(string $label, iterable $candidates, ConsultantSuspensionService $suspension, bool $dryRun, callable $action): void
{
$methodStartTime = microtime(true);
$this->info('START Command deleteInactiveUsers');
$count = 0;
$rows = [];
$done = 0;
$failed = 0;
$date = Carbon::now()->modify('-2 month');
$delete_users = User::where('admin', 0)->where('payment_account', '<', $date)->get();
foreach ($delete_users as $delete_user) {
\DB::beginTransaction();
foreach ($candidates as $user) {
$counts = $dryRun ? $suspension->preview($user) : null;
if (! $dryRun) {
try {
// Finde nächsten aktiven Sponsor
$active_sponsor = UserUtil::findNextActiveSponsor($delete_user->id);
if (! $active_sponsor) {
\Log::channel('cleanup')->error('deleteInactiveUsers find no active_sponsor by delete_user_id: '.$delete_user->id);
\DB::rollBack();
continue;
$counts = $action($user);
} catch (\Throwable $e) {
\Log::channel('cleanup')->error('user:cleanup '.$label.' fehlgeschlagen fuer user_id '.$user->id.': '.$e->getMessage());
$counts = null;
}
$counts === null ? $failed++ : $done++;
}
// Prüfe ob User Account-Daten hat
if (! $delete_user->account) {
\Log::channel('cleanup')->error('deleteInactiveUsers: User has no account data, skipping user_id: '.$delete_user->id);
\DB::rollBack();
continue;
}
// Setze alle Vertriebspartner-Kinder zum neuen Sponsor
UserUtil::setNewSponsorToChilds($delete_user->id, $active_sponsor->id);
// Übertrage Shopping-User zum neuen Sponsor
UserUtil::setShoppingUserToNewMember($delete_user->id, $active_sponsor->id);
// Konvertiere User zu Client beim neuen Sponsor
UserUtil::setUserToClient($delete_user->id, $active_sponsor->id);
$data = [
'user_id' => $delete_user->id,
'email' => $delete_user->email,
'm_account' => $delete_user->account->m_account,
'm_first_name' => $delete_user->account->m_first_name,
'm_last_name' => $delete_user->account->m_last_name,
$rows[] = [
$user->id,
trim($user->account?->first_name.' '.$user->account?->last_name),
$user->email,
$user->payment_account ? Carbon::parse($user->payment_account)->format('d.m.Y') : '',
$counts['team'] ?? '-',
$counts['customers'] ?? '-',
$counts['abos'] ?? '-',
$counts['portal_customers'] ?? '-',
$counts['parked'] ?? '-',
];
// Lösche User (soft delete)
UserUtil::deleteUser($delete_user);
\DB::commit();
$count++;
\Log::channel('cleanup')->info('deleteUser: '.json_encode($data));
} catch (\Exception $e) {
\DB::rollBack();
\Log::channel('cleanup')->error('deleteInactiveUsers failed for user_id: '.$delete_user->id.' | Error: '.$e->getMessage());
continue;
}
}
$diff = microtime(true) - $methodStartTime;
$sec = intval($diff);
$micro = $diff - $sec;
$this->line('');
$this->info($label.': '.count($rows).' Berater'.($dryRun ? '' : " (erledigt {$done}, übersprungen {$failed})"));
$this->info('END Command deleteInactiveUsers: '.$count.' | Time: '.$sec.'sec :'.round($micro * 1000, 4).' ms');
if ($rows !== []) {
$this->table(['ID', 'Name', 'E-Mail', 'Ablauf', 'Team', 'Kunden', 'Kundenabos', 'Portal-Logins', 'beim Sponsor geparkt'], $rows);
}
/**
* Deaktiviert User, die länger als 2 Wochen inaktiv sind
* - Weist deren Vertriebspartner-Kinder dem nächsten aktiven Sponsor zu
* - Deaktiviert den User (behält Account, speichert Sponsor in pre_sponsor)
* - Shopping-Kunden werden NICHT übertragen (bleiben beim deaktivierten User)
*/
private function cleanUpInActiveUser()
{
$methodStartTime = microtime(true);
$this->info('START Command cleanUpInActiveUser');
$count = 0;
// Finde User die länger als 2 Wochen inaktiv sind
$date = Carbon::now()->modify('-2 weeks');
$inactive_users = User::where('active', true)
->where('m_sponsor', '!=', null)
->where('payment_account', '<', $date)
->get();
foreach ($inactive_users as $inactive_user) {
\DB::beginTransaction();
try {
// Finde nächsten aktiven Sponsor
$active_sponsor = UserUtil::findNextActiveSponsor($inactive_user->m_sponsor);
if (! $active_sponsor) {
\Log::channel('cleanup')->error('cleanUpInActiveUser find no active_sponsor by inactive_user: '.$inactive_user->id);
\DB::rollBack();
continue;
}
// Setze alle Vertriebspartner-Kinder zum neuen Sponsor
UserUtil::setNewSponsorToChilds($inactive_user->id, $active_sponsor->id);
$data = [
'user_id' => $inactive_user->id,
'email' => $inactive_user->email,
'm_account' => $inactive_user->account ? $inactive_user->account->m_account : '',
'm_first_name' => $inactive_user->account ? $inactive_user->account->m_first_name : '',
'm_last_name' => $inactive_user->account ? $inactive_user->account->m_last_name : '',
];
// Deaktiviere User (setzt pre_sponsor, entfernt m_sponsor, setzt active=false)
UserUtil::deactiveUser($inactive_user);
\DB::commit();
$count++;
\Log::channel('cleanup')->info('inactive_user: '.json_encode($data));
} catch (\Exception $e) {
\DB::rollBack();
\Log::channel('cleanup')->error('cleanUpInActiveUser failed for user_id: '.$inactive_user->id.' | Error: '.$e->getMessage());
continue;
}
}
$diff = microtime(true) - $methodStartTime;
$sec = intval($diff);
$micro = $diff - $sec;
$this->info('END Command cleanUpInActiveUser: '.$count.' | Time: '.$sec.'sec :'.round($micro * 1000, 4).' ms');
}
}

View file

@ -7,7 +7,7 @@ use App\Console\Commands\BusinessStoreOptimized;
use App\Console\Commands\CheckPaymentsAccount;
use App\Console\Commands\CheckPaymentUptime;
use App\Console\Commands\DhlUpdateTracking;
use App\Console\Commands\UserCleanup;
use App\Console\Commands\UserCleanUp;
use App\Console\Commands\UserMakeAboOrder;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
@ -25,7 +25,7 @@ class Kernel extends ConsoleKernel
CheckPaymentUptime::class,
CheckPaymentsAccount::class,
UserMakeAboOrder::class,
UserCleanup::class,
UserCleanUp::class,
DhlUpdateTracking::class,
];

View file

@ -315,13 +315,10 @@ class AdminUserCleanupController extends Controller
}
try {
// Führe Artisan Command aus
\Artisan::call('user:restore', ['user_id' => $userId]);
$output = \Artisan::output();
// Prüfe ob Command erfolgreich war (Exit Code 0)
// Genau ein Lauf: der Command holt Team und Sponsor zurueck, ein zweiter
// Aufruf wuerde auf einem bereits wiederhergestellten User arbeiten.
$exitCode = \Artisan::call('user:restore', ['user_id' => $userId]);
$output = \Artisan::output();
\Log::channel('cleanup')->info('AdminUserCleanupController restore via web: user_id='.$userId.' | exitCode='.$exitCode);

View file

@ -6,6 +6,7 @@ use App\Exceptions\GdprDeletionException;
use App\Mail\UserRestoreEmail;
use App\Models\UserAccount;
use App\Repositories\UserRepository;
use App\Services\ConsultantSuspensionService;
use App\Services\GdprDeletionService;
use App\Services\HTMLHelper;
use App\Services\SysLog;
@ -167,12 +168,12 @@ class AdminUserController extends Controller
UserUtil::markPartnerPaid($user);
if ($user->active === 0) {
$user->active = true;
UserUtil::reactiveUserResetChilds($user->id, 'on save-account AdminUserController');
app(ConsultantSuspensionService::class)->reinstate($user, 'on save-account AdminUserController');
}
} else {
if ($user->active === 1) {
$user->active = false;
UserUtil::deactiveUserNewSponsorChilds($user->id, 'on save-account AdminUserController');
app(ConsultantSuspensionService::class)->suspend($user, 'on save-account AdminUserController');
}
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Mail;
use App\User;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
/**
* Kurzinfo an den Sponsor, wenn ein Teampartner pausiert wurde (Entwicklungsplan B2,
* Stufe 1). Bewusst ohne Listen: der Teampartner kann noch zahlen und bekommt dann
* Team und Kunden zurück.
*/
class SponsorSuspensionNoticeMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $sponsor, public User $consultant, public ?Carbon $deadline) {}
public function build(): self
{
$name = trim($this->consultant->account?->first_name.' '.$this->consultant->account?->last_name) ?: $this->consultant->email;
$days = $this->deadline ? max(0, (int) now()->startOfDay()->diffInDays($this->deadline->copy()->startOfDay(), false)) : 0;
return $this->subject(__('suspension.notice_subject', ['name' => $name]))
->view('emails.consultant_suspension')
->with([
'headline' => __('suspension.notice_subject', ['name' => $name]),
'salutation' => __('email.hello').' '.($this->sponsor->account?->first_name ?? ''),
'paragraphs' => [
__('suspension.notice_intro', [
'name' => $name,
'date' => $this->deadline?->format('d.m.Y') ?? '',
'days' => $days,
]),
__('suspension.notice_hint'),
],
'customers' => [],
'team' => [],
'greetings' => __('email.greetings'),
'sender' => __('email.sender'),
]);
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Mail;
use App\Services\UserUtil;
use App\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
/**
* Mail an den Sponsor beim harten Cut (Entwicklungsplan B2, Stufe 2): welche Kunden
* und Teampartner des ausgeschiedenen Beraters jetzt endgültig bei ihm liegen, mit
* Vorname, Nachname, Telefon und E-Mail. Beim Sponsor des harten Cuts steht der
* Berater selbst als neuer Kunde mit auf der Liste.
*/
class SponsorTransferMail extends Mailable
{
use Queueable, SerializesModels;
/**
* Die Listen heissen bewusst nicht `customers`/`team`: oeffentliche Eigenschaften
* eines Mailables landen in der View und wuerden die aufbereiteten Listen aus
* build() ueberschreiben.
*
* @param list<array{first_name: string, last_name: string, phone: string, email: string}> $customerRows
* @param list<array{first_name: string, last_name: string, phone: string, email: string}> $teamRows
*/
public function __construct(
public User $sponsor,
public User $consultant,
public array $customerRows,
public array $teamRows,
public bool $consultantBecomesCustomer,
) {}
public function build(): self
{
$name = trim($this->consultant->account?->first_name.' '.$this->consultant->account?->last_name) ?: $this->consultant->email;
$customers = $this->customerRows;
if ($this->consultantBecomesCustomer) {
array_unshift($customers, [
'first_name' => (string) $this->consultant->account?->first_name,
'last_name' => (string) $this->consultant->account?->last_name,
'phone' => trim((string) $this->consultant->account?->getPhoneNumber()),
'email' => UserUtil::stripDeletePrefix($this->consultant->email),
]);
}
return $this->subject(__('suspension.transfer_subject', ['name' => $name]))
->view('emails.consultant_suspension')
->with([
'headline' => __('suspension.transfer_subject', ['name' => $name]),
'salutation' => __('email.hello').' '.($this->sponsor->account?->first_name ?? ''),
'paragraphs' => [__('suspension.transfer_intro', ['name' => $name])],
'customers' => $customers,
'team' => $this->teamRows,
'greetings' => __('email.greetings'),
'sender' => __('email.sender'),
]);
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Models;
use App\Services\ConsultantSuspensionService;
use App\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Ein Kundendatensatz, der beim Pausieren eines Beraters an den Sponsor ging
* (Entwicklungsplan B2, {@see ConsultantSuspensionService}).
*
* @property int $id
* @property int $consultant_id
* @property int $sponsor_id
* @property string $record_type
* @property int $record_id
* @property Carbon|null $returned_at
*/
class ConsultantSuspensionTransfer extends Model
{
public const TYPE_SHOPPING_USER = 'shopping_user';
public const TYPE_USER_ABO = 'user_abo';
public const TYPE_CUSTOMER = 'customer';
protected $fillable = [
'consultant_id',
'sponsor_id',
'record_type',
'record_id',
'returned_at',
];
protected function casts(): array
{
return [
'consultant_id' => 'int',
'sponsor_id' => 'int',
'record_id' => 'int',
'returned_at' => 'datetime',
];
}
public function consultant(): BelongsTo
{
return $this->belongsTo(User::class, 'consultant_id')->withTrashed();
}
public function sponsor(): BelongsTo
{
return $this->belongsTo(User::class, 'sponsor_id')->withTrashed();
}
/**
* Noch beim Sponsor liegende Datensätze eines Beraters.
*
* @param Builder<self> $query
* @return Builder<self>
*/
public function scopeOpenFor(Builder $query, int $consultantId): Builder
{
return $query->where('consultant_id', $consultantId)->whereNull('returned_at');
}
}

View file

@ -0,0 +1,416 @@
<?php
namespace App\Services;
use App\Mail\SponsorSuspensionNoticeMail;
use App\Mail\SponsorTransferMail;
use App\Models\ConsultantSuspensionTransfer;
use App\Models\Customer;
use App\Models\ShoppingUser;
use App\Models\ShoppingUserMemberLog;
use App\Models\UserAbo;
use App\Models\UserCleanUpLog;
use App\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Ablauf, wenn ein Berater seine Mitgliedschaft nicht verlängert
* (Entwicklungsplan dev/_tickets/entwicklungsplan-protokoll-september-2026.md, B2).
*
* 1. Pausieren ({@see suspend()}), Ablauf + `mivita.cleanup_suspend_days`: Team, Kunden,
* Kundenabos und Portal-Logins gehen an den nächsten aktiven Sponsor, der Berater
* fällt aus dem Provisionsbaum. Alles wird protokolliert und ist umkehrbar. Der
* Sponsor bekommt eine Kurzinfo ohne Listen.
* 2. Rückgabe ({@see reinstate()}): Zahlt der Berater vor dem harten Cut, bekommt er
* Team und Kunden zurück — nur Datensätze, die noch beim Sponsor liegen. Umsätze
* der Zwischenzeit bleiben beim Sponsor, weil Punkte zum Bestellzeitpunkt gebucht
* werden.
* 3. Harter Cut ({@see terminate()}), Ablauf + `mivita.cleanup_delete_days`: der Berater
* wird Kunde seines Sponsors und gelöscht; der Sponsor bekommt die Listen.
*
* Cron (`user:cleanup`), Zahlungseingang ({@see Payment::paymentStatusPaidAction()})
* und das Ablaufdatum im Admin laufen über diese Klasse, damit es nur einen Weg gibt.
*/
class ConsultantSuspensionService
{
public function suspendDays(): int
{
return max(0, (int) config('mivita.cleanup_suspend_days', 2));
}
public function deleteDays(): int
{
return max($this->suspendDays(), (int) config('mivita.cleanup_delete_days', 28));
}
/**
* Tag der Pause: Ablauftag + `cleanup_suspend_days` Kalendertage, im Nachtlauf dieses
* Tages. Die Fristen zählen in Kalendertagen, nicht ab der Uhrzeit des Ablaufs —
* sonst läge der Nachtlauf je nach Uhrzeit einen Tag später als das Datum in der Mail.
*/
public function suspensionDateFor(User $user): ?Carbon
{
return $user->payment_account ? Carbon::parse($user->payment_account)->startOfDay()->addDays($this->suspendDays()) : null;
}
/**
* Letzter Tag, an dem der Berater durch Zahlung alles zurückholen kann. Im Nachtlauf
* des Folgetags (Ablauftag + `cleanup_delete_days`) folgt der harte Cut.
*/
public function deadlineFor(User $user): ?Carbon
{
return $user->payment_account ? Carbon::parse($user->payment_account)->startOfDay()->addDays($this->deleteDays() - 1)->endOfDay() : null;
}
/**
* Wer vor diesem Zeitpunkt abgelaufen ist, ist heute an der Reihe: der Ablauftag
* liegt mindestens `$days` Kalendertage zurück. Bei 0 Tagen gilt jetzt.
*/
private function expiredBefore(int $days): Carbon
{
return $days > 0 ? now()->startOfDay()->subDays($days - 1) : now();
}
/**
* Aktive Berater, deren Mitgliedschaft seit mehr als `cleanup_suspend_days` abgelaufen ist.
*
* @return Collection<int, User>
*/
public function suspensionCandidates(): Collection
{
return User::query()
->with('account')
->where('active', true)
->whereNotNull('m_sponsor')
->where('payment_account', '<', $this->expiredBefore($this->suspendDays()))
->get();
}
/**
* Berater (keine Admins), deren Mitgliedschaft seit mehr als `cleanup_delete_days` abgelaufen ist.
*
* @return Collection<int, User>
*/
public function terminationCandidates(): Collection
{
return User::query()
->with('account')
->where('admin', 0)
->where('payment_account', '<', $this->expiredBefore($this->deleteDays()))
->get();
}
/**
* Was ein Lauf für diesen Berater bewegen würde (Trockenlauf).
*
* @return array{team: int, customers: int, abos: int, portal_customers: int, parked: int}
*/
public function preview(User $user): array
{
return [
'team' => User::where('m_sponsor', $user->id)->count(),
'customers' => ShoppingUser::withTrashed()->where('member_id', $user->id)->count(),
'abos' => UserAbo::withTrashed()->where('member_id', $user->id)->count(),
'portal_customers' => Customer::where('member_id', $user->id)->count(),
'parked' => ConsultantSuspensionTransfer::openFor($user->id)->count(),
];
}
/**
* Stufe 1: Berater pausieren, Team und Kundendaten umkehrbar an den Sponsor.
*
* @return array{sponsor_id: int, team: int, customers: int, abos: int, portal_customers: int}|null null, wenn kein Sponsor gefunden wurde
*/
public function suspend(User $user, string $info = ''): ?array
{
$user = User::with('account')->find($user->id);
if (! $user) {
return null;
}
$sponsor = $user->m_sponsor ? UserUtil::findNextActiveSponsor($user->m_sponsor) : null;
if (! $sponsor || (int) $sponsor->id === (int) $user->id) {
Log::channel('cleanup')->error('ConsultantSuspension::suspend kein aktiver Sponsor fuer user_id '.$user->id.' '.$info);
return null;
}
$summary = DB::transaction(function () use ($user, $sponsor): array {
$team = User::where('m_sponsor', $user->id)->count();
UserUtil::setNewSponsorToChilds($user->id, $sponsor->id);
$moved = $this->moveCustomerData($user, $sponsor);
UserUtil::deactiveUser($user);
return ['sponsor_id' => (int) $sponsor->id, 'team' => $team] + $moved;
});
Log::channel('cleanup')->info('ConsultantSuspension::suspend '.$info.' : '.json_encode(['user_id' => $user->id, 'email' => $user->email] + $summary));
$this->mailSponsor($sponsor, new SponsorSuspensionNoticeMail($sponsor, $user, $this->deadlineFor($user)));
return $summary;
}
/**
* Rückgabe nach Zahlung: Berater aktivieren, Team und Kundendaten zurückholen.
*
* @return array{customers: int, abos: int, portal_customers: int, kept: int}
*/
public function reinstate(User $user, string $info = ''): array
{
$user = User::find($user->id);
if (! $user) {
return ['customers' => 0, 'abos' => 0, 'portal_customers' => 0, 'kept' => 0];
}
$summary = DB::transaction(function () use ($user): array {
UserUtil::reactiveUser($user);
UserUtil::resetChildsToSponsor($user->id);
return $this->returnCustomerData($user);
});
Log::channel('cleanup')->info('ConsultantSuspension::reinstate '.$info.' : '.json_encode(['user_id' => $user->id, 'email' => $user->email] + $summary));
return $summary;
}
/**
* Stufe 2: harter Cut. Was noch am Berater hängt, geht endgültig an den Sponsor,
* der Berater wird dessen Kunde und gelöscht. Danach gehen die Listen an jeden
* Sponsor, der Team oder Kunden dieses Beraters hält.
*
* @return array{sponsor_id: int, team: int, customers: int, abos: int, portal_customers: int}|null
*/
public function terminate(User $user): ?array
{
$user = User::with('account')->find($user->id);
if (! $user) {
return null;
}
$sponsor = UserUtil::findNextActiveSponsor($user->id);
if (! $sponsor || (int) $sponsor->id === (int) $user->id) {
Log::channel('cleanup')->error('ConsultantSuspension::terminate kein aktiver Sponsor fuer user_id '.$user->id);
return null;
}
if (! $user->account) {
Log::channel('cleanup')->error('ConsultantSuspension::terminate ohne Account-Daten, uebersprungen: user_id '.$user->id);
return null;
}
$summary = DB::transaction(function () use ($user, $sponsor): array {
$team = User::where('m_sponsor', $user->id)->count();
UserUtil::setNewSponsorToChilds($user->id, $sponsor->id);
$moved = $this->moveCustomerData($user, $sponsor);
$this->convertToCustomer($user, $sponsor);
UserUtil::deleteUser($user);
return ['sponsor_id' => (int) $sponsor->id, 'team' => $team] + $moved;
});
Log::channel('cleanup')->info('ConsultantSuspension::terminate : '.json_encode(['user_id' => $user->id, 'email' => $user->email] + $summary));
foreach ($this->transferListsBySponsor($user, $sponsor) as $sponsorId => $lists) {
$receiver = User::with('account')->find($sponsorId);
if ($receiver) {
$this->mailSponsor($receiver, new SponsorTransferMail(
$receiver,
$user,
$lists['customers'],
$lists['team'],
(int) $sponsorId === (int) $sponsor->id,
));
}
}
return $summary;
}
/**
* Kunden, Kundenabos und Portal-Logins des Beraters an den Sponsor, je Datensatz protokolliert.
*
* @return array{customers: int, abos: int, portal_customers: int}
*/
private function moveCustomerData(User $user, User $sponsor): array
{
$customers = 0;
foreach (ShoppingUser::withTrashed()->where('member_id', $user->id)->get() as $customer) {
$this->logTransfer($user, $sponsor, ConsultantSuspensionTransfer::TYPE_SHOPPING_USER, $customer->id);
ShoppingUserMemberLog::create([
'pre_member_id' => $user->id,
'shopping_user_id' => $customer->id,
'new_member_id' => $sponsor->id,
]);
$customer->member_id = $sponsor->id;
$customer->save();
$customers++;
}
$abos = 0;
foreach (UserAbo::withTrashed()->where('member_id', $user->id)->pluck('id') as $aboId) {
$this->logTransfer($user, $sponsor, ConsultantSuspensionTransfer::TYPE_USER_ABO, (int) $aboId);
$abos++;
}
UserAbo::withTrashed()->where('member_id', $user->id)->update(['member_id' => $sponsor->id]);
$portal = 0;
foreach (Customer::where('member_id', $user->id)->pluck('id') as $customerId) {
$this->logTransfer($user, $sponsor, ConsultantSuspensionTransfer::TYPE_CUSTOMER, (int) $customerId);
$portal++;
}
Customer::where('member_id', $user->id)->update(['member_id' => $sponsor->id]);
return ['customers' => $customers, 'abos' => $abos, 'portal_customers' => $portal];
}
/**
* Gibt alle offenen Umhängungen zurück — aber nur Datensätze, die noch bei dem
* Sponsor liegen, der sie bekommen hat. Wurde ein Kunde inzwischen von Hand
* woanders hin übertragen, bleibt er dort.
*
* @return array{customers: int, abos: int, portal_customers: int, kept: int}
*/
private function returnCustomerData(User $user): array
{
$summary = ['customers' => 0, 'abos' => 0, 'portal_customers' => 0, 'kept' => 0];
foreach (ConsultantSuspensionTransfer::openFor($user->id)->get() as $transfer) {
$record = match ($transfer->record_type) {
ConsultantSuspensionTransfer::TYPE_SHOPPING_USER => ShoppingUser::withTrashed()->find($transfer->record_id),
ConsultantSuspensionTransfer::TYPE_USER_ABO => UserAbo::withTrashed()->find($transfer->record_id),
ConsultantSuspensionTransfer::TYPE_CUSTOMER => Customer::find($transfer->record_id),
default => null,
};
if ($record && (int) $record->member_id === $transfer->sponsor_id) {
if ($record instanceof ShoppingUser) {
ShoppingUserMemberLog::create([
'pre_member_id' => $transfer->sponsor_id,
'shopping_user_id' => $record->id,
'new_member_id' => $user->id,
]);
}
// Ohne Model-Events: an Abo und Portal-Login haengt sonst Status-/Audit-Logik,
// die hier nichts zu suchen hat — es aendert sich nur die Zuordnung.
$record->newQuery()->withoutGlobalScopes()->whereKey($record->getKey())->update(['member_id' => $user->id]);
$summary[match ($transfer->record_type) {
ConsultantSuspensionTransfer::TYPE_SHOPPING_USER => 'customers',
ConsultantSuspensionTransfer::TYPE_USER_ABO => 'abos',
default => 'portal_customers',
}]++;
} else {
$summary['kept']++;
}
$transfer->returned_at = now();
$transfer->save();
}
return $summary;
}
/**
* Der Berater wird Kunde des Sponsors. Liegt schon ein Kundendatensatz mit seiner
* E-Mail oder seinem Login beim Sponsor (z. B. aus seinen eigenen Bestellungen, der
* eben mit umgezogen ist), wird kein zweiter angelegt.
*/
private function convertToCustomer(User $user, User $sponsor): void
{
$exists = ShoppingUser::query()
->where('member_id', $sponsor->id)
->where(function ($query) use ($user): void {
$query->where('auth_user_id', $user->id)
->orWhere('billing_email', $user->email);
})
->exists();
if (! $exists) {
UserUtil::setUserToClient($user->id, $sponsor->id);
}
}
/**
* Listen für die Sponsor-Mail beim harten Cut, gruppiert nach dem Sponsor, der die
* Datensätze heute hält. Der Berater selbst steht beim Sponsor des harten Cuts.
*
* @return array<int, array{customers: list<array{first_name: string, last_name: string, phone: string, email: string}>, team: list<array{first_name: string, last_name: string, phone: string, email: string}>}>
*/
private function transferListsBySponsor(User $user, User $sponsor): array
{
$lists = [(int) $sponsor->id => ['customers' => [], 'team' => []]];
// Nur Datensaetze, die noch bei dem Sponsor liegen, der sie ueber diesen Ablauf
// bekommen hat — von Hand weiter uebertragene Kunden gehoeren nicht in seine Liste.
$sponsorByCustomer = ConsultantSuspensionTransfer::openFor($user->id)
->where('record_type', ConsultantSuspensionTransfer::TYPE_SHOPPING_USER)
->pluck('sponsor_id', 'record_id');
$customers = ShoppingUser::query()->whereIn('id', $sponsorByCustomer->keys())->orderBy('billing_lastname')->get();
foreach ($customers as $customer) {
$holder = (int) $customer->member_id;
if ($holder !== (int) $sponsorByCustomer->get($customer->id)) {
continue;
}
$lists[$holder] ??= ['customers' => [], 'team' => []];
$lists[$holder]['customers'][] = [
'first_name' => (string) $customer->billing_firstname,
'last_name' => (string) $customer->billing_lastname,
'phone' => (string) $customer->billing_phone,
'email' => (string) $customer->billing_email,
];
}
$sponsorByMember = UserCleanUpLog::where('inactive_sponsor_id', $user->id)->pluck('new_sponsor_id', 'child_user_id');
$team = User::query()->with('account')->whereIn('id', $sponsorByMember->keys())->get();
foreach ($team as $member) {
$holder = (int) ($member->m_sponsor ?: $member->pre_sponsor);
if (! $holder || $holder !== (int) $sponsorByMember->get($member->id)) {
continue;
}
$lists[$holder] ??= ['customers' => [], 'team' => []];
$lists[$holder]['team'][] = [
'first_name' => (string) $member->account?->first_name,
'last_name' => (string) $member->account?->last_name,
'phone' => trim((string) $member->account?->getPhoneNumber()),
'email' => (string) $member->email,
];
}
return $lists;
}
private function logTransfer(User $user, User $sponsor, string $type, int $recordId): void
{
ConsultantSuspensionTransfer::create([
'consultant_id' => $user->id,
'sponsor_id' => $sponsor->id,
'record_type' => $type,
'record_id' => $recordId,
]);
}
/**
* Keine Mail an das Firmenkonto; ein Mailfehler darf den Lauf nicht abbrechen.
*/
private function mailSponsor(User $sponsor, Mailable $mail): void
{
if ((int) $sponsor->id === (int) config('mivita.company_user_id') || ! $sponsor->email) {
return;
}
try {
Mail::to($sponsor->email)->locale($sponsor->getLocale())->send($mail);
} catch (\Throwable $e) {
Log::channel('cleanup')->error('ConsultantSuspension Sponsor-Mail fehlgeschlagen fuer sponsor_id '.$sponsor->id.': '.$e->getMessage());
}
}
}

View file

@ -238,7 +238,8 @@ class Payment
if ($date > \Carbon::now()) {
if ($user->active === 0) {
$user->active = true;
UserUtil::reactiveUserResetChilds($user->id, 'on payment_for_account Payment');
// Rueckgabe von Team und Kunden (Entwicklungsplan B2)
app(ConsultantSuspensionService::class)->reinstate($user, 'on payment_for_account Payment');
}
}

View file

@ -6,6 +6,12 @@ return [
'remind_first_days' => env('MIVITA_REMIND_FIRST_DAYS', '21'),
'remind_sec_days' => env('MIVITA_REMIND_SEC_DAYS', '14'),
'remind_last_days' => env('MIVITA_REMIND_LAST_DAYS', '2'),
// Ablauf der Mitgliedschaft (Entwicklungsplan B2): nach so vielen Tagen wird der
// Berater pausiert, Team und Kunden gehen vorübergehend an den Sponsor; zahlt er
// bis zum Ablauf der zweiten Frist, bekommt er alles zurück, sonst harter Cut.
'cleanup_suspend_days' => (int) env('MIVITA_CLEANUP_SUSPEND_DAYS', 2),
'cleanup_delete_days' => (int) env('MIVITA_CLEANUP_DELETE_DAYS', 28),
'edit_data_pass' => env('MIVITA_EDIT_DATA_PASS', 'iq1OeUUpTUaXwNUdh8xHmx7RXTif4='),
'add_number_id' => env('MIVITA_ADD_NUMBER_ID', '946'),

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Protokoll der Kundendaten, die beim Pausieren eines Beraters (Ablauf der
* Mitgliedschaft, Entwicklungsplan B2) vorübergehend beim Sponsor liegen:
* Kunden (shopping_users), Kundenabos (user_abos) und Portal-Logins (customers).
*
* Zahlt der Berater innerhalb der Frist, gibt das Protokoll genau diese
* Datensätze zurück; beim harten Cut ist es die Quelle der Sponsor-Mail.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('consultant_suspension_transfers', function (Blueprint $table) {
$table->increments('id');
$table->unsignedInteger('consultant_id')->comment('pausierter Berater, bisheriger member_id');
$table->unsignedInteger('sponsor_id')->comment('Sponsor, der die Datensaetze voruebergehend erhaelt');
$table->string('record_type', 20)->comment('shopping_user, user_abo oder customer');
$table->unsignedBigInteger('record_id');
$table->timestamp('returned_at')->nullable()->comment('Rueckgabe an den Berater nach Zahlung');
$table->timestamps();
$table->index(['consultant_id', 'returned_at']);
$table->index(['record_type', 'record_id']);
});
}
public function down(): void
{
Schema::dropIfExists('consultant_suspension_transfers');
}
};

View file

@ -0,0 +1,38 @@
<?php
use App\Services\UserUtil;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* `shopping_users.auth_user_id` ist auf Produktion längst nullable — Kunden ohne
* Login und der beim harten Cut angelegte Kundendatensatz eines ausgeschiedenen
* Beraters ({@see UserUtil::setUserToClient()}) haben keinen.
* Die Ursprungsmigration legt die Spalte aber NOT NULL an, sodass Testdatenbank und
* Neuinstallationen anders reagieren als Produktion (wie `status_turnover`,
* Migration 2026_09_09_140000).
*
* Ändert nur, wo die Spalte noch NOT NULL ist; auf Produktion also nichts.
*/
return new class extends Migration
{
public function up(): void
{
$column = collect(Schema::getColumns('shopping_users'))->firstWhere('name', 'auth_user_id');
if ($column === null || $column['nullable']) {
return;
}
Schema::table('shopping_users', function (Blueprint $table) {
$table->unsignedInteger('auth_user_id')->nullable()->change();
});
}
public function down(): void
{
// Bewusst leer: Produktion war schon vorher nullable, ein Zurückdrehen würde
// bestehende Kundendatensätze ohne Login ungültig machen.
}
};

View file

@ -0,0 +1,237 @@
# Wenn ein Berater seine Mitgliedschaft nicht verlängert
**Übersicht für mivita · Stand 23.09.2026**
Diese Seite beschreibt, was ab dem nächsten Update passiert, wenn die Jahresgebühr eines Beraters
nicht bezahlt wird: wann was geschieht, wer welche E-Mail bekommt und was mit Team, Kunden, Abos und
Punkten passiert. Grundlage sind die Absprachen aus dem Zoom-Meeting vom 08.09. und eure Rückmeldung
vom 14.09.
---
## Das Wichtigste in Kürze
- **2 Tage nach Ablauf** wird der Berater pausiert. Sein Team und seine Kunden gehen
**vorübergehend** an seinen Sponsor. Ab dann zählen ihre Umsätze beim Sponsor.
- **Bis zum 27. Tag** nach Ablauf kann der Berater zahlen und bekommt Team und Kunden **zurück**. Was
der Sponsor in der Zwischenzeit an Umsatz bekommen hat, bleibt beim Sponsor.
- **Am 28. Tag** ist Schluss: Team und Kunden bleiben **endgültig** beim Sponsor. Der Berater
wird Kunde seines Sponsors und als Berater entfernt. Der Sponsor bekommt eine E-Mail mit allen
Namen und Kontaktdaten.
Gezählt wird in Kalendertagen ab dem Ablauftag, die Uhrzeit spielt keine Rolle. Die 2 und die 28
Tage lassen sich ohne Programmierung ändern.
---
## Was sich gegenüber heute ändert
| | bisher | neu |
| --- | --- | --- |
| Berater pausiert | nach **14 Tagen** | nach **2 Tagen** |
| Team zum Sponsor | nach 14 Tagen | nach 2 Tagen |
| **Kunden** zum Sponsor | erst nach **2 Monaten** | nach **2 Tagen** |
| **Kundenabos** zum Sponsor | **nie** | nach 2 Tagen |
| Kunden-Logins (Kundenportal) zum Sponsor | **nie** | nach 2 Tagen |
| Umsätze der Kunden in der Pause | kamen **bei niemandem** an | zählen beim Sponsor |
| Zahlung in der Pause | nur das Team kam zurück | Team **und** Kunden kommen zurück |
| Endgültiges Entfernen | nach **2 Monaten** | am **28. Tag** |
| E-Mail an den Sponsor | keine | Kurzinfo bei der Pause, Liste beim Entfernen |
Der wichtigste Unterschied: Bisher hingen die Kunden eines pausierten Beraters bis zu zwei Monate
bei ihm fest. Er stand nicht mehr in der Struktur, deshalb kamen ihre Bestellungen bei niemandem
an, weder bei ihm noch beim Sponsor. Kundenabos wurden gar nicht umgehängt. Das ist jetzt behoben.
---
## Der Ablauf an einem Beispiel
Die Mitgliedschaft läuft am **1. Oktober** ab.
| Wann | Was passiert | Wer bekommt eine E-Mail |
| --- | --- | --- |
| ca. 10. September | 1. Erinnerung (21 Tage vorher) | Berater |
| ca. 17. September | 2. Erinnerung (14 Tage vorher) | Berater |
| ca. 29. September | letzte Erinnerung (2 Tage vorher) | Berater, Kopie an mivita |
| **1. Oktober** | Mitgliedschaft abgelaufen. Der Berater steht noch bis zur Pause in der Struktur. | – |
| 2. Oktober, 02:00 Uhr | Hinweis: „Ab dem 03.10. pausieren wir deinen Account … zurück, wenn du bis zum 28.10. verlängerst“ | Berater, Kopie an mivita |
| **3. Oktober, 03:30 Uhr** | **Pause:** Team, Kunden, Kundenabos und Kunden-Logins gehen an den Sponsor. Der Berater ist nicht mehr in der Struktur. | Sponsor (Kurzinfo) |
| 3. bis 28. Oktober | Bestellungen der Kunden und Abo-Lieferungen zählen beim Sponsor. Der Berater kann sich weiter einloggen und seine Mitgliedschaft bezahlen. | – |
| **Zahlung bis 28. Oktober** | Der Berater ist sofort wieder aktiv und bekommt Team und Kunden zurück. | – |
| **29. Oktober, 03:30 Uhr** (ohne Zahlung) | **Endgültig:** Der Berater wird Kunde seines Sponsors und als Berater entfernt. | Sponsor (mit Liste) |
Die nächtlichen Läufe finden um 02:00 Uhr (Erinnerungen) und um 03:30 Uhr (Pause und Entfernen)
statt. Ob die Mitgliedschaft am 1. Oktober morgens oder abends abläuft, ändert an den Terminen
nichts.
---
## Was genau bei der Pause passiert
**Beim Berater**
- Sein Account ist pausiert. Er kann sich einloggen und seine Mitgliedschaft bezahlen.
- Er steht nicht mehr in der Struktur und bekommt für diese Zeit keine Punkte und keine Provision.
- Bereits verdiente Provisionen werden wie gewohnt ausgezahlt.
**Beim Sponsor**
- Er übernimmt die **Teampartner** des Beraters (die Firstline; alles darunter hängt an ihnen und
zieht mit).
- Er übernimmt die **Kunden** des Beraters, deren **Kundenabos** und deren **Logins im
Kundenportal**.
- Alles, was diese Kunden und Teampartner ab jetzt bestellen, zählt bei ihm. Es wird nichts
rückwirkend umgebucht: Bestellungen vor der Pause bleiben beim Berater.
**Wer „der Sponsor“ ist:** der nächste aktive Berater nach oben. Ist der direkte Sponsor selbst
pausiert, geht es eine Ebene weiter. Ist das die Firma, bekommt niemand eine E-Mail.
Jede einzelne Umhängung wird protokolliert. Nur deshalb kann das System später genau diese
Kunden und Teampartner zurückgeben.
---
## Was bei einer Zahlung vor dem Ende der Frist passiert
Sobald die Zahlung eingeht (Karte und PayPal sofort, Überweisung beim Zahlungseingang):
- Der Berater ist wieder aktiv und steht wieder unter seinem Sponsor in der Struktur.
- Er bekommt seine Teampartner, Kunden, Kundenabos und Kunden-Logins zurück.
- **Ausnahme:** Wurde ein Kunde oder Teampartner in der Zwischenzeit von Hand zu jemand anderem
übertragen, bleibt er dort. Zurück kommt nur, was noch beim Sponsor liegt.
- Umsätze, Punkte und Provisionen aus der Pause **bleiben beim Sponsor**.
Dasselbe passiert, wenn im Admin das Ablaufdatum eines Beraters in die Zukunft gesetzt wird. Setzt
man es im Admin in die Vergangenheit, wird der Berater sofort pausiert, ohne die 2 Tage.
---
## Was am 28. Tag ohne Zahlung passiert
- Alles, was noch am Berater hängt, geht **endgültig** an den Sponsor.
- Der Berater wird **Kunde seines Sponsors**. Gibt es beim Sponsor schon einen Kundeneintrag mit
seiner E-Mail-Adresse, wird kein zweiter angelegt.
- Der Berater-Account wird entfernt. Will er später wieder Berater werden, registriert er sich
neu mit Starterpaket.
- Der Sponsor bekommt die E-Mail mit der vollständigen Liste.
Eine Wiederherstellung durch mivita bleibt als Ausnahme möglich. Sie holt aber nur die
Teampartner zurück, nicht die Kunden.
---
## Die E-Mails im Wortlaut
### 1. An den Berater, nach dem Ablauf (nachts danach)
**Betreff:** Deine Mitgliedschaft bei mivita.care
> Deine Mitgliedschaft ist am **01.10.2026** abgelaufen. Ab dem **03.10.2026** pausieren wir Deinen
> Berater-Account: Dein Team und Deine Kunden werden dann vorübergehend Deinem Sponsor zugeordnet,
> ihre Umsätze zählen in dieser Zeit bei ihm.
>
> Verlängerst Du Deine Mitgliedschaft bis zum **28.10.2026**, bekommst Du Dein Team und Deine
> Kunden zurück. Danach wird Deine Mitgliedschaft dauerhaft beendet, und Team und Kunden bleiben
> bei Deinem Sponsor.
>
> Klick auf den Button, um Deine Mitgliedschaft jetzt zu verlängern:
>
> **[ MITGLIEDSCHAFT VERWALTEN ]**
>
> **Hinweis:** Bereits erarbeitete, noch offene Provisionen werden Dir selbstverständlich im
> nächsten Zahlungslauf ausgezahlt. Solltest Du Deinen Account vollständig löschen wollen, dann
> schreibe bitte eine Email an info@mivita.care. Unser Berater-Service wird sich dann umgehend
> darum kümmern.
>
> Wir hoffen, Dich ganz bald wieder als aktives Mitglied der MIVITA Family begrüßen zu dürfen.
Denselben Inhalt sieht der Berater als Hinweis auf seinem Dashboard, solange er nicht gezahlt hat.
### 2. An den Sponsor, bei der Pause (Kurzinfo, ohne Liste)
**Betreff:** Dein Teampartner Anna Beispiel ist pausiert
> Hallo Petra,
>
> dein Teampartner Anna Beispiel hat seine Mitgliedschaft wegen ausstehender Zahlung nicht
> verlängert. Er hat noch bis zum **28.10.2026** (25 Tage) Zeit, sie wieder aufzunehmen, sonst wird
> die Mitgliedschaft dauerhaft entfernt. Bis dahin sind seine Kunden und Teampartner dir
> zugeordnet.
>
> Umsätze seiner Kunden und Teampartner zählen in dieser Zeit bei dir. Zahlt er rechtzeitig,
> bekommt er seine Kunden und Teampartner zurück; die Umsätze der Zwischenzeit bleiben bei dir.
>
> Mit freundlichen Grüßen
> Dein Team von mivita.care
### 3. An den Sponsor, nach 28 Tagen (mit Liste)
**Betreff:** Kunden und Teampartner von Anna Beispiel gehören jetzt zu dir
> Hallo Petra,
>
> die Mitgliedschaft von Anna Beispiel wurde dauerhaft beendet, weil sie nicht verlängert wurde.
> Seine Kunden und Teampartner sind jetzt endgültig dir zugeordnet. Hier ist die Übersicht, damit du
> Kontakt aufnehmen kannst:
>
> **Kunden (3)**
>
> | Vorname | Nachname | Telefon | E-Mail |
> | --- | --- | --- | --- |
> | Anna | Beispiel | 0170 1234567 | anna@beispiel.de |
> | Maria | Muster | 0171 999999 | maria@muster.de |
> | Klaus | Kunde | – | klaus@kunde.de |
>
> **Teampartner (1)**
>
> | Vorname | Nachname | Telefon | E-Mail |
> | --- | --- | --- | --- |
> | Ben | Partner | 0172 555555 | ben@partner.de |
>
> Mit freundlichen Grüßen
> Dein Team von mivita.care
Der ausgeschiedene Berater steht selbst als Erster in der Kundenliste, weil er jetzt Kunde des
Sponsors ist.
Alle E-Mails gehen in der Sprache des Empfängers raus (Deutsch, Englisch, Spanisch, Französisch).
Die Namen oben sind Beispiele.
---
## Beim ersten Lauf nach dem Update
Der erste nächtliche Lauf holt alles nach, was nach den neuen Fristen schon fällig ist. Gemessen am
Datenstand vom 23.09.2026:
| | Anzahl |
| --- | --- |
| Berater, die sofort pausiert werden (Ablauf vor mindestens 2 Tagen) | 6 |
| … dabei umgehängte Kundeneinträge | 64 |
| … dabei umgehängte Teampartner | 2 |
| Berater, die endgültig entfernt werden (Ablauf vor mindestens 28 Tagen) | 20 |
| … dabei umgehängte Kundeneinträge | 150 |
| Betroffene aktive Kundenabos | 0 |
Die Sponsoren dieser Berater bekommen die E-Mails aus diesem Lauf alle auf einmal. Wer mehrere
betroffene Teampartner hat, bekommt mehrere E-Mails.
**Vor dem ersten Lauf** erstellen wir eine Liste aller betroffenen Berater mit Namen und Anzahl der
Kunden und schicken sie euch. So könnt ihr noch jemanden anrufen, bevor es passiert.
---
## Zur Abstimmung
1. **Texte der Erinnerungen vor dem Ablauf.** Die drei Erinnerungen vor dem Ablauf sagen heute:
„Solltest Du allerdings die Frist verstreichen lassen, wird Dein Account **solange** automatisch
pausiert, **bis** Du Deine Mitgliedschaft erneuerst.“ Das klingt, als gäbe es kein Ende. Unser
Vorschlag:
> Solltest Du die Frist verstreichen lassen, pausieren wir Deinen Account 2 Tage nach Ablauf.
> Dein Team und Deine Kunden gehen dann vorübergehend an Deinen Sponsor. Nach 28 Tagen ohne
> Zahlung bleiben sie endgültig dort.
2. **Eigenes Abo eines pausierten Beraters.** Hat ein pausierter Berater ein eigenes Berater-Abo,
läuft es heute zu Beraterpreisen weiter. Aktuell betrifft das niemanden. Sollen wir es bei der
Pause anhalten?
3. **Eine Sammel-E-Mail je Sponsor beim ersten Lauf** statt mehrerer einzelner E-Mails? Nur
sinnvoll, wenn ein Sponsor mehrere Betroffene hat.

View file

@ -22,7 +22,7 @@ return [
'home_copy_last_33' => 'Es bleiben Dir jetzt noch :days Tage Zeit, Deine Mitgliedschaft zu verlängern. Solltest Du allerdings die Frist verstreichen lassen, wird Dein Account solange automatisch pausiert bis Du Deine Mitgliedschaft erneuerst.',
'home_copy_last_34' => 'huiii... jetzt wird es aber Zeit! Deine Mitgliedschaft läuft bereits in :days Tagen aus, und zwar exakt am :datetime. Solltest Du allerdings die Frist verstreichen lassen, wird Dein Account solange automatisch pausiert bis Du Deine Mitgliedschaft erneuerst.',
'home_copy_last_35' => 'huiii... jetzt wird es aber Zeit! Deine Mitgliedschaft läuft bereits heute aus, und zwar exakt am :datetime. Solltest Du allerdings die Frist verstreichen lassen, wird Dein Account solange automatisch pausiert bis Du Deine Mitgliedschaft erneuerst.',
'home_copy_last_36' => 'wie angekündigt, haben wir Deinen Berater-Account pausiert, da Du Deine Mitgliedschaft nicht rechtzeitig verlängert hast. Wenn Du wieder von den Vorteilen und Features bei MIVITA profitieren möchtest, kannst Du Deinen Account jederzeit reaktivieren.',
'home_copy_last_36' => 'Deine Mitgliedschaft ist abgelaufen. Ab dem :suspend_date sind Dein Team und Deine Kunden vorübergehend Deinem Sponsor zugeordnet. Verlängerst Du bis zum :deadline, bekommst Du sie zurück; danach wird Deine Mitgliedschaft dauerhaft beendet.',
'home_hl' => 'Mitgliedschaft / Jahresbeitrag',
'inactive' => 'inaktiv',
'membership' => 'Mitgliedschaft',

View file

@ -29,12 +29,13 @@ return [
<li>und von Vertriebspartnerumsätzen partizipieren möchtest</li>
</ul>
<p>solltest Du jetzt schnell handeln und Deine Mitgliedschaft verlängern:</p>',
'copy_first_35' => '<p>wie angekündigt, haben wir Deinen Berater-Account pausiert, da Du Deine Mitgliedschaft nicht rechtzeitig verlängert hast. Wenn Du wieder von den Vorteilen und Features bei MIVITA profitieren möchtest, kannst Du Deinen Account jederzeit reaktivieren.</p>
<p>Klick auf den Button, falls Du Änderungen an Deiner Mitgliedschaft vornehmen möchtest:</p>',
'copy_first_35' => '<p>Deine Mitgliedschaft ist am :datetime abgelaufen. Ab dem :suspend_date pausieren wir Deinen Berater-Account: Dein Team und Deine Kunden werden dann vorübergehend Deinem Sponsor zugeordnet, ihre Umsätze zählen in dieser Zeit bei ihm.</p>
<p>Verlängerst Du Deine Mitgliedschaft bis zum <b>:deadline</b>, bekommst Du Dein Team und Deine Kunden zurück. Danach wird Deine Mitgliedschaft dauerhaft beendet, und Team und Kunden bleiben bei Deinem Sponsor.</p>
<p>Klick auf den Button, um Deine Mitgliedschaft jetzt zu verlängern:</p>',
'copy_last_31' => '<p><b>Wichtig:</b><br>Läuft Deine Mitgliedschaft aus, wird Dein Berater-Account pausiert. Es werden Dir dann keine Punkte und keine Provisionen mehr gutgeschrieben. Bereits erarbeitete, noch offene Provisionen zahlen wir Dir selbstverständlich im nächsten Zahlungslauf aus.</p>',
'copy_last_33' => '<p><b>Wichtig:</b><br>Läuft Deine Mitgliedschaft aus, wird Dein Berater-Account pausiert. Es werden Dir dann keine Punkte und keine Provisionen mehr gutgeschrieben. Bereits erarbeitete, noch offene Provisionen zahlen wir Dir selbstverständlich im nächsten Zahlungslauf aus.</p>',
'copy_last_34' => '<p><b>Wichtig:</b><br>Läuft Deine Mitgliedschaft aus, wird Dein Berater-Account pausiert. Es werden Dir dann keine Punkte und keine Provisionen mehr gutgeschrieben. Bereits erarbeitete, noch offene Provisionen zahlen wir Dir selbstverständlich im nächsten Zahlungslauf aus.</p>',
'copy_last_35' => '<p><b>Hinweis:</b><br>Auch wenn Dein Account pausiert, werden Dir vergangene bzw. noch offene Provisionen selbstverständlich im nächsten Zahlungslauf ausgezahlt.Solltest Du Deinen Account vollständig löschen wollen, dann schreibe bitte eine Email an info@mivita.care. Unser Berater-Service wird sich dann umgehend darum kümmern.</p>
'copy_last_35' => '<p><b>Hinweis:</b><br>Bereits erarbeitete, noch offene Provisionen werden Dir selbstverständlich im nächsten Zahlungslauf ausgezahlt. Solltest Du Deinen Account vollständig löschen wollen, dann schreibe bitte eine Email an info@mivita.care. Unser Berater-Service wird sich dann umgehend darum kümmern.</p>
<p>Wir hoffen, Dich ganz bald wieder als aktives Mitglied der MIVITA Family begrüßen zu dürfen.</p>',
'subject' => 'Deine Mitgliedschaft bei mivita.care',
];

View file

@ -0,0 +1,15 @@
<?php
return [
'notice_subject' => 'Dein Teampartner :name ist pausiert',
'notice_intro' => 'dein Teampartner :name hat seine Mitgliedschaft wegen ausstehender Zahlung nicht verlängert. Er hat noch bis zum :date (:days Tage) Zeit, sie wieder aufzunehmen, sonst wird die Mitgliedschaft dauerhaft entfernt. Bis dahin sind seine Kunden und Teampartner dir zugeordnet.',
'notice_hint' => 'Umsätze seiner Kunden und Teampartner zählen in dieser Zeit bei dir. Zahlt er rechtzeitig, bekommt er seine Kunden und Teampartner zurück; die Umsätze der Zwischenzeit bleiben bei dir.',
'transfer_subject' => 'Kunden und Teampartner von :name gehören jetzt zu dir',
'transfer_intro' => 'die Mitgliedschaft von :name wurde dauerhaft beendet, weil sie nicht verlängert wurde. Seine Kunden und Teampartner sind jetzt endgültig dir zugeordnet. Hier ist die Übersicht, damit du Kontakt aufnehmen kannst:',
'list_customers' => 'Kunden (:count)',
'list_team' => 'Teampartner (:count)',
'col_first_name' => 'Vorname',
'col_last_name' => 'Nachname',
'col_phone' => 'Telefon',
'col_email' => 'E-Mail',
];

View file

@ -22,7 +22,7 @@ return [
'home_copy_last_33' => 'You now have :days days left to extend your membership. However, if you let the deadline pass, your account will automatically be paused until you renew your membership.',
'home_copy_last_34' => 'Wow... now it\'s time! Your membership expires in :days days, exactly on :datetime. However, if you let the deadline pass, your account will automatically be paused until you renew your membership.',
'home_copy_last_35' => 'Wow... now it\'s time! Your membership expires today, exactly on :datetime. However, if you let the deadline pass, your account will automatically be paused until you renew your membership.',
'home_copy_last_36' => 'As announced, we have paused your advisor account because you did not renew your membership in time. If you would like to benefit from the advantages and features of MIVITA again, you can reactivate your account at any time.',
'home_copy_last_36' => 'Your membership has expired. From :suspend_date your team and your customers are temporarily assigned to your sponsor. If you renew by :deadline, you will get them back; after that your membership will be ended permanently.',
'home_hl' => 'membership / annual fee',
'inactive' => 'inactive',
'membership' => 'membership',

View file

@ -8,8 +8,8 @@ return [
'copy_first_31' => '<p>We wanted to let you know in good time that your membership will expire at :datetime. Therefore, you should take action now and extend it in good time so that you can continue to</p> <ul> <li>purchase products from MIVITA</li> <li>Customers can purchase via your online shop (optional)</li> <li >You can participate in sales partner sales</li> </ul> <p>But all is well - you still have a few relaxing :days days left to extend your membership. However, if you let the deadline pass, your account will be automatically paused until you renew your membership.</p> <p>We\'ll send you another reminder very soon, but you\'ll have it in the back of your mind for now. If you would like to extend your connection by 1 year now, you can do so by clicking on the button below. What\'s gone is gone :) ...</p>',
'copy_first_33' => '<p>Here\'s a quick reminder that your membership expires at :datetime. Therefore, you should take action now and extend it in good time so that you can continue to </p> <ul> <li>purchase products from MIVITA</li> <li>Customers can purchase via your online shop (optional)</li> <li >You can participate in sales partner sales</li> </ul> <p>But all is well - you still have a few relaxing :days days left to extend your membership. However, if you let the deadline pass, your account will be automatically paused until you renew your membership.</p> <p>We\'ll send you another reminder very soon, but you\'ll have it in the back of your mind for now. If you would like to extend your connection by 1 year now, you can do so by clicking on the button below. What\'s gone is gone :) ...</p>',
'copy_first_34' => '<p>huiii... now it\'s time! Your membership expires in :days days, exactly on :datetime. Therefore, you should take action now and extend your membership, otherwise your account will be paused.</p> <p>If you continue to</p> <ul> <li>purchase products from MIVITA</li> <li>an online shop (optional)</li> <li>and want to participate in sales partner sales</li> </ul> <p>you should act quickly now and extend your membership:</p>',
'copy_first_35' => '<p>As announced, we have paused your advisor account because you did not renew your membership on time. If you would like to benefit from the advantages and features of MIVITA again, you can reactivate your account at any time.</p> <p>Click on the button if you would like to make changes to your membership:</p>',
'copy_last_35' => '<p><b>Note:</b><br>Even if your account is paused, past or outstanding commissions will of course be paid out to you in the next payment run. If you want to delete your account completely, please write an email to info @mivita.care. Our advisor service will then take care of this immediately.</p> <p>We hope to welcome you again very soon as an active member of the MIVITA Family.</p>',
'copy_first_35' => '<p>Your membership expired on :datetime. From :suspend_date we will pause your consultant account: your team and your customers will then be assigned to your sponsor temporarily, and their sales will count for your sponsor during this time.</p> <p>If you renew your membership by <b>:deadline</b>, you will get your team and your customers back. After that, your membership will be ended permanently and your team and customers will stay with your sponsor.</p> <p>Click on the button to renew your membership now:</p>',
'copy_last_35' => '<p><b>Note:</b><br>Commissions you have already earned and that are still outstanding will of course be paid out to you in the next payment run. If you want to delete your account completely, please write an email to info@mivita.care. Our advisor service will then take care of this immediately.</p> <p>We hope to welcome you again very soon as an active member of the MIVITA Family.</p>',
'subject' => 'Your membership at mivita.care',
'copy_last_31' => '<p><b>Important:</b><br>If your membership expires, your consultant account will be paused. From then on you will no longer be credited with any points or commissions. Commissions you have already earned and that are still outstanding will of course be paid out to you in the next payment run.</p>',
'copy_last_33' => '<p><b>Important:</b><br>If your membership expires, your consultant account will be paused. From then on you will no longer be credited with any points or commissions. Commissions you have already earned and that are still outstanding will of course be paid out to you in the next payment run.</p>',

View file

@ -0,0 +1,15 @@
<?php
return [
'notice_subject' => 'Your team partner :name has been paused',
'notice_intro' => 'your team partner :name has not renewed their membership because the payment is outstanding. They have until :date (:days days) to resume it, otherwise the membership will be removed permanently. Until then, their customers and team partners are assigned to you.',
'notice_hint' => 'During this time, sales of their customers and team partners count for you. If they pay in time, they get their customers and team partners back; sales made in the meantime stay with you.',
'transfer_subject' => 'The customers and team partners of :name now belong to you',
'transfer_intro' => 'the membership of :name has been ended permanently because it was not renewed. Their customers and team partners are now permanently assigned to you. Here is the overview so you can get in touch:',
'list_customers' => 'Customers (:count)',
'list_team' => 'Team partners (:count)',
'col_first_name' => 'First name',
'col_last_name' => 'Last name',
'col_phone' => 'Phone',
'col_email' => 'Email',
];

View file

@ -22,7 +22,7 @@ return [
'home_copy_last_33' => 'Ahora le quedan :días días para ampliar su membresía. Sin embargo, si deja pasar la fecha límite, su cuenta se pausará automáticamente hasta que renueve su membresía.',
'home_copy_last_34' => 'Vaya... ¡ahora es el momento! Su membresía vence en :days días, exactamente el :datetime. Sin embargo, si deja pasar la fecha límite, su cuenta se pausará automáticamente hasta que renueve su membresía.',
'home_copy_last_35' => 'Vaya... ¡ahora es el momento! Su membresía vence hoy, exactamente el :datetime. Sin embargo, si deja pasar la fecha límite, su cuenta se pausará automáticamente hasta que renueve su membresía.',
'home_copy_last_36' => 'Como lo anunciamos, hemos pausado su cuenta de asesor porque no renovó su membresía a tiempo. Si deseas volver a beneficiarte de las ventajas y características de MIVITA, puedes reactivar tu cuenta en cualquier momento.',
'home_copy_last_36' => 'Tu membresía ha caducado. A partir del :suspend_date tu equipo y tus clientes están asignados temporalmente a tu patrocinador. Si renuevas hasta el :deadline, los recuperarás; después tu membresía finalizará definitivamente.',
'home_hl' => 'membresía / cuota anual',
'inactive' => 'inactivo',
'membership' => 'membresía',

View file

@ -8,8 +8,8 @@ return [
'copy_first_31' => '<p>Queríamos informarle a tiempo que su membresía caducará en :datetime. Por lo tanto, debes tomar medidas ahora y ampliarlo a su debido tiempo para que puedas seguir</p> <ul> <li>comprando productos de MIVITA</li> productos de MIVITA</li> <li>Los clientes pueden comprar a través de tu tienda online (opcional)</li> <li >Puedes participar en ventas de socios de ventas</li> </ul> <p>Pero todo está bien: todavía te quedan algunos días relajantes para ampliar tu membresía. Sin embargo, si deja pasar la fecha límite, su cuenta se pausará automáticamente hasta que renueve su membresía.</p> <p>Le enviaremos otro recordatorio muy pronto, pero lo tendrá en mente. por ahora. Si desea extender su conexión por 1 año ahora, puede hacerlo haciendo clic en el botón a continuación. Lo que se fue, se fue :) ...</p>',
'copy_first_33' => '<p>Aquí le recordamos rápidamente que su membresía vence en :datetime. Por lo tanto, debes tomar medidas ahora y ampliarlo a su debido tiempo para que puedas seguir </p> <ul> <li>comprando productos de MIVITA</li> <li>Los clientes pueden comprar a través de tu tienda online (opcional)</li> <li >Puedes participar en ventas de socios de ventas</li> </ul> <p>Pero todo está bien: todavía te quedan algunos días relajantes para ampliar tu membresía. Sin embargo, si deja pasar la fecha límite, su cuenta se pausará automáticamente hasta que renueve su membresía.</p> <p>Le enviaremos otro recordatorio muy pronto, pero lo tendrá en mente. por ahora. Si desea extender su conexión por 1 año ahora, puede hacerlo haciendo clic en el botón a continuación. Lo que se fue, se fue :) ...</p>',
'copy_first_34' => '<p>huiii... ¡ahora es el momento! Su membresía vence en :days días, exactamente el :datetime. Por lo tanto, debe tomar medidas ahora y ampliar su membresía; de lo contrario, su cuenta se pausará.</p> <p>Si continúa</p> <ul> <li>comprando productos de MIVITA</li> <li >una tienda en línea (opcional)</li> <li>y desea participar en las ventas de socios de ventas</li> </ul> <p>debe actuar rápidamente ahora y ampliar su membresía:</p>',
'copy_first_35' => '<p>Como lo anunciamos, hemos pausado su cuenta de asesor porque no renovó su membresía a tiempo. Si deseas volver a beneficiarte de las ventajas y características de MIVITA, puedes reactivar tu cuenta en cualquier momento.</p> <p>Haz clic en el botón si deseas realizar cambios en tu membresía:</p>',
'copy_last_35' => '<p><b>Nota:</b><br>Incluso si su cuenta está pausada, las comisiones pasadas o pendientes, por supuesto, se le pagarán en la siguiente ejecución de pago. Si desea eliminar su cuenta por completo, por favor escribe un correo electrónico a info@mivita.care. Nuestro servicio de asesoría se encargará de esto inmediatamente.</p> <p>Esperamos darle la bienvenida nuevamente muy pronto como miembro activo de la Familia MIVITA.</p>',
'copy_first_35' => '<p>Tu membresía caducó el :datetime. A partir del :suspend_date pausaremos tu cuenta de asesor: tu equipo y tus clientes se asignarán temporalmente a tu patrocinador y, durante ese tiempo, sus ventas contarán para él.</p> <p>Si renuevas tu membresía hasta el <b>:deadline</b>, recuperarás a tu equipo y a tus clientes. Después, tu membresía finalizará definitivamente y tu equipo y tus clientes se quedarán con tu patrocinador.</p> <p>Haz clic en el botón para renovar tu membresía ahora:</p>',
'copy_last_35' => '<p><b>Nota:</b><br>Las comisiones ya generadas y aún pendientes se te pagarán, por supuesto, en la siguiente ejecución de pago. Si deseas eliminar tu cuenta por completo, escribe un correo electrónico a info@mivita.care. Nuestro servicio de asesoría se encargará de ello inmediatamente.</p> <p>Esperamos darte la bienvenida nuevamente muy pronto como miembro activo de la Familia MIVITA.</p>',
'subject' => 'Tu membresía en mivita.care',
'copy_last_31' => '<p><b>Importante:</b><br>Si tu afiliación caduca, tu cuenta de asesor quedará en pausa. A partir de ese momento ya no se te abonarán puntos ni comisiones. Las comisiones ya generadas y aún pendientes se te pagarán, por supuesto, en la siguiente ejecución de pago.</p>',
'copy_last_33' => '<p><b>Importante:</b><br>Si tu afiliación caduca, tu cuenta de asesor quedará en pausa. A partir de ese momento ya no se te abonarán puntos ni comisiones. Las comisiones ya generadas y aún pendientes se te pagarán, por supuesto, en la siguiente ejecución de pago.</p>',

View file

@ -0,0 +1,15 @@
<?php
return [
'notice_subject' => 'Tu compañero de equipo :name está en pausa',
'notice_intro' => 'tu compañero de equipo :name no ha renovado su membresía porque el pago está pendiente. Tiene hasta el :date (:days días) para reanudarla; de lo contrario, la membresía se eliminará definitivamente. Hasta entonces, sus clientes y compañeros de equipo están asignados a ti.',
'notice_hint' => 'Durante este tiempo, las ventas de sus clientes y compañeros de equipo cuentan para ti. Si paga a tiempo, recupera a sus clientes y compañeros de equipo; las ventas realizadas mientras tanto se quedan contigo.',
'transfer_subject' => 'Los clientes y compañeros de equipo de :name ahora son tuyos',
'transfer_intro' => 'la membresía de :name ha finalizado definitivamente porque no se renovó. Sus clientes y compañeros de equipo están ahora asignados a ti de forma definitiva. Aquí tienes el resumen para que puedas ponerte en contacto:',
'list_customers' => 'Clientes (:count)',
'list_team' => 'Compañeros de equipo (:count)',
'col_first_name' => 'Nombre',
'col_last_name' => 'Apellido',
'col_phone' => 'Teléfono',
'col_email' => 'Correo electrónico',
];

View file

@ -22,7 +22,7 @@ return [
'home_copy_last_33' => 'Il vous reste encore :days jours pour renouveler votre adhésion. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusqu’au renouvellement.',
'home_copy_last_34' => 'Il est vraiment temps d’agir ! Votre adhésion expire déjà dans :days jours, exactement le :datetime. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusqu’au renouvellement.',
'home_copy_last_35' => 'Il est vraiment temps d’agir ! Votre adhésion expire aujourd’hui, exactement le :datetime. Si vous laissez toutefois passer ce délai, votre compte sera automatiquement mis en pause jusqu’au renouvellement.',
'home_copy_last_36' => 'Comme annoncé, nous avons mis votre compte conseiller en pause car vous n’avez pas renouvelé votre adhésion à temps. Si vous souhaitez profiter à nouveau des avantages et fonctionnalités de MIVITA, vous pouvez réactiver votre compte à tout moment.',
'home_copy_last_36' => 'Votre adhésion a expiré. À partir du :suspend_date, votre équipe et vos clients sont attribués temporairement à votre parrain. Si vous renouvelez d’ici le :deadline, vous les récupérerez ; ensuite, votre adhésion sera définitivement terminée.',
'home_hl' => 'Adhésion / cotisation annuelle',
'inactive' => 'inactif',
'membership' => 'Adhésion',

View file

@ -29,12 +29,13 @@ return [
<li>et participer aux chiffres d’affaires des partenaires commerciaux</li>
</ul>
<p>vous devriez agir rapidement et renouveler votre adhésion :</p>',
'copy_first_35' => '<p>comme annoncé, nous avons mis votre compte conseiller en pause car vous n’avez pas renouvelé votre adhésion à temps. Si vous souhaitez profiter à nouveau des avantages et fonctionnalités de MIVITA, vous pouvez réactiver votre compte à tout moment.</p>
<p>Cliquez sur le bouton si vous souhaitez modifier votre adhésion :</p>',
'copy_first_35' => '<p>Votre adhésion a expiré le :datetime. À partir du :suspend_date, nous mettrons votre compte conseiller en pause : votre équipe et vos clients seront alors attribués temporairement à votre parrain, et leurs ventes compteront pour lui pendant cette période.</p>
<p>Si vous renouvelez votre adhésion d’ici le <b>:deadline</b>, vous récupérerez votre équipe et vos clients. Ensuite, votre adhésion sera définitivement terminée et votre équipe et vos clients resteront chez votre parrain.</p>
<p>Cliquez sur le bouton pour renouveler votre adhésion maintenant :</p>',
'copy_last_31' => '<p><b>Important :</b><br>Si votre adhésion expire, votre compte conseiller sera mis en pause. Aucun point ni aucune commission ne vous seront alors plus crédités. Les commissions déjà acquises et encore ouvertes vous seront bien entendu versées lors du prochain cycle de paiement.</p>',
'copy_last_33' => '<p><b>Important :</b><br>Si votre adhésion expire, votre compte conseiller sera mis en pause. Aucun point ni aucune commission ne vous seront alors plus crédités. Les commissions déjà acquises et encore ouvertes vous seront bien entendu versées lors du prochain cycle de paiement.</p>',
'copy_last_34' => '<p><b>Important :</b><br>Si votre adhésion expire, votre compte conseiller sera mis en pause. Aucun point ni aucune commission ne vous seront alors plus crédités. Les commissions déjà acquises et encore ouvertes vous seront bien entendu versées lors du prochain cycle de paiement.</p>',
'copy_last_35' => '<p><b>Remarque :</b><br>Même si votre compte est mis en pause, les commissions passées ou encore ouvertes vous seront bien entendu versées lors du prochain cycle de paiement. Si vous souhaitez supprimer complètement votre compte, veuillez écrire un e-mail à info@mivita.care. Notre service conseiller s’en occupera rapidement.</p>
'copy_last_35' => '<p><b>Remarque :</b><br>Les commissions déjà acquises et encore ouvertes vous seront bien entendu versées lors du prochain cycle de paiement. Si vous souhaitez supprimer complètement votre compte, veuillez écrire un e-mail à info@mivita.care. Notre service conseiller s’en occupera rapidement.</p>
<p>Nous espérons vous revoir très bientôt comme membre actif de la famille MIVITA.</p>',
'subject' => 'Votre adhésion chez mivita.care',
];

View file

@ -0,0 +1,15 @@
<?php
return [
'notice_subject' => 'Ton partenaire d’équipe :name est en pause',
'notice_intro' => 'ton partenaire d’équipe :name n’a pas renouvelé son adhésion car le paiement est en attente. Il a jusqu’au :date (:days jours) pour la reprendre, sinon l’adhésion sera supprimée définitivement. D’ici là, ses clients et partenaires d’équipe te sont attribués.',
'notice_hint' => 'Pendant cette période, les ventes de ses clients et partenaires d’équipe comptent pour toi. S’il paie à temps, il récupère ses clients et partenaires d’équipe ; les ventes réalisées entre-temps restent chez toi.',
'transfer_subject' => 'Les clients et partenaires d’équipe de :name t’appartiennent désormais',
'transfer_intro' => 'l’adhésion de :name a été définitivement terminée car elle n’a pas été renouvelée. Ses clients et partenaires d’équipe te sont désormais attribués définitivement. Voici l’aperçu pour que tu puisses les contacter :',
'list_customers' => 'Clients (:count)',
'list_team' => 'Partenaires d’équipe (:count)',
'col_first_name' => 'Prénom',
'col_last_name' => 'Nom',
'col_phone' => 'Téléphone',
'col_email' => 'E-mail',
];

View file

@ -17,7 +17,8 @@
@else
<h5 class="alert badge-danger">{{__('membership.home_copy_alert_36', ['days'=>$user->daysActiveAccount()*-1, 'datetime'=>$user->getPaymentAccountDateFormat()])}}</h5>
@endif
<p>{{__('membership.home_copy_last_36', ['days'=>$user->daysActiveAccount(), 'datetime'=>$user->getPaymentAccountDateFormat()])}}</p>
@php($suspension = app(\App\Services\ConsultantSuspensionService::class))
<p>{{__('membership.home_copy_last_36', ['days'=>$user->daysActiveAccount(), 'datetime'=>$user->getPaymentAccountDateFormat(), 'suspend_date'=>$suspension->suspensionDateFor($user)?->format('d.m.Y'), 'deadline'=>$suspension->deadlineFor($user)?->format('d.m.Y')])}}</p>
@endif
@elseif($user->daysActiveAccount() < config('mivita.remind_last_days') )
{{-- 34 --}}

View file

@ -0,0 +1,115 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{{ $headline }}</title>
<style type="text/css">
a { text-decoration: none; border: 0; outline: none; color: #919f7a; }
a:hover { color: #b6b600; }
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
td { text-align: left; }
body { -webkit-font-smoothing: antialiased; width: 100%; height: 100%; color: #37302d; background: #ffffff; font-size: 15px; line-height: 26px; }
table { border-collapse: separate !important; }
.headline { color: #37302d; font-size: 18px; }
.force-full-width { width: 100% !important; }
</style>
</head>
<body class="body" style="padding:0; margin:0; display:block; background:#f8f8f8; -webkit-text-size-adjust:none" bgcolor="#f8f8f8">
<table align="left" cellpadding="0" cellspacing="0" width="100%" height="100%">
<tr>
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
<br>
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="700" class="w320">
<tr>
<td align="left" valign="top">
<table style="margin: 0 auto;" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="text-align:center;">
<center>
<picture style="text-align: center">
<img src="https://my.mivita.care/images/logo_mivita.png" alt="mivita.care" style="border:none" width="230">
</picture>
</center>
<br>
</td>
</tr>
</table>
<table cellpadding="10" style="padding: 10px;" cellspacing="0" border="0" width="100%" bgcolor="#ffffff">
<tr>
<td class="headline">
<b>{{ $headline }}</b>
</td>
</tr>
<tr>
<td style="color:#37302d;line-height:1.6em;">
{{ trim($salutation) }},
@foreach ($paragraphs as $paragraph)
<br><br>
{{ $paragraph }}
@endforeach
</td>
</tr>
@foreach (['customers' => $customers, 'team' => $team] as $listKey => $rows)
@if (count($rows) > 0)
<tr>
<td style="color:#37302d;line-height:1.6em;">
<b>{{ __('suspension.list_'.$listKey, ['count' => count($rows)]) }}</b>
<table cellpadding="6" cellspacing="0" border="0" width="100%" style="border-collapse: collapse !important; font-size: 13px; margin-top: 6px;">
<tr style="background:#f6fdf5;">
<td style="border-bottom:1px solid #b5c49b;"><b>{{ __('suspension.col_first_name') }}</b></td>
<td style="border-bottom:1px solid #b5c49b;"><b>{{ __('suspension.col_last_name') }}</b></td>
<td style="border-bottom:1px solid #b5c49b;"><b>{{ __('suspension.col_phone') }}</b></td>
<td style="border-bottom:1px solid #b5c49b;"><b>{{ __('suspension.col_email') }}</b></td>
</tr>
@foreach ($rows as $row)
<tr>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['first_name'] }}</td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['last_name'] }}</td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['phone'] !== '' ? $row['phone'] : '–' }}</td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['email'] }}</td>
</tr>
@endforeach
</table>
</td>
</tr>
@endif
@endforeach
<tr>
<td style="color:#37302d;line-height:1.6em;">
<br>
{{ $greetings }}<br>
{{ $sender }}
</td>
</tr>
</table>
<center>
<table cellpadding="0" cellspacing="0" class="force-full-width" bgcolor="#f8f8f8" style="margin: 0 auto; text-align: center;line-height:1.6em;">
<tr>
<td style="color:#7B7B7E; font-size:14px; text-align: center;line-height:1.6em;" align="center">
<p>{{__('email.footer_copy1')}}</p>
<br>
<a href="https://www.mivita.care" style="color: #7B7B7E; text-decoration: underline;">www.mivita.care</a>
<br>
</td>
</tr>
<tr>
<td style="color:#bbbbbb; font-size:12px; text-align: center;line-height:1.6em;" align="center">
<p>{{__('email.footer_copy2')}}</p>
<a href="https://www.mivita.care/datenschutz">{{ __('dataprotect.data_protect') }}</a> <br>
<p>{{__('email.footer_copy3')}}</p>
<br><br>
</td>
</tr>
</table>
</center>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,342 @@
<?php
use App\Http\Controllers\AdminUserCleanupController;
use App\Mail\SponsorSuspensionNoticeMail;
use App\Mail\SponsorTransferMail;
use App\Models\ConsultantSuspensionTransfer;
use App\Models\Country;
use App\Models\Customer;
use App\Models\Product;
use App\Models\ShoppingOrder;
use App\Models\ShoppingOrderItem;
use App\Models\ShoppingUser;
use App\Models\UserAbo;
use App\Models\UserAccount;
use App\Models\UserCleanUpLog;
use App\Services\ConsultantSuspensionService;
use App\Services\Payment;
use App\User;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Entwicklungsplan B2: Berater faellt aus — pausieren nach 2 Tagen, Rueckgabe bei
* Zahlung, harter Cut nach 28 Tagen, Sponsor-Mails.
*/
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-10-01 03:30:00'));
config(['mivita.cleanup_suspend_days' => 2, 'mivita.cleanup_delete_days' => 28, 'mivita.company_user_id' => 6]);
Mail::fake();
});
afterEach(fn () => Carbon::setTestNow());
function susConsultant(?User $sponsor, string $expires, string $firstName = 'Berater'): User
{
$account = UserAccount::create([
'first_name' => $firstName,
'last_name' => 'Test',
'm_first_name' => $firstName,
'm_last_name' => 'Test',
'm_account' => 'M'.random_int(1000, 99999),
'mobil' => '170 1234567',
]);
return User::forceCreate([
'email' => strtolower($firstName).'-'.uniqid('', true).'@example.com',
'password' => bcrypt('secret'),
'lang' => 'de',
'active' => 1,
'blocked' => 0,
'admin' => 0,
'wizard' => 100,
'm_level' => 1,
'm_sponsor' => $sponsor?->id,
'payment_account' => Carbon::parse($expires),
'account_id' => $account->id,
])->fresh();
}
function susCustomer(User $member, string $firstName = 'Kundin'): ShoppingUser
{
$country = Country::first() ?? Country::create([
'code' => 'DE', 'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland',
'es' => 'Alemania', 'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]);
return ShoppingUser::create([
'auth_user_id' => 0,
'member_id' => $member->id,
'number' => random_int(1000, 99999),
'billing_firstname' => $firstName,
'billing_lastname' => 'Muster',
'billing_phone' => '0171 999',
'billing_email' => strtolower($firstName).'-'.uniqid('', true).'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
]);
}
function susCustomerAbo(User $member, ShoppingUser $customer): UserAbo
{
return UserAbo::create([
'user_id' => null,
'member_id' => $member->id,
'shopping_user_id' => $customer->id,
'is_for' => 'ot',
'email' => $customer->billing_email,
'payone_userid' => 1,
'clearingtype' => 'cc',
'active' => true,
'status' => 2,
'abo_interval' => 10,
'start_date' => '2026-01-10',
'next_date' => '2026-10-10',
]);
}
/**
* Sponsor mit Berater darunter: Kundin mit Abo und Portal-Login, ein Teampartner.
*
* @return array{sponsor: User, consultant: User, child: User, customer: ShoppingUser, abo: UserAbo, portal: Customer}
*/
function susScenario(string $expires = '2026-09-28 10:00:00'): array
{
$sponsor = susConsultant(null, '2027-06-01', 'Sponsor');
$consultant = susConsultant($sponsor, $expires, 'Anna');
$child = susConsultant($consultant, '2027-03-01', 'Ben');
$customer = susCustomer($consultant);
$abo = susCustomerAbo($consultant, $customer);
$portal = Customer::create([
'email' => $customer->billing_email,
'shopping_user_id' => $customer->id,
'member_id' => $consultant->id,
'number' => $customer->number,
]);
return compact('sponsor', 'consultant', 'child', 'customer', 'abo', 'portal');
}
describe('Stufe 1: pausieren', function () {
it('laesst den Berater am ersten Tag nach Ablauf noch in Ruhe', function () {
$s = susScenario('2026-09-30 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
expect($s['consultant']->fresh()->active)->toBe(1)
->and($s['customer']->fresh()->member_id)->toBe($s['consultant']->id);
Mail::assertNothingSent();
});
it('haengt nach 2 Tagen Team, Kunden, Kundenabos und Portal-Logins an den Sponsor', function () {
$s = susScenario('2026-09-28 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
$consultant = $s['consultant']->fresh();
expect($consultant->active)->toBe(0)
->and($consultant->m_sponsor)->toBeNull()
->and($consultant->pre_sponsor)->toBe($s['sponsor']->id)
->and($s['child']->fresh()->m_sponsor)->toBe($s['sponsor']->id)
->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
->and($s['abo']->fresh()->member_id)->toBe($s['sponsor']->id)
->and($s['portal']->fresh()->member_id)->toBe($s['sponsor']->id)
->and(ConsultantSuspensionTransfer::openFor($consultant->id)->count())->toBe(3);
});
it('schickt dem Sponsor die Kurzinfo ohne Listen, mit Frist', function () {
$s = susScenario('2026-09-28 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
Mail::assertSent(SponsorSuspensionNoticeMail::class, function (SponsorSuspensionNoticeMail $mail) use ($s) {
$html = $mail->render();
return $mail->hasTo($s['sponsor']->email)
&& str_contains($html, 'Anna Test')
&& str_contains($html, '25.10.2026')
&& str_contains($html, '(24 Tage)')
&& ! str_contains($html, $s['customer']->billing_email);
});
});
it('schickt dem Firmenkonto keine Mail', function () {
$company = User::forceCreate(['id' => 6, 'email' => 'firma@example.com', 'password' => 'x', 'lang' => 'de', 'active' => 1, 'payment_account' => '2030-01-01']);
$consultant = susConsultant($company, '2026-09-28 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
expect($consultant->fresh()->active)->toBe(0);
Mail::assertNothingSent();
});
it('aendert im Trockenlauf nichts und schickt nichts', function () {
$s = susScenario('2026-09-28 10:00:00');
$this->artisan('user:cleanup', ['--dry-run' => true])
->expectsOutputToContain('Pausieren (Ablauf vor mind. 2 Tagen): 1 Berater')
->assertSuccessful();
expect($s['consultant']->fresh()->active)->toBe(1)
->and($s['customer']->fresh()->member_id)->toBe($s['consultant']->id);
Mail::assertNothingSent();
});
});
describe('Rueckgabe bei Zahlung', function () {
it('gibt Team und Kundendaten zurueck, wenn der Berater innerhalb der Frist zahlt', function () {
$s = susScenario('2026-09-28 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
Carbon::setTestNow(Carbon::parse('2026-10-08 12:00:00'));
$product = Product::forceCreate([
'name' => 'Mitgliedschaft', 'title' => 'Mitgliedschaft', 'price' => 49.0, 'tax' => 19.0,
'active' => true, 'pos' => 1, 'weight' => 0, 'points' => 0, 'action' => [0],
]);
$order = ShoppingOrder::forceCreate([
'shopping_user_id' => 0, 'auth_user_id' => $s['consultant']->id, 'country_id' => 0,
'user_shop_id' => 0, 'payment_for' => 0, 'paid' => false,
]);
ShoppingOrderItem::forceCreate(['shopping_order_id' => $order->id, 'product_id' => $product->id, 'qty' => 1, 'price' => 49.0]);
Payment::paymentStatusPaidAction($order->fresh(), true);
$consultant = $s['consultant']->fresh();
expect($consultant->active)->toBe(1)
->and($consultant->m_sponsor)->toBe($s['sponsor']->id)
->and($s['child']->fresh()->m_sponsor)->toBe($consultant->id)
->and($s['customer']->fresh()->member_id)->toBe($consultant->id)
->and($s['abo']->fresh()->member_id)->toBe($consultant->id)
->and($s['portal']->fresh()->member_id)->toBe($consultant->id)
->and(ConsultantSuspensionTransfer::openFor($consultant->id)->count())->toBe(0);
});
it('laesst einen inzwischen von Hand uebertragenen Kunden, wo er ist', function () {
$s = susScenario('2026-09-28 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
$other = susConsultant($s['sponsor'], '2027-05-01', 'Clara');
$s['customer']->update(['member_id' => $other->id]);
$summary = app(ConsultantSuspensionService::class)->reinstate($s['consultant']);
expect($s['customer']->fresh()->member_id)->toBe($other->id)
->and($s['abo']->fresh()->member_id)->toBe($s['consultant']->id)
->and($summary['kept'])->toBe(1)
->and($summary['abos'])->toBe(1);
});
});
describe('Stufe 2: harter Cut', function () {
it('loescht den Berater nach 28 Tagen, macht ihn zum Kunden des Sponsors und schickt die Listen', function () {
$s = susScenario('2026-09-28 10:00:00');
$this->artisan('user:cleanup')->assertSuccessful();
Carbon::setTestNow(Carbon::parse('2026-10-27 03:30:00'));
$this->artisan('user:cleanup')->assertSuccessful();
$consultant = User::withTrashed()->find($s['consultant']->id);
expect($consultant->deleted_at)->not->toBeNull()
->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
->and(ShoppingUser::where('member_id', $s['sponsor']->id)->where('billing_lastname', 'Test')->count())->toBe(1);
Mail::assertSent(SponsorTransferMail::class, function (SponsorTransferMail $mail) use ($s) {
$html = $mail->render();
return $mail->hasTo($s['sponsor']->email)
&& str_contains($html, $s['customer']->billing_email)
&& str_contains($html, '0171 999')
&& str_contains($html, $s['child']->email)
&& str_contains($html, 'Kunden (2)')
&& str_contains($html, 'Teampartner (1)');
});
});
it('legt keinen zweiten Kundendatensatz an, wenn der Berater schon Kunde beim Sponsor ist', function () {
$s = susScenario('2026-09-20 10:00:00');
$own = susCustomer($s['consultant'], 'Anna');
$own->update(['auth_user_id' => $s['consultant']->id]);
Carbon::setTestNow(Carbon::parse('2026-10-19 03:30:00'));
$this->artisan('user:cleanup')->assertSuccessful();
expect(ShoppingUser::where('auth_user_id', $s['consultant']->id)->count())->toBe(1)
->and($own->fresh()->member_id)->toBe($s['sponsor']->id);
});
it('uebernimmt beim harten Cut auch Kunden von Beratern, die noch nach der alten Regel inaktiv sind', function () {
$s = susScenario('2026-09-01 10:00:00');
// Alte 2-Wochen-Stufe: nur das Team war umgehaengt, die Kunden blieben stehen.
$s['consultant']->forceFill(['active' => 0, 'pre_sponsor' => $s['sponsor']->id, 'm_sponsor' => null])->save();
$this->artisan('user:cleanup')->assertSuccessful();
expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->not->toBeNull()
->and($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
->and($s['abo']->fresh()->member_id)->toBe($s['sponsor']->id)
->and($s['portal']->fresh()->member_id)->toBe($s['sponsor']->id);
});
});
describe('Admin setzt das Ablaufdatum', function () {
it('nutzt fuer Pausieren und Rueckgabe denselben Service', function () {
$s = susScenario('2027-01-01 10:00:00');
$service = app(ConsultantSuspensionService::class);
$service->suspend($s['consultant'], 'test');
expect($s['customer']->fresh()->member_id)->toBe($s['sponsor']->id)
->and(UserCleanUpLog::where('inactive_sponsor_id', $s['consultant']->id)->count())->toBe(1);
$service->reinstate($s['consultant'], 'test');
expect($s['customer']->fresh()->member_id)->toBe($s['consultant']->id)
->and($s['child']->fresh()->m_sponsor)->toBe($s['consultant']->id);
});
});
describe('User-Bereinigung: Wiederherstellen im Admin', function () {
it('ruft user:restore genau einmal auf', function () {
Artisan::shouldReceive('call')
->once()
->with('user:restore', ['user_id' => 42])
->andReturn(0);
Artisan::shouldReceive('output')->once()->andReturn('ok');
$response = app(AdminUserCleanupController::class)
->restore(new Request(['user_id' => 42]));
expect($response->getData(true))->toMatchArray(['success' => true, 'output' => 'ok']);
});
});
describe('Fristen in Kalendertagen (Beispiel aus der Kundenuebersicht)', function () {
it('pausiert bei Ablauf am 1. in der Nacht zum 3. und entfernt in der Nacht zum 29.', function () {
$s = susScenario('2026-10-01 23:00:00');
$service = app(ConsultantSuspensionService::class);
expect($service->suspensionDateFor($s['consultant'])->format('d.m.Y'))->toBe('03.10.2026')
->and($service->deadlineFor($s['consultant'])->format('d.m.Y'))->toBe('28.10.2026');
$run = function (string $at) {
Carbon::setTestNow(Carbon::parse($at));
$this->artisan('user:cleanup')->assertSuccessful();
};
$run('2026-10-02 03:30:00');
expect($s['consultant']->fresh()->active)->toBe(1);
$run('2026-10-03 03:30:00');
expect($s['consultant']->fresh()->active)->toBe(0);
$run('2026-10-28 03:30:00');
expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->toBeNull();
$run('2026-10-29 03:30:00');
expect(User::withTrashed()->find($s['consultant']->id)->deleted_at)->not->toBeNull();
});
});