248 lines
10 KiB
JavaScript
248 lines
10 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
|
||
for (const sub of stripeCustRS[1]) {
|
||
await stripe.subscriptions.del(sub.id, { prorate: false, invoice_now: false });
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
|
||
if (startMoment.isBefore(moment.utc().startOf('day'))) {
|
||
// 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: { type: SubType.PACKAGE } }, { items: [{ price: getStripePriceId(mCust.package) }] })
|
||
);
|
||
}
|
||
if (mCust.trackingQty && mCust.trackingQty > 0) {
|
||
await createSubscription(
|
||
Object.assign({}, subOps, { metadata: { type: SubType.ADDON } }, { items: [{ price: getStripePriceId('addon_1'), quantity: +mCust.trackingQty || 1 }] })
|
||
);
|
||
}
|
||
} 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. Marked the customer as as migrated when any steps failed
|
||
await models.Customer.updateOne({ username: { $regex: new RegExp(`^${mCust.username}$`, 'i') } }, {
|
||
$set: { migratedDate: new Date() }
|
||
});
|
||
} catch (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 },
|
||
] :
|
||
// 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');
|
||
|
||
|
||
try {
|
||
await migrateToSM(custList);
|
||
} catch (error) {
|
||
console.error(error);
|
||
throw error;
|
||
}
|
||
}
|