DEV Community

ADITYA DUTT PANDEY
ADITYA DUTT PANDEY

Posted on Originally published at lab.adpandey.dev on

We gave users a choice. That was the bug.

The hardest problem we had on a recent tunyt event had no stack trace.

Nothing crashed. No alert fired. Every line of code did exactly what we wrote it to do, and people still kept writing in, stuck and confused, asking us questions we couldn't answer for them.

It was a team event. People registered as teams, and the team had to pay. That sounds like a checkbox. It turned out to be four separate decisions, and the interesting part is how a series of reasonable choices added up to one that wasn't.

Decision 1: the leader pays

Our first plan was the simplest thing that could work: the team leader pays for everyone. One team, one payment, one receipt.

In a payment system every extra state is a place for money to get lost, and this design had almost none. A team was either paid or it wasn't. I still think that was right.

Decision 2: let teams split it too

After a lot of back and forth inside our team, we added a second option. A team could split the payment, so every member pays for their own ticket. It felt friendlier. Nobody has to put up money for four people and chase the others afterwards.

So we shipped both, and let the team leader pick.

That last sentence is the bug. I didn't see it yet.

What people actually did

Two kinds of chaos showed up, and on the surface they had nothing to do with each other.

The first was a team that changed shape. A team of three formed and the leader paid for all three. Then they removed one member and added two new ones, and wrote to us asking why they were suddenly being asked to pay for two more people. From their side they'd paid for "the team", and the team had changed. From our side, three tickets had been paid for, one belonged to someone no longer on the team, and two new people had none. The person they'd removed still held a valid ticket of their own, and nobody on the team knew.

The second was a payment button that never came. Members started writing in to say they had no button, no link and no email. Support asked the same question every time: has your team leader chosen to split the payment, or to pay for the whole team? Almost every time, the answer was that nobody knew.

Decision 3: say no

The first problem pulled us toward building more. If teams change after paying, the proper answer is refunds, ticket transfers, moving a ticket from one person to another, and handling every team that changes its plan halfway through.

I sat with what that would mean. Each of those is its own small product. Each moves money or ownership, and each has its own edge cases, its own support load and its own new ways to get a payment wrong. We'd be spending weeks to support behaviour that a few teams had stumbled into.

So we built none of it. We kept the model we had and stopped trying to make every possible team change free. It's the decision I'm surest about, and the hardest to make with someone upset in your inbox. "We won't support that" sounds like failure. Most of the time it's just scope, and a small team that says yes to every edge case ends up with a product made of edge cases.

Decision 4: move the choice

Saying no didn't fix the second problem, and the second problem was the real one.

When I drew the flow out as states, it became obvious. With the leader choosing at runtime, a team could be in any combination of: no choice made yet, leader pays, or split; paid, partly paid or unpaid; the same members as when it paid, or different ones. We understood every one of those states. The people inside them couldn't see any of them. Every support message was someone stuck in a state the product never showed them.

So we moved the choice. Now the organiser decides, once per event, how teams pay: either the leader pays for everyone, or it's split and each person pays as they join. Attendees never make the choice, so they can't be confused about what was chosen, and "no choice made yet" stops existing as a state at all.

We had almost all the logic already. In the end it was mostly a flag on the event:

// Simplified. Set by the organiser when they create the event.
type TeamPayment = "LEADER_PAYS" | "SPLIT";

Enter fullscreen mode Exit fullscreen mode

One more rule closes the gap that started all this. If a team has already paid and someone new joins, that person buys their own ticket. They join as a new member with a ticket of their own and never reopen a payment that's already settled. Money that's settled stays settled.

What I took from it

A choice you give users is a state you have to show them. We thought we were adding flexibility, and we were adding a hidden state that only one person on the team could see. How a team pays is the organiser's call, and the moment we let whoever clicked first decide, everyone else inherited a choice they never made.

Support asking the same question over and over was the product asking it in the wrong place. "Did your leader split or pay?" belonged on the event page, and it never needed to be a support chat.

Decision 1 was right. Decision 2 was reasonable. Putting decision 2 in the wrong hands was the bug.

Top comments (0)