Files
timebank-cc-public/references/CYCLOS_IMPORT.md
Ronald Huynen 2547717edb Initial commit
2026-03-23 21:37:59 +01:00

18 KiB

Cyclos Database Import Reference

This document describes how the legacy Cyclos database is imported into the Laravel Timebank application during the php artisan db:seed process.

Overview

The Cyclos import is a production-grade data migration system that transfers members, accounts, transactions, profile data, and images from a Cyclos timebank database into the new Laravel-based system. The migration preserves historical data integrity while transforming it to fit the new polymorphic multi-profile architecture.

Migration Flow

The import process is orchestrated through database/seeders/DatabaseSeeder.php and consists of several stages, each triggered by user confirmation prompts:

db:seed
├── Database refresh & base seeders
├── [Optional] migrate:cyclos              ← Main migration
│   ├── [Optional] migrate:cyclos-profiles ← Profile data
│   │   ├── profiles:clean-about           ← Cleanup
│   │   └── profiles:clean-cyclos_skills   ← Cleanup
│   └── [Optional] migrate:cyclos-gift-accounts ← Account consolidation
└── [Optional] Elasticsearch re-indexing

Source Database Configuration

The source Cyclos database name is prompted during the migration process. The database must be imported into MySQL and accessible from the application.

Setup Steps:

  1. Export the Cyclos database as a SQL dump
  2. Place the dump file in the application root
  3. Import into MySQL: mysql -u root -p < cyclos_dump.sql
  4. Run the migration and enter the database name when prompted

Input Validation:

  • If you enter the database name with a .sql extension (e.g., timebank_2025_09_19.sql), it will be automatically stripped
  • The command verifies the database exists before proceeding; if not found, it displays available databases to help identify typos

During the db:seed process, the source database name entered in migrate:cyclos is cached and automatically reused by migrate:cyclos-profiles, so you only need to enter it once.

The destination database is read from the environment:

$destinationDb = env('DB_DATABASE');

Stage 1: Main Migration (migrate:cyclos)

File: app/Console/Commands/MigrateCyclosCommand.php

This is the primary migration command that handles members, images, accounts, and transactions.

Member Group Mapping

Cyclos organizes members into groups. The migration maps these groups to Laravel model types:

Cyclos Group ID Group Name Laravel Model Status
5 Active Users User Active
6 Inactive Users User inactive_at set
8 Removed Users User deleted_at set
13 Local Banks (Level I) Bank level = 1
14 Organizations Organization Active
15 Projects (Level II) Bank level = 2
17 Local Admins Not migrated -
18 TEST: Projects Organization Active
22 TEST: Users User Active
27 Inactive Projects Organization inactive_at set

Member Field Mapping

Each member type maps Cyclos fields to Laravel columns:

Cyclos Field Laravel Column Notes
members.id cyclos_id Foreign key reference
members.name full_name Display name
members.email email -
members.member_activation_date email_verified_at Converted via FROM_UNIXTIME()
members.creation_date created_at Converted via FROM_UNIXTIME()
users.username name Profile URL slug
users.password password Preserved hash (may need reset)
users.salt cyclos_salt For potential password compatibility
users.last_login last_login_at Converted via FROM_UNIXTIME()
group_history_logs.start_date inactive_at / deleted_at For inactive/removed members

Member Migration SQL Pattern

Each member type uses a similar INSERT pattern with ON DUPLICATE KEY UPDATE for idempotency:

INSERT INTO {destination}.users (cyclos_id, full_name, email, ...)
SELECT
    m.id AS cyclos_id,
    m.name AS full_name,
    m.email AS email,
    FROM_UNIXTIME(UNIX_TIMESTAMP(m.creation_date)) AS created_at,
    ...
FROM {source}.members m
JOIN {source}.users u ON m.id = u.id
WHERE m.group_id = {group_id}
ON DUPLICATE KEY UPDATE ...

Removed User Handling

Removed users (group_id = 8) receive anonymized data:

CONCAT('Removed Cyclos user', m.id) AS full_name,
CONCAT(m.id, '@removed.mail') AS email,
CONCAT('Removed user ', m.id) AS name,
0 as limit_min,
0 as limit_max

Profile Images Migration

Images are stored as binary data in Cyclos. The migration:

  1. Selects the image with the lowest order_number for each member (primary profile photo)
  2. Extracts binary data from {source}.images.image
  3. Saves to storage/app/public/profile-photos/{type}_{uniqid}.jpg
  4. Updates the profile's profile_photo_path column
  5. Sets default avatar path for profiles without images
Storage::disk('public')->put($filename, $image->image);

Account Migration

Cyclos uses account types identified by type_id. These map to the polymorphic accounts table:

Cyclos Type ID Account Type Owner Types Notes
1 Debit Bank (id=1) System settlement account
2 Community Bank (id=1) Community pool
5 Work User, Organization, Bank Primary transaction account
6 Gift User, Organization, Bank Temporary, consolidated later
7 Project User, Organization, Bank Project-specific transactions

Account limits are pulled from white-label configuration:

$userLimitMin = timebank_config('accounts.user.limit_min');
$userLimitMax = timebank_config('accounts.user.limit_max');

Account Migration SQL Pattern

INSERT INTO {destination}.accounts (name, accountable_type, accountable_id, cyclos_id, ...)
SELECT
    '{accountName}' AS name,
    'App\\Models\\User' AS accountable_type,
    u.id AS accountable_id,
    a.id AS cyclos_id,
    ...
FROM {source}.accounts a
JOIN {destination}.users u ON a.member_id = u.cyclos_id
WHERE a.type_id = {type_id}

Transaction Migration

Critical Transformation: Cyclos stores amounts as decimal hours (e.g. 0.8667 = 52 minutes). Laravel stores amounts as integer minutes.

ROUND(t.amount * 60) AS amount

Known limitation: Rounding to integer minutes per transaction accumulates small errors across accounts with many transactions. For example, an account with 502 transactions may end up with a balance ±11 minutes off from Cyclos. The verify:cyclos-migration command reports these discrepancies. A per-account correction mechanism is planned to address this.

Transaction mapping:

Cyclos Field Laravel Column Notes
transfers.from_account_id from_account_id Via accounts.cyclos_id join
transfers.to_account_id to_account_id Via accounts.cyclos_id join
transfers.amount amount Multiplied by 60, rounded to integer minutes
transfers.description description -
transfers.type_id transaction_type_id Direct mapping
- transaction_status_id Always 1 (completed)
transfers.date created_at Converted via FROM_UNIXTIME()

Validation Checks

After migration, three inline integrity checks are performed:

  1. Member Count: Total Cyclos members (excluding group 17) must equal Users + Organizations + Banks
  2. Transaction Count: Total Cyclos transfers must equal Laravel transactions
  3. Balance Integrity: Net balance across all accounts must equal zero

For a full verification after the complete seed process, run:

php artisan verify:cyclos-migration

This checks member counts, transaction counts, per-account-type balances, gift account cleanup, and deleted profile cleanup. See references/CYCLOS_MIGRATION_DISCREPANCIES.md for the latest results.

// Balance check using window functions
$totalNetBalance = DB::query()
    ->fromSub($accountBalances, 'account_balances')
    ->selectRaw('SUM(net_balance) as total_net_balance')
    ->first();

if ($totalNetBalance->total_net_balance != 0) {
    $this->error("OUT OF BALANCE AMOUNT: {$totalNetBalance->total_net_balance}");
}

Laravel-Love Registration

After data migration, the command registers all profiles with the Laravel-Love package for reaction tracking:

Artisan::call('love:register-reacters', ['--model' => 'App\Models\User']);
Artisan::call('love:register-reactants', ['--model' => 'App\Models\User']);
// ... for Organization and Bank as well
Artisan::call('love:add-reactions-to-transactions');

Stage 2: Profile Data Migration (migrate:cyclos-profiles)

File: app/Console/Commands/MigrateCyclosProfilesCommand.php

This command migrates custom profile fields and preferences from Cyclos.

Custom Field Mapping

Cyclos stores custom fields in custom_field_values with field_id identifiers:

Field ID Cyclos Field Laravel Column Model Types Notes
1 Birthday date_of_birth User Date format: DD/MM/YYYYYYYY-MM-DD
7 Phone phone All Truncated to 20 characters
10 Website website All -
13 Skills cyclos_skills User, Organization Truncated to 495 chars + "..."
17 About about User -
28 General Newsletter message_settings.general_newsletter All 790=No, 791=Yes
29 Local Newsletter message_settings.local_newsletter All 792=No, 793=Yes
35 Motivation motivation All -
36 Country locations.country_id All Via code mapping
38 City locations.city_id All Via code mapping

Location Code Mapping

Cyclos uses possible_value_id for location selection. These map to Laravel location IDs:

Countries:

$countryCodeMap = [
    860 => 2,    // BE (Belgium)
    861 => 7,    // PT (Portugal)
    862 => 1,    // NL (Netherlands)
    863 => null, // Other/not set
];

Cities:

$cityCodeMap = [
    864 => 188, // Amsterdam
    865 => 200, // Haarlem
    866 => 316, // Leiden
    867 => 305, // The Hague
    868 => 300, // Delft
    869 => 331, // Rotterdam
    870 => 272, // Utrecht
    881 => 345, // Brussels
];

After creating location records, the migration calls $location->syncAllLocationData() to populate related fields (divisions, etc.) from the parent city/country.

Newsletter Preferences

Newsletter preferences migrate to the polymorphic message_settings table:

$model->message_settings()->updateOrCreate(
    ['message_settingable_id' => $model->id, 'message_settingable_type' => $modelClass],
    ['general_newsletter' => $value]
);

HTML Tag Stripping

After migration, HTML tags are stripped from text fields:

$cleaned = strip_tags($record->cyclos_skills);
$cleaned = strip_tags($record->about);

Stage 3: Cleanup Commands

profiles:clean-about

File: app/Console/Commands/CleanCyclosProfiles.php

Removes empty paragraph markup from about fields:

  • <p></p>
  • <p> </p>
  • <p>&nbsp;</p>
  • " (single double-quote character)

Sets these to null for clean display.

profiles:clean-cyclos_skills

File: app/Console/Commands/CleanCyclosSkills.php

Removes trailing pipe symbols from skills fields. Cyclos used pipe-delimited skill lists that sometimes had trailing pipes:

$cleaned = preg_replace('/(\s*\|\s*)+$/', '', $original);

Stage 4: Gift Account Consolidation (migrate:cyclos-gift-accounts)

File: app/Console/Commands/MigrateCyclosGiftAccounts.php

Cyclos had separate "gift" accounts (type_id = 6) that received welcome bonuses or gift transactions. This command consolidates gift account balances into primary work accounts and marks all gift accounts as inactive.

How Gift Accounts Are Created

During Stage 1 (migrate:cyclos), gift accounts are imported from Cyclos for all profile types:

  1. All gift accounts are imported from Cyclos accounts table where type_id = 6
  2. They are created with name = 'gift' and associated with Users, Organizations, or Banks
  3. All related transactions are also imported, preserving the original balances
  4. At this stage, gift accounts are active (no inactive_at set)

Consolidation Process

The migrate:cyclos-gift-accounts command then processes these accounts:

  1. Find all gift accounts - Queries all accounts where name = 'gift'

  2. For each gift account, check the balance:

    • If balance is zero or negative: Skip (nothing to transfer)
    • If balance is positive: Transfer the full balance to the owner's primary (non-gift) account
  3. Transfer positive balances:

    $transfer = new Transaction();
    $transfer->from_account_id = $fromAccount->id;  // Gift account
    $transfer->to_account_id = $toAccount->id;      // Primary work account
    $transfer->amount = $balance;
    $transfer->description = "Migration of balance from gift account (ID: {$fromAccount->id})";
    $transfer->transaction_type_id = 6;             // Migration type
    $transfer->save();
    
  4. Mark ALL gift accounts as inactive - After the loop completes, all gift accounts are marked inactive, regardless of whether they had a positive balance or not:

    $giftAccountIds = $giftAccounts->pluck('id');
    Account::whereIn('id', $giftAccountIds)->update(['inactive_at' => now()]);
    

Important Notes

  • All gift accounts become inactive after this command runs - not just those with positive balances
  • Accounts with zero/negative balance are skipped for the transfer step but still marked inactive
  • The original transactions remain in the gift account history (for audit purposes)
  • The migration transaction creates a clear record of when and how balances were moved
  • If no destination account is found for an owner, the gift account is logged as a warning but still marked inactive

Database Safety Measures

Transaction Wrapping

Every migration step is wrapped in database transactions:

DB::beginTransaction();
try {
    // Migration logic
    DB::commit();
} catch (\Exception $e) {
    DB::rollBack();
    $this->error('Migration failed: ' . $e->getMessage());
}

Job Queue Suppression

During migration, job dispatching is disabled to prevent side effects:

\Queue::fake();

Idempotency

Member migrations use ON DUPLICATE KEY UPDATE to allow re-running without data duplication.


Password Handling

Cyclos password hashes are preserved in the password column with the salt stored in cyclos_salt. However, Laravel uses a different hashing mechanism (bcrypt by default), so:

  • Users may need to use "Forgot Password" to set a new Laravel-compatible password
  • Alternatively, a custom hash driver could be implemented to verify Cyclos passwords

Running the Migration

Prerequisites

  1. Export the Cyclos database as a SQL dump
  2. Import the dump into MySQL:
    mysql -u root -p < cyclos_dump.sql
    
  3. Note the database name you used during import

Full Fresh Migration

Use seed.sh for a complete fresh setup — it handles dropping tables, running migrations, maintenance mode, immutability restrictions, and prompts for the Cyclos DB:

./seed.sh

If running the seeder manually:

php artisan db:seed

You will be prompted through the following steps:

  1. Seed base data? - Transaction types, permissions, countries, etc.
  2. Seed example tags? - Optional tag data
  3. Migrate cyclos database? - If yes:
    • You will be prompted to enter the source Cyclos database name
    • The database name is cached for use by subsequent commands
  4. Migrate cyclos profile data? - Uses cached database name automatically
  5. Migrate cyclos gift accounts? - Consolidates gift balances
  6. Assign Dev-Admin role? - Optional admin assignment
  7. Re-create Elasticsearch index? - Rebuilds search indices

Individual Commands

When running commands individually (outside of db:seed), each command will prompt for the source database name if not cached:

# Main migration - prompts for database name and caches it
php artisan migrate:cyclos

# Profile data - uses cached name or prompts if cache expired
php artisan migrate:cyclos-profiles

# Cleanup commands (no database prompt needed)
php artisan profiles:clean-about
php artisan profiles:clean-cyclos_skills

# Gift account consolidation (no database prompt needed)
php artisan migrate:cyclos-gift-accounts

Database Name Caching

The source database name is cached for 1 hour after being entered in migrate:cyclos. This allows migrate:cyclos-profiles to automatically use the same database without re-prompting during the db:seed process.

// Set by migrate:cyclos
cache()->put('cyclos_migration_source_db', $sourceDb, now()->addHours(1));

// Retrieved by migrate:cyclos-profiles
$sourceDb = cache()->get('cyclos_migration_source_db');

Key Files Reference

File Purpose
database/seeders/DatabaseSeeder.php Orchestrates the full seeding process
app/Console/Commands/MigrateCyclosCommand.php Members, images, accounts, transactions
app/Console/Commands/MigrateCyclosProfilesCommand.php Custom fields, locations, newsletters
app/Console/Commands/MigrateCyclosGiftAccounts.php Gift account consolidation
app/Console/Commands/CleanCyclosProfiles.php About field cleanup
app/Console/Commands/CleanCyclosSkills.php Skills field cleanup

Post-Migration Considerations

  1. Password Resets: Advise users to reset passwords via the forgot password flow
  2. Profile Photos: Verify images are accessible at /storage/profile-photos/
  3. Balance Verification: Run php artisan verify:cyclos-migration — all 20 checks should pass. See references/CYCLOS_MIGRATION_DISCREPANCIES.md for explanation of known discrepancies.
  4. Search Indexing: Ensure Elasticsearch indices are rebuilt after migration
  5. Gift Accounts: Verified automatically by verify:cyclos-migration (section 4)