#!/usr/bin/env node /** * Sync Subscription History Cache * * Builds/updates the subscription history cache by querying Stripe for all * customer subscription history. Run this: * - Initially to populate the cache * - After missed webhooks (server downtime > 72 h, Stripe retry window) * - After manual data fixes or migrations * * Usage (run from the server root directory): * node scripts/sync_subscription_history.js [options] * * Options: * --full Full rebuild: delete all history and resync from Stripe * --custId= Sync only a specific Stripe customer ID (e.g. cus_ABC) * --env Path to environment file, relative to cwd (default: ./environment.env) * --env= Alternate form of the above * --dry-run Show what would be done without writing to DB * * Examples (all run from the server root): * node scripts/sync_subscription_history.js * node scripts/sync_subscription_history.js --full * node scripts/sync_subscription_history.js --env ./environment_prod.env * node scripts/sync_subscription_history.js --env=environment_prod.env --full * node scripts/sync_subscription_history.js --custId=cus_ABC123 * * Note on --env path: * The path is resolved relative to the current working directory (cwd), NOT the * script file location. Run from the server root so that the default * (./environment.env) or a relative path like ./environment_prod.env resolves * correctly. Example from the server root: * node scripts/sync_subscription_history.js --env ./environment_prod.env */ /* The webhooks handlers within the app do handle real-time maintenance. Looking at the code: customer.subscription.created → updateSubscriptionHistoryOnCreate() customer.subscription.updated → updateSubscriptionHistoryOnUpdate() customer.subscription.deleted → updateSubscriptionHistoryOnDelete() So SubscriptionHistory stays current automatically via webhooks for all new activity. But the script is still useful for: 1. Initial/backfill population — for customers who subscribed before history tracking was implemented (the cache won't have their records) 2. Missed webhooks — if the server was down, Stripe only retries for 72 hours; gaps can occur 3. Disaster recovery / manual data fixes — the --full flag rebuilds from Stripe as source of truth 4. Single-customer repair — --custId=cus_xxx lets you fix one account without touching others For production operations, you don't need a cron job running it regularly — the webhooks keep things current. The script is more of an admin tool for: + One-time migration/onboarding + Post-incident recovery + Auditing data consistency */ const fs = require('fs'); const path = require('path'); // Parse arguments const args = process.argv.slice(2); let envFile = './environment.env'; let fullRebuild = false; let targetCustId = null; let dryRun = false; for (let i = 0; i < args.length; i++) { if (args[i] === '--env' && args[i + 1] && !args[i + 1].startsWith('--')) { envFile = args[i + 1]; i++; } else if (args[i].startsWith('--env=')) { envFile = args[i].split('=').slice(1).join('='); } else if (args[i] === '--full') { fullRebuild = true; } else if (args[i].startsWith('--custId=')) { targetCustId = args[i].split('=')[1]; } else if (args[i] === '--dry-run') { dryRun = true; } } // Load environment const envPath = path.resolve(process.cwd(), envFile); if (!fs.existsSync(envPath)) { console.error(`[sync_subscription_history] Environment file not found: ${envPath}`); process.exit(1); } require('dotenv').config({ path: envPath }); console.log(`[sync_subscription_history] Environment loaded from: ${envPath}`); console.log(`[sync_subscription_history] Mode: ${fullRebuild ? 'FULL REBUILD' : 'INCREMENTAL'}`); console.log(`[sync_subscription_history] Dry run: ${dryRun}`); if (targetCustId) console.log(`[sync_subscription_history] Target customer: ${targetCustId}`); const mongoose = require('mongoose'); const { DBConnection } = require('../helpers/db/connect'); const { stripe } = require('../helpers/subscription_util'); const Customer = require('../model/customer'); const SubscriptionHistory = require('../model/subscription_history'); // Helper: Get priceKey from priceId const PRICE_KEYS = { [process.env.ESS_1]: 'ess_1', [process.env.ESS_2]: 'ess_2', [process.env.ESS_3]: 'ess_3', [process.env.ESS_4]: 'ess_4', [process.env.ESS_5]: 'ess_5', [process.env.ENT_1]: 'ent_1', [process.env.ENT_2]: 'ent_2', [process.env.ENT_3]: 'ent_3', [process.env.ENT_4]: 'ent_4', [process.env.ADDON_1]: 'addon_1' }; function getPriceKeyFromId(priceId) { return PRICE_KEYS[priceId] || null; } async function syncCustomerHistory(custId) { console.log(`\n[${custId}] Syncing subscription history...`); try { // Fetch ALL subscriptions from Stripe using auto-pagination // Don't use limit - let Stripe SDK handle pagination automatically const allSubs = []; for await (const sub of stripe.subscriptions.list({ customer: custId, status: 'all', expand: ['data.items.data.price'] })) { allSubs.push(sub); } console.log(`[${custId}] Found ${allSubs.length} subscriptions in Stripe`); // Group by type and priceKey const historyMap = new Map(); for (const sub of allSubs) { const type = sub.metadata?.type; if (!type) continue; const items = sub.items.data || []; for (const item of items) { const priceKey = getPriceKeyFromId(item.price.id); const key = `${type}:${priceKey || 'any'}`; if (!historyMap.has(key)) { historyMap.set(key, { custId, type, priceKey, firstSubscribedAt: new Date(sub.created * 1000), lastSubscribedAt: new Date(sub.created * 1000), totalSubscriptions: 0, currentSubscriptionId: null, lastSubscriptionStatus: null, subscriptionIds: [] }); } const history = historyMap.get(key); history.totalSubscriptions++; history.subscriptionIds.push(sub.id); const subDate = new Date(sub.created * 1000); if (subDate < history.firstSubscribedAt) { history.firstSubscribedAt = subDate; } if (subDate >= history.lastSubscribedAt) { history.lastSubscribedAt = subDate; history.lastSubscriptionStatus = sub.status; // Track status of most recent subscription } // Track current active subscription if (sub.status === 'active' || sub.status === 'trialing') { history.currentSubscriptionId = sub.id; } } } console.log(`[${custId}] Processed ${historyMap.size} unique type/price combinations`); if (dryRun) { console.log(`[${custId}] DRY RUN - Would save:`); historyMap.forEach((history, key) => { console.log(` ${key}: ${history.totalSubscriptions} subs, first: ${history.firstSubscribedAt.toISOString()}`); }); return historyMap.size; } // Save to database for (const history of historyMap.values()) { const { subscriptionIds, ...historyData } = history; historyData.lastSyncedAt = new Date(); await SubscriptionHistory.findOneAndUpdate( { custId: history.custId, type: history.type, priceKey: history.priceKey }, historyData, { upsert: true, new: true } ); console.log(` Saved: ${history.type}/${history.priceKey || 'any'} (${history.totalSubscriptions} subs, status: ${history.lastSubscriptionStatus})`); } return historyMap.size; } catch (err) { console.error(`[${custId}] ERROR: ${err.message}`); return 0; } } async function main() { let exitCode = 0; const dbConnection = new DBConnection('Sync Subscription History Script'); try { // Connect to database await dbConnection.initialize({ debugMode: false, exitOnError: false, setupEventListeners: false, setupExitHandlers: false }); console.log('[sync_subscription_history] Connected to MongoDB'); if (!stripe) { throw new Error('Stripe not configured - check STRIPE_SEC_KEY'); } console.log('[sync_subscription_history] Stripe client initialized'); // Full rebuild: clear cache if (fullRebuild && !dryRun) { console.log('\n[sync_subscription_history] Full rebuild - clearing existing cache...'); const deleted = await SubscriptionHistory.deleteMany({}); console.log(`[sync_subscription_history] Deleted ${deleted.deletedCount} history records`); } // Get customers to sync let customers; if (targetCustId) { const customer = await Customer.findOne({ 'membership.custId': targetCustId }).lean(); customers = customer ? [customer] : []; } else { customers = await Customer.find({ 'membership.custId': { $exists: true, $ne: null } }).select('membership.custId').lean(); } console.log(`\n[sync_subscription_history] Found ${customers.length} customers to sync`); if (customers.length === 0) { console.log('[sync_subscription_history] No customers found'); process.exit(0); } // Sync each customer let totalSynced = 0; let totalHistoryRecords = 0; for (let i = 0; i < customers.length; i++) { const custId = customers[i].membership?.custId; if (!custId) continue; console.log(`\n[${i + 1}/${customers.length}] Processing customer ${custId}...`); const recordsCreated = await syncCustomerHistory(custId); if (recordsCreated > 0) { totalSynced++; totalHistoryRecords += recordsCreated; } // Rate limiting if (i < customers.length - 1) { await new Promise(resolve => setTimeout(resolve, 100)); } } console.log(`\n[sync_subscription_history] COMPLETE`); console.log(`[sync_subscription_history] Customers synced: ${totalSynced}/${customers.length}`); console.log(`[sync_subscription_history] History records: ${totalHistoryRecords}`); if (dryRun) { console.log(`[sync_subscription_history] DRY RUN - No changes made`); } } catch (err) { console.error('\n[sync_subscription_history] FATAL ERROR:', err); exitCode = 1; } finally { try { if (mongoose.connection && mongoose.connection.readyState !== 0) { await mongoose.connection.close(); } } catch (closeErr) { console.error('[sync_subscription_history] Error while closing MongoDB connection:', closeErr.message); exitCode = 1; } process.exit(exitCode); } } main();