agmission/Development/server/scripts/migrateToSM.js

328 lines
15 KiB
JavaScript

'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: /.*(?<!@agnav\.com)$/i */ };
const customers = await models.Customer.find(filterOps).lean();
const custSet = utils.arrayToObject(customers, "username", true) || {};
const notOKUsers = [], okCusts = [];
for (const cust of custList) {
cust.username = cust.username.toLowerCase();
if (!custSet[cust.username]) notOKUsers.push(cust.username);
else okCusts.push(cust);
}
if (!utils.isEmptyArray(notOKUsers)) {
debug(`Skipped ${notOKUsers.length} customers because the master account were NOT FOUND or are already in DB or were already migrated !.`, notOKUsers.join(','));
}
if (!VERIFY_ONLY) {
const noSubCusts = [];
const errorCusts = [];
let stripeCust;
for (const mCust of okCusts) {
const dbAppl = custSet[mCust.username];
try {
// Create subscriptions for the customer
// 1.1 Create a customer in Stripe if not exists
const stripeCustRS = await createStripeCustomer({ name: dbAppl.name.trim(), username: mCust.username, country: dbAppl.country });
stripeCust = stripeCustRS && (stripeCust = stripeCustRS[0]);
const startMoment = dateStrToMoment(mCust.startDate).startOf('day');
const endMoment = dateStrToMoment(mCust.endDate).endOf('day');
// 1.1.1 Update the customer's membership info in DB
await models.Customer.updateOne({ username: { $regex: new RegExp(`^${mCust.username}$`, 'i') } }, {
$set: {
membership: {
custId: stripeCust.id,
trials: {
type: 'byDate',
startDate: startMoment.toDate(),
byDate: endMoment.toDate(),
}
}
}
});
if (stripeCustRS.length > 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('./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-Mar09_26.json');
try {
await migrateToSM(custList);
} catch (error) {
console.error(error);
throw error;
}
}