3 * WHMCS to RMM Backend Migration Script (Direct Database)
5 * Migrates all data from WHMCS MySQL to RMM PostgreSQL:
6 * - tblclients → customers table
7 * - tblproducts → products table
8 * - tblinvoices → invoices table
9 * - tblhosting → contracts + contract_line_items tables
12const mysql = require('mysql2/promise');
13const { Pool } = require('pg');
15// ===========================
17// ===========================
19// WHMCS MySQL Configuration (via SSH tunnel on localhost:3307)
23 user: 'precisewebhostin_migration',
24 password: 'Zrepowfg1-cFS,TP',
25 database: 'precisewebhostin_whmcs'
28// RMM PostgreSQL Configuration
30 host: 'rmm-psa-db-do-user-28531160-0.i.db.ondigitalocean.com',
32 database: 'defaultdb',
34 password: 'AVNS_J8RJAmsEwsHFG52_-F2',
36 rejectUnauthorized: false
40// Default tenant (Independent Business Group)
41const DEFAULT_TENANT_NAME = 'Independent Business Group';
43// ===========================
45// ===========================
52async function getTenantId(pool, tenantName) {
53 const result = await pool.query(
54 'SELECT tenant_id FROM tenants WHERE name = $1 OR subdomain = $2 LIMIT 1',
55 [tenantName, tenantName.toLowerCase().replace(/\s+/g, '-')]
58 if (result.rows.length > 0) {
59 return result.rows[0].tenant_id;
62 // Create tenant if doesn't exist
63 console.log(`Creating tenant: ${tenantName}...`);
64 const insertResult = await pool.query(`
65 INSERT INTO tenants (name, subdomain, status, is_msp)
66 VALUES ($1, $2, 'active', false)
68 `, [tenantName, tenantName.toLowerCase().replace(/\s+/g, '-')]);
70 return insertResult.rows[0].tenant_id;
73// ===========================
75// ===========================
83async function migrateCustomers(mysqlConn, pgPool, tenantId) {
84 console.log('\n=== MIGRATING CUSTOMERS ===');
86 const [customers] = await mysqlConn.query(`
87 SELECT id, firstname, lastname, companyname, email, phonenumber,
88 address1, address2, city, state, postcode, country, status
93 console.log(`Found ${customers.length} customers in WHMCS`);
97 const customerMap = {}; // Map WHMCS ID to RMM ID
99 for (const customer of customers) {
101 // Check if customer already exists
102 const existing = await pgPool.query(
103 'SELECT customer_id FROM customers WHERE email = $1 AND tenant_id = $2',
104 [customer.email, tenantId]
107 if (existing.rows.length > 0) {
108 customerMap[customer.id] = existing.rows[0].customer_id;
113 // Map WHMCS fields to RMM schema
114 const customerName = customer.companyname ||
115 `${customer.firstname} ${customer.lastname}`.trim();
116 const contactName = `${customer.firstname} ${customer.lastname}`.trim();
117 const address = `${customer.address1}\n${customer.address2 || ''}`.trim();
118 const status = customer.status === 'Active' ? 'active' : 'inactive';
121 const result = await pgPool.query(`
122 INSERT INTO customers (
123 tenant_id, name, contact_name, email, phone,
124 address, city, state, postal_code, country, status
125 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
126 RETURNING customer_id
128 tenantId, customerName, contactName, customer.email, customer.phonenumber,
129 address, customer.city, customer.state, customer.postcode, customer.country, status
132 customerMap[customer.id] = result.rows[0].customer_id;
135 if (imported % 10 === 0) {
136 process.stdout.write(`\r Processed: ${imported}/${customers.length}`);
140 console.error(`\n✗ Failed to import ${customer.email}:`, error.message);
144 console.log(`\n✓ Customers migration complete: ${imported} imported, ${skipped} skipped`);
154async function migrateProducts(mysqlConn, pgPool, tenantId) {
155 console.log('\n=== MIGRATING PRODUCTS ===');
157 const [products] = await mysqlConn.query(`
158 SELECT id, name, description, type
164 console.log(`Found ${products.length} products in WHMCS`);
168 const productMap = {}; // Map WHMCS ID to RMM ID
170 for (const product of products) {
172 // Check if product already exists
173 const existing = await pgPool.query(
174 'SELECT product_id FROM products WHERE name = $1 AND tenant_id = $2',
175 [product.name, tenantId]
178 if (existing.rows.length > 0) {
179 productMap[product.id] = existing.rows[0].product_id;
184 // Get pricing for this product
185 const [pricing] = await mysqlConn.query(`
186 SELECT monthly, quarterly, semiannually, annually
188 WHERE relid = ? AND type = 'product'
192 const monthlyPrice = pricing.length > 0 ? parseFloat(pricing[0].monthly || 0) : 0;
195 const result = await pgPool.query(`
196 INSERT INTO products (
197 tenant_id, name, description, unit_price, price_retail, price_ex_tax, created_at
198 ) VALUES ($1, $2, $3, $4, $4, $4, NOW())
203 product.description || '',
207 productMap[product.id] = result.rows[0].product_id;
212 console.error(`✗ Failed to import ${product.name}:`, error.message);
216 console.log(`\n✓ Products migration complete: ${imported} imported, ${skipped} skipped`);
227async function migrateInvoices(mysqlConn, pgPool, tenantId, customerMap) {
228 console.log('\n=== MIGRATING INVOICES ===');
230 const [invoices] = await mysqlConn.query(`
231 SELECT id, userid, invoicenum, date, duedate, subtotal, tax, total, status
237 console.log(`Found ${invoices.length} invoices in WHMCS`);
242 for (const invoice of invoices) {
244 // Get RMM customer ID
245 const rmmCustomerId = customerMap[invoice.userid];
246 if (!rmmCustomerId) {
251 // Check if invoice already exists
252 const existing = await pgPool.query(
253 'SELECT invoice_id FROM invoices WHERE customer_id = $1 AND issued_date = $2 AND total = $3 AND tenant_id = $4',
254 [rmmCustomerId, invoice.date, parseFloat(invoice.total || 0), tenantId]
257 if (existing.rows.length > 0) {
262 // Map invoice status
263 let status = 'draft';
264 let paymentStatus = 'unpaid';
265 if (invoice.status === 'Paid') {
267 paymentStatus = 'paid';
268 } else if (invoice.status === 'Unpaid') {
270 paymentStatus = 'unpaid';
271 } else if (invoice.status === 'Cancelled') {
273 paymentStatus = 'cancelled';
278 INSERT INTO invoices (
279 tenant_id, customer_id, issued_date, due_date,
280 subtotal, tax_total, total, status, payment_status, description
281 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
287 parseFloat(invoice.subtotal || 0),
288 parseFloat(invoice.tax || 0),
289 parseFloat(invoice.total || 0),
292 `Migrated from WHMCS Invoice #${invoice.id} - ${invoice.invoicenum || ''}`
296 if (imported % 50 === 0) {
297 process.stdout.write(`\r Processed: ${imported}/${invoices.length}`);
301 console.error(`\n✗ Failed to import invoice ${invoice.id}:`, error.message);
305 console.log(`\n✓ Invoices migration complete: ${imported} imported, ${skipped} skipped`);
316async function migrateServicesAndContracts(mysqlConn, pgPool, tenantId, customerMap, productMap) {
317 console.log('\n=== MIGRATING CLIENT SERVICES & CREATING CONTRACTS ===');
319 const [services] = await mysqlConn.query(`
320 SELECT id, userid, packageid, domain, regdate, nextduedate,
321 billingcycle, amount, domainstatus
323 WHERE domainstatus NOT IN ('Terminated', 'Cancelled')
327 console.log(`Found ${services.length} active services in WHMCS`);
332 for (const service of services) {
334 // Get RMM customer ID
335 const rmmCustomerId = customerMap[service.userid];
336 if (!rmmCustomerId) {
341 // Get customer name for logging
342 const customerResult = await pgPool.query(
343 'SELECT name FROM customers WHERE customer_id = $1',
346 const customerName = customerResult.rows[0]?.name || 'Unknown';
349 const [productRows] = await mysqlConn.query(
350 'SELECT name FROM tblproducts WHERE id = ?',
353 const productName = productRows.length > 0 ? productRows[0].name : 'Service';
355 // Check if contract already exists
356 const existing = await pgPool.query(
357 'SELECT contract_id FROM contracts WHERE customer_id = $1 AND title ILIKE $2',
358 [rmmCustomerId, `%${productName}%`]
361 if (existing.rows.length > 0) {
366 // Map billing cycle to interval
367 let billingInterval = 'monthly';
368 if (service.billingcycle === 'Annually') billingInterval = 'yearly';
369 else if (service.billingcycle === 'Quarterly') billingInterval = 'quarterly';
370 else if (service.billingcycle === 'Semi-Annually') billingInterval = 'semi_annually';
371 else if (service.billingcycle === 'Free Account') {
373 continue; // Skip free accounts
376 // Calculate next billing date
377 const nextBillingDate = service.nextduedate ||
378 new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
380 // Determine contract status
381 let contractStatus = 'active';
382 if (service.domainstatus === 'Pending') contractStatus = 'draft';
383 else if (service.domainstatus === 'Suspended') contractStatus = 'suspended';
386 const contractResult = await pgPool.query(`
387 INSERT INTO contracts (
388 tenant_id, customer_id, title, description, status,
389 start_date, billing_interval, next_billing_date,
390 auto_invoice_enabled, labor_rate, notes
391 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
392 RETURNING contract_id
397 `${productName} - ${service.domain || ''}`,
399 service.regdate || new Date().toISOString().split('T')[0],
402 true, // auto_invoice_enabled
404 `Migrated from WHMCS Service #${service.id}\nDomain: ${service.domain || 'N/A'}`
407 const contractId = contractResult.rows[0].contract_id;
409 // Add contract line item for the service
410 const rmmProductId = productMap[service.packageid] || null;
411 const amount = parseFloat(service.amount || 0);
414 INSERT INTO contract_line_items (
415 contract_id, product_id, description, quantity, unit_price, recurring
416 ) VALUES ($1, $2, $3, $4, $5, $6)
427 if (imported % 10 === 0) {
428 process.stdout.write(`\r Processed: ${imported}/${services.length}`);
432 console.error(`\n✗ Failed to create contract for service ${service.id}:`, error.message);
436 console.log(`\n✓ Contracts migration complete: ${imported} created, ${skipped} skipped`);
439// ===========================
441// ===========================
446async function main() {
447 console.log('╔══════════════════════════════════════════════════════════╗');
448 console.log('║ WHMCS → RMM Backend Migration Script (Direct DB) ║');
449 console.log('╚══════════════════════════════════════════════════════════╝\n');
456 console.log('Connecting to WHMCS MySQL database...');
457 mysqlConn = await mysql.createConnection(MYSQL_CONFIG);
458 console.log('✓ WHMCS database connected\n');
460 // Connect to PostgreSQL
461 console.log('Connecting to RMM PostgreSQL database...');
462 pgPool = new Pool(PG_CONFIG);
463 await pgPool.query('SELECT NOW()');
464 console.log('✓ RMM database connected\n');
466 // Get or create tenant
467 console.log(`Getting tenant ID for: ${DEFAULT_TENANT_NAME}...`);
468 const tenantId = await getTenantId(pgPool, DEFAULT_TENANT_NAME);
469 console.log(`✓ Tenant ID: ${tenantId}\n`);
471 // Run migrations in sequence
472 const customerMap = await migrateCustomers(mysqlConn, pgPool, tenantId);
473 const productMap = await migrateProducts(mysqlConn, pgPool, tenantId);
474 await migrateInvoices(mysqlConn, pgPool, tenantId, customerMap);
475 await migrateServicesAndContracts(mysqlConn, pgPool, tenantId, customerMap, productMap);
477 console.log('\n╔══════════════════════════════════════════════════════════╗');
478 console.log('║ MIGRATION COMPLETE ✓ ║');
479 console.log('╚══════════════════════════════════════════════════════════╝\n');
482 const stats = await pgPool.query(`
484 (SELECT COUNT(*) FROM customers WHERE tenant_id = $1) as customers,
485 (SELECT COUNT(*) FROM products WHERE tenant_id = $1) as products,
486 (SELECT COUNT(*) FROM invoices WHERE tenant_id = $1) as invoices,
487 (SELECT COUNT(*) FROM contracts WHERE tenant_id = $1) as contracts
490 console.log('Final Statistics:');
491 console.log(` Customers: ${stats.rows[0].customers}`);
492 console.log(` Products: ${stats.rows[0].products}`);
493 console.log(` Invoices: ${stats.rows[0].invoices}`);
494 console.log(` Contracts: ${stats.rows[0].contracts}`);
498 console.error('\n❌ MIGRATION FAILED:', error.message);
499 console.error(error.stack);
502 if (mysqlConn) await mysqlConn.end();
503 if (pgPool) await pgPool.end();
508if (require.main === module) {
512module.exports = { main };