EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
migrate-whmcs-to-rmm.js
Go to the documentation of this file.
1#!/usr/bin/env node
2/**
3 * WHMCS to RMM Backend Migration Script
4 *
5 * Migrates all data from WHMCS to RMM PostgreSQL:
6 * - Customers → customers table
7 * - Products → products table
8 * - Invoices → invoices table
9 * - Client Services → contracts + contract_line_items tables
10 */
11
12const https = require('https');
13const { Pool } = require('pg');
14
15// ===========================
16// CONFIGURATION
17// ===========================
18
19// WHMCS API Configuration
20const WHMCS_CONFIG = {
21 hostname: 'clientarea.precisewebhosting.com.au',
22 identifier: '6o30xS9dkk1iQE6KjaPh1HwTFhTzgW5e',
23 secret: 'oHw4epOsuhTJuJcdTyFZpvHrgT9bCZp3'
24};
25
26// RMM PostgreSQL Configuration
27const PG_CONFIG = {
28 host: 'rmm-psa-db-do-user-28531160-0.i.db.ondigitalocean.com',
29 port: 25060,
30 database: 'defaultdb',
31 user: 'doadmin',
32 password: 'AVNS_J8RJAmsEwsHFG52_-F2',
33 ssl: {
34 rejectUnauthorized: false
35 }
36};
37
38// Default tenant (Independent Business Group)
39const DEFAULT_TENANT_NAME = 'Independent Business Group';
40
41// ===========================
42// WHMCS API HELPER
43// ===========================
44
45/**
46 *
47 * @param action
48 * @param params
49 */
50function whmcsAPI(action, params = {}) {
51 return new Promise((resolve, reject) => {
52 const postData = new URLSearchParams({
53 action,
54 identifier: WHMCS_CONFIG.identifier,
55 secret: WHMCS_CONFIG.secret,
56 responsetype: 'json',
57 ...params
58 }).toString();
59
60 const options = {
61 hostname: WHMCS_CONFIG.hostname,
62 path: '/includes/api.php',
63 method: 'POST',
64 headers: {
65 'Content-Type': 'application/x-www-form-urlencoded',
66 'Content-Length': postData.length
67 },
68 rejectUnauthorized: false
69 };
70
71 const req = https.request(options, (res) => {
72 let data = '';
73 res.on('data', (chunk) => data += chunk);
74 res.on('end', () => {
75 try {
76 const json = JSON.parse(data);
77 if (json.result === 'error') {
78 reject(new Error(json.message || 'WHMCS API Error'));
79 } else {
80 resolve(json);
81 }
82 } catch (e) {
83 reject(new Error(`Failed to parse WHMCS response: ${e.message}`));
84 }
85 });
86 });
87
88 req.on('error', (e) => reject(e));
89 req.write(postData);
90 req.end();
91 });
92}
93
94// ===========================
95// DATABASE HELPERS
96// ===========================
97
98/**
99 *
100 * @param pool
101 * @param tenantName
102 */
103async function getTenantId(pool, tenantName) {
104 const result = await pool.query(
105 'SELECT tenant_id FROM tenants WHERE name = $1 OR subdomain = $2 LIMIT 1',
106 [tenantName, tenantName.toLowerCase().replace(/\s+/g, '-')]
107 );
108
109 if (result.rows.length > 0) {
110 return result.rows[0].tenant_id;
111 }
112
113 // Create tenant if doesn't exist
114 console.log(`Creating tenant: ${tenantName}...`);
115 const insertResult = await pool.query(`
116 INSERT INTO tenants (name, subdomain, status, is_msp)
117 VALUES ($1, $2, 'active', false)
118 RETURNING tenant_id
119 `, [tenantName, tenantName.toLowerCase().replace(/\s+/g, '-')]);
120
121 return insertResult.rows[0].tenant_id;
122}
123
124// ===========================
125// MIGRATION FUNCTIONS
126// ===========================
127
128/**
129 *
130 * @param pool
131 * @param tenantId
132 */
133async function migrateCustomers(pool, tenantId) {
134 console.log('\n=== MIGRATING CUSTOMERS ===');
135
136 let imported = 0;
137 let skipped = 0;
138 let page = 0;
139 const limit = 100;
140
141 while (true) {
142 const response = await whmcsAPI('GetClients', {
143 limitstart: page * limit,
144 limitnum: limit
145 });
146
147 const clients = response.clients?.client || [];
148 if (clients.length === 0) break;
149
150 console.log(`Processing page ${page + 1} (${clients.length} clients)...`);
151
152 for (const client of clients) {
153 try {
154 // Check if customer already exists
155 const existing = await pool.query(
156 'SELECT customer_id FROM customers WHERE email = $1 AND tenant_id = $2',
157 [client.email, tenantId]
158 );
159
160 if (existing.rows.length > 0) {
161 skipped++;
162 continue;
163 }
164
165 // Map WHMCS fields to RMM schema
166 const customerName = client.companyname ||
167 `${client.firstname} ${client.lastname}`.trim();
168 const contactName = `${client.firstname} ${client.lastname}`.trim();
169 const address = `${client.address1}\n${client.address2 || ''}`.trim();
170 const status = client.status === 'Active' ? 'active' : 'inactive';
171
172 // Insert customer
173 const result = await pool.query(`
174 INSERT INTO customers (
175 tenant_id, name, contact_name, email, phone,
176 address, city, state, postal_code, country, status
177 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
178 RETURNING customer_id
179 `, [
180 tenantId, customerName, contactName, client.email, client.phonenumber,
181 address, client.city, client.state, client.postcode, client.country, status
182 ]);
183
184 // Store mapping for later use
185 client.rmm_customer_id = result.rows[0].customer_id;
186
187 imported++;
188 console.log(`✓ Imported: ${customerName} (${client.email})`);
189
190 } catch (error) {
191 console.error(`✗ Failed to import ${client.email}:`, error.message);
192 }
193 }
194
195 page++;
196 // Break if we got less than limit (last page)
197 if (clients.length < limit) break;
198 }
199
200 console.log(`\n✓ Customers migration complete: ${imported} imported, ${skipped} skipped`);
201}
202
203/**
204 *
205 * @param pool
206 * @param tenantId
207 */
208async function migrateProducts(pool, tenantId) {
209 console.log('\n=== MIGRATING PRODUCTS ===');
210
211 const response = await whmcsAPI('GetProducts');
212 const products = response.products?.product || [];
213
214 let imported = 0;
215 let skipped = 0;
216
217 for (const product of products) {
218 try {
219 // Check if product already exists
220 const existing = await pool.query(
221 'SELECT product_id FROM products WHERE name = $1 AND tenant_id = $2',
222 [product.name, tenantId]
223 );
224
225 if (existing.rows.length > 0) {
226 product.rmm_product_id = existing.rows[0].product_id;
227 skipped++;
228 continue;
229 }
230
231 // Determine product type
232 const isService = product.type === 'hostingaccount' ||
233 product.type === 'reselleraccount' ||
234 product.type === 'server';
235
236 // Insert product
237 const result = await pool.query(`
238 INSERT INTO products (
239 tenant_id, name, description, unit_price, created_at
240 ) VALUES ($1, $2, $3, $4, NOW())
241 RETURNING product_id
242 `, [
243 tenantId,
244 product.name,
245 product.description || '',
246 parseFloat(product.pricing?.USD?.monthly || 0)
247 ]);
248
249 product.rmm_product_id = result.rows[0].product_id;
250
251 imported++;
252 console.log(`✓ Imported: ${product.name}`);
253
254 } catch (error) {
255 console.error(`✗ Failed to import ${product.name}:`, error.message);
256 }
257 }
258
259 console.log(`\n✓ Products migration complete: ${imported} imported, ${skipped} skipped`);
260 return products; // Return for contract mapping
261}
262
263/**
264 *
265 * @param pool
266 * @param tenantId
267 */
268async function migrateInvoices(pool, tenantId) {
269 console.log('\n=== MIGRATING INVOICES ===');
270
271 let imported = 0;
272 let skipped = 0;
273 let page = 0;
274 const limit = 100;
275
276 while (true) {
277 const response = await whmcsAPI('GetInvoices', {
278 limitstart: page * limit,
279 limitnum: limit
280 });
281
282 const invoices = response.invoices?.invoice || [];
283 if (invoices.length === 0) break;
284
285 console.log(`Processing page ${page + 1} (${invoices.length} invoices)...`);
286
287 for (const invoice of invoices) {
288 try {
289 // Get customer by email
290 const customerResult = await pool.query(
291 'SELECT customer_id FROM customers WHERE tenant_id = $1 LIMIT 1 OFFSET $2',
292 [tenantId, parseInt(invoice.userid) - 1]
293 );
294
295 if (customerResult.rows.length === 0) {
296 console.log(`⏭️ Skipping invoice ${invoice.id} - customer not found`);
297 skipped++;
298 continue;
299 }
300
301 const customerId = customerResult.rows[0].customer_id;
302
303 // Check if invoice already exists
304 const existing = await pool.query(
305 'SELECT invoice_id FROM invoices WHERE customer_id = $1 AND date = $2 AND total = $3',
306 [customerId, invoice.date, parseFloat(invoice.total)]
307 );
308
309 if (existing.rows.length > 0) {
310 skipped++;
311 continue;
312 }
313
314 // Map invoice status
315 let status = 'draft';
316 if (invoice.status === 'Paid') status = 'paid';
317 else if (invoice.status === 'Unpaid') status = 'sent';
318 else if (invoice.status === 'Cancelled') status = 'void';
319
320 // Insert invoice
321 await pool.query(`
322 INSERT INTO invoices (
323 tenant_id, customer_id, invoice_number, date, due_date,
324 subtotal, tax, total, status, notes
325 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
326 `, [
327 tenantId,
328 customerId,
329 invoice.invoicenum || `INV-${invoice.id}`,
330 invoice.date,
331 invoice.duedate,
332 parseFloat(invoice.subtotal || 0),
333 parseFloat(invoice.tax || 0),
334 parseFloat(invoice.total || 0),
335 status,
336 `Migrated from WHMCS Invoice #${invoice.id}`
337 ]);
338
339 imported++;
340
341 } catch (error) {
342 console.error(`✗ Failed to import invoice ${invoice.id}:`, error.message);
343 }
344 }
345
346 page++;
347 if (invoices.length < limit) break;
348 }
349
350 console.log(`\n✓ Invoices migration complete: ${imported} imported, ${skipped} skipped`);
351}
352
353/**
354 *
355 * @param pool
356 * @param tenantId
357 * @param products
358 */
359async function migrateServicesAndContracts(pool, tenantId, products) {
360 console.log('\n=== MIGRATING CLIENT SERVICES & CREATING CONTRACTS ===');
361
362 let imported = 0;
363 let skipped = 0;
364 let page = 0;
365 const limit = 100;
366
367 // Build product lookup map
368 const productMap = {};
369 products.forEach(p => {
370 if (p.id && p.rmm_product_id) {
371 productMap[p.id] = p.rmm_product_id;
372 }
373 });
374
375 while (true) {
376 const response = await whmcsAPI('GetClientsProducts', {
377 limitstart: page * limit,
378 limitnum: limit
379 });
380
381 const services = response.products?.product || [];
382 if (services.length === 0) break;
383
384 console.log(`Processing page ${page + 1} (${services.length} services)...`);
385
386 for (const service of services) {
387 try {
388 // Get customer by userid
389 const customerResult = await pool.query(
390 'SELECT customer_id, name FROM customers WHERE tenant_id = $1 LIMIT 1 OFFSET $2',
391 [tenantId, parseInt(service.clientid) - 1]
392 );
393
394 if (customerResult.rows.length === 0) {
395 console.log(`⏭️ Skipping service ${service.id} - customer not found`);
396 skipped++;
397 continue;
398 }
399
400 const customer = customerResult.rows[0];
401
402 // Skip if service is terminated
403 if (service.status === 'Terminated' || service.status === 'Cancelled') {
404 skipped++;
405 continue;
406 }
407
408 // Check if contract already exists
409 const existing = await pool.query(
410 'SELECT contract_id FROM contracts WHERE customer_id = $1 AND title ILIKE $2',
411 [customer.customer_id, `%${service.name}%`]
412 );
413
414 if (existing.rows.length > 0) {
415 skipped++;
416 continue;
417 }
418
419 // Map billing cycle to interval
420 let billingInterval = 'monthly';
421 if (service.billingcycle === 'Annually') billingInterval = 'yearly';
422 else if (service.billingcycle === 'Quarterly') billingInterval = 'quarterly';
423 else if (service.billingcycle === 'Semi-Annually') billingInterval = 'semi_annually';
424
425 // Calculate next billing date
426 const nextBillingDate = service.nextduedate ||
427 new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
428
429 // Determine contract status
430 let contractStatus = 'active';
431 if (service.status === 'Pending') contractStatus = 'draft';
432 else if (service.status === 'Suspended') contractStatus = 'suspended';
433
434 // Create contract
435 const contractResult = await pool.query(`
436 INSERT INTO contracts (
437 tenant_id, customer_id, title, description, status,
438 start_date, billing_interval, next_billing_date,
439 auto_invoice_enabled, labor_rate, notes
440 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
441 RETURNING contract_id
442 `, [
443 tenantId,
444 customer.customer_id,
445 service.name || service.product || 'Service Contract',
446 service.product || '',
447 contractStatus,
448 service.regdate || new Date().toISOString().split('T')[0],
449 billingInterval,
450 nextBillingDate,
451 true, // auto_invoice_enabled
452 0, // labor_rate (can be updated later)
453 `Migrated from WHMCS Service #${service.id}\nDomain: ${service.domain || 'N/A'}`
454 ]);
455
456 const contractId = contractResult.rows[0].contract_id;
457
458 // Add contract line item for the service
459 const productId = productMap[service.pid] || null;
460 const amount = parseFloat(service.amount || 0);
461
462 await pool.query(`
463 INSERT INTO contract_line_items (
464 contract_id, product_id, description, quantity, unit_price, recurring
465 ) VALUES ($1, $2, $3, $4, $5, $6)
466 `, [
467 contractId,
468 productId,
469 service.product || service.name || 'Service',
470 1,
471 amount,
472 true
473 ]);
474
475 imported++;
476 console.log(`✓ Created contract: ${service.name} for ${customer.name} ($${amount}/${billingInterval})`);
477
478 } catch (error) {
479 console.error(`✗ Failed to create contract for service ${service.id}:`, error.message);
480 }
481 }
482
483 page++;
484 if (services.length < limit) break;
485 }
486
487 console.log(`\n✓ Contracts migration complete: ${imported} created, ${skipped} skipped`);
488}
489
490// ===========================
491// MAIN MIGRATION
492// ===========================
493
494/**
495 *
496 */
497async function main() {
498 console.log('╔══════════════════════════════════════════════════════════╗');
499 console.log('║ WHMCS → RMM Backend Migration Script ║');
500 console.log('╚══════════════════════════════════════════════════════════╝\n');
501
502 const pool = new Pool(PG_CONFIG);
503
504 try {
505 // Test database connection
506 console.log('Testing database connection...');
507 await pool.query('SELECT NOW()');
508 console.log('✓ Database connected\n');
509
510 // Get or create tenant
511 console.log(`Getting tenant ID for: ${DEFAULT_TENANT_NAME}...`);
512 const tenantId = await getTenantId(pool, DEFAULT_TENANT_NAME);
513 console.log(`✓ Tenant ID: ${tenantId}\n`);
514
515 // Test WHMCS API
516 console.log('Testing WHMCS API connection...');
517 await whmcsAPI('GetClients', { limitnum: 1 });
518 console.log('✓ WHMCS API connected\n');
519
520 // Run migrations in sequence
521 await migrateCustomers(pool, tenantId);
522 const products = await migrateProducts(pool, tenantId);
523 await migrateInvoices(pool, tenantId);
524 await migrateServicesAndContracts(pool, tenantId, products);
525
526 console.log('\n╔══════════════════════════════════════════════════════════╗');
527 console.log('║ MIGRATION COMPLETE ✓ ║');
528 console.log('╚══════════════════════════════════════════════════════════╝\n');
529
530 // Summary query
531 const stats = await pool.query(`
532 SELECT
533 (SELECT COUNT(*) FROM customers WHERE tenant_id = $1) as customers,
534 (SELECT COUNT(*) FROM products WHERE tenant_id = $1) as products,
535 (SELECT COUNT(*) FROM invoices WHERE tenant_id = $1) as invoices,
536 (SELECT COUNT(*) FROM contracts WHERE tenant_id = $1) as contracts
537 `, [tenantId]);
538
539 console.log('Final Statistics:');
540 console.log(` Customers: ${stats.rows[0].customers}`);
541 console.log(` Products: ${stats.rows[0].products}`);
542 console.log(` Invoices: ${stats.rows[0].invoices}`);
543 console.log(` Contracts: ${stats.rows[0].contracts}`);
544 console.log('');
545
546 } catch (error) {
547 console.error('\n❌ MIGRATION FAILED:', error.message);
548 console.error(error.stack);
549 process.exit(1);
550 } finally {
551 await pool.end();
552 }
553}
554
555// Run migration
556if (require.main === module) {
557 main();
558}
559
560module.exports = { main };