EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
migrate-whmcs-direct.js
Go to the documentation of this file.
1#!/usr/bin/env node
2/**
3 * WHMCS to RMM Backend Migration Script (Direct Database)
4 *
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
10 */
11
12const mysql = require('mysql2/promise');
13const { Pool } = require('pg');
14
15// ===========================
16// CONFIGURATION
17// ===========================
18
19// WHMCS MySQL Configuration (via SSH tunnel on localhost:3307)
20const MYSQL_CONFIG = {
21 host: 'localhost',
22 port: 3307,
23 user: 'precisewebhostin_migration',
24 password: 'Zrepowfg1-cFS,TP',
25 database: 'precisewebhostin_whmcs'
26};
27
28// RMM PostgreSQL Configuration
29const PG_CONFIG = {
30 host: 'rmm-psa-db-do-user-28531160-0.i.db.ondigitalocean.com',
31 port: 25060,
32 database: 'defaultdb',
33 user: 'doadmin',
34 password: 'AVNS_J8RJAmsEwsHFG52_-F2',
35 ssl: {
36 rejectUnauthorized: false
37 }
38};
39
40// Default tenant (Independent Business Group)
41const DEFAULT_TENANT_NAME = 'Independent Business Group';
42
43// ===========================
44// DATABASE HELPERS
45// ===========================
46
47/**
48 *
49 * @param pool
50 * @param tenantName
51 */
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, '-')]
56 );
57
58 if (result.rows.length > 0) {
59 return result.rows[0].tenant_id;
60 }
61
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)
67 RETURNING tenant_id
68 `, [tenantName, tenantName.toLowerCase().replace(/\s+/g, '-')]);
69
70 return insertResult.rows[0].tenant_id;
71}
72
73// ===========================
74// MIGRATION FUNCTIONS
75// ===========================
76
77/**
78 *
79 * @param mysqlConn
80 * @param pgPool
81 * @param tenantId
82 */
83async function migrateCustomers(mysqlConn, pgPool, tenantId) {
84 console.log('\n=== MIGRATING CUSTOMERS ===');
85
86 const [customers] = await mysqlConn.query(`
87 SELECT id, firstname, lastname, companyname, email, phonenumber,
88 address1, address2, city, state, postcode, country, status
89 FROM tblclients
90 ORDER BY id
91 `);
92
93 console.log(`Found ${customers.length} customers in WHMCS`);
94
95 let imported = 0;
96 let skipped = 0;
97 const customerMap = {}; // Map WHMCS ID to RMM ID
98
99 for (const customer of customers) {
100 try {
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]
105 );
106
107 if (existing.rows.length > 0) {
108 customerMap[customer.id] = existing.rows[0].customer_id;
109 skipped++;
110 continue;
111 }
112
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';
119
120 // Insert customer
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
127 `, [
128 tenantId, customerName, contactName, customer.email, customer.phonenumber,
129 address, customer.city, customer.state, customer.postcode, customer.country, status
130 ]);
131
132 customerMap[customer.id] = result.rows[0].customer_id;
133
134 imported++;
135 if (imported % 10 === 0) {
136 process.stdout.write(`\r Processed: ${imported}/${customers.length}`);
137 }
138
139 } catch (error) {
140 console.error(`\n✗ Failed to import ${customer.email}:`, error.message);
141 }
142 }
143
144 console.log(`\n✓ Customers migration complete: ${imported} imported, ${skipped} skipped`);
145 return customerMap;
146}
147
148/**
149 *
150 * @param mysqlConn
151 * @param pgPool
152 * @param tenantId
153 */
154async function migrateProducts(mysqlConn, pgPool, tenantId) {
155 console.log('\n=== MIGRATING PRODUCTS ===');
156
157 const [products] = await mysqlConn.query(`
158 SELECT id, name, description, type
159 FROM tblproducts
160 WHERE hidden = 0
161 ORDER BY id
162 `);
163
164 console.log(`Found ${products.length} products in WHMCS`);
165
166 let imported = 0;
167 let skipped = 0;
168 const productMap = {}; // Map WHMCS ID to RMM ID
169
170 for (const product of products) {
171 try {
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]
176 );
177
178 if (existing.rows.length > 0) {
179 productMap[product.id] = existing.rows[0].product_id;
180 skipped++;
181 continue;
182 }
183
184 // Get pricing for this product
185 const [pricing] = await mysqlConn.query(`
186 SELECT monthly, quarterly, semiannually, annually
187 FROM tblpricing
188 WHERE relid = ? AND type = 'product'
189 LIMIT 1
190 `, [product.id]);
191
192 const monthlyPrice = pricing.length > 0 ? parseFloat(pricing[0].monthly || 0) : 0;
193
194 // Insert product
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())
199 RETURNING product_id
200 `, [
201 tenantId,
202 product.name,
203 product.description || '',
204 monthlyPrice
205 ]);
206
207 productMap[product.id] = result.rows[0].product_id;
208
209 imported++;
210
211 } catch (error) {
212 console.error(`✗ Failed to import ${product.name}:`, error.message);
213 }
214 }
215
216 console.log(`\n✓ Products migration complete: ${imported} imported, ${skipped} skipped`);
217 return productMap;
218}
219
220/**
221 *
222 * @param mysqlConn
223 * @param pgPool
224 * @param tenantId
225 * @param customerMap
226 */
227async function migrateInvoices(mysqlConn, pgPool, tenantId, customerMap) {
228 console.log('\n=== MIGRATING INVOICES ===');
229
230 const [invoices] = await mysqlConn.query(`
231 SELECT id, userid, invoicenum, date, duedate, subtotal, tax, total, status
232 FROM tblinvoices
233 ORDER BY id
234 LIMIT 1000
235 `);
236
237 console.log(`Found ${invoices.length} invoices in WHMCS`);
238
239 let imported = 0;
240 let skipped = 0;
241
242 for (const invoice of invoices) {
243 try {
244 // Get RMM customer ID
245 const rmmCustomerId = customerMap[invoice.userid];
246 if (!rmmCustomerId) {
247 skipped++;
248 continue;
249 }
250
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]
255 );
256
257 if (existing.rows.length > 0) {
258 skipped++;
259 continue;
260 }
261
262 // Map invoice status
263 let status = 'draft';
264 let paymentStatus = 'unpaid';
265 if (invoice.status === 'Paid') {
266 status = 'paid';
267 paymentStatus = 'paid';
268 } else if (invoice.status === 'Unpaid') {
269 status = 'sent';
270 paymentStatus = 'unpaid';
271 } else if (invoice.status === 'Cancelled') {
272 status = 'void';
273 paymentStatus = 'cancelled';
274 }
275
276 // Insert invoice
277 await pgPool.query(`
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)
282 `, [
283 tenantId,
284 rmmCustomerId,
285 invoice.date,
286 invoice.duedate,
287 parseFloat(invoice.subtotal || 0),
288 parseFloat(invoice.tax || 0),
289 parseFloat(invoice.total || 0),
290 status,
291 paymentStatus,
292 `Migrated from WHMCS Invoice #${invoice.id} - ${invoice.invoicenum || ''}`
293 ]);
294
295 imported++;
296 if (imported % 50 === 0) {
297 process.stdout.write(`\r Processed: ${imported}/${invoices.length}`);
298 }
299
300 } catch (error) {
301 console.error(`\n✗ Failed to import invoice ${invoice.id}:`, error.message);
302 }
303 }
304
305 console.log(`\n✓ Invoices migration complete: ${imported} imported, ${skipped} skipped`);
306}
307
308/**
309 *
310 * @param mysqlConn
311 * @param pgPool
312 * @param tenantId
313 * @param customerMap
314 * @param productMap
315 */
316async function migrateServicesAndContracts(mysqlConn, pgPool, tenantId, customerMap, productMap) {
317 console.log('\n=== MIGRATING CLIENT SERVICES & CREATING CONTRACTS ===');
318
319 const [services] = await mysqlConn.query(`
320 SELECT id, userid, packageid, domain, regdate, nextduedate,
321 billingcycle, amount, domainstatus
322 FROM tblhosting
323 WHERE domainstatus NOT IN ('Terminated', 'Cancelled')
324 ORDER BY id
325 `);
326
327 console.log(`Found ${services.length} active services in WHMCS`);
328
329 let imported = 0;
330 let skipped = 0;
331
332 for (const service of services) {
333 try {
334 // Get RMM customer ID
335 const rmmCustomerId = customerMap[service.userid];
336 if (!rmmCustomerId) {
337 skipped++;
338 continue;
339 }
340
341 // Get customer name for logging
342 const customerResult = await pgPool.query(
343 'SELECT name FROM customers WHERE customer_id = $1',
344 [rmmCustomerId]
345 );
346 const customerName = customerResult.rows[0]?.name || 'Unknown';
347
348 // Get product name
349 const [productRows] = await mysqlConn.query(
350 'SELECT name FROM tblproducts WHERE id = ?',
351 [service.packageid]
352 );
353 const productName = productRows.length > 0 ? productRows[0].name : 'Service';
354
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}%`]
359 );
360
361 if (existing.rows.length > 0) {
362 skipped++;
363 continue;
364 }
365
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') {
372 skipped++;
373 continue; // Skip free accounts
374 }
375
376 // Calculate next billing date
377 const nextBillingDate = service.nextduedate ||
378 new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
379
380 // Determine contract status
381 let contractStatus = 'active';
382 if (service.domainstatus === 'Pending') contractStatus = 'draft';
383 else if (service.domainstatus === 'Suspended') contractStatus = 'suspended';
384
385 // Create contract
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
393 `, [
394 tenantId,
395 rmmCustomerId,
396 productName,
397 `${productName} - ${service.domain || ''}`,
398 contractStatus,
399 service.regdate || new Date().toISOString().split('T')[0],
400 billingInterval,
401 nextBillingDate,
402 true, // auto_invoice_enabled
403 0, // labor_rate
404 `Migrated from WHMCS Service #${service.id}\nDomain: ${service.domain || 'N/A'}`
405 ]);
406
407 const contractId = contractResult.rows[0].contract_id;
408
409 // Add contract line item for the service
410 const rmmProductId = productMap[service.packageid] || null;
411 const amount = parseFloat(service.amount || 0);
412
413 await pgPool.query(`
414 INSERT INTO contract_line_items (
415 contract_id, product_id, description, quantity, unit_price, recurring
416 ) VALUES ($1, $2, $3, $4, $5, $6)
417 `, [
418 contractId,
419 rmmProductId,
420 productName,
421 1,
422 amount,
423 true
424 ]);
425
426 imported++;
427 if (imported % 10 === 0) {
428 process.stdout.write(`\r Processed: ${imported}/${services.length}`);
429 }
430
431 } catch (error) {
432 console.error(`\n✗ Failed to create contract for service ${service.id}:`, error.message);
433 }
434 }
435
436 console.log(`\n✓ Contracts migration complete: ${imported} created, ${skipped} skipped`);
437}
438
439// ===========================
440// MAIN MIGRATION
441// ===========================
442
443/**
444 *
445 */
446async function main() {
447 console.log('╔══════════════════════════════════════════════════════════╗');
448 console.log('║ WHMCS → RMM Backend Migration Script (Direct DB) ║');
449 console.log('╚══════════════════════════════════════════════════════════╝\n');
450
451 let mysqlConn;
452 let pgPool;
453
454 try {
455 // Connect to MySQL
456 console.log('Connecting to WHMCS MySQL database...');
457 mysqlConn = await mysql.createConnection(MYSQL_CONFIG);
458 console.log('✓ WHMCS database connected\n');
459
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');
465
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`);
470
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);
476
477 console.log('\n╔══════════════════════════════════════════════════════════╗');
478 console.log('║ MIGRATION COMPLETE ✓ ║');
479 console.log('╚══════════════════════════════════════════════════════════╝\n');
480
481 // Summary query
482 const stats = await pgPool.query(`
483 SELECT
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
488 `, [tenantId]);
489
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}`);
495 console.log('');
496
497 } catch (error) {
498 console.error('\n❌ MIGRATION FAILED:', error.message);
499 console.error(error.stack);
500 process.exit(1);
501 } finally {
502 if (mysqlConn) await mysqlConn.end();
503 if (pgPool) await pgPool.end();
504 }
505}
506
507// Run migration
508if (require.main === module) {
509 main();
510}
511
512module.exports = { main };