Wellfire Interactive // Expertise for established Django SaaS applications

App architecture matters, or, how not to double charge your customers (This Old Pony #31)

What follows is a true story, a recent one at that, about the wrong architecture - from a customer’s perspective.


A couple weeks ago I discovered a billing discrepancy from a small SaaS vendor we use in our own business. While reviewing our books I noticed that for our monthly service we were billed twice in many months and a few times even thrice. Checking the account subscription showed only one value and one set of once-per-month payments. Something was amiss.

The individual numbers were small enough, and the vendor was known, so that it never raised an eyebrow from our bookkeeper. But I knew we should only have one subscription. After a few emails back and forth with the SaaS we confirmed that we only ever had one account which just happened to be billed multiple times. Subsequently, the owner reached out to me with confirmation that they had in fact billed us repeatedly and by accident.

There was nothing in their database to indicate a problem, but they’ve gone through their Stripe data and been able to trace back the payments and subscriptions via the credit card (or so I presume). 

And now this SaaS business is going to refund us over $500.

What happened? Putting on my deerstalker

The owner shared a payments spreadsheet with me for a once over before issuing a refund and I noticed something peculiar. Five different column groups with overlapping histories, each with a different customer ID. That’s customer ID, not subscription ID. The Stripe customer ID schema is a pretty good give away, by the way.

So what happened? Well since starting the service I’ve upgraded, downgraded, put it on hold, started it up again, etc etc etc. And each time I changed the subscription the SaaS created a new customer entry, either solely on Stripe or locally, too. That means a new subscription was necessarily created, too.

Most likely when the app was started it only had a concept of a single plan, and there was no allowance built it for customers pausing their accounts. The easiest and fastest way to add these two features was to just make a change with the subscription billing service (Stripe) by creating a new customer - but this requires that you manage the lifecycle of all the old customer data, too.

This strategy made it really easy for errors to translate to extra charges. Even for a small SaaS business $500 may not be a huge sum to refund, but what if this happened to other customers? How many of your customers could you refund $500 before it became more than a minor bookkeeping issue?

Prescribing solutions from afar

The missing step in this particular strategy was deactivating the original customer entry after this change, but that’s beside the point. The root problem is that this app’s subscription model mapped a customer to an individual plan subscription.

There’s a data hierarchy here[0] - a customer model (whether in a Django app or the architecture for a SaaS app) should map 1:1 to a user or account entity of some kind in your app.[1]

The app in question is a Rails app, not a Django app, but for description’s sake let’s assume it’s a Django app. The first thing I’d do is ensure that the customer billing feature works as-is. Before making any architectural changes it would be simpler to shore up the current functionality even if it’s not exactly correct when it does work. One of the major downsides of using third party APIs and data services is that there’s no concept of a committing data in a transaction. You can’t lump a series of API requests together, like add this subscription and then remove this one, and ask that they only be applied if all succeed and then rollback otherwise. Stripe is great but it isn’t PostgreSQL for payments.

That said, we’d add in a check that can be run via management command or scheduled task which will query for duplicate active customers in Stripe. This can log and/or email an admin, and from there customer support can manually resolve the problem.

Next ensure that the subscription change API calls are coupled, so that we know they’ll happen together. How they’re sequenced depends on what’s important to your business and what customers expect. You might start the new subscription first, then deactivate the old subscription - risking an error like we’ve just described - but execute an asynchronous task to retry the deactivation on any API failure.

The big change is the data modeling. In a Django project it’d be easier to start by extracting functionality into a new app if it’s not already isolated. In our new subscription management app we might set up a subscription model which is linked to whatever customer we choose. The subscription model might link back to a user profile (or an organization[1]) and use this information to populate the Stripe customer data when creating a new customer account. Changes to the subscription, including upgrade, downgrade, cancellation, would be handled via methods on the subscription model encapsulating the details. Since Stripe already has excellent facilities for handling upgrades and downgrades, including prorating payments, it’d be far better to lean on these. We’d also track changes in the subscription history locally in an audit model, yes, even though this is tracked in Stripe. This double bookkeeping is a good practice with third-party APIs either running critical processes consuming your data or sending your app important data.

Got a similar issue you’d like some insight into, either privately or to share with this list? Hit reply and let me know!

Recurringly yours,

P.s. I’m not naming the SaaS in question. Their follow up was handled very well and I have no desire to shame anyone - _ technical debt happens ™._

[0] See last week’s issue, The module hierarchy and your Django project
[1] I’m sure there are valid exceptions to this but this is damn fine rule for 99% of situations.
[2] Example https://github.com/bennylope/django-organizations

Learn from more articles like this how to make the most out of your existing Django site.