Migrating Payment Systems: Stripe to Chargebee in Production
Migrating payment systems in a production SaaS application is like changing the engines on a flying plane. One mistake and revenue stops, customers are angry, and the business is at risk.
We successfully migrated from Stripe.js to Chargebee.js for a SaaS product with 5,000+ paying customers, zero downtime, and zero revenue loss. Here’s how we did it.
Why Migrate?
Stripe is excellent for basic subscriptions, but we needed:
- Advanced subscription management (add-ons, metered billing)
- Multi-currency support with local payment methods
- Better dunning management
- Revenue recognition automation
- Consolidated billing for enterprise customers
Chargebee provides all of this out-of-the-box, plus APIs for complex subscription workflows.
Migration Strategy
Phase 1: Parallel Running (Weeks 1-2)
Run both systems simultaneously without switching customers:
interface PaymentProvider {
createSubscription(plan: Plan, customer: Customer): Promise<Subscription>;
updateSubscription(id: string, changes: Partial<Subscription>): Promise<Subscription>;
cancelSubscription(id: string): Promise<void>;
}
class StripeProvider implements PaymentProvider {
// Existing Stripe implementation
}
class ChargebeeProvider implements PaymentProvider {
// New Chargebee implementation
}
// Adapter pattern for seamless switching
class PaymentService {
private provider: PaymentProvider;
constructor() {
this.provider = config.paymentProvider === 'chargebee'
? new ChargebeeProvider()
: new StripeProvider();
}
async createSubscription(plan: Plan, customer: Customer) {
return this.provider.createSubscription(plan, customer);
}
}Phase 2: Shadow Mode Testing (Week 3)
For new transactions, call both APIs but only commit Stripe:
async function createSubscription(plan: Plan, customer: Customer) {
// Create in Stripe (production)
const stripeSubscription = await stripeProvider.createSubscription(plan, customer);
// Create in Chargebee (test mode)
try {
const chargebeeSubscription = await chargebeeProvider.createSubscription(plan, customer);
// Compare results
if (!subscriptionsMatch(stripeSubscription, chargebeeSubscription)) {
logger.error('Subscription mismatch', {
stripe: stripeSubscription,
chargebee: chargebeeSubscription
});
}
} catch (error) {
// Log but don't fail - Chargebee is not production yet
logger.error('Chargebee shadow mode error', error);
}
return stripeSubscription; // Return Stripe for now
}Phase 3: Gradual Migration (Weeks 4-8)
Migrate customers in cohorts:
interface MigrationCohort {
name: string;
criteria: (customer: Customer) => boolean;
percentage: number;
}
const cohorts: MigrationCohort[] = [
{ name: 'internal', criteria: c => c.email.endsWith('@company.com'), percentage: 100 },
{ name: 'beta-users', criteria: c => c.tags.includes('beta'), percentage: 100 },
{ name: 'free-tier', criteria: c => c.plan === 'free', percentage: 10 },
{ name: 'paid-monthly', criteria: c => c.plan !== 'free' && c.interval === 'month', percentage: 5 },
// Gradually increase percentages
];
async function getPaymentProvider(customer: Customer): Promise<PaymentProvider> {
// Check if already migrated
if (await isMigrated(customer.id)) {
return new ChargebeeProvider();
}
// Check cohort eligibility
for (const cohort of cohorts) {
if (cohort.criteria(customer)) {
if (Math.random() * 100 < cohort.percentage) {
await migrateCustomer(customer);
return new ChargebeeProvider();
}
}
}
// Default to Stripe
return new StripeProvider();
}Phase 4: Customer Data Migration
async function migrateCustomer(customer: Customer) {
const stripeCustomer = await stripe.customers.retrieve(customer.stripeId);
const stripeSubscription = await stripe.subscriptions.retrieve(customer.subscriptionId);
// Create in Chargebee
const chargebeeCustomer = await chargebee.customer.create({
id: customer.id,
email: customer.email,
first_name: customer.firstName,
last_name: customer.lastName,
billing_address: stripeCustomer.address,
}).request();
// Migrate subscription
const chargebeeSubscription = await chargebee.subscription.create({
customer_id: customer.id,
plan_id: mapStripePlanToChargebee(stripeSubscription.plan.id),
start_date: stripeSubscription.current_period_start,
billing_cycles: stripeSubscription.cancel_at_period_end ? 1 : undefined,
}).request();
// Migrate payment method
await migratePaymentMethod(customer, stripeCustomer.default_source);
// Update our database
await db.customers.update(customer.id, {
chargebeeCustomerId: chargebeeCustomer.customer.id,
chargebeeSubscriptionId: chargebeeSubscription.subscription.id,
migratedAt: new Date(),
});
logger.info(`Migrated customer ${customer.id} to Chargebee`);
}Critical Implementation Details
Webhook Handling
Run both webhook handlers during migration:
app.post('/webhooks/stripe', async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
await handleStripeWebhook(event);
res.json({ received: true });
});
app.post('/webhooks/chargebee', async (req, res) => {
const event = req.body;
await handleChargebeeWebhook(event);
res.json({ received: true });
});
async function handleSubscriptionUpdated(subscription: any, provider: 'stripe' | 'chargebee') {
const customer = provider === 'stripe'
? await db.customers.findOne({ stripeSubscriptionId: subscription.id })
: await db.customers.findOne({ chargebeeSubscriptionId: subscription.id });
if (!customer) {
logger.warn(`Customer not found for ${provider} subscription ${subscription.id}`);
return;
}
await db.customers.update(customer.id, {
subscriptionStatus: subscription.status,
currentPeriodEnd: subscription.current_period_end,
});
}Rollback Plan
async function rollbackCustomer(customerId: string) {
const customer = await db.customers.findById(customerId);
if (!customer.chargebeeSubscriptionId) {
throw new Error('Customer not migrated to Chargebee');
}
// Cancel Chargebee subscription
await chargebee.subscription.cancel(customer.chargebeeSubscriptionId).request();
// Restore Stripe subscription if it was cancelled
if (customer.stripeCancelledDuringMigration) {
await stripe.subscriptions.update(customer.stripeSubscriptionId, {
cancel_at_period_end: false,
});
}
// Update database
await db.customers.update(customerId, {
chargebeeCustomerId: null,
chargebeeSubscriptionId: null,
migratedAt: null,
});
logger.info(`Rolled back customer ${customerId} to Stripe`);
}Results
Migration timeline: 8 weeks Customers migrated: 5,243 Revenue loss: $0 Failed transactions during migration: 0 Rollbacks required: 3 (all successful)
Business benefits:
- 40% reduction in failed payments (better dunning)
- 25% increase in international conversions (local payment methods)
- $15K/year saved on payment processing (better rates)
- Revenue recognition automated (saved 20 hours/month)
Lessons Learned
- Shadow mode is critical: Found 12 edge cases before going live
- Gradual rollout saved us: Caught issues at 1% before affecting everyone
- Communication is key: Notified customers 2 weeks in advance
- Rollback plan must be tested: We tested rollback on 10 customers proactively
- Monitor everything: Real-time dashboards for transaction success rates
Key Mistakes to Avoid
- Don’t migrate payment methods automatically: Requires PCI compliance work
- Don’t migrate during month-end: Renewal charges are risky
- Don’t skip shadow mode: You will miss edge cases
- Don’t rush: Better to take 3 months safely than 1 month with issues
Planning a payment system migration? I’d love to discuss strategies and share war stories. Connect on LinkedIn.