mivita/app/Services/UserUtil.php
Kevin 03416b0afe Audit 18.09.: Zahlungsdatum nur bei Zahlung, Neupartner-IDs im Snapshot, Pausenende folgt dem Termin
F1: partner_paid_at wird beim Rechnungskauf (paymentStatusPaidAction mit
paid=false) nicht mehr gesetzt, sondern erst bei der Rechnungszahlung; der
Backfill nimmt das Aktivierungsdatum nur noch ohne jede
Registrierungsbestellung (offene Rechnung bleibt leer).
F2: Snapshots speichern je Linie new_partner_ids, die Neupartner-Liste zeigt
genau diese; aeltere Snapshots ohne IDs rechnen mit der damaligen Regel
(Registrierungsmonat, scopeNewPartnerInByActiveDate), damit Kachel und Liste
uebereinstimmen. Keine Payload-Versionsaenderung, kein Neuaufbau noetig.
F3: Liefertag-/Termin-Aenderung waehrend einer Pause zieht paused_until mit;
eine Ausfuehrung loescht auch den paused_by_*-Grund.
F4: expiredCount der Firstlines-Seite (Controller, View, Sprachen) zum bereits
committeten Test nachgezogen.
F5: Backfills schreiben ohne updated_at (toBase()->update, timestamps=false).
Kuendigungsbestaetigung abo_cancel_confirm wird im Selfservice serverseitig
verlangt. AboStatusExplainer laedt die Historie je Abo und Aktion nur einmal.

Stellungnahme und Deploy-Folgen im Audit-Dokument, Regressionstests je Befund.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 08:59:45 +00:00

427 lines
15 KiB
PHP

<?php
namespace App\Services;
use App\Models\ShoppingOrder;
use App\Models\ShoppingUser;
use App\Models\ShoppingUserMemberLog;
use App\Models\UserCleanUpLog;
use App\Models\UserSalesVolume;
use App\Models\UserShop;
use App\User;
use Carbon\Carbon;
class UserUtil
{
public static function setShoppingUserToNewMember($pre_member_id, $new_member_id)
{
$ShoppingUsers = ShoppingUser::where('member_id', $pre_member_id)->get();
foreach ($ShoppingUsers as $shopping_user) {
ShoppingUserMemberLog::create([
'pre_member_id' => $shopping_user->member_id,
'shopping_user_id' => $shopping_user->id,
'new_member_id' => $new_member_id,
]);
$shopping_user->member_id = $new_member_id;
$shopping_user->save();
}
}
public static function setNewSponsorToChilds($inactive_sponsor_id, $new_sponsor_id)
{
// alle User die diesen inaktivien Sponsor haben
$child_users = User::where('m_sponsor', $inactive_sponsor_id)->get(); // auch deaktiverte
foreach ($child_users as $child_user) {
UserCleanUpLog::create([
'inactive_sponsor_id' => $inactive_sponsor_id,
'child_user_id' => $child_user->id,
'new_sponsor_id' => $new_sponsor_id,
]);
$child_user->m_sponsor = $new_sponsor_id;
$child_user->save();
}
}
public static function resetChildsToSponsor($re_sponsor_id)
{
// alle alten Childs vom re_sponsor_id / User wieder herstellen
$UserCleanUpUsers = UserCleanUpLog::where('inactive_sponsor_id', $re_sponsor_id)->get();
foreach ($UserCleanUpUsers as $UserCleanUpUser) {
$child_user = User::find($UserCleanUpUser->child_user_id);
if ($child_user) {
// delete Logs from user child where is newer then this
$deleteUserCleanUpLogs = UserCleanUpLog::where('child_user_id', $UserCleanUpUser->child_user_id)->where('created_at', '>', $UserCleanUpUser->created_at)->get();
foreach ($deleteUserCleanUpLogs as $deleteUserCleanUpLog) {
$deleteUserCleanUpLog->delete();
}
if ($child_user->m_sponsor) { // child is active
$child_user->m_sponsor = $re_sponsor_id;
}
if ($child_user->pre_sponsor) { // child is inactive
$child_user->pre_sponsor = $re_sponsor_id;
}
$child_user->save();
// delete this log
$UserCleanUpUser->delete();
}
}
}
public static function setUserToClient($user_id, $sponsor_id)
{
$user = User::find($user_id);
if (! $user) {
\Log::channel('cleanup')->error('setUserToClient: User not found, user_id: '.$user_id);
return false;
}
if (! $user->account) {
\Log::channel('cleanup')->error('setUserToClient: User has no account data, user_id: '.$user_id);
return false;
}
try {
$data = [
'member_id' => $sponsor_id,
'language' => $user->lang ?? 'de',
'billing_salutation' => $user->account->salutation,
'billing_company' => $user->account->company,
'billing_firstname' => $user->account->first_name,
'billing_lastname' => $user->account->last_name,
'billing_address' => $user->account->address,
'billing_address_2' => $user->account->address_2,
'billing_zipcode' => $user->account->zipcode,
'billing_city' => $user->account->city,
'billing_country_id' => $user->account->country_id,
'billing_phone' => $user->account->getPhoneNumber(),
'billing_email' => $user->email,
'language' => $user->account->getLocale(),
'same_as_billing' => $user->account->same_as_billing,
'shipping_salutation' => $user->account->shipping_salutation,
'shipping_company' => $user->account->shipping_company,
'shipping_firstname' => $user->account->shipping_firstname,
'shipping_lastname' => $user->account->shipping_lastname,
'shipping_address' => $user->account->shipping_address,
'shipping_address_2' => $user->account->shipping_address_2,
'shipping_zipcode' => $user->account->shipping_zipcode,
'shipping_city' => $user->account->shipping_city,
'shipping_country_id' => $user->account->shipping_country_id,
'shipping_phone' => $user->account->getShippingPhoneFull(),
'shipping_postnumber' => $user->account->shipping_postnumber,
];
ShoppingUser::create($data);
return true;
} catch (\Exception $e) {
\Log::channel('cleanup')->error('setUserToClient failed for user_id: '.$user_id.' | Error: '.$e->getMessage());
return false;
}
}
/*
find next activ sponsor on user id
first $sponsor_id can user_id, looks has m_sponsor or pre_sponsor.
*/
public static function findNextActiveSponsor($sponsor_id)
{
$user = User::withTrashed()->find($sponsor_id);
if (! $user) { // kein User unter der ID - to root
return User::find(6);
}
// user ist aktiv
if ($user->isActiveAccount()) {
return $user;
}
if ($user->m_sponsor) { // hat der User einen m_sponsor
return self::findNextActiveSponsor($user->m_sponsor);
}
if ($user->pre_sponsor) { // hat der User einen pre_sponsor - schon inaktiv
return self::findNextActiveSponsor($user->pre_sponsor);
}
// dump('not sponsor');
return $user;
}
public static function deactiveUser($user)
{
$user->pre_sponsor = $user->m_sponsor; // den sponsor speichern für wiederherstellung
$user->m_sponsor = null;
$user->active = false;
$user->save();
}
public static function reactiveUser($user)
{
if ($user->pre_sponsor) {
$pre_sponsor = self::findNextActiveSponsor($user->pre_sponsor);
$user->m_sponsor = $pre_sponsor->id; // den sponsor wiederherstellen
$user->pre_sponsor = null;
}
$user->active = true;
$user->save();
}
/**
* Praefix fuer pseudonymisierte E-Mail-Adressen geloeschter Benutzer.
* Die User-ID gehoert zwingend dazu: ohne sie kollidiert die zweite
* Loeschung derselben Adresse mit dem Unique-Index auf users.email.
*/
public static function buildDeletedEmail(User $user): string
{
$prefix = 'delete-'.$user->id.'-';
return $prefix.mb_substr(self::stripDeletePrefix((string) $user->email), 0, 255 - mb_strlen($prefix));
}
/**
* Entfernt das Loesch-Praefix inklusive der optionalen User-ID.
* Beruecksichtigt bewusst auch das alte Format ohne ID.
*/
public static function stripDeletePrefix(?string $email): string
{
return (string) preg_replace('/^delete-(\d+-)?/', '', (string) $email);
}
public static function deleteUser(User $user, $complete = false)
{
// shop wird gelöscht
if ($user->shop) {
$user->shop->name = 'delete'.$user->shop->id;
$user->shop->slug = 'delete'.$user->shop->id;
$user->shop->save();
$user->shop->delete();
}
// user soll nicht komplett gelöscht werden
$user->email = self::buildDeletedEmail($user);
// password wird gelöscht
$user->password = 'delete'.time();
$user->confirmed = 0;
$user->confirmation_code = 'delete'.time();
$user->confirmation_date = null;
$user->confirmation_code_to = null;
$user->confirmation_code_remider = 2;
// $user->agreement = null;
$user->active = 0;
$user->remember_token = '';
$user->active_date = null;
$user->admin = 0;
$user->deleted_at = now();
$user->pre_deleted_at = now();
// user soll komplett gelöscht werden
if ($complete) {
$user->email = 'delete-'.$user->id.'-'.time().'-'.rand(1000, 9999);
if ($user->account) {
$user->account->delete();
}
$user->pre_deleted_at = null;
}
$user->save();
return true;
}
public static function checkEmailExists($user)
{
$email = self::stripDeletePrefix($user->email);
$user = User::where('email', $email)->first();
if ($user) {
return 'Der Account kann nicht wieder hergestellt werden, da die E-Mail-Adresse <b>'.$email.'</b> bereits in Verwendung ist.';
}
return null;
}
public static function restoreUser($user, $payment_account)
{
if ($user->pre_sponsor) {
$pre_sponsor = self::findNextActiveSponsor($user->pre_sponsor);
$user->m_sponsor = $pre_sponsor->id; // den sponsor wiederherstellen
$user->pre_sponsor = null;
}
$user->email = self::stripDeletePrefix($user->email);
$user->confirmed = 1;
$user->confirmation_date = now();
$user->confirmation_code = null;
$user->confirmation_code_to = null;
$user->confirmation_code_remider = 0;
$user->active = 1;
$user->active_date = now();
$user->deleted_at = null;
$user->pre_deleted_at = null;
$user->payment_account = $payment_account;
$user->payment_shop = $payment_account;
$user->wizard = 100;
$user->save();
$userShop = UserShop::withTrashed()->where('user_id', $user->id)->first();
if ($userShop) {
$userShop->name = null;
$userShop->slug = null;
$userShop->active = 0;
$userShop->deleted_at = null;
$userShop->save();
}
}
/**
* Haelt fest, wann der Partner zum bezahlten Partner wurde — nur beim ersten
* Mal. Verlaengerungen und Wiederherstellungen lassen den Wert stehen, sonst
* wuerde ein alter Partner in der Neupartner-Statistik wieder auftauchen.
*
* @return bool true, wenn der Wert gerade gesetzt wurde
*/
public static function markPartnerPaid(User $user, ?Carbon $paidAt = null): bool
{
if ($user->partner_paid_at !== null) {
return false;
}
$user->partner_paid_at = $paidAt ?? now();
return true;
}
/**
* Rueckfuellung von `users.partner_paid_at` fuer bestehende Partner, nur wo
* die Spalte leer ist — mehrfach ausfuehrbar.
*
* Quelle in dieser Reihenfolge:
* 1. die Punktebuchung der ersten bezahlten Registrierungsbestellung
* (`payment_for = 1`) — sie entsteht im Moment der Zahlung;
* 2. ersatzweise das Aenderungsdatum dieser Bestellung;
* 3. ohne jede Registrierungsbestellung (Konto vom Admin freigeschaltet) das
* Aktivierungsdatum — einen Zahlungszeitpunkt gibt es dort nicht. Gibt es
* eine Registrierungsbestellung, die nur noch nicht bezahlt ist (offene
* Rechnung), bleibt der Wert leer: er wird bei der Zahlung gesetzt
* (Audit 18.09., F1).
*
* Schreibt ohne Events und ohne `updated_at` anzufassen (Audit 18.09., F5).
*
* Absichtlich NICHT `payment_account` minus ein Jahr: bei Verlaengerungen
* wird das Konto vom alten Ablaufdatum aus verlaengert, das Ergebnis waere der
* Jahrestag der Erstzahlung und jeder Verlaengerer ein „Neupartner".
*
* @return int Anzahl gesetzter Zeilen
*/
public static function backfillPartnerPaidAt(): int
{
$set = 0;
User::withTrashed()
->whereNull('partner_paid_at')
->whereNotNull('payment_account')
->orderBy('id')
->chunkById(200, function ($users) use (&$set): void {
foreach ($users as $user) {
$paidAt = self::firstRegistrationPaidAt((int) $user->id);
if ($paidAt === null && ! self::hasRegistrationOrder((int) $user->id)) {
$paidAt = $user->active_date ? Carbon::parse($user->active_date) : null;
}
if ($paidAt === null) {
continue;
}
$user->partner_paid_at = $paidAt;
$user->timestamps = false;
$user->saveQuietly();
$user->timestamps = true;
$set++;
}
});
return $set;
}
private static function hasRegistrationOrder(int $userId): bool
{
return ShoppingOrder::query()
->where('auth_user_id', $userId)
->where('payment_for', 1)
->exists();
}
private static function firstRegistrationPaidAt(int $userId): ?Carbon
{
$order = ShoppingOrder::query()
->where('auth_user_id', $userId)
->where('payment_for', 1)
->where('paid', 1)
->orderBy('id')
->first(['id', 'updated_at']);
if ($order === null) {
return null;
}
$booking = UserSalesVolume::query()
->where('user_id', $userId)
->where('shopping_order_id', $order->id)
->orderBy('id')
->value('created_at');
return Carbon::parse($booking ?? $order->updated_at);
}
public static function reactiveUserResetChilds($user_id, $info = '')
{
$user = User::find($user_id);
if (! $user) {
\Log::channel('cleanup')->error('reactiveUserResetChilds find no user by user_id:'.$user_id);
return 0;
}
$data = [
'user_id' => $user->id,
'email' => $user->email,
'm_account' => $user->account ? $user->account->m_account : '',
'm_first_name' => $user->account ? $user->account->m_first_name : '',
'm_last_name' => $user->account ? $user->account->m_last_name : '',
];
\Log::channel('cleanup')->info('reactiveUserResetChilds '.$info.' : '.json_encode($data));
self::reactiveUser($user);
self::resetChildsToSponsor($user->id);
}
public static function deactiveUserNewSponsorChilds($user_id, $info = '')
{
$user = User::find($user_id);
if (! $user) {
\Log::channel('cleanup')->error('deactiveUserNewSponsorChilds find no user by user_id:'.$user_id);
return 0;
}
$data = [
'user_id' => $user->id,
'email' => $user->email,
'm_account' => $user->account ? $user->account->m_account : '',
'm_first_name' => $user->account ? $user->account->m_first_name : '',
'm_last_name' => $user->account ? $user->account->m_last_name : '',
];
$active_sponsor = self::findNextActiveSponsor($user->m_sponsor);
if ($active_sponsor) {
self::setNewSponsorToChilds($user->id, $active_sponsor->id);
} else {
\Log::channel('cleanup')->error('cleanUpInActiveUser find no active_sponsor by inactive_user:'.$user->id);
}
\Log::channel('cleanup')->info('deactiveUserNewSponsorChilds '.$info.' : '.json_encode($data));
self::deactiveUser($user);
}
}