310 lines
11 KiB
JavaScript
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();
|