#!/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();