Files
timebank-cc-public/app/Console/Commands/VerifyCyclosMigration.php
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

334 lines
16 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class VerifyCyclosMigration extends Command
{
protected $signature = 'verify:cyclos-migration {source_db? : Name of the source Cyclos database}';
protected $description = 'Verifies the Cyclos migration: member counts, account balances, gift accounts, and deleted profile cleanup.';
private int $passed = 0;
private int $failed = 0;
private int $warnings = 0;
public function handle()
{
$sourceDb = $this->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(" <fg=green>PASS</>: {$this->passed}");
if ($this->warnings > 0) {
$this->info(" <fg=yellow>WARN</>: {$this->warnings}");
}
if ($this->failed > 0) {
$this->error(" FAIL: {$this->failed}");
return 1;
}
$this->info('<fg=green>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(" <fg=green>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(" <fg=green>INFO</> Currency removal transactions: {$currRemovalCount}");
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private function check(string $label, $expected, $actual): void
{
if ($expected == $actual) {
$this->info(" <fg=green>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(" <fg=green>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++;
}
}
}