Claude Media
Claude CodeでStripeの返金とチャージバックを実装する — 状態の記録と証拠の準備

Claude CodeでStripeの返金とチャージバックを実装する — 状態の記録と証拠の準備

charge.refundedとcharge.dispute.createdを台帳に記録し、権限の保留と期限の管理、証拠の下書きまでをClaude Codeに実装させる手順です。提出を人に残す権限設定も示します。

返金とチャージバックは、課金の実装で最後に回されがちな部分です。ところが、受け取るイベントも、決めるべき状態も、通常の決済とは別の形をしています。

結論は、イベントを受けたら台帳に事実を記録し、権限の扱いは別の関数に分け、証拠の提出は人に残す構成です。この記事では、Stripeのwebhookを受信する土台が既にある前提で、返金とチャージバック(Stripeでは不審請求の申し立て)の処理をClaude Codeに書かせる流れを示します。受信箱の作り方はwebhookの冪等受信の記事にあります。

受け取るイベントと、書き換える状態を先に決める

Claude Codeへの指示は、先にイベントと状態の対応表を渡すと外れにくくなります。返金と不審請求で、起点になるイベントは次のとおりです。

イベント意味台帳に書くこと
charge.refunded意味一部返金を含め、支払いが返金された台帳に書くこと返金済み累計額と全額返金かどうか
refund.failed意味返金が失敗した台帳に書くこと返金の失敗
charge.dispute.created意味不審請求が申し立てられた台帳に書くこと申し立てID、回答期限、権限の保留
charge.dispute.closed意味銀行が結論を出した台帳に書くことwonかlost、権限の復帰か取り消し
charge.dispute.funds_reinstated意味クローズ後に売上が戻った台帳に書くこと資金が戻ったこと

返金を追う場合、Stripeは少なくともrefund.createdを受けるよう案内しています。一方でcharge.refundedは、返金額の累計を持つChargeがそのまま届くので、権限の判定には使いやすいイベントです。この記事はcharge.refundedを軸にします。

返金と不審請求は競合する

返金が保留中に顧客が不審請求を申し立てると、返金は失敗することがあります。Stripeの失敗理由の一覧にはcharge_for_pending_refund_disputedがあり、返金の代わりに申し立てを受け入れるか反論するよう案内されています。

つまり、同じ支払いに対して返金と不審請求が並行して走る場合があります。台帳は返金と不審請求を別の行として持ち、支払い(charge)のIDでつなぎます。

不審請求からサブスクへは直接たどれない

charge.dispute.createdのDisputeオブジェクトは、chargeと、null許容のpayment_intentを持ちます。InvoiceやSubscriptionへの直接の参照はありません。権限を調整したい場合は、PaymentIntentから請求書の支払い一覧、請求書、サブスクの順にたどります。

手順

不審請求からサブスクを特定する流れ(API版 2025-03-31.basil 以降)

  1. 1

    PaymentIntentのIDを取る

    Disputeのpayment_intentを使います。nullならChargeを取得し、そのpayment_intentを使います。

  2. 2

    請求書の支払いを引く

    payment.typeをpayment_intent、statusをpaidにして一覧を取ります。空なら、請求書と結びつかない支払いです。

  3. 3

    請求書からサブスクへ

    請求書のparent.typeがsubscription_detailsであることを確かめてから、parent.subscription_details.subscriptionでサブスクを取得します。

1つのPaymentIntentが複数の請求書に紐づく場合があるため、一覧はhas_moreが真の間、続けて取得します。API版が2025-03-31.basilより前なら、Chargeのinvoiceフィールドから進む別の経路になります。

CLAUDE.mdに置く規約と指示

規約は、毎回の指示で言い直さずに済むようCLAUDE.mdに置きます。上の表を、そのまま守らせる条件にします。

## 返金・不審請求
- 台帳 `payment_cases` の主キーは返金行・不審請求IDにし、同じ行は上書きで更新する
- イベントの中身は信じず、処理時点のAPIで取り直した値を台帳に書く
- 権限の変更は `src/billing/access.ts` の関数だけが行う。webhookの処理から直接触らない
- 不審請求の `evidence_details.due_by` を必ず台帳に残す
- 証拠のAPI呼び出しは `submit: false` のみ。`submit: true` はコードに書かない
- 検証は `npm test -- test/disputes`。落ちたまま完了と言わない

指示は、満たす条件を並べる形にします。

src/billing/ に返金と不審請求の処理を実装して。
CLAUDE.mdの規約に従うこと。
charge.refunded と charge.dispute.created / closed を受ける
ハンドラを書き、同じイベントを2回流しても台帳が変わらないテストを
test/disputes/ に先に書いて、落ちることを確認してから実装して。

実装前に方針を確かめたいときは、Claude Codeをプランモードで起動します。コマンドはclaude --permission-mode planで、承認するまでファイルは変更されません。台帳の列や権限の扱いのように、後から直しにくい設計は、この段階で見ておくのが安全です。

返金を台帳に記録するハンドラ

台帳の定義と、返金のハンドラの骨格です。ここではSQLite(better-sqlite3)で書いた例で、ライブラリは手元のスタックに置き換えてください。

// src/billing/cases.ts
db.exec(`CREATE TABLE IF NOT EXISTS payment_cases (
  case_id TEXT PRIMARY KEY,          -- "refund:ch_xxx" または dp_xxx(不審請求)
  kind TEXT NOT NULL CHECK (kind IN ('refund','dispute')),
  charge_id TEXT NOT NULL,
  payment_intent_id TEXT,
  amount INTEGER NOT NULL,           -- 通貨の最小単位
  currency TEXT NOT NULL,
  status TEXT NOT NULL,
  due_by INTEGER,                    -- 不審請求の回答期限(UNIX秒)
  updated_at TEXT NOT NULL
)`);
 
const upsert = db.prepare(`
  INSERT INTO payment_cases
    (case_id, kind, charge_id, payment_intent_id, amount, currency, status, due_by, updated_at)
  VALUES (@case_id, @kind, @charge_id, @payment_intent_id, @amount, @currency, @status, @due_by, @now)
  ON CONFLICT(case_id) DO UPDATE SET
    amount = excluded.amount, status = excluded.status,
    due_by = excluded.due_by, updated_at = excluded.updated_at`);
 
export async function onChargeRefunded(stripe: Stripe, event: Stripe.Event) {
  const fromEvent = event.data.object as Stripe.Charge;
  // 順序の入れ替わりに備え、処理時点のChargeを取り直す
  const charge = await stripe.charges.retrieve(fromEvent.id);
  const full = charge.amount_refunded >= charge.amount;
 
  upsert.run({
    case_id: `refund:${charge.id}`, kind: "refund", charge_id: charge.id,
    payment_intent_id: (charge.payment_intent as string | null) ?? null,
    amount: charge.amount_refunded, currency: charge.currency,
    status: full ? "refunded_full" : "refunded_partial",
    due_by: null, now: new Date().toISOString(),
  });
 
  if (full) await revokeAccessForCharge(stripe, charge);
}

返金の行は、返金ごとではなく支払いごとに1行にしています。charge.refundedは返金の累計を持つChargeを運ぶので、複数回の一部返金が届いても、行は最新の累計額に上書きされます。返金1件ごとの記録が必要なら、refund.createdを足して別の行にします。

全額返金かどうかはamount_refundedとamountの比較で判定します。一部返金で権限を取り消すかどうかは、事業の判断です。上の例は、全額返金のときだけ取り消します。

権限の変更は別の関数に閉じ込める

権限を変える関数を台帳の更新から分けておくと、「返金されたら必ず止める」という方針が変わったときの修正が1か所で済みます。

// src/billing/access.ts
export async function revokeAccessForCharge(stripe: Stripe, charge: Stripe.Charge) {
  const subId = await findSubscriptionId(stripe, charge.payment_intent as string | null);
  if (!subId) return; // 請求書と結びつかない支払い。サブスクの権限は触らない
  setEntitlement(subId, "revoked");
}
 
export async function holdAccessForCharge(stripe: Stripe, paymentIntentId: string | null) {
  const subId = await findSubscriptionId(stripe, paymentIntentId);
  if (subId) setEntitlement(subId, "on_hold");
}

findSubscriptionIdは、前節の3段の流れをそのまま関数にしたものです。請求書の支払い一覧を引くメソッド名と引数は、手元のstripeパッケージの型定義で確かめてから書かせてください。Claude Codeには「型定義を読んで、存在しないメソッドを使わない」と一言添えておくと、呼び出しの取り違えが減ります。

不審請求を受けたら、期限と保留を記録する

不審請求は、返金より時間に追われます。回答の期限は通常7日から21日で、期限までに回答しないと自動的に敗訴となり、申し立て対象の資金を取り戻せません。ハンドラは、期限を台帳に残すところから始めます。

function recordDispute(dispute: Stripe.Dispute, pi: string | null) {
  upsert.run({
    case_id: dispute.id, kind: "dispute", charge_id: dispute.charge as string,
    payment_intent_id: pi, amount: dispute.amount, currency: dispute.currency,
    status: dispute.status, due_by: dispute.evidence_details?.due_by ?? null,
    now: new Date().toISOString(),
  });
}
 
export async function onDisputeCreated(stripe: Stripe, event: Stripe.Event) {
  const dispute = event.data.object as Stripe.Dispute;
  const pi = (dispute.payment_intent as string | null)
    ?? ((await stripe.charges.retrieve(dispute.charge as string)).payment_intent as string | null);
 
  recordDispute(dispute, pi);
  await holdAccessForCharge(stripe, pi);
  await notifyOwner(`不審請求 ${dispute.id}: 期限 ${dispute.evidence_details?.due_by}`);
}

Stripeは、このイベントを使って反証資料を提出するか、アクセスを制限するか、その他の対応を行うかを判断するよう案内しています。どれを選ぶかは決まっていないので、上の例は取り消しではなく「保留」に留めました。保留なら、後で結果が出たときに戻せます。

結論が出たときの処理は、charge.dispute.closedで受けます。

export async function onDisputeClosed(stripe: Stripe, event: Stripe.Event) {
  const dispute = event.data.object as Stripe.Dispute; // status は won または lost
  const pi = dispute.payment_intent as string | null;
  recordDispute(dispute, pi);
 
  const subId = await findSubscriptionId(stripe, pi);
  if (subId) setEntitlement(subId, dispute.status === "won" ? "active" : "revoked");
}

wonは、銀行がチャージバック金額をStripeに返金し、Stripeがそれを返す結果です。lostは、返金が永続的になり、不審請求の手数料も戻りません。台帳のstatusにはStripeの値をそのまま入れ、独自の名前に置き換えない方が、後でダッシュボードと突き合わせやすくなります。

証拠を集めるスクリプトは「下書き」止まりにする

反証資料を出せる機会は1回きりです。送信後は、回答の編集も追加のファイル提出もできません。しかもAPIの更新には落とし穴があります。disputes.updateのsubmitは既定で真なので、証拠のフィールドを1つ更新しただけで、全フィールドが銀行への提出として扱われます。

落とし穴

証拠を更新しただけで提出される

submit: falseを付けた更新は、証拠が不審請求に下書きとして保存されるだけです。ダッシュボードとAPIで内容を見られ、もう一度submit: trueで更新したときに初めて銀行へ送られます。Claude Codeに書かせるスクリプトは、この下書きまでに限ります。

証拠のフィールドは、種類ごとに決まっています。デジタル商品やサービスなら、顧客のIPアドレス、名前、メールアドレス、アクセスのログが推奨に挙がっています。自社のDBから引ける項目だけを集めるスクリプトにします。

// scripts/dispute-evidence.ts  使い方: tsx scripts/dispute-evidence.ts dp_xxx [--stage]
const dispute = await stripe.disputes.retrieve(id);
const row = db.prepare(
  `SELECT u.name, u.email, u.signup_ip, o.service_started_at
     FROM orders o JOIN users u ON u.id = o.user_id
    WHERE o.charge_id = ?`
).get(dispute.charge as string);
 
const evidence = {
  customer_name: row?.name,
  customer_email_address: row?.email,
  customer_purchase_ip: row?.signup_ip,
  service_date: row?.service_started_at?.slice(0, 10),
  access_activity_log: buildAccessLog(dispute.charge as string), // 利用ログの要約
};
 
console.table(Object.entries(evidence).map(([k, v]) => [k, v ? "あり" : "空"]));
if (process.argv.includes("--stage")) {
  await stripe.disputes.update(id, { evidence: clean(evidence), submit: false });
  console.log("下書きを保存しました。提出はダッシュボードで行います。");
}

--stageなしでは、どの欄が埋まり、どの欄が空かを表示するだけです。空欄を見て、足りない資料を人が集めます。テキスト欄の合計は150,000文字までです。ファイルは、まずdispute_evidenceの目的でアップロードしてIDを取ります。種類ごとに出せるファイルは1つなので、複数あれば1つのPDFにまとめます。

事実と異なる内容を、Claude Codeに補わせないでください。解約や返金の申し出がなかった、といった主張は、台帳や問い合わせ履歴に根拠があるときだけuncategorized_textに入れます。証拠欄に何を書くかの考え方は、Stripe不審請求の反証資料を作る記事で扱っています。

本番での提出を、権限とフックで止める

下書きまでと決めても、指示の言い回し次第でClaude Codeが--stageを超える操作をしないとは限りません。コードにsubmit: trueを書かせない規約と併せて、設定側でも止めます。

{
  "permissions": {
    "deny": ["Read(./.env)", "Read(./.env.*)"]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/dispute-guard.sh"
          }
        ]
      }
    ]
  }
}

次のスクリプトは文字列を照合するだけの補助的なガードで、主な防御ではありません。判定はgrepなので、書き方を変えたコマンドには効きません。

#!/bin/bash
# .claude/hooks/dispute-guard.sh
COMMAND=$(jq -r '.tool_input.command')
 
if echo "$COMMAND" | grep -q 'dispute-evidence' && echo "$COMMAND" | grep -q 'sk_live'; then
  jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse",
    permissionDecision: "deny",
    permissionDecisionReason: "本番キーでの証拠スクリプトの実行は人が行います"}}'
else
  exit 0
fi

Read(./.env)の拒否は、Readツールに加え、Bash内のcat・head・tail・sedにも効きます。効かないのは、ファイル名を指定しないgrep -rや、PythonやNodeのスクリプト経由の読み取りです。すべてのプロセスを塞ぐにはサンドボックスが必要です。シェルの許可ルールは、引数で絞り込むほど回避されやすいと公式が注意しています。たとえばBash(curl http://github.com/ *)は、書き方を変えたコマンドにすり抜けられます。フックの文字列照合も同じ弱さを持ちます。主な防御は、本番キーをClaude Codeの環境に置かないことと、本番の提出を人がダッシュボードで行う運用に置きます。

テストカードでイベントを流して確かめる

Stripeのテストでは、カード番号ごとに不審請求の種類を再現できます。4061219946393810は、決済が成功した直後に不正利用の不審請求が作られるカードで、本番の挙動に近い流れです。4000000000002685は商品未受領、4000000404000079は複数回の申し立てになります。

検証ループは、次の順で回します。

  1. テストモードで4061219946393810の決済を作り、charge.dispute.createdを受ける。
  2. payment_casesにdue_byつきの行が1件入り、権限がon_holdになったことを確かめる。
  3. 同じイベントを再送し、行が増えないことを確かめる。
  4. scripts/dispute-evidence.tsを--stageなしで実行し、埋まった欄と空の欄を見る。
  5. 返金のケースでは、全額返金と一部返金の両方を流し、権限の差を確かめる。

stripe listenとstripe triggerの使い方、秘密鍵をClaude Codeに読ませない設定は、Stripe CLIのwebhook開発の記事にあります。サブスクの状態遷移と猶予期間はサブスク同期の実装の記事が受け持ちます。

実装の前に確かめておく落とし穴

  • 支払いごとに複数の申し立てがある: 1つの支払いに対して、不審請求が複数作られることがあります。台帳の主キーは支払いIDではなく、申し立てIDにします。
  • 回答は取り消せない: 提出後の編集や追加はできません。スクリプトに提出の経路を作らないのが、最も確実な予防です。
  • 反論には手数料がかかる: 反論すると、処理手数料に加えて反論手数料が請求されます。成功すれば反論手数料は返りますが、受け取った申し立て手数料は返りません。メキシコと日本の事業には、反論の手数料は適用されません。
  • 結論まで最長3か月: カード保有者の銀行の審査には、最長で3か月かかることがあります。under_reviewのまま保留の権限を放置しない運用が要ります。
  • 外部リンクは証拠にならない: 評価する銀行は外部コンテンツを審査しません。音声・動画、クリックして開くリンクは含めません。
  • 返金が失敗することもある: refund.failedを受けて、台帳の行を失敗に更新し、担当者へ知らせます。残高に戻るまで、計上日から最大30日かかる場合があります。

まとめ

返金と不審請求の実装で守るのは、3つの分離です。事実を台帳に記録する処理、権限を変える処理、証拠を提出する処理を別の関数と別の担当に分けておけば、方針が変わっても直す場所は1か所で済みます。Claude Codeには前の2つを書かせ、最後の1つは下書きで止めて人に残します。

最初の1本は、charge.refundedの全額返金だけを台帳に書き、権限を取り消す範囲から始めるのが現実的です。そこにテストが付いたら、不審請求の期限管理を足します。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.stripe.com/disputes/api
  2. docs.stripe.com/billing/subscriptions/webhooks
  3. docs.stripe.com/refunds
  4. code.claude.com/docs/en/hooks
この記事を共有:XはてブLinkedIn