02判断5 min

どのテンプレートでも最初に外すのは Stripe です

ほぼすべてのプロジェクトを、checkout とポータルと webhook を消すところから始めます。うっかりではありません。いつか必ず要るインフラと、まだ出番でないものを分けているのです。

著者: Salva Castells

カバー画像: MarkusHagenlocher, CC BY-SA 3.0.
この記事の内容

一年前に書いた勤怠管理アプリの履歴を見返しました。最初のコミットは 15:48。67 分後の 16:55、意味のある二つめのコミットの名前は "remove Stripe API routes for checkout, portal and webhook" でした。ファイル三つが外へ。checkout が 68 行、ポータルが 48 行、webhook が 222 行。メイン画面すらまだ書いていませんでした。

衝動ではありません。これがパターンです。ボイラープレートをクローンして、まず決済を切る。

反射: クローンして消す

使っているボイラープレートには Stripe が最初から組み込まれています。それ自体はいいことです。必要になったとき、解決済みなら一日浮きます。問題は、デフォルトでオンになっていることです。そしてオンということは、いろいろ引き連れてくるということです。

この春に始めた社内テンプレートでは、"remove Stripe integration and related billing features" というコミットがリポジトリの二つめで、init project と同じ日でした。差分はルートだけではありません。巻き込まれたのは次のものです。

  • user_billing テーブル丸ごと。 customer_id、price_id、sub_credits、otp_credits、initial_sub_date、last_otp_date、has_sub、cancel_next_month。存在しない課金を扱うための九つのカラムと user_id のインデックス。
  • 設定にハードコードされた priceId。 別プロジェクトからコピーされた、本物の Stripe 識別子(price_1R3Rhw...)を持つ例のプラン二つ。これは時限爆弾です。誰も消さず、誰かがデプロイします。
  • 環境変数が五つ。 USE_STRIPE、NEXT_PUBLIC_USE_STRIPE、STRIPE_PUBLIC_KEY、STRIPE_SECRET_KEY、STRIPE_WEBHOOK_SECRET。.env.example に五行増えて、誰かが「これは要るのか」と悩むことになります。
  • 最初のマイグレーション。 課金のテーブルが中に入っていたので、消して作り直しました。

そして [key: string]: string | string[] | PricingConfig | PricingConfig[] を宣言していた ConfigFile が string | string[] になりました。価格プランを考慮しなくてよくなった型は、その後の三か月ずっと扱いやすい型です。

決済がいちばん何も教えてくれない理由

Stripe の webhook はただのルートではありません。署名を検証し、checkout.session.completed と invoice.paid と customer.subscription.deleted を区別し、自分のセッションの外側からデータベースに書き込む 222 行です。CLI でイベントを転送しながらテストしなければならず、冪等にしなければならず、同じイベントが二度来たときどうするかを決めなければなりません。

その作業はどれも本物です。ただ、まだ自分が勝ち取っていない問いに答えているだけです。いくら課金するのか、という問い。そしてその問いは、もっと安く答えられる別の問いのあとに来ます。これは誰かの役に立つのか。

二つめがわからないうちは、webhook は保守するのに動かさないコードです。本当に必要になった日に、一晩で戻します。そのときは、引き継いだ例のプラン二つではなく、決まった価格モデルを持って戻します。

最初の刈り込みで他に落ちるもの

Stripe は単独で旅をしません。障害管理の三日間の MVP では、リポジトリの二つめのコミットが文字どおり "remove useless things" という名前です。最初のコミットの二時間十一分後。管理画面が丸ごと(250 行の client-page.tsx、380 行のユーザーテーブル、114 行のグラフ)、フィードバックのルート、フィードバックのテーブルとそのスキーマが消えました。Stripe のルートは消さず、.txt にリネームしました。route.ts を route.txt に。コンパイルされなくなり、Next からは存在しなくなり、それでもいつか必要になったときのためにそこに残ります。

その翌日、同じプロジェクトでマジックリンクが落ちました。認証ページには二つの経路がありました。Google と、確認コード付きのメール。nonce、magicLinkSent、magicLinkCode、handleMagicLinkCode を伴って。コミットの名前は "eliminar magic link (solo Google)" で、ページから 116 行が消えます。全員が Google アカウントを持つ会社の社内アプリでは、二つめの経路は保守すべき状態を増やすだけでした。

数か月前、別のテンプレートでは同じ刃が管理画面に当たりました。/admin を外し、/account を外し、hasAdminRole() を外し、.env.example から ADMIN_EMAILS を外す。そして管理画面のためだけにあったユーザースキーマのカラム四つ、role、banned、ban_reason、ban_expires も。さらにセッションテーブルの impersonated_by も。

最後のものが例としていちばん好きです。sessions の impersonated_by は便利機能ではありません。認証に触る前に誰かが理解しておくべきセキュリティ上の決定です。管理画面のないプロジェクトに「念のため」で引きずるのは、鍵のかかった扉を、まわりに壁もないまま置いておくようなものです。

残したケース

決済に反対する規則ではありません。いつ入れるかについての規則です。

画像生成の SaaS では、Stripe は早くに入ってそのまま残りました。checkout を有効にし、その六日後には webhook が動いて日次クレジットも回っていました。そこでの課金は事業上の仮説ではなく、コストの抑制でした。生成のたびに、ボタンを押したその瞬間に本物のお金がかかります。クレジットがなければ、あるのはプロダクトではなく請求書です。

文字起こし API でも同じです。課金と無料プランの上限は、まだユーザーアカウントを組み立てている最中に書きました。処理する音声の一分は、最初のリクエストから値段がついているからです。

並べてみると判断基準がはっきりします。使うたびにお金がかかるなら、課金はインフラであり初日から入れる。限界費用がゼロなら、課金はプロダクトであり、何を売るかわかってから入れる。 勤怠管理ツールも、社内の障害管理も、テンプレートも、ユーザーごとには何も消費しません。画像モデルは消費します。

刈り込んだあとに残るもの

その最初の一時間のあと、どのプロジェクトでも生き残るものはいつも同じです。プロバイダひとつの認証、ドメインのテーブルだけを持つデータベース、そしてアプリが自分の仕事をするのに必要なルート。これを自分はインフラと呼んでいます。それ以外は全部、プロダクトの未来に対する賭けです。そして賭けは、外れても保守で支払わされます。

完成したテンプレートは最初の一晩を節約してくれます。出番でないものを消すと、その後の三か月が節約できます。