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

  1. Shadow mode is critical: Found 12 edge cases before going live
  2. Gradual rollout saved us: Caught issues at 1% before affecting everyone
  3. Communication is key: Notified customers 2 weeks in advance
  4. Rollback plan must be tested: We tested rollback on 10 customers proactively
  5. Monitor everything: Real-time dashboards for transaction success rates

Key Mistakes to Avoid

  1. Don’t migrate payment methods automatically: Requires PCI compliance work
  2. Don’t migrate during month-end: Renewal charges are risky
  3. Don’t skip shadow mode: You will miss edge cases
  4. 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.