Back to Feed
article 3m read

Billing mirror deleted, seat cap left with three opinions

Swapping a local copy of billing state for plan entitlements in the session was the right call, but it left the free-tier seat cap answered by three pieces of code that disagree, a random retry key on customer creation, signup that now depends on the payment provider, and billing endpoints that trust whatever organization the request names.

Article DepthPractice

Flow

The gaps I'd close, in order

One step per gap found in this commit.

  1. 1Tests first

    A paid team past the free cap, and a signed-in stranger asking for another team's portal.

  2. 2One seat-cap function

    Fed by the plan and used by both the screens and the server, so the saved limit can go.

  3. 3A stable retry key

    Built from the organization, so a retry gets the same customer back.

  4. 4A plan for outages

    Either signup fails clearly, or the team is marked billing-pending and a job finishes it later.

  5. 5A membership check on each billing endpoint

    Compare the requested team with the caller's team before calling the provider.

  6. 6Something that goes red

    Count refresh presses and flag paying teams whose sessions show no plan.

Looking back through one of my own commits on the admin app, I found three different answers to "how many people can this organization have". Only one of them hears about it when a customer pays.

The copy I deleted

The app used to keep its own copy of billing data. A webhook took events from the payment provider and wrote them into tables. Health checks watched it. This commit deleted all of that, and I still think that was right. A copy that's only usually right causes bugs.

Now the plan comes from the user's session. The new code counts on the login provider putting the plan there as a list of tags. The app takes the highest plan on the list. No plan tag means free. A refresh button fetches a new session for someone who just upgraded.

Three answers about seats

Free organizations get a small seat cap. After the commit, three pieces of code answer the seat question:

  • The session helper says free is capped and paid is unlimited.
  • An interface helper ignores the plan and looks for a different, optional tag.
  • The server reads a limit saved on the organization when it signed up.

Only the session learns about upgrades. The saved limit never changes, because the webhook that could have changed it is gone. The server check runs when someone joins. If the saved limit says full, it logs a warning and skips the new member. So a paid team that grows past the free cap could end up with people the login provider knows about and my app doesn't.

The cap should come from the plan, in one function, everywhere it's checked.

One function turns the plan into a seat cap, with the free plan getting the free limit and every other plan unlimited.

A random key and a required signup step

When the app creates a customer at the payment provider, it used to send a retry key based on the organization. A retry got the same customer back. Now the key is random each time. A lost reply plus a retry can make two customers for one organization.

Billing setup also used to be optional during signup. If it failed, signup went on. Now it's required, with no retry job and no "billing pending" state. A provider outage can now block or break signup, and with the random key a second attempt can leave an extra customer behind.

Checking who's asking

The new checkout, portal and seat count endpoints check that someone is signed in. They then trust whatever organization the request names. Nobody checks that the caller belongs to it. The billing portal is the worst case, since it shows invoices and has a cancel button. The session already knows the caller's organization, so comparing the two is cheap.

Losing the alarms

The old health checks could flag webhook problems. Now a broken sync just looks like a free organization. Nothing goes red. The refresh button is the only hint, since each press means someone sees the wrong plan, and those presses aren't counted anywhere. The commit adds no tests for the seat check or the new endpoints either.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward
Related idea

Paying customers matched by email, even the addresses the billing code made up itself

The lookup that decided whether an organization already had a payment customer searched by an email my code sometimes guessed or generated, and could hand one org's billing record to another. I moved identity onto the identity provider's org ID and tied retries to it, but the same commit left an ignored retry flag, a delete check that reads error wording, a gutted seat counter, and no tests.

via #billing