316 lines
10 KiB
JavaScript
316 lines
10 KiB
JavaScript
/*
|
||
sync_stripe_customer_info.js — Admin/maintenance script (not a scheduled job)
|
||
|
||
What it does:
|
||
Pushes customer name, business_name, and individual_name from MongoDB → Stripe customer records.
|
||
|
||
Stripe customer resolution (priority order):
|
||
1. membership.custId — retrieved directly via stripe.customers.retrieve(custId).
|
||
This is the authoritative Stripe customer ID stored during subscription creation and is
|
||
immune to email/username changes.
|
||
2. Email search fallback — used only when membership.custId is absent (legacy/unsubscribed records).
|
||
Searches stripe.customers.search({ query: 'email:"..."' }). Less reliable: if the customer's
|
||
username was changed after Stripe registration the lookup will fail.
|
||
|
||
Why it's not a regular cron need:
|
||
The subscription controller already syncs the customer name to Stripe whenever the billing address
|
||
is updated (~subscription.js line 1076–1084). Customers going through the normal billing flow
|
||
will have their names kept in sync automatically.
|
||
|
||
When to run manually:
|
||
1. One-time backfill — customers created before name-sync logic was added.
|
||
2. Bulk name corrections — names changed directly in MongoDB (e.g. a migration) without going
|
||
through the billing address update path.
|
||
3. Audit/reconciliation — verify Stripe records match the DB after a data migration.
|
||
|
||
Usage:
|
||
node scripts/sync_stripe_customer_info.js [--env <envFile>] [--dry-run]
|
||
|
||
--env <file> Path to environment file (default: ./environment.env)
|
||
--dry-run Preview changes without writing to Stripe
|
||
*/
|
||
'use strict';
|
||
const path = require('path');
|
||
|
||
// Parse --env argument (default: ./environment.env)
|
||
const args = process.argv.slice(2);
|
||
let envFile = './environment.env';
|
||
let dryRun = false;
|
||
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '--env' && args[i + 1]) {
|
||
envFile = args[i + 1];
|
||
i++;
|
||
} else if (args[i] === '--dry-run' || args[i] === '--preview') {
|
||
dryRun = true;
|
||
}
|
||
}
|
||
|
||
// Load environment before requiring any modules
|
||
const envPath = path.resolve(process.cwd(), envFile);
|
||
require('dotenv').config({ path: envPath });
|
||
|
||
const debug = require('debug')('agm:sync-stripe-customer');
|
||
const env = require('../helpers/env.js');
|
||
const { DBConnection } = require('../helpers/db/connect.js');
|
||
const { Customer } = require('../model/index.js');
|
||
const { UserTypes } = require('../helpers/constants');
|
||
const stripe = require('stripe')(env.STRIPE_SEC_KEY, { apiVersion: env.STRIPE_API_VERSION });
|
||
|
||
// Initialize database connection
|
||
const workerDB = new DBConnection('Stripe Customer Sync Script');
|
||
|
||
const stats = {
|
||
total: 0,
|
||
matched: 0,
|
||
updated: 0,
|
||
unchanged: 0,
|
||
noStripeMatch: 0,
|
||
errors: []
|
||
};
|
||
|
||
process
|
||
.on('uncaughtException', function (err) {
|
||
console.error('Uncaught Exception:', err);
|
||
process.exit(1);
|
||
})
|
||
.on('unhandledRejection', (reason, p) => {
|
||
console.error('Unhandled Rejection at Promise', p, 'reason:', reason);
|
||
process.exit(1);
|
||
});
|
||
|
||
/**
|
||
* Check if Stripe customer needs updating
|
||
*/
|
||
function needsUpdate(stripeCustomer, businessName, individualName) {
|
||
const changes = {};
|
||
|
||
// Check customer name and business name (both should match DB customer's name)
|
||
if (businessName && businessName.trim()) {
|
||
const trimmedBusinessName = businessName.trim();
|
||
|
||
// Update 'name' field (customer's full name or business name)
|
||
if (stripeCustomer.name !== trimmedBusinessName) {
|
||
changes.name = trimmedBusinessName;
|
||
}
|
||
|
||
// Update 'business_name' field (dedicated business name field)
|
||
if (stripeCustomer.business_name !== trimmedBusinessName) {
|
||
changes.business_name = trimmedBusinessName;
|
||
}
|
||
}
|
||
|
||
// Check individual name (using Stripe's dedicated individual_name field)
|
||
if (individualName && individualName.trim()) {
|
||
const trimmedIndividualName = individualName.trim();
|
||
if (stripeCustomer.individual_name !== trimmedIndividualName) {
|
||
changes.individual_name = trimmedIndividualName;
|
||
}
|
||
}
|
||
|
||
return Object.keys(changes).length > 0 ? changes : null;
|
||
}
|
||
|
||
/**
|
||
* Process a single customer
|
||
*/
|
||
async function processCustomer(customer) {
|
||
try {
|
||
const custId = customer.membership?.custId;
|
||
const email = customer.username?.toLowerCase();
|
||
|
||
if (!custId && !email) {
|
||
console.log(`⚠ Customer ${customer._id} has no membership.custId or email/username`);
|
||
stats.errors.push({
|
||
customerId: customer._id,
|
||
name: customer.name,
|
||
reason: 'No membership.custId or email/username'
|
||
});
|
||
return;
|
||
}
|
||
|
||
let stripeCustomer;
|
||
|
||
if (custId) {
|
||
// Primary: resolve directly by membership.custId (authoritative, immune to email changes)
|
||
try {
|
||
const retrieved = await stripe.customers.retrieve(custId);
|
||
if (retrieved && !retrieved.deleted) {
|
||
stripeCustomer = retrieved;
|
||
} else {
|
||
console.log(`✗ Stripe customer ${custId} is deleted or not found for DB customer ${customer._id}`);
|
||
stats.noStripeMatch++;
|
||
stats.errors.push({
|
||
customerId: customer._id,
|
||
custId,
|
||
name: customer.name,
|
||
reason: 'Stripe customer deleted or not found'
|
||
});
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
console.log(`✗ Failed to retrieve Stripe customer ${custId}: ${err.message}`);
|
||
stats.noStripeMatch++;
|
||
stats.errors.push({
|
||
customerId: customer._id,
|
||
custId,
|
||
name: customer.name,
|
||
reason: err.message
|
||
});
|
||
return;
|
||
}
|
||
} else {
|
||
// Fallback: email search (for legacy records without membership.custId)
|
||
if (!email) {
|
||
console.log(`⚠ Customer ${customer._id} has no membership.custId and no email`);
|
||
stats.errors.push({ customerId: customer._id, name: customer.name, reason: 'No custId or email' });
|
||
return;
|
||
}
|
||
const searchResult = await stripe.customers.search({
|
||
query: `email:"${email}"`,
|
||
limit: 1
|
||
});
|
||
if (!searchResult.data || searchResult.data.length === 0) {
|
||
console.log(`✗ No Stripe match for: ${email} (${customer.name || 'N/A'}) — no custId, email fallback failed`);
|
||
stats.noStripeMatch++;
|
||
stats.errors.push({
|
||
customerId: customer._id,
|
||
username: email,
|
||
name: customer.name,
|
||
contact: customer.contact,
|
||
reason: 'No Stripe customer found (email fallback)'
|
||
});
|
||
return;
|
||
}
|
||
stripeCustomer = searchResult.data[0];
|
||
}
|
||
|
||
stats.matched++;
|
||
|
||
const businessName = customer.name || '';
|
||
const individualName = customer.contact || '';
|
||
const label = custId || email || String(customer._id);
|
||
|
||
// Check if update is needed
|
||
const changes = needsUpdate(stripeCustomer, businessName, individualName);
|
||
|
||
if (!changes) {
|
||
console.log(`✓ Already synced: ${label} (${businessName})`);
|
||
stats.unchanged++;
|
||
return;
|
||
}
|
||
|
||
// Show what would be updated
|
||
console.log(`\n→ Update needed for: ${label}`);
|
||
console.log(` Customer ID: ${customer._id}`);
|
||
console.log(` Stripe ID: ${stripeCustomer.id}`);
|
||
if (custId) console.log(` Resolved via: membership.custId`);
|
||
else console.log(` Resolved via: email fallback (${email})`);
|
||
|
||
if (changes.name) {
|
||
console.log(` Name: "${stripeCustomer.name || '(empty)'}" → "${changes.name}"`);
|
||
}
|
||
|
||
if (changes.business_name) {
|
||
console.log(` Business Name: "${stripeCustomer.business_name || '(empty)'}" → "${changes.business_name}"`);
|
||
}
|
||
|
||
if (changes.individual_name) {
|
||
console.log(` Individual Name: "${stripeCustomer.individual_name || '(empty)'}" → "${changes.individual_name}"`);
|
||
}
|
||
|
||
// Update Stripe customer if not in dry-run mode
|
||
if (!dryRun) {
|
||
await stripe.customers.update(stripeCustomer.id, changes);
|
||
console.log(` ✓ Updated in Stripe`);
|
||
stats.updated++;
|
||
} else {
|
||
console.log(` [DRY RUN] Would update in Stripe`);
|
||
stats.updated++;
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error(`✗ Error processing customer ${customer._id}:`, error.message);
|
||
stats.errors.push({
|
||
customerId: customer._id,
|
||
username: customer.username,
|
||
name: customer.name,
|
||
contact: customer.contact,
|
||
reason: error.message
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Main migration logic
|
||
*/
|
||
async function syncStripeCustomers() {
|
||
console.log('\n=== Stripe Customer Info Sync ===\n');
|
||
console.log(`Environment: ${envFile}`);
|
||
console.log(`Mode: ${dryRun ? 'DRY RUN (preview only)' : 'LIVE (will update Stripe)'}\n`);
|
||
|
||
try {
|
||
// Find all active master accounts (kind="1" means Customer/Applicator)
|
||
const customers = await Customer.find({
|
||
kind: UserTypes.APP,
|
||
active: true,
|
||
markedDelete: { $ne: true }
|
||
})
|
||
.select('_id username name contact email membership')
|
||
.lean();
|
||
|
||
stats.total = customers.length;
|
||
console.log(`Found ${stats.total} active master accounts\n`);
|
||
console.log('---\n');
|
||
|
||
// Process each customer
|
||
for (const customer of customers) {
|
||
await processCustomer(customer);
|
||
}
|
||
|
||
// Print summary
|
||
console.log('\n=== Summary ===\n');
|
||
console.log(`Total customers processed: ${stats.total}`);
|
||
console.log(`Matched with Stripe: ${stats.matched}`);
|
||
console.log(`Updated: ${stats.updated}`);
|
||
console.log(`Already synced: ${stats.unchanged}`);
|
||
console.log(`No Stripe match: ${stats.noStripeMatch}`);
|
||
console.log(`Errors: ${stats.errors.length}`);
|
||
|
||
// Show only non-Stripe matching customers in detail
|
||
const noStripeMatches = stats.errors.filter(e => e.reason === 'No Stripe customer found');
|
||
if (noStripeMatches.length > 0) {
|
||
console.log('\n=== Customers Without Stripe Match ===\n');
|
||
noStripeMatches.forEach((error, index) => {
|
||
console.log(`${index + 1}. Customer ID: ${error.customerId}`);
|
||
console.log(` Username: ${error.username || 'N/A'}`);
|
||
console.log(` Name: ${error.name || 'N/A'}`);
|
||
console.log(` Contact: ${error.contact || 'N/A'}\n`);
|
||
});
|
||
}
|
||
|
||
if (dryRun) {
|
||
console.log('\n⚠ This was a DRY RUN - no changes were made to Stripe');
|
||
console.log('Run without --dry-run flag to apply changes\n');
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('Fatal error during sync:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Initialize the database connection and start sync
|
||
workerDB.initialize({
|
||
setupExitHandlers: false,
|
||
onReady: async () => {
|
||
try {
|
||
await syncStripeCustomers();
|
||
process.exit(0);
|
||
} catch (error) {
|
||
console.error('Sync failed:', error);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
});
|