diff --git a/app/Console/Commands/CheckPaymentsAccount.php b/app/Console/Commands/CheckPaymentsAccount.php index be3f8889..80740eba 100644 --- a/app/Console/Commands/CheckPaymentsAccount.php +++ b/app/Console/Commands/CheckPaymentsAccount.php @@ -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); diff --git a/app/Console/Commands/UserCleanUp.php b/app/Console/Commands/UserCleanUp.php index 4ad50a5b..2d7290fd 100644 --- a/app/Console/Commands/UserCleanUp.php +++ b/app/Console/Commands/UserCleanUp.php @@ -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 $candidates + * @param callable(User): (array|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 ($candidates as $user) { + $counts = $dryRun ? $suspension->preview($user) : null; - foreach ($delete_users as $delete_user) { - \DB::beginTransaction(); - - 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; + if (! $dryRun) { + try { + $counts = $action($user); + } catch (\Throwable $e) { + \Log::channel('cleanup')->error('user:cleanup '.$label.' fehlgeschlagen fuer user_id '.$user->id.': '.$e->getMessage()); + $counts = null; } - - // 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, - ]; - - // 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; + $counts === null ? $failed++ : $done++; } + + $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'] ?? '-', + ]; } - $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'); - } - - /** - * 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; - } + if ($rows !== []) { + $this->table(['ID', 'Name', 'E-Mail', 'Ablauf', 'Team', 'Kunden', 'Kundenabos', 'Portal-Logins', 'beim Sponsor geparkt'], $rows); } - - $diff = microtime(true) - $methodStartTime; - $sec = intval($diff); - $micro = $diff - $sec; - - $this->info('END Command cleanUpInActiveUser: '.$count.' | Time: '.$sec.'sec :'.round($micro * 1000, 4).' ms'); } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index cbcb78f2..9100f989 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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, ]; diff --git a/app/Http/Controllers/AdminUserCleanupController.php b/app/Http/Controllers/AdminUserCleanupController.php index 4c26796c..ad97fbf4 100644 --- a/app/Http/Controllers/AdminUserCleanupController.php +++ b/app/Http/Controllers/AdminUserCleanupController.php @@ -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); diff --git a/app/Http/Controllers/AdminUserController.php b/app/Http/Controllers/AdminUserController.php index adcd903c..26dc9cd6 100644 --- a/app/Http/Controllers/AdminUserController.php +++ b/app/Http/Controllers/AdminUserController.php @@ -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'); } } } diff --git a/app/Mail/SponsorSuspensionNoticeMail.php b/app/Mail/SponsorSuspensionNoticeMail.php new file mode 100644 index 00000000..bf3a01de --- /dev/null +++ b/app/Mail/SponsorSuspensionNoticeMail.php @@ -0,0 +1,46 @@ +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'), + ]); + } +} diff --git a/app/Mail/SponsorTransferMail.php b/app/Mail/SponsorTransferMail.php new file mode 100644 index 00000000..526aef44 --- /dev/null +++ b/app/Mail/SponsorTransferMail.php @@ -0,0 +1,63 @@ + $customerRows + * @param list $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'), + ]); + } +} diff --git a/app/Models/ConsultantSuspensionTransfer.php b/app/Models/ConsultantSuspensionTransfer.php new file mode 100644 index 00000000..722c0ced --- /dev/null +++ b/app/Models/ConsultantSuspensionTransfer.php @@ -0,0 +1,69 @@ + '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 $query + * @return Builder + */ + public function scopeOpenFor(Builder $query, int $consultantId): Builder + { + return $query->where('consultant_id', $consultantId)->whereNull('returned_at'); + } +} diff --git a/app/Services/ConsultantSuspensionService.php b/app/Services/ConsultantSuspensionService.php new file mode 100644 index 00000000..df9909fb --- /dev/null +++ b/app/Services/ConsultantSuspensionService.php @@ -0,0 +1,416 @@ +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 + */ + 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 + */ + 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, team: list}> + */ + 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()); + } + } +} diff --git a/app/Services/Payment.php b/app/Services/Payment.php index 26b7c930..7ec73628 100644 --- a/app/Services/Payment.php +++ b/app/Services/Payment.php @@ -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'); } } diff --git a/config/mivita.php b/config/mivita.php index dc33a8f2..2cea184f 100755 --- a/config/mivita.php +++ b/config/mivita.php @@ -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'), diff --git a/database/migrations/2026_09_23_140613_create_consultant_suspension_transfers_table.php b/database/migrations/2026_09_23_140613_create_consultant_suspension_transfers_table.php new file mode 100644 index 00000000..f4dfe250 --- /dev/null +++ b/database/migrations/2026_09_23_140613_create_consultant_suspension_transfers_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_23_141152_make_auth_user_id_nullable_on_shopping_users.php b/database/migrations/2026_09_23_141152_make_auth_user_id_nullable_on_shopping_users.php new file mode 100644 index 00000000..8834f7c2 --- /dev/null +++ b/database/migrations/2026_09_23_141152_make_auth_user_id_nullable_on_shopping_users.php @@ -0,0 +1,38 @@ +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. + } +}; diff --git a/dev/_tickets/2026-09-23-mitgliedschaft-ablauf-uebersicht.md b/dev/_tickets/2026-09-23-mitgliedschaft-ablauf-uebersicht.md new file mode 100644 index 00000000..33beb23b --- /dev/null +++ b/dev/_tickets/2026-09-23-mitgliedschaft-ablauf-uebersicht.md @@ -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. diff --git a/resources/lang/de/membership.php b/resources/lang/de/membership.php index 212fabb8..2fad398c 100644 --- a/resources/lang/de/membership.php +++ b/resources/lang/de/membership.php @@ -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', diff --git a/resources/lang/de/reminder.php b/resources/lang/de/reminder.php index cfdb46c8..fad517a2 100644 --- a/resources/lang/de/reminder.php +++ b/resources/lang/de/reminder.php @@ -29,12 +29,13 @@ return [
  • und von Vertriebspartnerumsätzen partizipieren möchtest
  • solltest Du jetzt schnell handeln und Deine Mitgliedschaft verlängern:

    ', - 'copy_first_35' => '

    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.

    -

    Klick auf den Button, falls Du Änderungen an Deiner Mitgliedschaft vornehmen möchtest:

    ', + 'copy_first_35' => '

    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.

    +

    Verlängerst Du Deine Mitgliedschaft bis zum :deadline, 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:

    ', 'copy_last_31' => '

    Wichtig:
    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.

    ', 'copy_last_33' => '

    Wichtig:
    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.

    ', 'copy_last_34' => '

    Wichtig:
    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.

    ', - 'copy_last_35' => '

    Hinweis:
    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.

    + 'copy_last_35' => '

    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.

    ', 'subject' => 'Deine Mitgliedschaft bei mivita.care', ]; diff --git a/resources/lang/de/suspension.php b/resources/lang/de/suspension.php new file mode 100644 index 00000000..b17ff1ce --- /dev/null +++ b/resources/lang/de/suspension.php @@ -0,0 +1,15 @@ + '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', +]; diff --git a/resources/lang/en/membership.php b/resources/lang/en/membership.php index 0b3b4da9..77cf7983 100644 --- a/resources/lang/en/membership.php +++ b/resources/lang/en/membership.php @@ -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', diff --git a/resources/lang/en/reminder.php b/resources/lang/en/reminder.php index 71337c04..afea2952 100644 --- a/resources/lang/en/reminder.php +++ b/resources/lang/en/reminder.php @@ -8,8 +8,8 @@ return [ 'copy_first_31' => '

    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

    • purchase products from MIVITA
    • Customers can purchase via your online shop (optional)
    • You can participate in sales partner sales

    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.

    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 :) ...

    ', 'copy_first_33' => '

    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

    • purchase products from MIVITA
    • Customers can purchase via your online shop (optional)
    • You can participate in sales partner sales

    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.

    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 :) ...

    ', 'copy_first_34' => '

    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.

    If you continue to

    • purchase products from MIVITA
    • an online shop (optional)
    • and want to participate in sales partner sales

    you should act quickly now and extend your membership:

    ', - 'copy_first_35' => '

    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.

    Click on the button if you would like to make changes to your membership:

    ', - 'copy_last_35' => '

    Note:
    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.

    We hope to welcome you again very soon as an active member of the MIVITA Family.

    ', + 'copy_first_35' => '

    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.

    If you renew your membership by :deadline, 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.

    Click on the button to renew your membership now:

    ', + 'copy_last_35' => '

    Note:
    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.

    We hope to welcome you again very soon as an active member of the MIVITA Family.

    ', 'subject' => 'Your membership at mivita.care', 'copy_last_31' => '

    Important:
    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.

    ', 'copy_last_33' => '

    Important:
    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.

    ', diff --git a/resources/lang/en/suspension.php b/resources/lang/en/suspension.php new file mode 100644 index 00000000..832291c0 --- /dev/null +++ b/resources/lang/en/suspension.php @@ -0,0 +1,15 @@ + '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', +]; diff --git a/resources/lang/es/membership.php b/resources/lang/es/membership.php index 16d88e8c..26ff6d30 100644 --- a/resources/lang/es/membership.php +++ b/resources/lang/es/membership.php @@ -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', diff --git a/resources/lang/es/reminder.php b/resources/lang/es/reminder.php index b9191a32..f299a077 100644 --- a/resources/lang/es/reminder.php +++ b/resources/lang/es/reminder.php @@ -8,8 +8,8 @@ return [ 'copy_first_31' => '

    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

    • comprando productos de MIVITA
    • productos de MIVITA
    • Los clientes pueden comprar a través de tu tienda online (opcional)
    • Puedes participar en ventas de socios de ventas

    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.

    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 :) ...

    ', 'copy_first_33' => '

    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

    • comprando productos de MIVITA
    • Los clientes pueden comprar a través de tu tienda online (opcional)
    • Puedes participar en ventas de socios de ventas

    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.

    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 :) ...

    ', 'copy_first_34' => '

    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á.

    Si continúa

    • comprando productos de MIVITA
    • una tienda en línea (opcional)
    • y desea participar en las ventas de socios de ventas

    debe actuar rápidamente ahora y ampliar su membresía:

    ', - 'copy_first_35' => '

    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.

    Haz clic en el botón si deseas realizar cambios en tu membresía:

    ', - 'copy_last_35' => '

    Nota:
    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.

    Esperamos darle la bienvenida nuevamente muy pronto como miembro activo de la Familia MIVITA.

    ', + 'copy_first_35' => '

    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.

    Si renuevas tu membresía hasta el :deadline, 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.

    Haz clic en el botón para renovar tu membresía ahora:

    ', + 'copy_last_35' => '

    Nota:
    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.

    Esperamos darte la bienvenida nuevamente muy pronto como miembro activo de la Familia MIVITA.

    ', 'subject' => 'Tu membresía en mivita.care', 'copy_last_31' => '

    Importante:
    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.

    ', 'copy_last_33' => '

    Importante:
    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.

    ', diff --git a/resources/lang/es/suspension.php b/resources/lang/es/suspension.php new file mode 100644 index 00000000..c112f056 --- /dev/null +++ b/resources/lang/es/suspension.php @@ -0,0 +1,15 @@ + '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', +]; diff --git a/resources/lang/fr/membership.php b/resources/lang/fr/membership.php index 93c0706c..485ec5be 100644 --- a/resources/lang/fr/membership.php +++ b/resources/lang/fr/membership.php @@ -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', diff --git a/resources/lang/fr/reminder.php b/resources/lang/fr/reminder.php index d082aa04..17050f6c 100644 --- a/resources/lang/fr/reminder.php +++ b/resources/lang/fr/reminder.php @@ -29,12 +29,13 @@ return [
  • et participer aux chiffres d’affaires des partenaires commerciaux
  • vous devriez agir rapidement et renouveler votre adhésion :

    ', - 'copy_first_35' => '

    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.

    -

    Cliquez sur le bouton si vous souhaitez modifier votre adhésion :

    ', + 'copy_first_35' => '

    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.

    +

    Si vous renouvelez votre adhésion d’ici le :deadline, 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.

    +

    Cliquez sur le bouton pour renouveler votre adhésion maintenant :

    ', 'copy_last_31' => '

    Important :
    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.

    ', 'copy_last_33' => '

    Important :
    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.

    ', 'copy_last_34' => '

    Important :
    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.

    ', - 'copy_last_35' => '

    Remarque :
    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.

    + 'copy_last_35' => '

    Remarque :
    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.

    Nous espérons vous revoir très bientôt comme membre actif de la famille MIVITA.

    ', 'subject' => 'Votre adhésion chez mivita.care', ]; diff --git a/resources/lang/fr/suspension.php b/resources/lang/fr/suspension.php new file mode 100644 index 00000000..576cffef --- /dev/null +++ b/resources/lang/fr/suspension.php @@ -0,0 +1,15 @@ + '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', +]; diff --git a/resources/views/dashboard/_reminder.blade.php b/resources/views/dashboard/_reminder.blade.php index 0ba65357..f9687727 100755 --- a/resources/views/dashboard/_reminder.blade.php +++ b/resources/views/dashboard/_reminder.blade.php @@ -17,7 +17,8 @@ @else
    {{__('membership.home_copy_alert_36', ['days'=>$user->daysActiveAccount()*-1, 'datetime'=>$user->getPaymentAccountDateFormat()])}}
    @endif -

    {{__('membership.home_copy_last_36', ['days'=>$user->daysActiveAccount(), 'datetime'=>$user->getPaymentAccountDateFormat()])}}

    + @php($suspension = app(\App\Services\ConsultantSuspensionService::class)) +

    {{__('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')])}}

    @endif @elseif($user->daysActiveAccount() < config('mivita.remind_last_days') ) {{-- 34 --}} diff --git a/resources/views/emails/consultant_suspension.blade.php b/resources/views/emails/consultant_suspension.blade.php new file mode 100644 index 00000000..6009c527 --- /dev/null +++ b/resources/views/emails/consultant_suspension.blade.php @@ -0,0 +1,115 @@ + + + + + + {{ $headline }} + + + + + + + +
    +
    + + + + +
    + + + + +
    +
    + + mivita.care + +
    +
    +
    + + + + + + + + + @foreach (['customers' => $customers, 'team' => $team] as $listKey => $rows) + @if (count($rows) > 0) + + + + @endif + @endforeach + + + +
    + {{ $headline }} +
    + {{ trim($salutation) }}, + @foreach ($paragraphs as $paragraph) +

    + {{ $paragraph }} + @endforeach +
    + {{ __('suspension.list_'.$listKey, ['count' => count($rows)]) }} + + + + + + + + @foreach ($rows as $row) + + + + + + + @endforeach +
    {{ __('suspension.col_first_name') }}{{ __('suspension.col_last_name') }}{{ __('suspension.col_phone') }}{{ __('suspension.col_email') }}
    {{ $row['first_name'] }}{{ $row['last_name'] }}{{ $row['phone'] !== '' ? $row['phone'] : '–' }}{{ $row['email'] }}
    +
    +
    + {{ $greetings }}
    + {{ $sender }} +
    +
    + + + + + + + +
    +

    {{__('email.footer_copy1')}}

    +
    + www.mivita.care +
    +
    +

    {{__('email.footer_copy2')}}

    + {{ __('dataprotect.data_protect') }}
    +

    {{__('email.footer_copy3')}}

    +

    +
    +
    +
    +
    + + diff --git a/tests/Feature/ConsultantSuspensionTest.php b/tests/Feature/ConsultantSuspensionTest.php new file mode 100644 index 00000000..18503deb --- /dev/null +++ b/tests/Feature/ConsultantSuspensionTest.php @@ -0,0 +1,342 @@ + 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(); + }); +});