'use strict'; const debug = require('debug')('agm:migrateToSM_util'), env = require('../helpers/env.js'), isProd = env.PRODUCTION, { DBConnection } = require('../helpers/db/connect.js'), { SubType } = require('../model/subscription.js'), models = require('../model/index.js'), utils = require('../helpers/utils.js'), moment = require('moment'), stripe = require('stripe')(env.STRIPE_SEC_KEY, { apiVersion: env.STRIPE_API_VERSION }); // Initialize database connection const workerDB = new DBConnection('Subscription Migration Worker'); const TEST_MODE = false; const VERIFY_ONLY = false; process .on('uncaughtException', function (err) { debug(err); process.exit(1); }) .on('unhandledRejection', (reason, p) => { debug(reason, 'Unhandled Rejection at Promise', p); }); // Initialize the database connection and start migration workerDB.initialize({ setupExitHandlers: false, onReady: async () => { try { await doMigration(); process.exit(0); } catch (error) { debug('Migration failed:', error); process.exit(1); } } }); function getStripePriceId(pkgName) { const priceKey = pkgName && pkgName.toLowerCase().replace(/-/g, '_'); return env.PRICES[priceKey]; } // Quick convert from date string (MM-DD-YYYY or MM/DD/YYYY) to moment object function dateStrToMoment(str) { // Check if the date string is in the format of MM-DD-YYYY or MM/DD/YYYY if (!str || !str.match(/(\d{2}[-/]){2}\d{4}/)) throw new Error(`Invalid date string format: ${str}`); // Convert the date string to the format of YYYY-MM-DD const _isoDateStr = str && (str.split(/[-/]/).reverse().join('-')); return moment.utc(_isoDateStr); } async function createSubscription(params) { return stripe && (await stripe.subscriptions.create(params)); } async function createStripeCustomer(params) { const custsRS = await stripe.customers.search({ query: `email:"${params.username}"`, }); if (!utils.isEmptyArray(custsRS.data)) { const subsRS = await stripe.subscriptions.list({ customer: custsRS.data[0].id, }); return subsRS.data && subsRS.data.length ? [custsRS.data[0], subsRS.data] : [custsRS.data[0]]; } else { return [await stripe.customers.create({ name: params.name, // The customer's (appplicator) business name. email: params.username, address: { ...((params.country == 'CA' && params.username.endsWith('@agnav.com')) && { line1: '30 Churchill Drive', city: 'Barrie', state: 'ON', postal_code: 'L4N 9P8' }), country: params.country } })]; } } async function updateStripeCustomerInfoFromDB() { const customers = await models.Customer.find({ active: true, migratedDate: null, "membership.custId": { $ne: null } }).lean(); for (const cust of customers) { const stripeCusts = await stripe.customers.search({ query: `email:"${cust.username.toLowerCase()}"` }); if (stripeCusts && stripeCusts.data && stripeCusts.data.length == 1) { await stripe.customers.update(stripeCusts.data[0].id, { name: cust.name.trim() }); } } } async function migrateToSM(custList) { if (utils.isEmptyArray(custList)) return; // Check and only proceed when is idle and the db connection is connected if (!workerDB.isReady()) return; const filterOps = { markedDelete: { $ne: true }, kind: '1', /*active: true, membership: { $ne: null }, "membership.custId": { $ne: null },migratedDate: null, /*username: /.*(? 1) { // 1.1.2 Cancel all existing subscriptions for the customer // Mark with upgrade_operation to avoid sending multiple emails during migration debug(`Found ${stripeCustRS[1].length} existing subscriptions for ${mCust.username}, cancelling them...`); for (const sub of stripeCustRS[1]) { // Skip if already cancelled or incomplete_expired if (['canceled', 'incomplete_expired'].includes(sub.status)) { debug(`Skipping already cancelled subscription ${sub.id}`); continue; } // Update metadata before deletion to skip webhook email await stripe.subscriptions.update(sub.id, { metadata: { ...sub.metadata, upgrade_operation: 'true' } }); await stripe.subscriptions.cancel(sub.id, { prorate: false, invoice_now: false }); debug(`Cancelled subscription ${sub.id} (type: ${sub.metadata?.type})`); } } if (endMoment.isAfter(moment.utc().endOf('day'))) { // 1.2 Create subscriptions for the customer const subOps = { cancel_at_period_end: true, automatic_tax: { enabled: utils.stringToBoolean(mCust.taxable) && dbAppl.country === 'CA' }, // Making the initial period up to the first full invoice date free. This action doesn't generate an invoice at all until the first billing cycle. Ref: https://docs.stripe.com/billing/subscriptions/billing-cycle#new-subscriptions proration_behavior: 'none', trial_end: endMoment.unix(), trial_settings: { end_behavior: { missing_payment_method: "cancel" } }, customer: stripeCust.id, // Mark as migration operation to avoid sending emails for each subscription metadata: { migration_operation: 'true' } }; if (startMoment.format('YYYY-MM-DD') <= moment.utc().format('YYYY-MM-DD')) { // Ref: https://docs.stripe.com/billing/subscriptions/backdating?dashboard-or-api=api subOps.backdate_start_date = startMoment.unix(); if (mCust?.package.trim().length) { await createSubscription( Object.assign({}, subOps, { metadata: { ...subOps.metadata, type: SubType.PACKAGE } }, { items: [{ price: getStripePriceId(mCust.package) }] }) ); debug(`Created PACKAGE subscription for ${mCust.username}: ${mCust.package}`); } if (mCust.trackingQty && mCust.trackingQty > 0) { await createSubscription( Object.assign({}, subOps, { metadata: { ...subOps.metadata, type: SubType.ADDON } }, { items: [{ price: getStripePriceId('addon_1'), quantity: +mCust.trackingQty || 1 }] }) ); debug(`Created ADDON subscription for ${mCust.username}: quantity ${mCust.trackingQty}`); } } else { // Only Log the customer as a future trial user if the start date is in the future debug(`Customer ${mCust.username} is a future trial user. Start date: ${startMoment.format('YYYY-MM-DD')}, End date: ${endMoment.format('YYYY-MM-DD')}`); } } // 2. Update membership info for the customer. This can be done by the webhooks handler which NEEDS TO BE ACTIVE BEFORE THE MIGRATION. // 3. Wait a bit for webhooks to process await new Promise(resolve => setTimeout(resolve, 5000)); // Wait 5 seconds for webhooks // 4. Fetch all subscriptions from Stripe and update DB with replaceAllExisting=true to avoid duplicates try { const { updateCustSubscriptions } = require('../controllers/subscription'); const allSubs = await stripe.subscriptions.list({ customer: stripeCust.id, status: 'all' }); if (allSubs && allSubs.data && allSubs.data.length > 0) { debug(`Updating DB with ${allSubs.data.length} subscriptions for ${mCust.username}`); await updateCustSubscriptions(dbAppl._id, allSubs.data, true); // true = replaceAllExisting } } catch (updateError) { debug(`Error updating subscriptions in DB for ${mCust.username}:`, updateError.message); // Continue anyway - webhooks should have handled it } // 5. Send final email with updated subscription state try { const updatedCustomer = await models.Customer.findOne({ username: { $regex: new RegExp(`^${mCust.username}$`, 'i') } }); if (updatedCustomer && updatedCustomer.membership && updatedCustomer.membership?.subscriptions && updatedCustomer.membership?.subscriptions.length > 0) { debug(`Sending final subscription email to ${mCust.username} with ${updatedCustomer.membership?.subscriptions.length} subscriptions`); const { emailCurSubcriptions } = require('../controllers/subscription'); // Create a minimal req object that looks like an Express request const req = { locals: {}, protocol: 'https', hostname: process.env.PRODUCTION ? 'agmission.agnav.com' : 'localhost', get: (header) => { if (header === 'host') return process.env.PRODUCTION ? 'agmission.agnav.com' : 'localhost:4200'; return null; } }; await emailCurSubcriptions(updatedCustomer, updatedCustomer.membership?.subscriptions, req); debug(`Email sent successfully to ${mCust.username}`); } else { debug(`No subscriptions found for ${mCust.username}, skipping email`); } } catch (emailError) { debug(`Error sending email to ${mCust.username}:`, emailError.message); } // 6. Marked the customer as migrated await models.Customer.updateOne({ username: { $regex: new RegExp(`^${mCust.username}$`, 'i') } }, { $set: { migratedDate: new Date() } }); debug(`Successfully migrated ${mCust.username}`); } catch (error) { debug(`Error migrating ${mCust.username}:`, error); if (error.type && error.type.startsWith('Stripe')) { noSubCusts.push(mCust.username + ' (' + mCust.endDate + ')'); } else { errorCusts.push(mCust.username + ' (' + mCust.endDate + ')'); } } } !utils.isEmptyArray(noSubCusts) && (debug(`Can't create subscriptions for ${noSubCusts.length} customers !. Please inform them about the expiry issue.`, noSubCusts.join(','))); !utils.isEmptyArray(errorCusts) && (debug(`Can't create subscriptions for ${errorCusts.length} customers !. Please check again.`, errorCusts.join(','))); if (!utils.isEmptyArray(okCusts)) { debug(`DONE. Migrated ${okCusts.length} customers to DB !.`); } } else { debug(`DONE Verifying AGM Customer. There are ${okCusts.length} of ${custList.length} customers ready to be migrated to SM.`); } } async function doMigration() { const custList = TEST_MODE ? [ // { username: 'trungh1@agnav.com', package: 'ess-1', trackingQty: 1, startDate: '01-11-2024', endDate: '02-11-2025', taxable: false }, { username: 'trungduyhoang@gmail.com', package: 'ess-2', trackingQty: 0, startDate: '28-10-2025', endDate: '30-12-2025', taxable: false }, ] : // require('./custList-Mar14_25.json'); // require('./custList2.json'); // require('./custList-Mar24_25-2.json'); // require('./custList-Mar25_25.json'); // require('./custList-May_06_25.json'); // require('./custList-May07_25-up.json'); // require('./custList-May08_25-Crabbe.json'); // require('./custList-May08_25-Eastern.json'); // require('./custList-May12_25-Metro_NancyR.json'); // require('./custList-May12_25-Volusia.json'); // require('./custList-May14_25-FloridaKeys.json'); // require('./custList-May16_25-Osbone_Aviation.json'); // require('./sub-migration/custList-May20_25-VDCI.json'); // require('./custList-May21_25-reviewed.json'); // require('./custList-May26_25-AEROTREILE.json'); // require('./custList-May27_25-Rimin_Air-trial.json'); // require('./custList-May28_25-3SB.json'); // require('./custList-May29_25-Wyatt_Trost.json'); // require('./sub-migration/custList-Jun_03-Beaufort_2-3_corrected.json'); // require('./sub-migration/custList-June11_25-Fazeda-renewed-manually.json'); // require('./sub-migration/custList-June24_25-Skyline-Helicopers.json'); // require('./sub-migration/custList-July09_25-Eastern.json'); // require('./sub-migration/custList-July21_25-Fazenda_Embu_merge_3.json'); // require('./sub-migration/custList-July31_25-East_Baton_Rouge_Mosquito.json'); // require('./sub-migration/custList-Aug05_25-National_Airways.json'); // require('./sub-migration/custList-Aug06_25-Amaggi_extend.json'); // require('./sub-migration/custList-Aug06_25-Amaggi_update_Oct25.json'); // require('./sub-migration/custList-Sept09_25_correct_pkg3.json'); // require('./sub-migration/custList-Oct30_25.json'); // require('./sub-migration/custList-Nov04_25.json'); // require('./sub-migration/custList-Nov04_25-Alexandre_Burin.json'); // require('./sub-migration/custList-Nov27_25-Bom-Futuro.json'); // require('./sub-migration/custList-Dec15_25-Marcos Antonio Busato.json'); // require('./sub-migration/custList-Jan20_26.json'); // require('./sub-migration/custList-Jan21_26.json'); // require('./sub-migration/custList-Feb05_26.json'); // require('./sub-migration/custList-Feb11_26-SatLoc.json'); // require('./sub-migration/custList-Feb13_26.json'); // require('./sub-migration/custList-Feb27_26.json'); // require('./sub-migration/custList-May12_25-Volusia copy.json'); require('./sub-migration/custList-Apr_26.json'); try { await migrateToSM(custList); } catch (error) { console.error(error); throw error; } }