DEV Community

Cover image for Six Weeks of Work. Three Lines of Code. Two Hours to Break It All.
Sneh
Sneh

Posted on

Six Weeks of Work. Three Lines of Code. Two Hours to Break It All.

I was one of three engineers building the checkout flow for a new SaaS product, and six weeks in, I was sure it was the safest part of the whole app. Saved cards worked. So did the three payment methods, the promo codes, the subscriptions. We'd tested every path twice, some of them three times.

Launch night, the first two hours felt almost boring. Orders came in. Payments cleared. Nobody was hovering over the logs waiting for a fire.

Then the fire showed up.

The two-hour mark

We'd scheduled a launch email for two hours after the site went live, so the first wave of visitors could warm up the servers before the real crowd hit. It went out right on time.

Ten minutes later, my phone buzzed with a failed-payment alert. Then another.

My first guess was Stripe, so I checked their status page. Nothing. Someone else guessed traffic, so we scaled up the servers. The failures kept coming anyway, at almost exactly the pace of new orders.

It took us about forty minutes to see the real pattern. Every failed payment had a promo code attached to it. Every order without one had gone through fine, for two straight hours. The second that email hit people's inboxes and they started using the code inside it, checkout broke.

The three lines

We'd added promo codes late, four days before launch, as a small add-on to a flow that was otherwise finished.

Here's the one thing you need to know to follow the bug: Stripe doesn't charge in dollars, it charges in cents. A $50 order gets turned into a plain number, 5,000, before it ever reaches Stripe. One line handles that:

js:
const amountInCents = toStripeAmount(orderTotal);

When we added promo codes, someone needed to apply the discount before that number went out the door. So they wrote this:

js:
if (promoCode) {
const discounted = applyDiscount(orderTotal, promoCode);
amountInCents = toStripeAmount(discounted);
}

Three lines. Read it yourself, it looks completely fine. That's exactly what made it dangerous.

Somewhere else in the flow, in a part this change never touched, that same discounted number got converted into cents again. Twice, total. So a $50 order with a 10% discount should have charged $45. It charged $4,500 instead, a hundred times too much, on every card that could physically go that high. Most declined right away for exceeding their limit, which is the only reason this didn't turn into a much bigger mess of refunds.

Nobody caught it, because nobody really reviewed it. It was three lines, added four days before launch, sitting next to six weeks of work that felt like it mattered more. Someone dropped a thumbs-up in Slack, and it shipped. You've probably done the same thing yourself on a change that felt too small to slow down for.

Why the small change is the dangerous one

Nobody skims a five-hundred-line pull request. It looks like real work, so people actually sit down and read it.

Three lines get the opposite treatment. They look too small to hide anything, so they get a glance instead of a real read, if they get looked at at all.

I think that's backwards. A big feature usually breaks loudly, and only in the one place it lives. A tiny change to something shared, a function that half the app quietly depends on, can reach into places the person writing it never opened. Our checkout flow was fine. The bug was sitting in one small, shared function that three lines had no business touching twice.

How long a change takes to read has almost nothing to do with how far it can reach. That's the part I didn't get until it happened to me.

What would have caught it

Nothing complicated. One test, a single line checking that a $50 order with a 10% discount charges $45, would have failed instantly, days before launch instead of two hours after it. Nobody wrote it, for the same reason nobody gave the change a real review: it felt too small to bother with.

A blunt ceiling on the charge amount would have worked too, something like "block anything over a set limit and page a person." That alone turns a two-hour outage into a five-minute fix and one Slack message. A real QA pass exists to catch exactly this kind of edge case, the kind that only shows up when two ordinary features collide in a way nobody staged on purpose. So does a build process with a real review step built in, instead of one that only gets applied to changes that look big enough to deserve it.

This happens more than people admit out loud. A lot of early products run into some version of this, usually right around launch, exactly when the pressure to ship is highest and the checks feel optional. This is close to the actual rule we hold at EnactOn on anything that touches money: the size of the change doesn't decide how careful you get, what it touches does. A three-line payment fix gets the same scrutiny as a full rewrite, every time, because getting it wrong isn't measured in review minutes saved. It's measured in how many customers get charged the wrong amount before anyone notices.

What happened after

The real fix took six minutes: stop converting an already-converted number a second time. The cleanup took the rest of the day. We checked every failed charge by hand to confirm it was actually declined and not charged, since a few high-limit cards had gone through at the wrong amount and needed a manual refund. Support spent the rest of launch day answering "did you really just charge me $4,500 for a $45 order" instead of welcoming new users.

None of that means we built the checkout flow badly. It was six weeks of genuinely careful work, and it did exactly what we designed it to do, everywhere except the one spot none of us looked at closely enough. I review every payment-related diff the same way now, no matter how many lines it touches, because the real cost of skipping that step almost never shows up the moment you skip it. It shows up two hours later, in front of the exact customers you spent six weeks trying to win.

Has your team ever had a "just three lines" change turn into the real incident? What was in it?

Top comments (0)