accounts()->active()->notRemoved()->get(); foreach ($userAccounts as $account) { \Cache::forget("account_balance_{$account->id}"); if ($account->balance < 0) { \Log::error('Profile deletion blocked: negative balance detected', [ 'user_id' => $user->id, 'account_id' => $account->id, 'account_name' => $account->name, 'balance' => $account->balance ]); throw new \Exception('Cannot delete profile with negative balance. Please settle all debts before deleting your profile.'); } } // Store balance handling preferences in cache for later use // This will be used by permanentlyDelete() after grace period // Fallback: if cache is lost, currency will be destroyed (transferred to debit account) $balanceHandlingData = [ 'option' => $balanceHandlingOption, 'donation_account_id' => $donationAccountId, 'stored_at' => now()->toDateTimeString(), ]; // Store in cache with TTL = grace period + 7 days buffer $gracePeriodDays = timebank_config('delete_profile.grace_period_days', 30); $cacheKey = 'balance_handling_' . get_class($user) . '_' . $user->id; \Cache::put($cacheKey, $balanceHandlingData, now()->addDays($gracePeriodDays + 7)); // Set human-readable comment (always in English for database storage) if ($isAutoDeleted) { // Auto-deletion due to inactivity $daysInactive = timebank_config('profile_inactive.days_not_logged_in') + timebank_config('delete_profile.days_after_inactive.run_delete'); $user->comment = 'Profile automatically deleted after ' . $daysInactive . ' days of inactivity.'; } elseif ($deletedByUsername) { // Admin/manager deletion $user->comment = 'Profile deleted by ' . $deletedByUsername; } else { // Self-deletion by profile owner $user->comment = 'Profile deleted by self-deletion'; } // Mark profile as deleted (soft delete with grace period) // Balances will be handled after grace period by scheduled command // Accounts remain active during grace period to allow restoration $user->deleted_at = now(); $user->save(); // Delete tokens to force logout if ($user instanceof \App\Models\User) { $user->tokens->each->delete(); } }); // STOP // End of transaction return ['status' => 'success']; } catch (Throwable $e) { return ['status' => 'error', 'message' => $e->getMessage()]; } } /** * Donate user's account balances to an organization. * * @param mixed $user * @param int $donationAccountId * @return void */ protected function donateBalancesToOrganization($user, $donationAccountId) { \Log::info('Starting balance donation', [ 'user_id' => $user->id, 'donation_account_id' => $donationAccountId ]); // Get the donation target account $toAccount = \App\Models\Account::find($donationAccountId); if (!$toAccount) { \Log::error('Donation account not found', ['donation_account_id' => $donationAccountId]); throw new \Exception('Donation account not found.'); } \Log::info('Donation target account found', [ 'account_id' => $toAccount->id, 'account_name' => $toAccount->name, 'accountable_type' => $toAccount->accountable_type ]); // Verify the target account is an organization if ($toAccount->accountable_type !== 'App\\Models\\Organization') { \Log::error('Target account is not an organization', [ 'accountable_type' => $toAccount->accountable_type ]); throw new \Exception('The selected account is not an organization account.'); } // Get all active accounts belonging to the user with positive balances $userAccounts = $user->accounts() ->active() ->notRemoved() ->get(); // Clear balance cache for all accounts to ensure we get current values foreach ($userAccounts as $account) { \Cache::forget("account_balance_{$account->id}"); } \Log::info('User accounts found', [ 'count' => $userAccounts->count(), 'accounts' => $userAccounts->map(function($acc) { return [ 'id' => $acc->id, 'name' => $acc->name, 'balance' => $acc->balance ]; }) ]); $totalTransferred = 0; $transactionsCreated = 0; foreach ($userAccounts as $fromAccount) { // Calculate the current balance $balance = $fromAccount->balance; \Log::info('Processing account', [ 'account_id' => $fromAccount->id, 'balance' => $balance ]); // Only create transaction if there's a positive balance if ($balance > 0) { try { // Create a donation transaction $transaction = \App\Models\Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'transaction_type_id' => 3, // Donation type 'amount' => $balance, 'description' => 'Balance donation from deleted profile ' . $user->name, 'created_at' => now(), 'updated_at' => now(), ]); $totalTransferred += $balance; $transactionsCreated++; \Log::info('Transaction created successfully', [ 'transaction_id' => $transaction->id, 'amount' => $balance ]); } catch (\Exception $e) { \Log::error('Failed to create transaction', [ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'amount' => $balance, 'error' => $e->getMessage() ]); throw new \Exception('Failed to create donation transaction: ' . $e->getMessage()); } } } \Log::info('Balance donation completed', [ 'transactions_created' => $transactionsCreated, 'total_transferred' => $totalTransferred ]); // Mark associated accounts inactive $user->accounts()->update(['inactive_at' => now(), 'deleted_at' => now()]); } /** * Transfer user's account balances to a bank the profile was a client of. * * @param mixed $user * @return void */ protected function transferBalancesToBankClient($user) { // Get all active accounts belonging to the user with positive balances $userAccounts = $user->accounts() ->active() ->notRemoved() ->get(); // Find banks the user was a client of // This would need to be implemented based on your bank-client relationship structure // For now, this is a placeholder for the implementation foreach ($userAccounts as $fromAccount) { $balance = $fromAccount->balance; if ($balance > 0) { // Transfer to bank account logic would go here // You would need to determine which bank account to use } } } /** * Transfer user's account balances to a specific account ID. * * @param mixed $user * @param int $accountId * @return void */ protected function transferBalancesToSpecificAccount($user, $accountId) { // Get the target account $toAccount = \App\Models\Account::find($accountId); if (!$toAccount) { throw new \Exception('Target account not found.'); } // Get all active accounts belonging to the user with positive balances $userAccounts = $user->accounts() ->active() ->notRemoved() ->get(); foreach ($userAccounts as $fromAccount) { $balance = $fromAccount->balance; if ($balance > 0) { // Create a donation transaction to the specified account \App\Models\Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $toAccount->id, 'transaction_type_id' => 3, // Donation type 'amount' => $balance, 'description' => 'Balance transfer from deleted profile ' . $user->name, 'created_at' => now(), 'updated_at' => now(), ]); } } // Mark associated accounts inactive $user->accounts()->update(['inactive_at' => now(), 'deleted_at' => now()]); } /** * Transfer user's account balances to debit account (remove currency from circulation). * * @param mixed $user * @return void */ protected function transferBalancesToDebitAccount($user) { // Get all active accounts belonging to the user with positive balances $userAccounts = $user->accounts() ->active() ->notRemoved() ->get(); // Find the debit account (typically a system account for currency removal) $debitAccount = \App\Models\Account::where('name', 'debit') ->whereHasMorph('accountable', [\App\Models\Bank::class]) ->first(); if (!$debitAccount) { throw new \Exception('Debit account not found for currency removal.'); } foreach ($userAccounts as $fromAccount) { $balance = $fromAccount->balance; if ($balance > 0) { // Create a currency removal transaction \App\Models\Transaction::create([ 'from_account_id' => $fromAccount->id, 'to_account_id' => $debitAccount->id, 'transaction_type_id' => 5, // Currency removal type 'amount' => $balance, 'description' => 'Currency removal from deleted profile ' . $user->name, 'created_at' => now(), 'updated_at' => now(), ]); } } // Mark associated accounts inactive $user->accounts()->update(['inactive_at' => now(), 'deleted_at' => now()]); } /** * Permanently delete a profile by handling balances and anonymizing all data. * Called by scheduled command after grace period expires. * * @param mixed $user * @return array */ public function permanentlyDelete($user) { try { DB::transaction(function () use ($user): void { $profileType = get_class($user); $profileTypeName = class_basename($profileType); // Retrieve balance handling preferences from cache $cacheKey = 'balance_handling_' . get_class($user) . '_' . $user->id; $balanceHandlingData = \Cache::get($cacheKey); // Fallback: try to parse from comment field if it's JSON (old format compatibility) if (!$balanceHandlingData && $user->comment && str_starts_with($user->comment, '{')) { $balanceHandlingData = json_decode($user->comment, true); } // Handle balances before anonymization if ($balanceHandlingData && isset($balanceHandlingData['option'])) { $option = $balanceHandlingData['option']; $donationAccountId = $balanceHandlingData['donation_account_id'] ?? null; // Execute balance handling based on stored option if ($option === 'donate' && $donationAccountId) { $this->donateBalancesToOrganization($user, $donationAccountId); } elseif ($option === 'delete') { // User chose to delete balance - destroy currency $this->transferBalancesToDebitAccount($user); } } else { // FALLBACK: Cache lost or no data stored // Destroy currency (transfer to debit account) as safe default \Log::warning('Balance handling cache lost for profile deletion', [ 'user_id' => $user->id, 'user_name' => $user->name, 'fallback' => 'destroying_currency' ]); $this->transferBalancesToDebitAccount($user); } // Handle WireChat kept messages to prevent orphaned data // This is ALWAYS done when profile is permanently deleted (not optional) if (timebank_config('wirechat.profile_deletion.release_kept_messages', true)) { $releasedCount = \DB::table('wirechat_messages') ->where('sendable_id', $user->id) ->where('sendable_type', get_class($user)) ->whereNotNull('kept_at') ->update([ 'kept_at' => null, 'updated_at' => now() ]); if ($releasedCount > 0) { \Log::info('WireChat kept messages released for deleted profile', [ 'profile_id' => $user->id, 'profile_type' => get_class($user), 'profile_name' => $user->name, 'messages_released' => $releasedCount ]); } } // Detach all relationships that do not need any historic record // User-specific relationships if ($user instanceof \App\Models\User) { if (method_exists($user, 'locations')) { $user->locations()->delete(); } if (method_exists($user, 'languages')) { $user->languages()->detach(); } if (method_exists($user, 'socials')) { $user->socials()->detach(); } if (method_exists($user, 'organizations')) { $user->organizations()->detach(); } if (method_exists($user, 'bankClients')) { $user->bankClients()->detach(); } if (method_exists($user, 'banksManaged')) { $user->banksManaged()->detach(); } if (method_exists($user, 'admins')) { $user->admins()->detach(); } } // Organization/Bank/Admin specific relationships if ($user instanceof \App\Models\Organization) { if (method_exists($user, 'users')) { $user->users()->detach(); } } if ($user instanceof \App\Models\Bank) { if (method_exists($user, 'managers')) { $user->managers()->detach(); } } if ($user instanceof \App\Models\Admin) { if (method_exists($user, 'users')) { $user->users()->detach(); } } // Common relationships for all profile types if (method_exists($user, 'locations')) { $user->locations()->delete(); } // Anonymize profile $anonymousId = $this->generateAnonymousId($profileType); $user->name = 'Removed ' . strtolower($profileTypeName) . ' ' . $anonymousId; $user->full_name = 'Removed ' . strtolower($profileTypeName) . ' ' . $anonymousId; $user->email = 'removed-' . $anonymousId . '@remove.ed'; $user->email_verified_at = null; $user->password = ""; if (property_exists($user, 'two_factor_secret')) { $user->two_factor_secret = null; } if (property_exists($user, 'two_factor_recovery_codes')) { $user->two_factor_recovery_codes = null; } if (property_exists($user, 'two_factor_confirmed_at')) { $user->two_factor_confirmed_at = null; } $user->deleteProfilePhoto(); $user->profile_photo_path = 'app-images/profile-user-removed.svg'; $user->about = null; $user->about_short = null; $user->motivation = null; if (property_exists($user, 'date_of_birth')) { $user->date_of_birth = null; } $user->website = null; $user->phone = null; $user->phone_public = 0; if (property_exists($user, 'remember_token')) { $user->remember_token = null; } if (property_exists($user, 'current_team_id')) { $user->current_team_id = null; } if (property_exists($user, 'cyclos_id')) { $user->cyclos_id = null; } if (property_exists($user, 'cyclos_salt')) { $user->cyclos_salt = null; } if (property_exists($user, 'cyclos_skills')) { $user->cyclos_skills = null; } $user->limit_min = 0; $user->limit_max = 0; $user->comment = null; $user->lang_preference = null; if (property_exists($user, 'principles_terms_accepted')) { $user->principles_terms_accepted = null; } $user->last_login_ip = null; $user->save(); // Unreact all Laravel-love reactions if (!($user instanceof \App\Models\Admin)) { $reacterFacade = $user->getloveReacter(); $reactions = $reacterFacade->getReactions()->load(['reactant', 'type']); foreach ($reactions as $reaction) { if ($reaction->reactant && $reaction->type) { $reacterFacade->unReactTo($reaction->reactant, $reaction->type); } } $reactantFacade = $user->getloveReactant(); $receivedReactions = $reactantFacade->getReactions()->load(['reacter', 'type']); foreach ($receivedReactions as $reaction) { if ($reaction->reacter && $reaction->type) { $reaction->reacter->unReactTo($reaction->reactant, $reaction->type); } } } // Remove all taggable skills if (!($user instanceof \App\Models\Admin)) { $user->detag(); } // Clear the balance handling cache $cacheKey = 'balance_handling_' . get_class($user) . '_' . $user->id; \Cache::forget($cacheKey); }); return ['status' => 'success']; } catch (Throwable $e) { return ['status' => 'error', 'message' => $e->getMessage()]; } } /** * Generate a short, anonymous, unique identifier for deleted profiles. * * @param string $profileType The profile model class name * @return string 8-character alphanumeric ID */ protected function generateAnonymousId($profileType) { $attempts = 0; $maxAttempts = 100; do { // Generate 8-character random alphanumeric string (lowercase for consistency) $anonymousId = strtolower(substr(str_shuffle('abcdefghijklmnopqrstuvwxyz0123456789'), 0, 8)); // Check if this ID is already used in name or email fields $nameExists = $profileType::where('name', 'like', '%' . $anonymousId . '%')->exists(); $emailExists = $profileType::where('email', 'like', '%' . $anonymousId . '%')->exists(); $attempts++; if ($attempts >= $maxAttempts) { // Fallback to timestamp-based ID if we can't find a unique random one $anonymousId = strtolower(substr(md5(microtime()), 0, 8)); break; } } while ($nameExists || $emailExists); return $anonymousId; } }