argument('source_db'); if (empty($sourceDb)) { $this->info('The source Cyclos database should be imported into MySQL and accessible from this application.'); $this->info('Hint: Use the following commands to create the database and import the dump:'); $this->info(' mysql -h 127.0.0.1 -u root -p -e "CREATE DATABASE IF NOT EXISTS cyclos_db CHARACTER SET utf8mb3;"'); $this->info(' mysql -h 127.0.0.1 -u root -p --force cyclos_db < cyclos_dump.sql'); $this->info('Note: Use --force to skip non-critical errors (e.g. Cyclos statistics views).'); $this->info('Note: Do NOT use GUI tools like Antares or TablePlus to import — they corrupt binary BLOB data (profile images).'); $sourceDb = $this->ask('Enter the name of the source Cyclos database'); } if (empty($sourceDb)) { $this->error('Source database name is required.'); return 1; } // Remove .sql extension if present if (str_ends_with(strtolower($sourceDb), '.sql')) { $sourceDb = substr($sourceDb, 0, -4); $this->info("Using database name: {$sourceDb}"); } // Verify the database exists $databases = DB::select('SHOW DATABASES'); $databaseNames = array_map(fn($db) => $db->Database, $databases); if (!in_array($sourceDb, $databaseNames)) { $this->error("Database '{$sourceDb}' does not exist."); $this->info('Available databases:'); foreach ($databaseNames as $name) { if (!in_array($name, ['information_schema', 'mysql', 'performance_schema', 'sys'])) { $this->line(" - {$name}"); } } return 1; } // Store in cache for use by subsequent commands during db:seed cache()->put('cyclos_migration_source_db', $sourceDb, now()->addHours(1)); $destinationDb = env('DB_DATABASE'); $userAccountName = timebank_config('accounts.user.name'); $userLimitMin = timebank_config('accounts.user.limit_min') === null ? 'NULL' : timebank_config('accounts.user.limit_min'); $userLimitMax = timebank_config('accounts.user.limit_max') === null ? 'NULL' : timebank_config('accounts.user.limit_max'); $userProjectAccountName = timebank_config('accounts.user.name'); $userProjectLimitMin = timebank_config('accounts.user_project.limit_min') === null ? 'NULL' : timebank_config('accounts.user_project.limit_min'); $userProjectLimitMax = timebank_config('accounts.user_project.limit_max') === null ? 'NULL' : timebank_config('accounts.user_project.limit_max'); $giftAccountName = 'gift'; // Temporary name as this account will be removed after migration $giftLimitMin = 0; $giftLimitMax = 5 * 60; $giftAccountName = $giftAccountName === null ? 'NULL' : "'$giftAccountName'"; $giftLimitMin = $giftLimitMin === null ? 'NULL' : $giftLimitMin; $giftLimitMax = $giftLimitMax === null ? 'NULL' : $giftLimitMax; $orgAccountName = timebank_config('accounts.organization.name'); $orgLimitMin = timebank_config('accounts.organization.limit_min') === null ? 'NULL' : timebank_config('accounts.organization.limit_min'); $orgLimitMax = timebank_config('accounts.organization.limit_max') === null ? 'NULL' : timebank_config('accounts.organization.limit_max'); $bankAccountName = timebank_config('accounts.bank.name'); $bankLimitMin = timebank_config('accounts.bank.limit_min') === null ? 'NULL' : timebank_config('accounts.bank.limit_min'); $bankLimitMax = timebank_config('accounts.bank.limit_max') === null ? 'NULL' : timebank_config('accounts.bank.limit_max'); $communityAccountName = timebank_config('accounts.community.name'); $communityLimitMin = timebank_config('accounts.community.limit_min') === null ? 'NULL' : timebank_config('accounts.community.limit_min'); $communityLimitMax = timebank_config('accounts.community.limit_max') === null ? 'NULL' : timebank_config('accounts.community.limit_max'); $debitAccountName = timebank_config('accounts.debit.name'); $debitLimitMin = timebank_config('accounts.debit.limit_min') === null ? 'NULL' : timebank_config('accounts.debit.limit_min'); $debitLimitMax = timebank_config('accounts.debit.limit_max') === null ? 'NULL' : timebank_config('accounts.debit.limit_max'); // MIGRATE MEMBERS // Active Users (group_id 5) DB::beginTransaction(); try { $activeUsers = DB::affectingStatement(" INSERT INTO {$destinationDb}.users (cyclos_id, full_name, email, email_verified_at, created_at, updated_at, name, cyclos_salt, password, limit_min, limit_max, last_login_at) SELECT m.id AS cyclos_id, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, u.username AS name, u.salt AS cyclos_salt, u.password AS password, " . $userLimitMin . " as limit_min, " . $userLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id WHERE m.group_id = 5 ON DUPLICATE KEY UPDATE full_name = VALUES(full_name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(), name = VALUES(name), cyclos_salt = VALUES(cyclos_salt), password = VALUES(password), last_login_at = VALUES(last_login_at); "); DB::commit(); $this->info("Users: $activeUsers"); $this->info("Users migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Users migration failed: ' . $e->getMessage()); } // Reset last_login_at to yesterday for active users whose last login is older than // the inactivity threshold. This prevents the profiles:mark-inactive cron from // immediately marking imported active users as inactive on first run. $inactiveDays = timebank_config('profile_inactive.days_not_logged_in', 350); $threshold = now()->subDays($inactiveDays)->toDateTimeString(); $resetUsers = DB::affectingStatement(" UPDATE {$destinationDb}.users SET last_login_at = DATE_SUB(NOW(), INTERVAL 1 DAY) WHERE inactive_at IS NULL AND deleted_at IS NULL AND (last_login_at IS NULL OR last_login_at < '{$threshold}') "); $this->info("Reset last_login_at to yesterday for {$resetUsers} active users older than {$inactiveDays} days."); // Inactive Users (group_id 6) DB::beginTransaction(); try { $inActiveUsers = DB::affectingStatement(" INSERT INTO {$destinationDb}.users (cyclos_id, full_name, email, email_verified_at, created_at, updated_at, name, cyclos_salt, password, limit_min, limit_max, last_login_at, inactive_at) SELECT m.id AS cyclos_id, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, u.username AS name, u.salt AS cyclos_salt, u.password AS password, " . $userLimitMin . " as limit_min, " . $userLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at, FROM_UNIXTIME(UNIX_TIMESTAMP(ghl.start_date)) AS inactive_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id LEFT JOIN `{$sourceDb}`.`group_history_logs` ghl ON m.id = ghl.element_id WHERE ghl.group_id = 6 AND ghl.end_date IS NULL AND m.group_id = 6 "); DB::commit(); $this->info("Inactive Users: $inActiveUsers"); $this->info("Inactive Users migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Inactive Users migration failed: ' . $e->getMessage()); } //Removed Users (cyclos group_id 8): DB::beginTransaction(); try { $removedUsers = DB::affectingStatement(" INSERT INTO {$destinationDb}.users (cyclos_id, full_name, email, email_verified_at, created_at, updated_at, name, cyclos_salt, password, limit_min, limit_max, last_login_at, deleted_at) SELECT m.id AS cyclos_id, CONCAT('Removed Cyclos user', m.id) AS full_name, CONCAT(m.id, '@removed.mail') AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, CONCAT('Removed user ', m.id) AS name, u.salt AS cyclos_salt, u.password AS password, 0 as limit_min, 0 as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at, FROM_UNIXTIME(UNIX_TIMESTAMP(ghl.start_date)) AS deleted_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id LEFT JOIN `{$sourceDb}`.`group_history_logs` ghl ON m.id = ghl.element_id WHERE ghl.group_id = 8 AND ghl.end_date IS NULL AND m.group_id = 8 ON DUPLICATE KEY UPDATE full_name = VALUES(full_name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(), name = VALUES(name), cyclos_salt = VALUES(cyclos_salt), password = VALUES(password), last_login_at = VALUES(last_login_at), deleted_at = VALUES(deleted_at); "); DB::commit(); $this->info("Removed Users: $removedUsers"); $this->info("Removed Users migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Removed Users migration failed: ' . $e->getMessage()); } //Local Bank (Level I) (Cyclos group_id 13) DB::beginTransaction(); try { $localBanks = DB::affectingStatement(" INSERT INTO {$destinationDb}.banks (cyclos_id, name, full_name, email, email_verified_at, cyclos_salt, password, level, limit_min, limit_max, created_at, updated_at, last_login_at) SELECT m.id AS cyclos_id, u.username AS name, m.name as full_name, m.email as email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, u.salt AS cyclos_salt, u.password AS password, '1' as level, " . $bankLimitMin . " as limit_min, " . $bankLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id WHERE m.group_id = 13 ON DUPLICATE KEY UPDATE name = VALUES(name), name = VALUES(full_name), email = VALUES(email), email_verified_at = VALUES(email_verified_at), created_at = VALUES(created_at), updated_at = NOW(); "); DB::commit(); $this->info("Local Banks (Level I): $localBanks"); $this->info("Local Banks (Level I) migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Local Banks (Level I) migration failed: ' . $e->getMessage()); } //Organizations (cyclos group_id 14) DB::beginTransaction(); try { $organizations = DB::affectingStatement(" INSERT INTO {$destinationDb}.organizations (cyclos_id, name, full_name, email, email_verified_at, cyclos_salt, password, limit_min, limit_max, created_at, updated_at, last_login_at) SELECT m.id AS cyclos_id, u.username AS name, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, u.salt AS cyclos_salt, u.password AS password, " . $orgLimitMin . " as limit_min, " . $orgLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id WHERE m.group_id = 14 ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(); "); DB::commit(); $this->info("Organizations: $organizations"); $this->info("Organizations migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('User migration failed: ' . $e->getMessage()); } // Same last_login_at reset for active organizations. $resetOrgs = DB::affectingStatement(" UPDATE {$destinationDb}.organizations SET last_login_at = DATE_SUB(NOW(), INTERVAL 1 DAY) WHERE inactive_at IS NULL AND deleted_at IS NULL AND (last_login_at IS NULL OR last_login_at < '{$threshold}') "); $this->info("Reset last_login_at to yesterday for {$resetOrgs} active organizations older than {$inactiveDays} days."); //Projects to create Hours (Level II) (Cyclos group_id 15)y DB::beginTransaction(); try { $projectsCreateHour = DB::affectingStatement(" INSERT INTO {$destinationDb}.banks (cyclos_id, name, full_name, email, email_verified_at, cyclos_salt, password, level, limit_min, limit_max, created_at, updated_at, last_login_at) SELECT m.id AS cyclos_id, u.username AS name, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, u.salt AS cyclos_salt, u.password AS password, '2' as level, " . $bankLimitMin . " as limit_min, " . $bankLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id WHERE m.group_id = 15 ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(); "); DB::commit(); $this->info("Projects to create Hours (Level II): $projectsCreateHour"); $this->info("Projects to create Hours (Level II) migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Projects to create Hours (Level II) migration failed: ' . $e->getMessage()); } //TEST: Projects (Cyclos group_id 18) DB::beginTransaction(); try { $projectsCreateHour = DB::affectingStatement(" INSERT INTO {$destinationDb}.organizations (cyclos_id, name, full_name, email, email_verified_at, cyclos_salt, password, limit_min, limit_max, created_at, updated_at, last_login_at) SELECT m.id AS cyclos_id, u.username AS name, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, u.salt AS cyclos_salt, u.password AS password, " . $orgLimitMin . " as limit_min, " . $orgLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id WHERE m.group_id = 18 ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(); "); DB::commit(); $this->info("TEST: Projects: $projectsCreateHour"); $this->info("TEST: Projects migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('TEST: Projects migration failed: ' . $e->getMessage()); } //TEST: Users (Cyclos group_id 18) DB::beginTransaction(); try { $projectsCreateHour = DB::affectingStatement(" INSERT INTO {$destinationDb}.users (cyclos_id, name, full_name, email, email_verified_at, cyclos_salt, password, limit_min, limit_max, created_at, updated_at, last_login_at) SELECT m.id AS cyclos_id, u.username AS name, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, u.salt AS cyclos_salt, u.password AS password, " . $userLimitMin . " as limit_min, " . $userLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id WHERE m.group_id = 22 ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(); "); DB::commit(); $this->info("TEST: Users: $projectsCreateHour"); $this->info("TEST: Users migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('TEST: Users migration failed: ' . $e->getMessage()); } // Inactive Projects (group_id 27) DB::beginTransaction(); try { $inActiveUsers = DB::affectingStatement(" INSERT INTO {$destinationDb}.organizations (cyclos_id, full_name, email, email_verified_at, created_at, updated_at, name, cyclos_salt, password, limit_min, limit_max, last_login_at, inactive_at) SELECT m.id AS cyclos_id, m.name AS full_name, m.email AS email, FROM_UNIXTIME(UNIX_TIMESTAMP(m.member_activation_date)) AS email_verified_at, FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at, NOW() AS updated_at, u.username AS name, u.salt AS cyclos_salt, u.password AS password, " . $orgLimitMin . " as limit_min, " . $orgLimitMax . " as limit_max, FROM_UNIXTIME(UNIX_TIMESTAMP(u.last_login)) AS last_login_at, FROM_UNIXTIME(UNIX_TIMESTAMP(ghl.start_date)) AS inactive_at FROM `{$sourceDb}`.`members` m JOIN `{$sourceDb}`.`users` u ON m.id = u.id LEFT JOIN `{$sourceDb}`.`group_history_logs` ghl ON m.id = ghl.element_id WHERE ghl.group_id = 27 AND ghl.end_date IS NULL AND m.group_id = 27 ON DUPLICATE KEY UPDATE name = VALUES(name), full_name = VALUES(full_name), email = VALUES(email), created_at = VALUES(created_at), updated_at = NOW(), name = VALUES(name), cyclos_salt = VALUES(cyclos_salt), password = VALUES(password), last_login_at = VALUES(last_login_at), inactive_at = VALUES(inactive_at); "); DB::commit(); $this->info("Inactive projects: $inActiveUsers"); $this->info("Inactive projects migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Inactive projects migration failed: ' . $e->getMessage()); } // MIGRATE IMAGES // Set charset to latin1 so binary blob data is not mangled by UTF-8 encoding DB::statement("SET NAMES latin1"); DB::beginTransaction(); try { $total_images = 0; // Select images with the lowest order_number for each member_id. // The image with lowest order_number will become the new Laravel profile image $minOrderImages = DB::table($sourceDb . '.images') ->select('member_id', DB::raw('MIN(order_number) as min_order_number')) ->groupBy('member_id'); // Processing bank images $images = DB::table($sourceDb . '.images as i') ->joinSub(clone $minOrderImages, 'min_order_images', function ($join) { $join->on('i.member_id', '=', 'min_order_images.member_id') ->on('i.order_number', '=', 'min_order_images.min_order_number'); }) ->join($sourceDb . '.members as m', 'i.member_id', '=', 'm.id') ->join($sourceDb . '.users as u', 'm.id', '=', 'u.id') ->select('i.image', 'u.id', 'i.member_id', 'i.order_number') ->whereIn('m.group_id', [13, 15]) // Banks & Projects to create Hours ->get(); foreach ($images as $image) { $ext = $this->detectImageExtension($image->image); $filename = 'profile-photos/bank_' . uniqid() . '.' . $ext; Storage::disk('public')->put($filename, $image->image); DB::table($destinationDb . '.banks') ->where('cyclos_id', $image->id) ->update(['profile_photo_path' => $filename]); } // Records without profile photo DB::table($destinationDb . '.banks') ->whereNull('profile_photo_path') ->update(['profile_photo_path' => 'app-images/profile-user-default.svg']); $total_images += $images->count(); $this->info('Banks: ' . $images->count() . ' images'); // Processing user images $images = DB::table($sourceDb . '.images as i') ->joinSub(clone $minOrderImages, 'min_order_images', function ($join) { $join->on('i.member_id', '=', 'min_order_images.member_id') ->on('i.order_number', '=', 'min_order_images.min_order_number'); }) ->join($sourceDb . '.members as m', 'i.member_id', '=', 'm.id') ->join($sourceDb . '.users as u', 'm.id', '=', 'u.id') ->where('m.group_id', '=', 5) // Users ->select('i.image', 'u.id', 'i.member_id', 'i.order_number') ->get(); foreach ($images as $image) { $ext = $this->detectImageExtension($image->image); $filename = 'profile-photos/user_' . uniqid() . '.' . $ext; Storage::disk('public')->put($filename, $image->image); DB::table($destinationDb . '.users') ->where('cyclos_id', $image->id) ->update(['profile_photo_path' => $filename]); } // Records without profile photo DB::table($destinationDb . '.users') ->whereNull('profile_photo_path') ->update(['profile_photo_path' => 'app-images/profile-user-default.svg']); $total_images += $images->count(); $this->info('Users: ' . $images->count() . ' images'); // Processing organization images $images = DB::table($sourceDb . '.images as i') ->joinSub(clone $minOrderImages, 'min_order_images', function ($join) { $join->on('i.member_id', '=', 'min_order_images.member_id') ->on('i.order_number', '=', 'min_order_images.min_order_number'); }) ->join($sourceDb . '.members as m', 'i.member_id', '=', 'm.id') ->join($sourceDb . '.users as u', 'm.id', '=', 'u.id') ->select('i.image', 'u.id', 'i.member_id', 'i.order_number') ->where('m.group_id', '=', 14) // Orgaizations ->get(); foreach ($images as $image) { $ext = $this->detectImageExtension($image->image); $filename = 'profile-photos/organization_' . uniqid() . '.' . $ext; Storage::disk('public')->put($filename, $image->image); DB::table($destinationDb . '.organizations') ->where('cyclos_id', $image->id) ->update(['profile_photo_path' => $filename]); } // Records without profile photo DB::table($destinationDb . '.organizations') ->whereNull('profile_photo_path') ->update(['profile_photo_path' => 'app-images/profile-user-default.svg']); $total_images += $images->count(); $this->info('Organizations: ' . $images->count() . ' images'); $this->info('Total: ' . $total_images . ' images written to disk'); DB::commit(); $this->info("Images migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Images migration failed: ' . $e->getMessage()); } // Restore charset to utf8 for remaining queries DB::statement("SET NAMES utf8mb4"); // MIGRATE ACCOUNTS // Debit Account (cyclos type_id 1, id 1) DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$debitAccountName}' AS name, 'App\\\\Models\\\\Bank' AS accountable_type, 1 AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $debitLimitMin . " AS limit_min, " . $debitLimitMax . " AS limit_max FROM {$sourceDb}.accounts a WHERE a.type_id = 1; "); DB::commit(); $this->info("Debit account: $accounts"); $this->info("Debit account migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Debit account failed: ' . $e->getMessage()); } // Community Account (cyclos type_id 2) DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$communityAccountName}' AS name, 'App\\\\Models\\\\Bank' AS accountable_type, 1 AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $communityLimitMin . " AS limit_min, " . $communityLimitMax . " AS limit_max FROM {$sourceDb}.accounts a WHERE a.type_id = 2; "); DB::commit(); $this->info("Community account: $accounts"); $this->info("Community account migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Community account failed: ' . $e->getMessage()); } // Work account of users (cyclos type_id 5) // Note that because of cyclos permission group changes, a Work account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$userAccountName}' AS name, 'App\\\\Models\\\\User' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $userLimitMin . " as limit_min, " . $userLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.users u ON a.member_id = u.cyclos_id WHERE a.type_id = 5; "); DB::commit(); $this->info("Work account: $accounts"); $this->info("Work account migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Work account migration failed: ' . $e->getMessage()); } // Work account of organizations (cyclos type_id 5) // Note that because of cyclos permission group changes, a Work account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$orgAccountName}' AS name, 'App\\\\Models\\\\Organization' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $orgLimitMin . " as limit_min, " . $orgLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.organizations u ON a.member_id = u.cyclos_id WHERE a.type_id = 5; "); DB::commit(); $this->info("Work account organizations: $accounts"); $this->info("Work account organizations migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Work account organizations migration failed: ' . $e->getMessage()); } // Work account of banks (cyclos type_id 5) // Note that because of cyclos permission group changes, a Work account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$bankAccountName}' AS name, 'App\\\\Models\\\\Bank' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $bankLimitMin . " as limit_min, " . $bankLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.banks u ON a.member_id = u.cyclos_id WHERE a.type_id = 5; "); DB::commit(); $this->info("Work account banks: $accounts"); $this->info("Work account banks migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Work banks organizations migration failed: ' . $e->getMessage()); } // Gift account users (cyclos type_id 6) // Note that because of cyclos permission group changes, a Gift account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT {$giftAccountName} AS name, 'App\\\\Models\\\\User' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $giftLimitMin . " as limit_min, " . $giftLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.users u ON a.member_id = u.cyclos_id WHERE a.type_id = 6; "); DB::commit(); $this->info("Gift account users: $accounts"); $this->info("Gift account users migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Gift account users migration failed: ' . $e->getMessage()); } //TODO Remove this account on each user if it contains 0 transactions! // Gift account organizations (cyclos type_id 6) // Note that because of cyclos permission group changes, a Gift account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT {$giftAccountName} AS name, 'App\\\\Models\\\\Organization' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $orgLimitMin . " as limit_min, " . $orgLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.organizations u ON a.member_id = u.cyclos_id WHERE a.type_id = 6; "); DB::commit(); $this->info("Gift account organizations: $accounts"); $this->info("Gift account organizations migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Gift account organizations migration failed: ' . $e->getMessage()); } // Gift account banks (cyclos type_id 6) // Note that because of cyclos permission group changes, a Gift account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT {$giftAccountName} AS name, 'App\\\\Models\\\\Bank' AS accountable_type, u.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $bankLimitMin . " as limit_min, " . $bankLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.banks u ON a.member_id = u.cyclos_id WHERE a.type_id = 6; "); DB::commit(); $this->info("Gift account banks: $accounts"); $this->info("Gift account banks migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Gift account banks migration failed: ' . $e->getMessage()); } // Project account users (cyclos type_id 7) // Note that because of cyclos permission group changes, a Project account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$userProjectAccountName}' AS name, 'App\\\\Models\\\\User' AS accountable_type, o.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $userProjectLimitMin . " as limit_min, " . $userProjectLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.users o ON a.member_id = o.cyclos_id WHERE a.type_id = 7; "); DB::commit(); $this->info("Project accounts users: $accounts"); $this->info("Project accounts users completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Project accounts users failed: ' . $e->getMessage()); } // Project account organizations (cyclos type_id 7) // Note that because of cyclos permission group changes, a Project account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$orgAccountName}' AS name, 'App\\\\Models\\\\Organization' AS accountable_type, o.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $orgLimitMin . " as limit_min, " . $orgLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.organizations o ON a.member_id = o.cyclos_id WHERE a.type_id = 7; "); DB::commit(); $this->info("Project accounts organizations: $accounts"); $this->info("Project accounts organizations completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Project accounts organizations failed: ' . $e->getMessage()); } // Project account banks (cyclos type_id 7) // Note that because of cyclos permission group changes, a Project account can also be owned by Projects, Local banks etc. DB::beginTransaction(); try { $accounts = DB::affectingStatement(" INSERT INTO {$destinationDb}.accounts (name, accountable_type, accountable_id, cyclos_id, created_at, updated_at, limit_min, limit_max) SELECT '{$orgAccountName}' AS name, 'App\\\\Models\\\\Bank' AS accountable_type, o.id AS accountable_id, a.id AS cyclos_id, a.creation_date AS created_at, a.last_closing_date AS updated_at, " . $bankLimitMin . " as limit_min, " . $bankLimitMax . " as limit_max FROM {$sourceDb}.accounts a JOIN {$destinationDb}.banks o ON a.member_id = o.cyclos_id WHERE a.type_id = 7; "); DB::commit(); $this->info("Project accounts banks: $accounts"); $this->info("Project accounts banks completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Project accounts banks failed: ' . $e->getMessage()); } // MIGRATE TRANSACTIONS DB::beginTransaction(); try { $transactions = DB::affectingStatement(" INSERT INTO {$destinationDb}.transactions (from_account_id, to_account_id, amount, description, transaction_type_id, transaction_status_id, created_at, updated_at) SELECT fromAcc.id AS from_account_id, toAcc.id AS to_account_id, ROUND(t.amount * 60) AS amount, t.description AS description, t.type_id AS transaction_type_id, 1 AS transaction_status_id, FROM_UNIXTIME(UNIX_TIMESTAMP(t.date)) AS created_at, FROM_UNIXTIME(UNIX_TIMESTAMP(t.date)) AS updated_at FROM `{$sourceDb}`.`transfers` t LEFT JOIN {$destinationDb}.accounts fromAcc ON t.from_account_id = fromAcc.cyclos_id LEFT JOIN {$destinationDb}.accounts toAcc ON t.to_account_id = toAcc.cyclos_id "); DB::commit(); $this->info("Transactions: $transactions"); $this->info("Transaction migration completed successfully"); } catch (\Exception $e) { DB::rollBack(); $this->error('Transaction migration failed: ' . $e->getMessage()); } $this->info(""); // ROUNDING CORRECTIONS (post-migration, per account per year) // Cyclos stores amounts as decimal hours; ROUND(amount * 60) per transaction // accumulates small errors. This inserts one correction per account per calendar // year, dated {year}-12-31 23:59:59, so each correction only covers one year's drift. $this->info("Applying rounding corrections..."); DB::beginTransaction(); try { $debitAccountId = DB::table("{$destinationDb}.accounts") ->where('accountable_type', 'App\\Models\\Bank') ->where('accountable_id', 1) ->where('name', $debitAccountName) ->value('id'); if (!$debitAccountId) { $this->warn("Could not find Central Bank debit account — skipping rounding corrections."); } else { // Fetch per-year drift and tx counts (without cumulative balance — added separately below). $corrections = DB::select(" SELECT la.id AS laravel_account_id, ca.id AS cyclos_account_id, YEAR(t.date) AS tx_year, COUNT(t.id) AS tx_count, ROUND(SUM(IF(t.to_account_id = ca.id, t.amount, -t.amount)) * 60) - SUM(ROUND(IF(t.to_account_id = ca.id, t.amount, -t.amount) * 60)) AS diff_min FROM {$destinationDb}.accounts la INNER JOIN {$sourceDb}.accounts ca ON la.cyclos_id = ca.id INNER JOIN {$sourceDb}.transfers t ON t.from_account_id = ca.id OR t.to_account_id = ca.id WHERE la.cyclos_id IS NOT NULL GROUP BY la.id, ca.id, YEAR(t.date) HAVING ABS(diff_min) > 0 "); // Fetch cumulative balances per (cyclos_account_id, year) using a self-join // to avoid the correlated subquery that fails under strict GROUP BY mode. $cumulativeBalances = DB::select(" SELECT a_outer.id AS cyclos_account_id, YEAR(t_outer.date) AS tx_year, SUM(IF(t_inner.to_account_id = a_outer.id, t_inner.amount, -t_inner.amount)) AS cumulative_end_balance FROM {$sourceDb}.accounts a_outer INNER JOIN {$sourceDb}.transfers t_outer ON t_outer.from_account_id = a_outer.id OR t_outer.to_account_id = a_outer.id INNER JOIN {$sourceDb}.transfers t_inner ON (t_inner.from_account_id = a_outer.id OR t_inner.to_account_id = a_outer.id) AND YEAR(t_inner.date) <= YEAR(t_outer.date) INNER JOIN {$destinationDb}.accounts la ON la.cyclos_id = a_outer.id WHERE la.cyclos_id IS NOT NULL GROUP BY a_outer.id, YEAR(t_outer.date) "); $cumulativeBalanceMap = []; foreach ($cumulativeBalances as $cb) { $cumulativeBalanceMap[(int) $cb->cyclos_account_id][(int) $cb->tx_year] = (float) $cb->cumulative_end_balance; } // Also fetch the total drift per account (ROUND of sum vs sum of ROUNDs across all years) // to catch any residual that doesn't distribute evenly across year boundaries. $totalDrifts = DB::select(" SELECT la.id AS laravel_account_id, ROUND(SUM(IF(t.to_account_id = ca.id, t.amount, -t.amount)) * 60) - SUM(ROUND(IF(t.to_account_id = ca.id, t.amount, -t.amount) * 60)) AS total_drift, COUNT(t.id) AS total_tx_count, SUM(IF(t.to_account_id = ca.id, t.amount, -t.amount)) AS total_cumulative_balance FROM {$destinationDb}.accounts la INNER JOIN {$sourceDb}.accounts ca ON la.cyclos_id = ca.id INNER JOIN {$sourceDb}.transfers t ON t.from_account_id = ca.id OR t.to_account_id = ca.id WHERE la.cyclos_id IS NOT NULL GROUP BY la.id, ca.id "); $totalDriftMap = []; foreach ($totalDrifts as $td) { $totalDriftMap[(int) $td->laravel_account_id] = [ 'drift' => (int) $td->total_drift, 'txCount' => (int) $td->total_tx_count, 'balance' => (float) $td->total_cumulative_balance, ]; } $correctionCount = 0; $now = now()->format('Y-m-d H:i:s'); $currentYear = (int) now()->year; $yearlyDriftSums = []; // track sum of per-year corrections per account $currentYearTxCounts = []; // tx count per account for the current year foreach ($corrections as $row) { $diff = (int) $row->diff_min; $txCount = (int) $row->tx_count; $year = (int) $row->tx_year; $accountId = (int) $row->laravel_account_id; // Skip the current (final) year — its drift will be absorbed into // the final correction below, timestamped at the actual migration datetime. if ($year >= $currentYear) { $currentYearTxCounts[$accountId] = $txCount; continue; } $yearlyDriftSums[$accountId] = ($yearlyDriftSums[$accountId] ?? 0) + $diff; $cyclosAccountId = (int) $row->cyclos_account_id; $avg = $txCount > 0 ? round(abs($diff) / $txCount * 60, 1) : 0; $endBalanceHours = number_format($cumulativeBalanceMap[$cyclosAccountId][$year] ?? 0.0, 2, ',', ''); $description = "Yearly transaction to correct rounding discrepancy between decimal values " . "and time-based values.\n" . "This account had {$txCount} transactions in {$year}, the average correction per " . "transaction was {$avg} seconds. " . "Excluding this correction, this account's balance at the end of {$year} was H {$endBalanceHours}."; DB::table("{$destinationDb}.transactions")->insert([ 'from_account_id' => $diff > 0 ? $debitAccountId : $accountId, 'to_account_id' => $diff > 0 ? $accountId : $debitAccountId, 'amount' => abs($diff), 'description' => $description, 'transaction_type_id' => 7, 'transaction_status_id' => 1, 'created_at' => "{$year}-12-31 23:59:59", 'updated_at' => "{$year}-12-31 23:59:59", ]); $correctionCount++; } // Insert a final correction for any account where the sum of yearly // corrections doesn't fully cover the total drift. This captures both // cross-year rounding artifacts and the current (final) year's drift, // and is always dated at the actual migration datetime. foreach ($totalDriftMap as $accountId => $data) { $yearlySum = $yearlyDriftSums[$accountId] ?? 0; $residual = $data['drift'] - $yearlySum; if ($residual === 0) { continue; } $txCount = $currentYearTxCounts[$accountId] ?? 0; $avg = $txCount > 0 ? round(abs($residual) / $txCount * 60, 1) : 0; $endBalance = number_format($data['balance'], 2, ',', ''); $migrationDate = now()->format('F j, Y'); $finalDescription = "Final transaction to correct rounding discrepancy between decimal values " . "and time-based values.\n" . "This account had {$txCount} transactions in {$currentYear}, the average correction per " . "transaction was {$avg} seconds. " . "Excluding this correction, this account's balance at the migration date ({$migrationDate}) was H {$endBalance}."; DB::table("{$destinationDb}.transactions")->insert([ 'from_account_id' => $residual > 0 ? $debitAccountId : $accountId, 'to_account_id' => $residual > 0 ? $accountId : $debitAccountId, 'amount' => abs($residual), 'description' => $finalDescription, 'transaction_type_id' => 7, 'transaction_status_id' => 1, 'created_at' => $now, 'updated_at' => $now, ]); $correctionCount++; } $this->info("Rounding corrections applied: {$correctionCount} year/account combinations adjusted."); } DB::commit(); } catch (\Exception $e) { DB::rollBack(); $this->error('Rounding corrections failed: ' . $e->getMessage()); } $this->info(""); // REGISTRATION OF PACKAGE MODELS // Register laravel-love models $exitCode = Artisan::call('love:register-reacters', ['--model' => 'App\Models\User'], new \Symfony\Component\Console\Output\ConsoleOutput()); // Optionally, check if the command was successful if ($exitCode === 0) { $this->info('Laravel-love Reacters registered: Users'); } else { $this->error('Laravel-love Reacters registration failed: Users'); } $exitCode = Artisan::call('love:register-reacters', ['--model' => 'App\Models\Organization'], new \Symfony\Component\Console\Output\ConsoleOutput()); // Optionally, check if the command was successful if ($exitCode === 0) { $this->info('Laravel-love Reacters registered: Organizations'); } else { $this->error('Laravel-love Reacters registration failed: Organizations'); } $exitCode = Artisan::call('love:register-reacters', ['--model' => 'App\Models\Bank'], new \Symfony\Component\Console\Output\ConsoleOutput()); // Optionally, check if the command was successful if ($exitCode === 0) { $this->info('Laravel-love Reacters registered: Banks'); } else { $this->error('Laravel-love Reacters registration failed: Banks'); } $exitCode = Artisan::call('love:register-reactants', ['--model' => 'App\Models\User'], new \Symfony\Component\Console\Output\ConsoleOutput()); // Optionally, check if the command was successful if ($exitCode === 0) { $this->info('Laravel-love Reactants registered: Users'); } else { $this->error('Laravel-love Reactants registration failed.'); } $exitCode = Artisan::call('love:register-reactants', ['--model' => 'App\Models\Organization'], new \Symfony\Component\Console\Output\ConsoleOutput()); // Optionally, check if the command was successful if ($exitCode === 0) { $this->info('Laravel-love Reactants registered: Organizations'); } else { $this->error('Laravel-love Reactants registration failed: Organizations'); } $exitCode = Artisan::call('love:register-reactants', ['--model' => 'App\Models\Bank'], new \Symfony\Component\Console\Output\ConsoleOutput()); // Optionally, check if the command was successful if ($exitCode === 0) { $this->info('Laravel-love Reactants registered: Banks'); } else { $this->error('Laravel-love Reactants registration failed.'); } // Add Love Reactions to each transaction. DB::beginTransaction(); try { // Add Love Reactions to each transaction. Artisan::call('love:add-reactions-to-transactions'); $this->info(Artisan::output()); DB::commit(); $this->info('Love Reactions added to transactions successfully.'); } catch (\Exception $e) { DB::rollBack(); $this->error('Failed to add Love Reactions to transactions: ' . $e->getMessage()); } $this->warn('Do not run this migration again without refreshing the database and deleting all files in storage/app/public/profile-photo\'s'); $this->warn('You can do this with the command: bash seed.sh'); $this->info(''); } private function detectImageExtension(string $blob): string { $header = substr($blob, 0, 4); if (str_starts_with($header, "\xFF\xD8\xFF")) return 'jpg'; if (str_starts_with($header, "\x89PNG")) return 'png'; if (str_starts_with($header, 'GIF8')) return 'gif'; if (str_starts_with($header, 'RIFF')) return 'webp'; if (str_starts_with($header, "\x00\x00\x01\x00")) return 'ico'; return 'jpg'; // fallback } }