399 lines
15 KiB
JavaScript
399 lines
15 KiB
JavaScript
// /home/trung/work/AgMission/branches/satloc-resume/server/scripts/pause_addon_subs.js
|
|
|
|
/**
|
|
* Pause Addon Subscriptions Script
|
|
*
|
|
* Pauses all active addon_1 subscriptions with optional auto-resume date.
|
|
*
|
|
* Usage:
|
|
* node scripts/pause_addon_subs.js [options]
|
|
*
|
|
* Options:
|
|
* --env <path> Path to environment file (default: ./environment.env)
|
|
* --resume-date <date> Date to auto-resume (ISO format, e.g., 2026-03-01)
|
|
* --reason <string> Reason for pausing (stored in metadata)
|
|
* --dry-run Preview changes without applying
|
|
* --limit <number> Max subscriptions to process (default: 500)
|
|
*
|
|
* Examples:
|
|
* node scripts/pause_addon_subs.js --env ./environment_prod.env --resume-date 2026-03-01
|
|
* node scripts/pause_addon_subs.js --dry-run
|
|
* node scripts/pause_addon_subs.js --reason "addon_launch_promo" --resume-date 2026-06-01
|
|
*/
|
|
|
|
const path = require('path');
|
|
const moment = require('moment');
|
|
|
|
// Parse command line arguments
|
|
const args = process.argv.slice(2);
|
|
const options = {
|
|
envFile: './environment.env',
|
|
resumeDate: null,
|
|
reason: 'addon_promo',
|
|
dryRun: false,
|
|
limit: 500
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case '-env':
|
|
case '--env':
|
|
options.envFile = args[++i];
|
|
break;
|
|
case '-resume-date':
|
|
case '--resume-date':
|
|
options.resumeDate = args[++i];
|
|
break;
|
|
case '-reason':
|
|
case '--reason':
|
|
options.reason = args[++i];
|
|
break;
|
|
case '-dry-run':
|
|
case '--dry-run':
|
|
options.dryRun = true;
|
|
break;
|
|
case '-limit':
|
|
case '--limit':
|
|
options.limit = parseInt(args[++i], 10);
|
|
break;
|
|
case '--help':
|
|
console.log(`
|
|
Pause Addon Subscriptions Script
|
|
|
|
Usage: node scripts/pause_addon_subs.js [options]
|
|
|
|
Options:
|
|
--env <path> Path to environment file (default: ./environment.env)
|
|
--resume-date <date> Date to auto-resume (ISO format, e.g., 2026-03-01)
|
|
--reason <string> Reason for pausing (stored in metadata, default: addon_promo)
|
|
--dry-run Preview changes without applying
|
|
--limit <number> Max subscriptions to process (default: 100)
|
|
--help Show this help message
|
|
|
|
Examples:
|
|
node scripts/pause_addon_subs.js --env ./environment_prod.env --resume-date 2026-03-01
|
|
node scripts/pause_addon_subs.js --dry-run
|
|
node scripts/pause_addon_subs.js --reason "addon_launch_promo" --resume-date 2026-06-01
|
|
`);
|
|
process.exit(0);
|
|
}
|
|
}
|
|
|
|
// Resolve and load environment file
|
|
const envPath = path.resolve(process.cwd(), options.envFile);
|
|
console.log(`Loading environment from: ${envPath}`);
|
|
|
|
require('dotenv').config({ path: envPath });
|
|
|
|
const Stripe = require('stripe');
|
|
|
|
if (!process.env.STRIPE_SEC_KEY) {
|
|
console.error('Error: STRIPE_SEC_KEY not found in environment file');
|
|
process.exit(1);
|
|
}
|
|
|
|
if (!process.env.ADDON_1) {
|
|
console.error('Error: ADDON_1 price ID not found in environment file');
|
|
process.exit(1);
|
|
}
|
|
|
|
const stripe = Stripe(process.env.STRIPE_SEC_KEY, {
|
|
apiVersion: process.env.STRIPE_API_VERSION || '2025-01-27.acacia'
|
|
});
|
|
|
|
const ADDON_PRICE_ID = process.env.ADDON_1;
|
|
|
|
(async () => {
|
|
console.log('\n=== Pause Addon Subscriptions ===\n');
|
|
console.log(`Price ID: ${ADDON_PRICE_ID}`);
|
|
console.log(`Resume Date: ${options.resumeDate || 'Not set (manual resume required)'}`);
|
|
console.log(`Reason: ${options.reason}`);
|
|
console.log(`Dry Run: ${options.dryRun}`);
|
|
console.log(`Limit: ${options.limit}`);
|
|
console.log('');
|
|
|
|
const resumeDateTs = options.resumeDate ? moment.utc(options.resumeDate).unix() : null;
|
|
|
|
if (resumeDateTs && (isNaN(resumeDateTs) || resumeDateTs <= moment.utc().unix())) {
|
|
console.error('Error: Invalid or past resume date');
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
// Find all active subscriptions with addon price
|
|
// Use auto-pagination to get all subscriptions, filtering by status
|
|
const addonSubs = [];
|
|
const allSubsByCustomer = new Map(); // customerId -> { addon: sub, package: sub }
|
|
|
|
// Fetch all active subscriptions to find both addon and package subs
|
|
for await (const sub of stripe.subscriptions.list({
|
|
status: 'active',
|
|
limit: 500,
|
|
expand: ['data.customer', 'data.items.data.price']
|
|
})) {
|
|
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
|
if (!allSubsByCustomer.has(customerId)) {
|
|
allSubsByCustomer.set(customerId, { addon: null, package: null });
|
|
}
|
|
|
|
if (sub.metadata?.type === 'addon') {
|
|
allSubsByCustomer.get(customerId).addon = sub;
|
|
addonSubs.push(sub);
|
|
} else if (sub.metadata?.type === 'package') {
|
|
allSubsByCustomer.get(customerId).package = sub;
|
|
}
|
|
|
|
if (addonSubs.length >= options.limit) break;
|
|
}
|
|
|
|
// Also fetch trialing subscriptions if we haven't hit the limit
|
|
if (addonSubs.length < options.limit) {
|
|
for await (const sub of stripe.subscriptions.list({
|
|
status: 'trialing',
|
|
limit: 100,
|
|
expand: ['data.customer', 'data.items.data.price']
|
|
})) {
|
|
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
|
if (!allSubsByCustomer.has(customerId)) {
|
|
allSubsByCustomer.set(customerId, { addon: null, package: null });
|
|
}
|
|
|
|
if (sub.metadata?.type === 'addon') {
|
|
allSubsByCustomer.get(customerId).addon = sub;
|
|
addonSubs.push(sub);
|
|
} else if (sub.metadata?.type === 'package') {
|
|
allSubsByCustomer.get(customerId).package = sub;
|
|
}
|
|
|
|
if (addonSubs.length >= options.limit) break;
|
|
}
|
|
}
|
|
|
|
console.log(`Found ${addonSubs.length} active/trialing addon subscriptions`);
|
|
console.log(`Found ${allSubsByCustomer.size} unique customers with subscriptions\n`);
|
|
|
|
// Separate paid vs trialing addon subscriptions
|
|
const paidAddonSubs = addonSubs.filter(sub => sub.status === 'active');
|
|
const trialingAddonSubs = addonSubs.filter(sub => sub.status === 'trialing');
|
|
|
|
// Calculate quantities
|
|
const paidQuantity = paidAddonSubs.reduce((sum, sub) =>
|
|
sum + (sub.items?.data?.reduce((s, item) => s + (item.quantity || 1), 0) || 1), 0);
|
|
const trialingQuantity = trialingAddonSubs.reduce((sum, sub) =>
|
|
sum + (sub.items?.data?.reduce((s, item) => s + (item.quantity || 1), 0) || 1), 0);
|
|
|
|
console.log('=== Addon Subscription Breakdown ===');
|
|
console.log(`Paid (active): ${paidAddonSubs.length} subscriptions, ${paidQuantity} total quantity`);
|
|
console.log(`Trialing: ${trialingAddonSubs.length} subscriptions, ${trialingQuantity} total quantity`);
|
|
console.log('');
|
|
|
|
// Show paid addon subscriptions with customer info
|
|
if (paidAddonSubs.length > 0) {
|
|
console.log('--- Paid Addon Subscriptions ---');
|
|
for (const sub of paidAddonSubs) {
|
|
const customerEmail = sub.customer?.email || sub.customer;
|
|
const customerName = sub.customer?.name || 'N/A';
|
|
const applicatorUsername = sub.metadata?.applicatorUsername || sub.metadata?.username || 'N/A';
|
|
const subQuantity = sub.items?.data?.reduce((s, item) => s + (item.quantity || 1), 0) || 1;
|
|
const periodEnd = moment.utc(sub.current_period_end * 1000).format('YYYY-MM-DD');
|
|
console.log(` ${sub.id}: ${customerName} (${customerEmail})`);
|
|
console.log(` Applicator: ${applicatorUsername}, Qty: ${subQuantity}, Period end: ${periodEnd}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
// Show trialing addon subscriptions with customer info
|
|
if (trialingAddonSubs.length > 0) {
|
|
console.log('--- Trialing Addon Subscriptions ---');
|
|
for (const sub of trialingAddonSubs) {
|
|
const customerEmail = sub.customer?.email || sub.customer;
|
|
const customerName = sub.customer?.name || 'N/A';
|
|
const applicatorUsername = sub.metadata?.applicatorUsername || sub.metadata?.username || 'N/A';
|
|
const subQuantity = sub.items?.data?.reduce((s, item) => s + (item.quantity || 1), 0) || 1;
|
|
const trialEnd = sub.trial_end ? moment.utc(sub.trial_end * 1000).format('YYYY-MM-DD') : 'N/A';
|
|
console.log(` ${sub.id}: ${customerName} (${customerEmail})`);
|
|
console.log(` Applicator: ${applicatorUsername}, Qty: ${subQuantity}, Trial end: ${trialEnd}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
if (addonSubs.length === 0) {
|
|
console.log('No active addon subscriptions to pause.');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Filter eligible subscriptions based on resume date validation
|
|
const eligibleSubs = [];
|
|
const skippedSubs = [];
|
|
|
|
for (const addonSub of addonSubs) {
|
|
const customerId = typeof addonSub.customer === 'string' ? addonSub.customer : addonSub.customer?.id;
|
|
const customerData = allSubsByCustomer.get(customerId);
|
|
const packageSub = customerData?.package;
|
|
|
|
// Determine the reference end date: package sub end date, or addon end date if no package
|
|
let referenceEndDate;
|
|
let referenceType;
|
|
|
|
if (packageSub) {
|
|
referenceEndDate = packageSub.current_period_end;
|
|
referenceType = 'package';
|
|
} else {
|
|
referenceEndDate = addonSub.current_period_end;
|
|
referenceType = 'addon-only';
|
|
}
|
|
|
|
// Check if package or addon has a trial end date later than resume date
|
|
const packageTrialEnd = packageSub?.trial_end || 0;
|
|
const addonTrialEnd = addonSub.trial_end || 0;
|
|
const maxTrialEnd = Math.max(packageTrialEnd, addonTrialEnd);
|
|
|
|
// If resume date is specified, check if it's not later than the reference end date
|
|
// Also skip if trial end date is later than resume date
|
|
if (resumeDateTs && resumeDateTs > referenceEndDate) {
|
|
skippedSubs.push({
|
|
sub: addonSub,
|
|
reason: `resume-date (${options.resumeDate}) is later than ${referenceType} end date (${moment.utc(referenceEndDate * 1000).format('YYYY-MM-DD')})`,
|
|
referenceType,
|
|
referenceEndDate
|
|
});
|
|
} else if (resumeDateTs && maxTrialEnd > 0 && resumeDateTs < maxTrialEnd) {
|
|
const trialSource = addonTrialEnd >= packageTrialEnd ? 'addon' : 'package';
|
|
skippedSubs.push({
|
|
sub: addonSub,
|
|
reason: `resume-date (${options.resumeDate}) is earlier than ${trialSource} trial end date (${moment.utc(maxTrialEnd * 1000).format('YYYY-MM-DD')})`,
|
|
referenceType,
|
|
referenceEndDate
|
|
});
|
|
} else {
|
|
eligibleSubs.push({
|
|
sub: addonSub,
|
|
packageSub,
|
|
referenceType,
|
|
referenceEndDate
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`Eligible to pause: ${eligibleSubs.length}`);
|
|
console.log(`Skipped (resume date too late): ${skippedSubs.length}\n`);
|
|
|
|
// Show skipped subscriptions
|
|
if (skippedSubs.length > 0) {
|
|
console.log('--- Skipped Subscriptions ---');
|
|
for (const { sub, reason } of skippedSubs) {
|
|
const customerEmail = sub.customer?.email || sub.customer;
|
|
const customerName = sub.customer?.name || 'N/A';
|
|
const applicatorUsername = sub.metadata?.applicatorUsername || sub.metadata?.username || 'N/A';
|
|
console.log(` SKIP: ${sub.id}`);
|
|
console.log(` Customer: ${customerName} (${customerEmail})`);
|
|
console.log(` Applicator: ${applicatorUsername}`);
|
|
console.log(` Reason: ${reason}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
if (eligibleSubs.length === 0) {
|
|
console.log('No addon subscriptions eligible to pause.');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Build pause configuration
|
|
const pauseConfig = {
|
|
pause_collection: {
|
|
behavior: 'void'
|
|
},
|
|
metadata: {
|
|
pauseReason: options.reason,
|
|
pausedAt: moment.utc().toISOString(),
|
|
pausedBy: 'pause_addon_subs_script'
|
|
}
|
|
};
|
|
|
|
if (resumeDateTs) {
|
|
pauseConfig.pause_collection.resumes_at = resumeDateTs;
|
|
pauseConfig.metadata.scheduledResumeAt = options.resumeDate;
|
|
}
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
let totalQuantity = 0;
|
|
const customerStats = new Map(); // Track by customer
|
|
|
|
console.log('--- Processing Eligible Subscriptions ---');
|
|
for (const { sub, referenceType, referenceEndDate } of eligibleSubs) {
|
|
const customerEmail = sub.customer?.email || sub.customer;
|
|
const customerName = sub.customer?.name || 'N/A';
|
|
const applicatorUsername = sub.metadata?.applicatorUsername || sub.metadata?.username || 'N/A';
|
|
const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id;
|
|
|
|
// Get total quantity from subscription items
|
|
const subQuantity = sub.items?.data?.reduce((sum, item) => sum + (item.quantity || 1), 0) || 1;
|
|
|
|
// Track customer stats
|
|
if (!customerStats.has(customerId)) {
|
|
customerStats.set(customerId, {
|
|
name: customerName,
|
|
email: customerEmail,
|
|
applicatorUsername,
|
|
subscriptionCount: 0,
|
|
totalQuantity: 0
|
|
});
|
|
}
|
|
customerStats.get(customerId).subscriptionCount++;
|
|
customerStats.get(customerId).totalQuantity += subQuantity;
|
|
totalQuantity += subQuantity;
|
|
|
|
if (options.dryRun) {
|
|
console.log(`[DRY RUN] Would pause: ${sub.id}`);
|
|
console.log(` Customer: ${customerName} (${customerEmail})`);
|
|
console.log(` Applicator: ${applicatorUsername}`);
|
|
console.log(` Quantity: ${subQuantity}`);
|
|
console.log(` Reference: ${referenceType} ends ${moment.utc(referenceEndDate * 1000).format('YYYY-MM-DD')}`);
|
|
console.log(` Addon period end: ${moment.utc(sub.current_period_end * 1000).format('YYYY-MM-DD')}`);
|
|
successCount++;
|
|
} else {
|
|
try {
|
|
await stripe.subscriptions.update(sub.id, pauseConfig);
|
|
console.log(`✓ Paused: ${sub.id}`);
|
|
console.log(` Customer: ${customerName} (${customerEmail})`);
|
|
console.log(` Applicator: ${applicatorUsername}`);
|
|
successCount++;
|
|
} catch (err) {
|
|
console.error(`✗ Failed: ${sub.id} - ${err.message}`);
|
|
failCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('\n=== Summary ===');
|
|
console.log(`Total addon subscriptions found: ${addonSubs.length}`);
|
|
console.log(`Eligible to pause: ${eligibleSubs.length}`);
|
|
console.log(`Skipped (resume date too late): ${skippedSubs.length}`);
|
|
console.log(`Successful: ${successCount}`);
|
|
console.log(`Failed: ${failCount}`);
|
|
console.log(`Total quantity (items): ${totalQuantity}`);
|
|
|
|
// Customer/Applicator statistics
|
|
console.log(`\n=== Customer/Applicator Stats ===`);
|
|
console.log(`Unique customers affected: ${customerStats.size}`);
|
|
console.log('');
|
|
for (const [customerId, stats] of customerStats) {
|
|
console.log(` ${stats.name} (${stats.email})`);
|
|
console.log(` Applicator: ${stats.applicatorUsername}`);
|
|
console.log(` Subscriptions: ${stats.subscriptionCount}, Quantity: ${stats.totalQuantity}`);
|
|
}
|
|
|
|
if (options.dryRun) {
|
|
console.log('\n[DRY RUN] No changes were made. Remove --dry-run to apply changes.');
|
|
} else if (options.resumeDate) {
|
|
console.log(`\nSubscriptions will auto-resume on: ${options.resumeDate}`);
|
|
} else {
|
|
console.log('\nSubscriptions paused indefinitely. Run resume_addon_subs.js to resume.');
|
|
}
|
|
|
|
} catch (err) {
|
|
console.error('Error:', err.message);
|
|
process.exit(1);
|
|
}
|
|
})(); |