Katto
← Back to blog
EngineeringBuild in PublicPricing

We sold a plan that didn't work

We launched a €4.90/month plan. The annual option would have charged €58.80 every month, the plan granted no entitlements, and the worker ignored its duration cap. All three found by buying it ourselves.

October 4, 2026

We sold a plan that didn't work

Created: October 4, 2026 · Updated: October 4, 2026

A €4.90/month plan went live on our pricing page. Anyone who picked the annual option would have been charged €58.80 every month.

Nobody was.

We caught it before a single customer bought the annual tier, not by reading the code, but by buying the thing ourselves with a real card.

That part matters. The build was green. TypeScript was happy. The API returned 200. None of that meant the product actually worked.

We found three defects that morning. Our first paying Solo customer subscribed twenty-seven minutes after the last of those three fixes went out. Half an hour the other way, and he would have been the one paying for a plan that granted him nothing.

Defect 1: the price was right, the interval wasn't

Our new entry tier, Solo, is €4.90/month billed annually, or €8.90 month-to-month. €4.90 × 12 = €58.80 once a year.

The Stripe price behind the annual option had been created with a monthly billing interval.

So Stripe was ready to charge €58.80 every month — twelve times the intended price.

The pricing page was correct. The code was using the correct price ID. The bug lived outside the repository, in a Stripe object created through the dashboard.

That is what made it easy to miss.

Stripe does not let you change a Price's billing interval in place. You create a new Price and swap the ID. So we added a check before checkout is created:

const expected = effectivePeriod === "annual" ? "year" : "month";
const price = await stripe.prices.retrieve(priceId);

if (price.recurring?.interval !== expected) {
  throw new Error("PRICE_INTERVAL_MISMATCH");
}

One API call per checkout.

If Stripe and the pricing page disagree, we refuse to sell.

The bad Stripe object can still exist. It just cannot make it through checkout unnoticed.

Defect 2: the plan existed commercially, not functionally

The second bug failed in the opposite direction: a customer could have paid us and still been treated as free.

Adding a pricing tier feels like a commercial change. In practice, it touches every place in the product that asks: "What is this account allowed to do?"

In our codebase, that question was being answered in 29 files.

Those checks had accumulated over time across exports, uploads, queueing, API endpoints, billing and the dashboard. Most were string comparisons written against the plans that existed when that code was added.

Solo was new.

So checks like is it pro, creator or studio? simply fell through to the default branch. And the default branch was free.

That meant a paid Solo account could still get the free quota, free upload limits, watermark rules and other free-tier restrictions.

The instinct behind that default was not stupid. In security, failing closed is usually what you want.

In billing, though, it creates a different failure mode: the customer has already paid, and your safest fallback becomes the thing that withholds what they bought.

The fix was not to add "solo" to 29 conditionals. We replaced that with one place where plan behaviour is defined:

export type Plan = "free" | "solo" | "pro" | "creator" | "studio";

export function isPaid(plan): boolean
export function hasDubbing(plan): boolean
export function hasScheduling(plan): boolean
export function videoQuota(plan, status): number
export function maxDurationMinutes(plan): number
export function maxUploadSize(plan): number
export function retentionHours(plan): number | null

The names above are simplified for the article, but the structure is the one we shipped.

The rule at the top of the file now says: a plan does not exist until it is here.

There were two useful near-misses while fixing this.

First, we almost used that one isPaid() check for every gate, including feature-specific ones. That would have given Solo customers things they do not pay for, including AI dubbing and post scheduling. "Paid" and "entitled to this feature" are not the same question.

Second, we briefly thought scheduling had no plan check because searching the route for "plan" returned nothing. The gate was inside a shared helper one call away.

A grep that comes back empty is not an answer.

Defect 3: the limit we advertised but never enforced

The pricing page says Solo accepts source videos up to 60 minutes. The worker that downloads and processes videos knew only two limits:

  • 30 minutes for free accounts
  • 90 minutes for paid accounts

Solo is paid. So Solo got 90.

This bug leaked in the other direction. Nobody was overcharged and nobody lost access. We were simply giving away more compute than the plan was supposed to include.

For a video product, that matters. Source duration is one of the inputs most directly tied to transcription, rendering and bandwidth cost.

The worker now carries the same ceiling as the application:

def _max_minutes(plan: str | None) -> int:
    """Mirror of the plan limits used by the app.

    What the pricing page announces and what the worker enforces
    must be the same number.

    A cap announced without being applied is margin walking out;
    applied without being announced is a trap.
    """

A limit that exists only on the pricing page is not a limit. It is a sentence on a page.

What actually found these bugs

Not the type checker. Not the test suite. Not a code review.

What found them was opening the site, pretending we had never seen it before, clicking the cheapest option and putting in a real card.

The billing mismatch appeared in Stripe's own checkout summary. The missing entitlements appeared when the paid account still behaved like a free one. The duration issue appeared when we checked what limit the worker would actually apply.

We keep relearning the same lesson: a passing build does not prove a product works.

Each bug lived in the gap between two systems that were individually doing what they had been told to do:

  • the pricing page and Stripe
  • a plan name and its actual entitlements
  • the application and the worker

The cheap way to find that class of bug is surprisingly boring: use the product the way the person paying for it will.

An epilogue about attribution

Our first Solo subscriber arrived the same morning.

For about an hour, we told ourselves a nice story: Solo had just launched, somebody found it on their own, hit the free limit and converted.

The event log told a less tidy story.

They had found Katto through ChatGPT, signed up, used the free plan heavily, hit the quota, opened checkout several times and left without paying.

The next morning an abandoned-checkout email went out. A few hours later, they subscribed.

They chose the €8.90 month-to-month option over €4.90 billed annually, paying 82% more per month to avoid a twelve-month commitment. The founding offer they had clicked and abandoned the day before was €6.

We had also assumed the 30-minute source-video limit was what pushed them toward Solo. It wasn't. The thing they kept running into was the two-video monthly quota.

So two assumptions died at once. The conversion was not simply organic — the recovery email probably mattered. And the limit creating the strongest pressure to upgrade was not the one we thought it was.

That has the same shape as the three bugs above. From the outside, a clean story was easy to tell. Then we looked at what actually happened.


Katto turns long videos into vertical clips. We publish the numbers we measure, including the ones that make us look bad.

Related articles

Ready to turn your videos into viral clips?

Katto automatically clips, captions, and reframes your long-form videos into short-form content.

Try Katto for free →
We sold a plan that didn't work, Katto Blog