agmission/Development/server/scripts/sync_stripe_customer_info.js

316 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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 10761084). 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);
}
}
});