← ブログに戻る
EngineeringBuild in PublicPricing

動かない料金プランを販売していた

€4.90/月のプランを公開した。年額オプションは毎月 €58.80 を請求し、プランは権限を一切付与せず、ワーカーは尺の上限を無視していた。三つとも自分で購入して見つけた。

2026年10月4日

動かない料金プランを販売していた

作成:2026年10月4日 · 更新:2026年10月4日

€4.90/月のプランが料金ページで公開された。年額オプションを選んだ人は、毎月 €58.80 を請求されるところだった。

誰も請求されなかった。

年額プランを一人も購入する前に気づけた。コードを読んだからではなく、実際のカードで自分たちで買ってみたからだ。

そこが肝心だ。ビルドは緑。TypeScript も満足。API は 200 を返していた。そのどれも、製品が実際に動くことの証明にはならない。

その朝、三つの欠陥が見つかった。最初の有料 Solo 顧客が契約したのは、三つ目の修正が出てから二十七分後だった。半時間ずれていれば、何の権限も与えないプランに金を払っていたのは彼だった。

欠陥1:価格は正しく、課金間隔が違っていた

新しい入門プラン Solo は、年額払いで €4.90/月、月払いで €8.90。€4.90 × 12 = €58.80 を年に一度。

年額オプションの裏にある Stripe の価格オブジェクトは、課金間隔が「月」で作成されていた。

つまり Stripe は 毎月 €58.80 ——想定価格の十二倍を請求する用意ができていた。

料金ページは正しかった。コードも正しい価格 ID を使っていた。欠陥はリポジトリの外、ダッシュボードから作られた Stripe オブジェクトの中に住んでいた。

だからこそ見落としやすかった。

Stripe は既存の価格の課金間隔をその場で変更させてくれない。新しい価格を作って ID を差し替えるしかない。そこで、チェックアウトセッションを作る前に検査を一つ入れた。

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

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

チェックアウトごとに API 呼び出し一回。

Stripe と料金ページが食い違っていたら、販売を拒否する。

不正な Stripe オブジェクトは依然として存在しうる。ただ、気づかれずにチェックアウトを通り抜けることはできない。

欠陥2:プランは商業的には存在し、機能的には存在しなかった

二つ目の欠陥は逆方向に壊れていた。顧客が支払っても、無料扱いのままになりうる。

プランを一つ足すのは商業上の変更のように感じる。実際には、製品の中で「このアカウントは何をしてよいのか」と問うすべての場所に触れる。

我々のコードベースでは、その問いは 29 ファイルで答えられていた。

これらの判定は、エクスポート、アップロード、処理キュー、API エンドポイント、課金、ダッシュボードに、長い時間をかけて積み上がってきたものだ。大半は文字列比較で、そのコードを書いた当時に存在していたプランに合わせて書かれていた。

Solo は新しかった。

だからpro か、creator か、studio か?という判定は、そのままデフォルト分岐に落ちた。そしてデフォルト分岐は無料だった。

結果として、支払い済みの Solo アカウントが、無料の割当、無料のアップロード上限、ウォーターマーク、その他無料プランの制限を受けうる状態になっていた。

そのデフォルトの背後にある直感は愚かではない。セキュリティでは「閉じる方向に失敗する」のが普通は望ましい。

しかし課金では、これは別種の故障になる。顧客はすでに支払っており、最も安全な退避先が、買ったものを渡さない仕組みそのものになってしまう。

修正は "solo" を 29 箇所の条件分岐に足すことではなかった。プランの振る舞いを記述する場所を一つに置き換えた。

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

上の名前は記事向けに簡略化しているが、構造は実際に出したものだ。

このファイルの先頭には今、こう書かれている。プランはここに書かれるまで存在しない。

修正中に、学びのある「あやうく」が二つあった。

一つ目。その isPaid() という一つの判定を、機能別のものまで含めてすべての関門に使ってしまうところだった。そうしていれば Solo の顧客に、支払っていないもの——AI 吹き替えや投稿予約——を渡していた。「支払っている」と「この機能の権限がある」は同じ問いではない。

二つ目。予約機能にプラン判定が無いと一瞬思い込んだ。ルート内を "plan" で検索しても何も出てこなかったからだ。関門は共有ヘルパーの中、呼び出し一回の先にあった。

空で返ってくる grep は、答えではない。

欠陥3:告知しながら一度も適用していなかった上限

料金ページには、Solo は最長 60 分の元動画を受け付けると書いてある。動画をダウンロードして処理するワーカーが知っていた数字は二つだけだった。

Solo は有料だ。だから Solo には 90 が適用された。

この欠陥は逆方向に漏れていた。誰も過大請求されず、誰もアクセスを失わなかった。ただ、プランに含むべき量を超えた計算資源を無償で渡していた。

動画製品ではこれが効く。元動画の尺は、文字起こし・レンダリング・帯域のコストに最も直接結びつく要素の一つだ。

ワーカーは今、アプリケーションと同じ上限を持っている。

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.
    """

料金ページにしか存在しない上限は、上限ではない。ページに書かれた一文にすぎない。

実際にこれらを見つけたもの

型チェッカではない。テストスイートでもない。コードレビューでもない。

見つけたのは、サイトを開き、初めて見た人のふりをして、いちばん安い選択肢を押し、本物のカードを入れることだった。

課金の不一致は Stripe 自身のチェックアウト要約に現れた。欠けた権限は、支払い済みアカウントが無料のまま振る舞い続けたときに現れた。尺の問題は、ワーカーに「実際どの上限を適用するのか」と尋ねたときに現れた。

我々は同じ教訓を何度も学び直している。ビルドが通ることは、製品が動くことの証明ではない。

どの欠陥も、それぞれが指示どおりに正しく動いていた二つのシステムの隙間に住んでいた。

この種の欠陥を見つける安価な方法は、驚くほど退屈だ。金を払う人がするとおりに製品を使うことだ。

アトリビューションについての後記

最初の Solo 契約者は、同じ朝に現れた。

一時間ほど、我々はきれいな物語を自分たちに語っていた。Solo が公開されたばかりで、誰かが自力で見つけ、無料の上限にぶつかり、課金に至った、と。

イベントログは、もっと整っていない話をしていた。

その人は ChatGPT 経由で Katto を見つけ、登録し、無料プランを相当使い込み、割当を使い切り、チェックアウトを何度も開き、支払わずに去っていた。

翌朝、カート放棄の復帰メールが送られた。数時間後、契約した。

選んだのは年額 €4.90 ではなく月払い €8.90。十二か月の拘束を避けるために、月あたり 82% 多く払った。前日にクリックして離脱した創業者向けオファーは €6 だった。

さらに我々は、元動画 60 分の上限が Solo への後押しだったと思い込んでいた。違った。彼が繰り返しぶつかっていたのは月 2 本という本数の割当だった。

仮説が二つ同時に死んだ。この転換は単に自然発生ではなく、復帰メールがおそらく効いていた。そして最も強い課金圧力を生んでいた制限は、我々が思っていたものではなかった。

これは上の三つの欠陥と同じ形をしている。外から見れば、きれいな物語は簡単に語れた。そのあとで、実際に起きたことを見た。


Katto は長い動画を縦型クリップに変える。我々は測った数字を公開する。自分たちが悪く見える数字も含めて。

関連記事

動画をバズるクリップに変える準備はできましたか?

Katto は長尺動画を自動でクリップ化・字幕付け・リフレームし、ショート動画に変換します。

Katto を無料で試す →
動かない料金プランを販売していた | Katto