agmission/Development/server/scripts/sync_subscription_history.js

310 lines
11 KiB
JavaScript

#!/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=<id> Sync only a specific Stripe customer ID (e.g. cus_ABC)
* --env <file> Path to environment file, relative to cwd (default: ./environment.env)
* --env=<file> 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();