All checks were successful
Server Tests / Mocha – Unit & Utility Tests (push) Successful in 42s
+ Added public data export API enhancements, tests, and customer documentation + Extended /api/v1 data export endpoints with richer session, records, area, and async export output + Added confirmed/fallback report values, client metadata, mapped area, over-spray, volume/apprate (string) units, and weather blocks + Normalized flowController to "No FC" and align record field names with playback output + Converted record wind speed output to knots, add Fligh Mater only record/export fields behind fm=true, and persist fm on export jobs + Added export status/area constants, HTTP 202 support, route-level API docs, and per-account export rate limiting support + Added comprehensive endpoint, format, and verification test coverage plus test-suite README + Added customer-facing data export design, integration, rate-limit, and documentation index guides + Updated README/DLQ docs and related documentation links to current HTTPS dashboard paths
328 lines
15 KiB
JavaScript
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('./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;
|
|
}
|
|
}
|