argument('source_db') ?? cache()->get('cyclos_migration_source_db'); if (empty($sourceDb)) { $sourceDb = $this->ask('Enter the name of the source Cyclos database'); } if (empty($sourceDb)) { $this->error('Source database name is required.'); return 1; } $destDb = env('DB_DATABASE'); $this->info("=== Cyclos Migration Verification ==="); $this->info("Source: {$sourceDb} → Destination: {$destDb}"); $this->newLine(); $this->checkMembers($sourceDb, $destDb); $this->newLine(); $this->checkTransactions($sourceDb, $destDb); $this->newLine(); $this->checkBalances($sourceDb, $destDb); $this->newLine(); $this->checkGiftAccounts($destDb); $this->newLine(); $this->checkDeletedProfiles($sourceDb, $destDb); $this->newLine(); $this->info("=== Summary ==="); $this->info(" PASS: {$this->passed}"); if ($this->warnings > 0) { $this->info(" WARN: {$this->warnings}"); } if ($this->failed > 0) { $this->error(" FAIL: {$this->failed}"); return 1; } $this->info('All checks passed!'); return 0; } // ------------------------------------------------------------------------- // 1. MEMBER COUNTS // ------------------------------------------------------------------------- private function checkMembers(string $sourceDb, string $destDb): void { $this->info('--- 1. Member counts ---'); // Cyclos group_id mapping: // 5 = active users // 6 = inactive users // 8 = removed users // 13 = local banks (level I) // 14 = organizations // 15 = projects to create hours (level II banks) // 18 = TEST projects (organizations) // 22 = TEST users // 27 = inactive projects (organizations) $cyclosActive = DB::table("{$sourceDb}.members")->where('group_id', 5)->count(); $cyclosInactive = DB::table("{$sourceDb}.members")->where('group_id', 6)->count(); $cyclosRemoved = DB::table("{$sourceDb}.members")->where('group_id', 8)->count(); $cyclosBanksL1 = DB::table("{$sourceDb}.members")->where('group_id', 13)->count(); $cyclosOrgs = DB::table("{$sourceDb}.members")->where('group_id', 14)->count(); $cyclosBanksL2 = DB::table("{$sourceDb}.members")->where('group_id', 15)->count(); $cyclosTestOrgs = DB::table("{$sourceDb}.members")->where('group_id', 18)->count(); $cyclosTestUsers = DB::table("{$sourceDb}.members")->where('group_id', 22)->count(); $cyclosInactProj = DB::table("{$sourceDb}.members")->where('group_id', 27)->count(); $laravelUsers = DB::table("{$destDb}.users")->whereNull('deleted_at')->whereNull('inactive_at')->whereNotNull('cyclos_id')->count(); $laravelInactive = DB::table("{$destDb}.users")->whereNotNull('inactive_at')->count(); $laravelRemoved = DB::table("{$destDb}.users")->whereNotNull('deleted_at')->count(); $laravelBanks = DB::table("{$destDb}.banks")->where('id', '!=', 1)->count(); // exclude source bank $laravelOrgs = DB::table("{$destDb}.organizations")->whereNull('inactive_at')->count(); $laravelInactOrgs = DB::table("{$destDb}.organizations")->whereNotNull('inactive_at')->count(); $expectedUsers = $cyclosActive + $cyclosTestUsers; $expectedOrgs = $cyclosOrgs + $cyclosTestOrgs; $expectedBanks = $cyclosBanksL1 + $cyclosBanksL2; $this->check('Active users', $expectedUsers, $laravelUsers); $this->check('Inactive users', $cyclosInactive, $laravelInactive); $this->check('Removed/deleted users', $cyclosRemoved, $laravelRemoved); $this->check('Banks (L1+L2)', $expectedBanks, $laravelBanks); $this->check('Active organizations', $expectedOrgs, $laravelOrgs); $this->check('Inactive organizations', $cyclosInactProj, $laravelInactOrgs); } // ------------------------------------------------------------------------- // 2. TRANSACTION COUNTS // ------------------------------------------------------------------------- private function checkTransactions(string $sourceDb, string $destDb): void { $this->info('--- 2. Transaction counts ---'); $cyclosCount = DB::table("{$sourceDb}.transfers")->count(); $laravelTotal = DB::table("{$destDb}.transactions")->count(); // Post-import transactions added after Cyclos import: // type 5 = currency removals (for deleted profiles) // type 6 = gift account migrations (migrate:cyclos-gift-accounts moves gift balances to personal accounts) // type 7 = rounding corrections (one per account per year, inserted by migrate:cyclos) $giftMigCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 6)->count(); $currRemovalCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 5)->count(); $roundingCorrCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 7)->count(); $laravelImported = $laravelTotal - $giftMigCount - $currRemovalCount - $roundingCorrCount; $this->check('Imported transactions match Cyclos transfers', $cyclosCount, $laravelImported); $this->info(" (Total Laravel: {$laravelTotal} = {$laravelImported} imported + {$giftMigCount} gift migrations + {$currRemovalCount} currency removals + {$roundingCorrCount} rounding corrections)"); // NULL account IDs — should be zero $nullTx = DB::select(" SELECT COUNT(*) as cnt FROM {$destDb}.transactions WHERE from_account_id IS NULL OR to_account_id IS NULL ")[0]->cnt; $this->check('No transactions with NULL account IDs', 0, $nullTx); } // ------------------------------------------------------------------------- // 3. ACCOUNT BALANCES // ------------------------------------------------------------------------- private function checkBalances(string $sourceDb, string $destDb): void { $this->info('--- 3. Account balances ---'); // Laravel system must be balanced (sum of all net balances = 0) $laravelNetBalance = DB::select(" SELECT SUM(net) as total FROM ( SELECT a.id, COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as net FROM {$destDb}.accounts a LEFT JOIN {$destDb}.transactions t ON t.from_account_id = a.id OR t.to_account_id = a.id GROUP BY a.id ) balances ")[0]->total; $this->check('Laravel system is balanced (net = 0)', 0, (int) $laravelNetBalance); // Compare per-account balances directly via cyclos_id mapping. // Cyclos stores amounts in hours, Laravel in minutes. // Exclude post-import transactions so we compare only the imported data: // type 5 = currency removals (deleted profiles) // type 6 = gift migrations (migrate:cyclos-gift-accounts moves gift balances to personal accounts) // type 7 = rounding corrections (inserted by migrate:cyclos, one per account per year) $rows = DB::select(" SELECT cyclos_type.type_id, ROUND(cyclos_type.cyclos_hours * 60) as cyclos_min, COALESCE(laravel_type.laravel_min, 0) as laravel_min FROM ( SELECT a.type_id, COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as cyclos_hours FROM {$sourceDb}.accounts a LEFT JOIN {$sourceDb}.transfers t ON t.from_account_id = a.id OR t.to_account_id = a.id GROUP BY a.type_id ) cyclos_type LEFT JOIN ( SELECT ca.type_id, COALESCE(SUM(CASE WHEN t.to_account_id = la.id THEN t.amount ELSE -t.amount END), 0) as laravel_min FROM {$sourceDb}.accounts ca INNER JOIN {$destDb}.accounts la ON ca.id = la.cyclos_id LEFT JOIN {$destDb}.transactions t ON (t.from_account_id = la.id OR t.to_account_id = la.id) AND t.transaction_type_id NOT IN (5, 6, 7) GROUP BY ca.type_id ) laravel_type ON cyclos_type.type_id = laravel_type.type_id ORDER BY cyclos_type.type_id "); $typeNames = [ 1 => 'Debit account', 2 => 'Community account', 3 => 'Voucher account', 4 => 'Organization account', 5 => 'Work accounts (all owners)', 6 => 'Gift accounts', 7 => 'Project accounts', ]; // Types 5 and 7 (work and project accounts) are checked combined because // some profiles are intentionally remapped between these types during migration. $combined = [5 => ['cyclos' => 0, 'laravel' => 0], 7 => ['cyclos' => 0, 'laravel' => 0]]; foreach ($rows as $row) { if (in_array($row->type_id, [5, 7])) { $combined[$row->type_id]['cyclos'] = $row->cyclos_min; $combined[$row->type_id]['laravel'] = $row->laravel_min; continue; } $label = $typeNames[$row->type_id] ?? "Account type {$row->type_id}"; $this->checkBalance($label, $row->cyclos_min / 60, $row->laravel_min / 60); } $combinedCyclos = ($combined[5]['cyclos'] + $combined[7]['cyclos']) / 60; $combinedLaravel = ($combined[5]['laravel'] + $combined[7]['laravel']) / 60; $this->checkBalance('Work + Project accounts combined (remappings allowed)', $combinedCyclos, $combinedLaravel); } // ------------------------------------------------------------------------- // 4. GIFT ACCOUNTS // ------------------------------------------------------------------------- private function checkGiftAccounts(string $destDb): void { $this->info('--- 4. Gift account cleanup ---'); // All gift accounts should be marked inactive $activeGiftAccounts = DB::table("{$destDb}.accounts") ->where('name', 'gift') ->whereNull('inactive_at') ->count(); $this->check('All gift accounts marked inactive', 0, $activeGiftAccounts); // All gift account net balances should be 0 (migrated away) $nonZeroGiftBalances = DB::select(" SELECT COUNT(*) as cnt FROM ( SELECT a.id, COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as net FROM {$destDb}.accounts a LEFT JOIN {$destDb}.transactions t ON t.from_account_id = a.id OR t.to_account_id = a.id WHERE a.name = 'gift' GROUP BY a.id HAVING ABS(net) > 0 ) nonzero "); $this->check('All gift account balances are zero after migration', 0, $nonZeroGiftBalances[0]->cnt); // Gift migration transactions (type 6) should exist and move from gift → personal/org $giftMigrations = DB::table("{$destDb}.transactions as t") ->join("{$destDb}.accounts as fa", 't.from_account_id', '=', 'fa.id') ->join("{$destDb}.accounts as ta", 't.to_account_id', '=', 'ta.id') ->where('t.transaction_type_id', 6) ->where('fa.name', 'gift') ->whereIn('ta.name', ['personal', 'organization', 'banking system']) ->count(); $totalGiftMigrations = DB::table("{$destDb}.transactions")->where('transaction_type_id', 6)->count(); $this->check('Gift migration transactions go from gift → work account', $totalGiftMigrations, $giftMigrations); } // ------------------------------------------------------------------------- // 5. DELETED PROFILE CLEANUP // ------------------------------------------------------------------------- private function checkDeletedProfiles(string $sourceDb, string $destDb): void { $this->info('--- 5. Deleted profile cleanup ---'); // Removed users (group_id 8) should be soft-deleted in Laravel $removedCyclos = DB::table("{$sourceDb}.members")->where('group_id', 8)->count(); $deletedLaravel = DB::table("{$destDb}.users")->whereNotNull('deleted_at')->count(); $this->check('Removed Cyclos users are soft-deleted in Laravel', $removedCyclos, $deletedLaravel); // Deleted users should have had their balances removed (currency removals, type 5). // Tolerance of 6 minutes to account for rounding artifacts from hours→minutes conversion. $deletedUsersWithBalance = DB::select(" SELECT COUNT(*) as cnt FROM ( SELECT u.id, COALESCE(SUM(CASE WHEN t.to_account_id = a.id THEN t.amount ELSE -t.amount END), 0) as net FROM {$destDb}.users u INNER JOIN {$destDb}.accounts a ON a.accountable_id = u.id AND a.accountable_type = 'App\\\\Models\\\\User' LEFT JOIN {$destDb}.transactions t ON t.from_account_id = a.id OR t.to_account_id = a.id WHERE u.deleted_at IS NOT NULL GROUP BY u.id HAVING ABS(net) > 6 ) nonzero "); $this->check('Deleted users have zero remaining balance (tolerance: 6min)', 0, $deletedUsersWithBalance[0]->cnt); // Accounts of deleted users should exist but with zero balance $deletedUserAccountsCount = DB::table("{$destDb}.accounts as a") ->join("{$destDb}.users as u", function ($join) use ($destDb) { $join->on('a.accountable_id', '=', 'u.id') ->where('a.accountable_type', '=', 'App\\Models\\User'); }) ->whereNotNull('u.deleted_at') ->count(); if ($deletedUserAccountsCount > 0) { $this->warn(" Deleted users still have {$deletedUserAccountsCount} account records (expected — accounts are kept for transaction history)"); $this->warnings++; } else { $this->info(" PASS No accounts found for deleted users (all cleaned up)"); $this->passed++; } // Currency removal transactions (type 5) are optional — only present if deleted users had balances. // Balance check above already confirms deleted users have zero balance, so 0 here is also valid. $currRemovalCount = DB::table("{$destDb}.transactions")->where('transaction_type_id', 5)->count(); $this->info(" INFO Currency removal transactions: {$currRemovalCount}"); } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private function check(string $label, $expected, $actual): void { if ($expected == $actual) { $this->info(" PASS {$label}: {$actual}"); $this->passed++; } else { $this->error(" FAIL {$label}: expected={$expected} actual={$actual} (diff=" . ($actual - $expected) . ")"); $this->failed++; } } private function checkBalance(string $label, float $cyclosHours, float $laravelHours, float $toleranceHours = 0.1): void { $diff = abs($cyclosHours - $laravelHours); if ($diff <= $toleranceHours) { $this->info(sprintf(" PASS %s: %.2fh (diff: %.4fh)", $label, $laravelHours, $cyclosHours - $laravelHours)); $this->passed++; } else { $this->error(sprintf(" FAIL %s: cyclos=%.2fh laravel=%.2fh diff=%.4fh", $label, $cyclosHours, $laravelHours, $cyclosHours - $laravelHours)); $this->failed++; } } }