Claude CodeでStripeのサブスクを同期する実装 — 状態遷移表と猶予期間
Stripe Billingのサブスク状態をDBへ写す実装をClaude Codeで進める手順です。状態遷移表を先に作らせ、猶予期間の持ち方と、テストモード以外を許可しないpermissionsまで示します。
自社アプリの課金実装でいちばん事故が出やすいのは、Stripeのサブスク状態を自社DBへ写す部分です。支払いが失敗した顧客をいつまで使わせるか。解約済みの顧客を誰が閉め出すか。ここがずれると、無料で使い続ける人か、払っているのに使えない人のどちらかが出ます。
Claude Codeに任せるなら、順番は次のとおりです。まずpermissionsでテストモード以外を閉じます。次にPlan Modeで状態遷移表を作らせます。コードはそのあとです。この記事は、その3段を設定例とハンドラの形つきで示します。
同期で先に決める3つの前提
最初に決めるのは、真実の置き場所です。サブスクの状態はStripeが持ち、自社DBは写しにすぎません。
この前提から、実装の骨子が3つ決まります。
- イベントは到着順に頼らない。Stripeはイベントを生成順に配信する保証をしておらず、
createdは秒単位なので順序判定にも使えません - 同じイベントは複数回届きうる。処理済みのイベントIDを記録して弾きます
- 本文のスナップショットを信用せず、受信のたびにAPIでサブスクを取り直す
3つ目が収束の鍵です。到着順がばらばらでも、毎回「いまの状態」を取り直して上書きすれば、最後は同じ値に落ち着きます。
Stripeの説明でも、サブスクの処理は非同期で進むため、実装にはWebhookを受ける送信先が要るとされています。受信エンドポイントでは署名検証を先に通し、重い処理の前に2xxを返すことが求められます。署名検証には未加工のリクエスト本文が必要で、フレームワークがJSONに解釈し直した本文は検証に失敗します。
ローカルでのstripe listenの扱いと秘密の守り方は、Stripe CLIのwebhook開発の記事にまとめています。この記事は、その先のサブスク状態の同期を扱います。
テストモードだけを許可するpermissionsの切り方
課金コードを書かせる前に、.claude/settings.jsonへ次を入れます。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Bash(stripe * --live)",
"Bash(stripe * --live *)",
"Bash(stripe * --api-key *)",
"Bash(stripe login *)",
"Bash(stripe listen *)"
],
"allow": [
"Bash(stripe trigger *)",
"Bash(npm test *)"
]
}
}ルールの評価は、拒否、確認、許可の順です。最初に一致したものが結果を決めるので、allowのstripe trigger *とdenyのstripe * --liveが重なっても拒否が勝ちます。
Bash(stripe * --live)とBash(stripe * --live *)を2本並べるのは、ワイルドカードの仕様のためです。末尾の*が引数なしにも一致するのは、それがルール内で唯一のワイルドカードのときだけです。途中にも*がある形は、--liveが末尾に来る呼び出しを拾えません。
「テストモードのキーだけを使わせる」という指定は、設定1行では書けません。次の3つを重ねて実現します。
.envをdenyして、キーそのものを読ませない- テスト用のキーを人が環境変数に置き、本番キーは同じシェルに置かない
--liveと--api-keyをdenyして、コマンド側から本番へ切り替える口を塞ぐ
ルールの全体像は権限モデルの変遷を扱った記事で追えます。
状態遷移表を先に作らせる
コードを書かせる前に、表を作らせます。表が合っていれば、ハンドラの分岐は表の転記で済みます。
Plan Modeでは、Claudeはファイルを読んでシェルの読み取りコマンドを走らせるだけで、ソースを編集しません。表づくりに向いたモードです。使い方はPlan Modeのガイドにあります。
プロンプト例は次のとおりです。
Stripe Billingのサブスク状態をusersテーブルへ同期する設計を考えて。
まだコードは書かない。次の表を作って。
- 行: customer.subscription.created / updated / deleted / paused / resumed /
trial_will_end と invoice.paid / payment_failed / finalization_failed
- 列: そのイベントで起きること、DBのstatus、アクセス可否
根拠はStripeのサブスクWebhookのドキュメントだけにして、
ドキュメントに書かれていない遷移は「要確認」と書いて。最後の一文が効きます。状態の意味は、Stripeの説明に沿った表以外を信じない約束にしておきます。
作られた表を照合する基準
Claudeが返した表は、Stripeの説明と突き合わせて直します。照合に使える写しを、次に置きます。イベントとその扱いです。
| イベント | DBでやること |
|---|---|
customer.subscription.created | DBでやること行を作る。incompleteのことがあるので、アクセスは開けない |
customer.subscription.updated | DBでやること取り直したstatusで上書きする。プラン変更やクーポンもここに来る |
customer.subscription.deleted | DBでやること終了として記録し、アクセスを閉じる |
customer.subscription.paused / resumed | DBでやること一時停止と再開を反映する |
customer.subscription.trial_will_end | DBでやること終了の3日前に届く。決済手段の有無を確認し、顧客に知らせる |
invoice.paid | DBでやることサブスクを取り直し、activeを確かめてから期限を延ばす |
invoice.payment_failed | DBでやること顧客へ通知する。状態の上書きはupdated側で受ける |
invoice.finalization_failed | DBでやること請求書の確定失敗。サブスクはactiveのまま残る |
状態の側は、次のとおりです。
status | アクセス | 補足 |
|---|---|---|
trialing | アクセス開ける | 補足初回の支払いが済むとactiveへ移る |
active | アクセス開ける | 補足全請求書の支払い済みを意味しない場合がある |
incomplete | アクセス閉める | 補足作成後23時間以内に支払いが済まなければincomplete_expiredへ |
past_due | アクセス方針次第 | 補足次節の猶予期間で扱う |
unpaid / canceled | アクセス閉める | 補足canceledは更新できない最終状態 |
paused | アクセス閉める | 補足請求書が作られなくなっている |
invoice.paidの行に「activeを確かめてから」と入れてある点を見てください。請求書が1枚支払われても、サブスクがactiveに移るとは限りません。ステータスの判定に全請求書を使う設定のときは、他の未払い請求書が残っているとpast_dueのままです。表にこの注意書きがなければ、Claudeに戻します。
猶予期間はStripeのどこにあるのか
猶予期間という名前の項目は、サブスクWebhookの説明には出てきません。実体はpast_dueのあいだです。
支払いが失敗すると、サブスクはpast_dueに移ります。この間も請求書は作られ続け、Stripeは決済を再試行することがあります。再試行が尽きたあとの行き先は、ダッシュボードのサブスク設定で選びます。canceled、unpaid、past_dueのまま維持、の3択です。
つまり、次の2つは別の話です。
- Stripe側の猶予: 再試行の回数と、尽きたあとの行き先(ダッシュボードの設定)
- 自社アプリ側の猶予:
past_dueの顧客を何日間、機能を使わせ続けるか
後者は、アプリの課金ポリシーです。Stripeの設定は変えず、DBに猶予の期限を持たせると、ポリシー変更がコードだけで済みます。
DBは次の形にしておきます。
create table subscriptions (
stripe_subscription_id text primary key,
user_id bigint not null,
status text not null,
past_due_since timestamptz,
updated_at timestamptz not null default now()
);
create table processed_events (
event_id text primary key,
processed_at timestamptz not null default now()
);past_due_sinceは、past_dueに最初に入った時刻を覚える列です。past_dueからactiveに戻ったら空に戻します。アクセス判定は、この列と猶予日数の設定から導きます。
const GRACE_DAYS = 7; // アプリ側の方針。Stripeの設定とは別
export function hasAccess(sub: { status: string; pastDueSince: Date | null }, now = new Date()) {
if (sub.status === "active" || sub.status === "trialing") return true;
if (sub.status === "past_due" && sub.pastDueSince) {
const limit = new Date(sub.pastDueSince.getTime() + GRACE_DAYS * 86_400_000);
return now < limit;
}
return false; // incomplete / unpaid / canceled / paused など
}この関数は、Claudeに単体テストごと書かせるのに向いています。入力と出力が閉じていて、Stripeに触れないからです。
ハンドラの形
受信側は、署名検証、重複排除、取り直し、上書きの順です。次はExpressでの骨子です。
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, req.headers["stripe-signature"] as string, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return res.sendStatus(400);
}
try {
await db.tx(async (tx) => {
const fresh = await tx.insertIgnore("processed_events", { event_id: event.id });
if (!fresh) return; // 配信の重複
const subId = await resolveSubscriptionId(event);
if (!subId) return; // サブスクに紐づかないイベント
const sub = await stripe.subscriptions.retrieve(subId); // 本文でなくAPIの現在値
await upsertSubscription(tx, sub);
});
res.sendStatus(200);
} catch {
res.sendStatus(500); // 失敗は500で返し、Stripeの再試行に任せる
}
});resolveSubscriptionIdは、イベントの型ごとに入口が違う点を吸収する関数です。customer.subscription.*はオブジェクト自身のIDを使います。invoice.*は、APIバージョンが2025-03-31.basil以降ならparent.subscription_details.subscription、それより前ならsubscriptionフィールドです。
重複排除の記録と状態の上書きを同じトランザクションに入れているのは、途中で落ちたときに「処理済みと記録されたのに反映されていない」状態を作らないためです。500を返せば、本番ではStripeが指数バックオフで最長3日間、再配信を試みます。
同じサブスクに対するイベントが並行して届いたときは、行ロックなどで更新を直列にします。取り直しが古い順に完了すると、新しい値を古い値で上書きしかねません。
返金や不審請求の申し立ては、サブスクのイベントとは別のIDからたどります。charge.refundedやcharge.dispute.createdが来たら、PaymentIntentから請求書、サブスクの順にたどる手順がStripeの説明にあります。まずは上の表のイベントだけで同期を成立させ、課金事故の対応は次の段で足すほうが、Claudeへの指示も小さく済みます。
機能の出し入れを別イベントで受ける場合
プランごとに使える機能を切り替える設計なら、entitlements.active_entitlement_summary.updatedも候補です。顧客の有効なエンタイトルメントが更新されると届き、機能アクセスの付与と取り消しに使えます。
この場合も、同期の骨子は変わりません。イベントIDで重複を弾き、本文でなく取り直した値を書きます。ステータスで開け閉めするか、エンタイトルメントで開け閉めするかは、どちらか一方に決めます。両方で判定すると、2つの判定が食い違った瞬間に原因を追えなくなります。
顧客情報をDBに写す場合は、customer.updatedも購読します。カスタマーポータルでの請求先や既定の決済手段の変更は、このイベントで届きます。
書かせたあとに、表との差分を見直させる
ハンドラができたら、同じセッションで表との突き合わせを頼みます。
さっきの状態遷移表と、実装したハンドラを突き合わせて。
表にあるのにswitchに無いイベント、switchにあるのに表に無いイベントを
それぞれ列挙して。差分が無ければ「差分なし」と書いて。表を先に作ってあるから成立する確認です。表がなければ、Claudeは自分が書いたコードと自分の記憶を照らすことになり、見落としがそのまま通ります。
検証ループ: triggerで送り、テストで固める
実装と検証は、2種類に分けて回します。
- 単体テスト: 同じイベントIDを2回渡して、行が1つのままであること。
past_dueからactiveに戻したときpast_due_sinceが空になること - 結線の確認:
stripe triggerでテストモードのイベントを送り、ハンドラが200を返すこと
stripe trigger customer.subscription.updated
npm test -- subscriptionsstripe listenは人のターミナルで起動したままにします。Claudeはtriggerとテストの実行だけを担当し、失敗したらログから直します。
テスト環境での再試行は、本番とは違います。サンドボックスで作ったイベントは、数時間のうちに3回再試行されるだけです。本番の3日間バックオフの挙動を、テストで再現したと思わないでください。
CLAUDE.mdに残す規約
セッションをまたいでも同じ設計を守らせるため、CLAUDE.mdに短く書きます。
## Stripeサブスク同期
- 真実はStripe。DBは写し。イベント本文の状態を信用せず、受信ごとに subscriptions.retrieve で取り直す
- イベントの順序と重複を前提にする。processed_events に event.id を入れてから処理する
- 猶予期間のロジックは hasAccess() だけに置く。他の場所で status を直接見てアクセス判定しない
- 本番キー・--live・.env は触らない3行目が肝です。アクセス判定が複数の場所に散ると、猶予日数を変えたときに直し漏れが出ます。
つまずきやすい点
invoice.finalization_failedを無視する: 請求書が確定せずに回収できなくなっても、サブスクはactiveのまま残ります。アクセスは開いたままですinvoice.createdに応答しない: 自動回収の請求書の確定が最大72時間遅れますincompleteを有効扱いにする: 初回支払いの未完了はアクセスを開けません。23時間以内に支払われなければincomplete_expiredです- JSONパーサーを先に通す: 署名検証が通らなくなります。このルートだけ未加工の本文を受けます
unpaidとpast_dueを混ぜる:unpaidは決済の試行が止まった状態です。ここに入った時点でアクセスは閉じます
まとめ
Stripeのサブスク同期は、設計の約束が3つ決まれば、コードの大半は表の転記になります。Stripeを真実とし、イベントは順不同・重複ありと見なし、受信のたびに取り直す。この3点を、先に状態遷移表とCLAUDE.mdで固定してからClaude Codeに実装させます。
猶予期間は、Stripeの設定とアプリの方針を分けて持つ項目です。past_dueの入り時刻をDBに残し、hasAccess()の1か所で判定すると、日数の変更が1行で済みます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。