421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* Test script to trigger and verify promo expired email sending
|
|
*
|
|
* This script:
|
|
* 1. Creates a subscription with a promo (with cancel_at_period_end: false)
|
|
* 2. Verifies the schedule was created with correct phases
|
|
* 3. Provides instructions to advance Stripe test clock to trigger the email
|
|
* 4. Can optionally trigger the webhook event manually for testing
|
|
*
|
|
* Usage:
|
|
* node tests/test_promo_expiry_workflow.js --customerId cus_xxx [--useTestClock]
|
|
* node tests/test_promo_expiry_workflow.js --email test@example.com [--useTestClock]
|
|
* node tests/test_promo_expiry_workflow.js --triggerWebhook sub_sched_xxx
|
|
*/
|
|
|
|
const path = require('path');
|
|
|
|
// Parse arguments
|
|
const args = process.argv.slice(2);
|
|
let envFile = './environment.env';
|
|
const options = {
|
|
customerId: null,
|
|
email: null,
|
|
useTestClock: false,
|
|
triggerWebhook: null,
|
|
scheduleId: null
|
|
};
|
|
|
|
for (let i = 0; i < args.length; i++) {
|
|
switch (args[i]) {
|
|
case '--env':
|
|
if (args[i + 1]) envFile = args[++i];
|
|
break;
|
|
case '--customerId':
|
|
if (args[i + 1]) options.customerId = args[++i];
|
|
break;
|
|
case '--email':
|
|
if (args[i + 1]) options.email = args[++i];
|
|
break;
|
|
case '--useTestClock':
|
|
options.useTestClock = true;
|
|
break;
|
|
case '--triggerWebhook':
|
|
if (args[i + 1]) options.scheduleId = args[++i];
|
|
options.triggerWebhook = true;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Load environment
|
|
const envPath = path.resolve(process.cwd(), envFile);
|
|
require('dotenv').config({ path: envPath });
|
|
|
|
const ObjectId = require('mongodb').ObjectId;
|
|
const moment = require('moment');
|
|
const { stripe } = require('../helpers/subscription_util');
|
|
const { Customer } = require('../model');
|
|
const Settings = require('../model/setting');
|
|
const connectDB = require('../helpers/db/connect');
|
|
|
|
const COLORS = {
|
|
reset: '\x1b[0m',
|
|
bright: '\x1b[1m',
|
|
red: '\x1b[31m',
|
|
green: '\x1b[32m',
|
|
yellow: '\x1b[33m',
|
|
blue: '\x1b[34m',
|
|
cyan: '\x1b[36m'
|
|
};
|
|
|
|
function log(msg, color = 'reset') {
|
|
console.log(`${COLORS[color]}${msg}${COLORS.reset}`);
|
|
}
|
|
|
|
function section(title) {
|
|
console.log(`\n${COLORS.bright}${COLORS.cyan}${'='.repeat(60)}${COLORS.reset}`);
|
|
console.log(`${COLORS.bright}${COLORS.cyan}${title}${COLORS.reset}`);
|
|
console.log(`${COLORS.bright}${COLORS.cyan}${'='.repeat(60)}${COLORS.reset}\n`);
|
|
}
|
|
|
|
async function findOrCreateCustomer(email) {
|
|
section('Finding/Creating Test Customer');
|
|
|
|
let dbCustomer = await Customer.findOne({ username: email });
|
|
|
|
if (!dbCustomer) {
|
|
log(`Customer not found in database for email: ${email}`, 'yellow');
|
|
log('Please create a customer first via the registration flow.', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
log(`✓ Found DB customer: ${dbCustomer.name} (${dbCustomer.username})`, 'green');
|
|
|
|
if (!dbCustomer.membership || !dbCustomer.membership.custId) {
|
|
log('Customer has no Stripe customer ID. Creating...', 'yellow');
|
|
// This would require calling resolvePaymentUser from subscription.js
|
|
log('Please create a Stripe customer for this user first.', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
log(`✓ Stripe customer ID: ${dbCustomer.membership.custId}`, 'green');
|
|
|
|
return dbCustomer;
|
|
}
|
|
|
|
async function findActivePromo() {
|
|
section('Finding Active Promo');
|
|
|
|
// Match production lookup pattern: global settings are stored with userId: null.
|
|
// Keep a fallback for environments that still have legacy settings documents.
|
|
let settings = await Settings.findOne({ userId: null });
|
|
if (!settings || !Array.isArray(settings.subscriptionPromos) || settings.subscriptionPromos.length === 0) {
|
|
settings = await Settings.findOne({ subscriptionPromos: { $exists: true, $ne: [] } });
|
|
}
|
|
|
|
if (!settings || !Array.isArray(settings.subscriptionPromos) || settings.subscriptionPromos.length === 0) {
|
|
log('No promos configured in settings!', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
const now = moment.utc();
|
|
const activePromo = settings.subscriptionPromos.find(p =>
|
|
p.enabled &&
|
|
p.validUntil &&
|
|
moment.utc(p.validUntil).isAfter(now)
|
|
);
|
|
|
|
if (!activePromo) {
|
|
log('No active promos with validUntil found!', 'red');
|
|
log('Please create a promo with validUntil set to future date.', 'yellow');
|
|
process.exit(1);
|
|
}
|
|
|
|
log(`✓ Found promo: ${activePromo.name}`, 'green');
|
|
log(` - ID: ${activePromo._id}`, 'cyan');
|
|
log(` - Coupon: ${activePromo.couponId}`, 'cyan');
|
|
log(` - Valid Until: ${moment.utc(activePromo.validUntil).format('YYYY-MM-DD HH:mm:ss')} UTC`, 'cyan');
|
|
log(` - Applies To: ${activePromo.appliesTo || 'all'}`, 'cyan');
|
|
|
|
return activePromo;
|
|
}
|
|
|
|
async function createTestSubscription(custId, promo) {
|
|
section('Creating Test Subscription');
|
|
|
|
log('Creating subscription with:', 'yellow');
|
|
log(` - Customer: ${custId}`, 'cyan');
|
|
log(` - Promo: ${promo.name}`, 'cyan');
|
|
log(` - cancel_at_period_end: false (IMPORTANT!)`, 'bright');
|
|
|
|
const validUntilTs = moment.utc(promo.validUntil).unix();
|
|
|
|
// Create subscription with schedule
|
|
const scheduleParams = {
|
|
customer: custId,
|
|
start_date: 'now',
|
|
end_behavior: 'release',
|
|
metadata: {
|
|
type: 'addon',
|
|
promoId: promo._id.toString()
|
|
},
|
|
phases: [
|
|
{
|
|
items: [{ price: process.env.ADDON_1, quantity: 1 }],
|
|
coupon: promo.couponId,
|
|
end_date: validUntilTs,
|
|
proration_behavior: 'none',
|
|
metadata: { type: 'addon', promoId: promo._id.toString() }
|
|
},
|
|
{
|
|
items: [{ price: process.env.ADDON_1, quantity: 1 }],
|
|
proration_behavior: 'none',
|
|
metadata: { type: 'addon', promoId: promo._id.toString() }
|
|
}
|
|
]
|
|
};
|
|
|
|
log('Creating subscription schedule...', 'yellow');
|
|
const schedule = await stripe.subscriptionSchedules.create(scheduleParams);
|
|
|
|
log(`✓ Schedule created: ${schedule.id}`, 'green');
|
|
log(`✓ Subscription created: ${schedule.subscription}`, 'green');
|
|
|
|
// Retrieve subscription to check status
|
|
const subscription = await stripe.subscriptions.retrieve(schedule.subscription, {
|
|
expand: ['latest_invoice.payment_intent']
|
|
});
|
|
|
|
log(`✓ Subscription status: ${subscription.status}`, 'green');
|
|
|
|
// Check if invoice needs payment
|
|
if (subscription.latest_invoice) {
|
|
const invoice = subscription.latest_invoice;
|
|
log(` - Invoice status: ${invoice.status}`, 'cyan');
|
|
log(` - Amount due: $${(invoice.amount_due / 100).toFixed(2)}`, 'cyan');
|
|
|
|
if (invoice.status === 'draft') {
|
|
log('⚠ Invoice is in draft status. You may need to finalize and pay it.', 'yellow');
|
|
log(' Run: stripe invoices finalize ' + invoice.id, 'cyan');
|
|
}
|
|
}
|
|
|
|
// Update subscription metadata to include scheduleId
|
|
await stripe.subscriptions.update(subscription.id, {
|
|
metadata: {
|
|
...subscription.metadata,
|
|
scheduleId: schedule.id,
|
|
promoId: promo._id.toString()
|
|
}
|
|
});
|
|
|
|
log(`✓ Updated subscription metadata with scheduleId`, 'green');
|
|
|
|
return { schedule, subscription };
|
|
}
|
|
|
|
async function verifyScheduleConfiguration(scheduleId) {
|
|
section('Verifying Schedule Configuration');
|
|
|
|
const schedule = await stripe.subscriptionSchedules.retrieve(scheduleId);
|
|
|
|
log(`Schedule ID: ${schedule.id}`, 'cyan');
|
|
log(`Status: ${schedule.status}`, schedule.status === 'active' ? 'green' : 'yellow');
|
|
log(`End Behavior: ${schedule.end_behavior}`, 'cyan');
|
|
log(`Phases: ${schedule.phases.length}`, 'cyan');
|
|
|
|
if (schedule.phases.length !== 2) {
|
|
log('⚠ Warning: Expected 2 phases, found ' + schedule.phases.length, 'yellow');
|
|
}
|
|
|
|
schedule.phases.forEach((phase, idx) => {
|
|
log(`\nPhase ${idx + 1}:`, 'bright');
|
|
log(` - Coupon: ${phase.coupon || 'none'}`, 'cyan');
|
|
if (phase.end_date) {
|
|
log(` - End Date: ${moment.unix(phase.end_date).format('YYYY-MM-DD HH:mm:ss')} UTC`, 'cyan');
|
|
const daysUntil = moment.unix(phase.end_date).diff(moment(), 'days');
|
|
log(` - Days Until End: ${daysUntil}`, daysUntil > 0 ? 'green' : 'red');
|
|
} else {
|
|
log(` - End Date: ongoing (Phase 2)`, 'cyan');
|
|
}
|
|
log(` - Items: ${phase.items.map(i => i.price).join(', ')}`, 'cyan');
|
|
});
|
|
|
|
if (!schedule.metadata.promoId) {
|
|
log('\n⚠ Warning: Schedule metadata missing promoId!', 'yellow');
|
|
} else {
|
|
log(`\n✓ PromoId in metadata: ${schedule.metadata.promoId}`, 'green');
|
|
}
|
|
|
|
return schedule;
|
|
}
|
|
|
|
async function provideTestInstructions(schedule, promo) {
|
|
section('Testing Instructions');
|
|
|
|
const phase1EndDate = moment.unix(schedule.phases[0].end_date);
|
|
const now = moment();
|
|
const daysUntil = phase1EndDate.diff(now, 'days');
|
|
|
|
log('To trigger the promo expired email, you need to advance time past the promo validUntil date.', 'yellow');
|
|
log(`\nPromo expires: ${phase1EndDate.format('YYYY-MM-DD HH:mm:ss')} UTC (${daysUntil} days from now)`, 'bright');
|
|
|
|
log('\n📋 METHOD 1: Using Stripe Test Clocks (Recommended)', 'bright');
|
|
log('─────────────────────────────────────────────────', 'cyan');
|
|
|
|
// Check if subscription is on a test clock
|
|
const subscription = await stripe.subscriptions.retrieve(schedule.subscription);
|
|
const customer = await stripe.customers.retrieve(subscription.customer);
|
|
|
|
if (customer.test_clock) {
|
|
log(`✓ Customer is on test clock: ${customer.test_clock}`, 'green');
|
|
log('\nAdvance the test clock:', 'yellow');
|
|
log(` stripe test_clocks advance ${customer.test_clock} \\`, 'cyan');
|
|
log(` --frozen-time "${phase1EndDate.add(1, 'hour').toISOString()}"`, 'cyan');
|
|
} else {
|
|
log('⚠ Customer is NOT on a test clock.', 'yellow');
|
|
log('\nTo use test clocks:', 'yellow');
|
|
log('1. Create a test clock:', 'cyan');
|
|
log(` stripe test_clocks create --frozen-time "2024-01-01T00:00:00Z"`, 'cyan');
|
|
log('\n2. Recreate customer on test clock:', 'cyan');
|
|
log(` (This requires creating a new customer with --test-clock=clock_xxx)`, 'cyan');
|
|
}
|
|
|
|
log('\n📋 METHOD 2: Manual Webhook Trigger (For Testing Only)', 'bright');
|
|
log('─────────────────────────────────────────────────', 'cyan');
|
|
log('You can manually trigger the webhook handler to test email sending:', 'yellow');
|
|
log(` node tests/test_promo_expiry_workflow.js --triggerWebhook ${schedule.id}`, 'cyan');
|
|
|
|
log('\n📋 METHOD 3: Wait for Real Time (Not Recommended)', 'bright');
|
|
log('─────────────────────────────────────────────────', 'cyan');
|
|
log(`Wait ${daysUntil} days until ${phase1EndDate.format('YYYY-MM-DD')}`, 'yellow');
|
|
|
|
log('\n🔍 What to Look For After Time Advance:', 'bright');
|
|
log('─────────────────────────────────────────────────', 'cyan');
|
|
log('1. Stripe webhook event: subscription_schedule.completed', 'yellow');
|
|
log('2. Server logs should show:', 'yellow');
|
|
log(' - "Subscription schedule completed: ..."', 'cyan');
|
|
log(' - "Promo expired email sent to ..."', 'cyan');
|
|
log('3. Email received at customer address', 'yellow');
|
|
log(`4. Check email preview: test-logs/promo-expired-preview.html`, 'cyan');
|
|
}
|
|
|
|
async function manuallyTriggerWebhook(scheduleId) {
|
|
section('Manually Triggering Webhook Handler');
|
|
|
|
log('⚠ This simulates the webhook event locally (for testing only)', 'yellow');
|
|
log(`Schedule ID: ${scheduleId}`, 'cyan');
|
|
|
|
// Retrieve schedule
|
|
const schedule = await stripe.subscriptionSchedules.retrieve(scheduleId);
|
|
log(`✓ Retrieved schedule: ${schedule.id}`, 'green');
|
|
log(` Status: ${schedule.status}`, 'cyan');
|
|
|
|
if (schedule.status !== 'active') {
|
|
log(`⚠ Warning: Schedule status is '${schedule.status}', not 'active'`, 'yellow');
|
|
log('The webhook handler expects an active schedule.', 'yellow');
|
|
}
|
|
|
|
// Find applicator
|
|
const promoId = schedule.metadata?.promoId;
|
|
if (!promoId) {
|
|
log('⚠ Schedule metadata missing promoId!', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
const subscription = await stripe.subscriptions.retrieve(schedule.subscription);
|
|
const custId = subscription.customer;
|
|
|
|
const dbCustomer = await Customer.findOne({ 'membership.custId': custId });
|
|
if (!dbCustomer) {
|
|
log('⚠ Customer not found in database!', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
log(`✓ Found customer: ${dbCustomer.name} (${dbCustomer.username})`, 'green');
|
|
|
|
// Import the handler
|
|
const subscriptionController = require('../controllers/subscription');
|
|
|
|
// Mock req object
|
|
const mockReq = {
|
|
protocol: 'https',
|
|
get: (header) => header === 'host' ? 'localhost:4200' : null,
|
|
hostname: 'localhost',
|
|
locals: {}
|
|
};
|
|
|
|
log('\nCalling handleSubscriptionScheduleCompleted...', 'yellow');
|
|
|
|
// This won't work directly because handleSubscriptionScheduleCompleted is not exported
|
|
// We need to simulate the webhook
|
|
log('⚠ Direct handler call not available. Use Stripe CLI instead:', 'yellow');
|
|
log(` stripe trigger subscription_schedule.completed \\`, 'cyan');
|
|
log(` --add subscription_schedule:id=${scheduleId}`, 'cyan');
|
|
|
|
log('\nOr forward webhooks to your local server:', 'yellow');
|
|
log(` stripe listen --forward-to localhost:4100/api/subscription/webhooks`, 'cyan');
|
|
}
|
|
|
|
async function main() {
|
|
try {
|
|
await connectDB();
|
|
|
|
if (options.triggerWebhook && options.scheduleId) {
|
|
await manuallyTriggerWebhook(options.scheduleId);
|
|
process.exit(0);
|
|
}
|
|
|
|
// Find or create customer
|
|
let dbCustomer;
|
|
if (options.customerId) {
|
|
const customer = await stripe.customers.retrieve(options.customerId);
|
|
dbCustomer = await Customer.findOne({ 'membership.custId': options.customerId });
|
|
if (!dbCustomer) {
|
|
log('Customer not found in database!', 'red');
|
|
process.exit(1);
|
|
}
|
|
} else if (options.email) {
|
|
dbCustomer = await findOrCreateCustomer(options.email);
|
|
} else {
|
|
log('Usage: node tests/test_promo_expiry_workflow.js --email test@example.com', 'red');
|
|
log(' or: node tests/test_promo_expiry_workflow.js --customerId cus_xxx', 'red');
|
|
log(' or: node tests/test_promo_expiry_workflow.js --triggerWebhook sub_sched_xxx', 'red');
|
|
process.exit(1);
|
|
}
|
|
|
|
const custId = dbCustomer.membership.custId;
|
|
|
|
// Find active promo
|
|
const promo = await findActivePromo();
|
|
|
|
// Create subscription
|
|
const { schedule, subscription } = await createTestSubscription(custId, promo);
|
|
|
|
// Verify schedule
|
|
await verifyScheduleConfiguration(schedule.id);
|
|
|
|
// Provide instructions
|
|
await provideTestInstructions(schedule, promo);
|
|
|
|
log('\n✅ Test subscription created successfully!', 'green');
|
|
log(`\n📝 Important Details:`, 'bright');
|
|
log(` Schedule ID: ${schedule.id}`, 'cyan');
|
|
log(` Subscription ID: ${subscription.id}`, 'cyan');
|
|
log(` Customer Email: ${dbCustomer.username}`, 'cyan');
|
|
|
|
process.exit(0);
|
|
} catch (error) {
|
|
log('\n❌ Error:', 'red');
|
|
console.error(error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|