Migrating Cover from Balanced to Stripe

Chris
Chris
Mar 16, 2015 · 5 min read

Cover started the migration process from Balanced to Stripe before the recent news. Cover’s business is payments, so the decision wasn’t taken lightly.

At a high level, the steps needed for the transition were:

  1. Prepare the apps and our API
  2. Begin tokenizing new cards to Stripe
  3. Migrate existing card data

The last step isn’t strictly necessary. We could have asked customers to re-enter their card data. But for a company whose core product is a frictionless payment experience, that was a non-starter.

The other major requirement was to be able to gradually phase Stripe charges into the system. By, for example, initially testing on employees, we found out that Cover charges weren’t classified as dining. And by slowly adding customer cards, we were able to measure decline rate differences.

Finally, it goes without saying that we couldn’t have any downtime that affected the ability of customers to signup or add a card.

Preparing the API

The Balanced and Stripe APIs are very similar. There are endpoints to associate cards with customers, charge cards, etc. Unfortunately, the Cover API had references to Balanced objects and concepts scattered throughout models and API endpoints.

For example, our customer object looked like this:

{"id": ...,
"name": ...,
"credit_cards": [...],
"balanced_account_uri": ...}

The credit card object was similar:

{"id": ...,
"last_four": ...,
"balanced_card_uri": ...}

The API endpoint for adding a credit card looked like this:

POST '/customers/:id/credit_cards' do
balanced_card = Balanced::Card.find(params[:balanced_card_uri])
customer.balanced_account.add_card(balanced_card)
end

And when it was time to charge a card (ignoring error handling):

class Payment
def charge!
balanced_card = credit_card.balanced_card
balanced_charge = balanced_card.debit({:amount => amount})
balanced_uri = balanced_charge.id
save!
end
end
class CreditCard
def balanced_card
Balanced::Card.find(balanced_card_uri)
end
end

Rather than drop Stripe code in parallel with Balanced, we determined to make models and endpoints agnostic to the underlying payment system.

This work comprised the bulk of the time spent on the transition, but it was worth it for us. The code is cleaner and there’s a better separation of responsiblity. Any future transitions will be much less time consuming.

Once we were done, the customer object looked like this:

{"id": ...,
"name": ...,
"credit_cards": [...],
"payments_accounts": [{"source": ...,
"external_id": ...},
...]}

Customers can have multiple pointers to external payments systems. In fact, during the transition phase, all customers are required to have both Balanced and Stripe accounts.

The new credit card object:

{"id": ...,
"last_four": ...,
"external_ids": [{"source": ...,
"external_id": ...,
"active": true},
...]}

This allows credit cards to be tokenized in multiple places. The active flag is critical. Only one external id can be active at a time because it determines which system’s API to charge against.

To charge a card:

class Payment
def charge!
debit = Cover::Debitor.charge!(self)
external_debit_id = debit.id
source = debit.source
save!
end
end

The Debitor class lives in the lib directory:

module Cover
class Debitor
def self.charge!(payment)
case payment.credit_card.active_external_id.source
when 'stripe'
Cover::StripeDebitor.charge!(payment)
when 'balanced'
Cover::BalancedDebitor.charge!(payment)
end
end
end
end

The StripeDebitor and BalancedDebitor classes perform the specific API actions required for that platform, but they both return a generic Debit object to the caller. Platform-specific errors are mapped to generic exception classes that the Payment can handle.

What about the apps?

Cover’s mobile apps are coupled to the underlying payment platform for one critical function: card tokenization. The apps collect card information and send it directly to the payment processor. The token that is returned is then sent to the Cover API so it can be associated with a customer and charged.

As with the API, simply integrating the Stripe SDK was the easy part. However, given a credit card, the app has to know if it should tokenize it with Balanced or Stripe. To do this, the app first makes a request to the Cover API. This allows us full control during the transition phase. For example, we could decide based on the caller (are they an employee?) or based on configuration (should this card be one of the 25% we should tokenize with Stripe?).

But just shipping an app update that can optionally tokenize to Balanced or Stripe isn’t enough. We had to make sure that customers had apps that were capable of doing so during the transition.

So we also built a gating feature. That would allow us to control which app versions were allowed to interact with the Cover API. And, in fact, since we knew we might switch at some point, we built the gating feature months in advance of beginning the migration. We think it’s a best practice to for all apps to build a gating feature as soon as possible (even if you don’t plan on making such a large change anytime soon).

The gating mechanism itself is simple: the app makes a request each time it it’s opened. The API reads the version number of the app and returns a failure code if the version is too old. If the app sees a failure, it pops up a screen asking the user to update their app. Since being forced to update isn’t a great customer experience, we only gate when absolutely necessary and wait a few days after releasing an update before turning it on.

The Migration

Let’s recap the current state:

  • The Cover API is creating Balanced and Stripe accounts for all customers
  • The Cover API can accept tokenized cards and can charge against either Balanced or Stripe
  • The apps are updated and can tokenize cards with either Balanced or Stripe

At this point, we began the migration. The first step was to gate customers on the latest version of the app. Then, over the next few days, we started ramping up the percentage of new cards that were tokenized with Stripe.

Once all new cards were going to Stripe, we engaged both Balanced and Stripe support to securely copy existing cards between the systems. (We migrated our data just days before the automated tool was released).

After the cards were copied, Stripe provided a file that mapped our stored Balanced card object to the corresponding Stripe one. This final step was rather anticlimactic. After over a month of work, we simply ran a script on the mapping file. A few minutes later, we were done.

Except…

As we began charging ex-Balanced cards via Stripe, we started seeing the following error:

Message: (Status 404) Customer cus_7AJJfi5eircVGD does not have card with ID card_9ajwCrYsu4FWNj

It turns out that the Stripe and Balanced APIs differ in a subtle way: Balanced can charge card objects directly; Stripe requires a customer context.

The Cover API code that loaded the customer context looked like this:

payment.customer.payments_accounts.find{|a| a.source == 'stripe'}

While we already had pre-migration Stripe accounts for every customer, the migrated cards lived under a different account. That should have been fine, since nothing in the system required that a customer only have one account for a given source. Except the above code.

We had two options to fix the situation:

  1. Have Stripe merge the duplicate accounts
  2. Keep track of the mapping between the credit card and the payments account

We chose the second option. It gave us full control over the situation and is a more generic solution going forward.

Wrapping up

Our transition to Stripe was very smooth. Customer cards were charged and our restaurant partners were paid.

The keys to the transition were:

  1. Building gating into our apps early
  2. Controlling the flow of Stripe cards into the system

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store