agmission/Development/server/tests/test_promo_expiry_workflow.js

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();