Claude Media
Claude CodeでStripe Connectの売上分配を実装する手順

Claude CodeでStripe Connectの売上分配を実装する手順

デスティネーション支払いと支払いと送金別方式の選び方、オンボーディング完了の判定、account.updatedの受け方を、Claude Codeに計画から書かせる手順です。

マーケットプレイスで売上を出品者に分ける実装は、決済そのものより前に決める項目が多く、Claude Codeに「Stripe Connectで分配して」と頼むと、支払い方式の選択を曖昧にしたまま動くコードが返ってきがちです。後から方式を変えると、返金・手数料・残高の挙動がまるごと変わります。

結論は3点です。支払い方式は実装前に人が決める。出品者の「売れる状態」は戻りURLではなくaccount.updatedで保存する。分配の計算は、金額を動かす関数の単体テストで固める。この記事は、この順にClaude Codeへ渡す指示と、そのまま使えるコードを示します。Stripeの受信側の作法はStripe webhookの冪等受信の記事にまとめてあるので、ここでは分配に固有の部分に絞ります。

実装前に決める支払い方式 — デスティネーションか、別方式か

Connectの間接支払いには2種類あります。デスティネーション支払いは、プラットフォームで支払いを作り、売上を連結アカウントへ即時に送ります。支払いと送金別方式は、支払いと送金を切り離し、送金を自分のタイミングと金額で作ります。

マーケットプレイスのガイドは、この2つのどちらかで連結アカウントへの入金を制御する構成を前提にしています。この構成ではマーケットプレイス自身がマーチャントオブレコードになり、Stripe手数料、不審請求の申し立て、返金を負います。

くらべる

どちらの方式で書かせるか

1取引 = 1出品者

デスティネーション支払い

購入者が1人の出品者の商品を買う取引向けです。支払いの作成時に送金先と手数料を指定できるため、実装が短く済みます。

1取引 = 複数の受取人

支払いと送金別方式

1つのカートに複数の出品者の商品が入る場合や、支払い時点で送金先が決まっていない場合に使います。送金は別のAPI呼び出しで作ります。

選ぶときの分かれ目は、Stripeの比較表に沿うと次のとおりです。

状況向く方式
1回の支払いが単一の出品者に対応する向く方式デスティネーション支払い
1つのカートに複数の出品者の商品が入る向く方式支払いと送金別方式
支払いの時点で送金先がまだ決まっていない向く方式支払いと送金別方式
送金額が支払い額を上回ることがある(割引分をプラットフォームが負担)向く方式支払いと送金別方式
出品者を購入者の目に見せず、出品者自身が売主になる向く方式ダイレクト支払い(この記事の範囲外)

支払いと送金別方式は、より複雑な実装になります。事業上の理由があるときだけ選ぶ方式です。そのため、迷う場合は単一出品者の取引にデスティネーション支払いを使い、複数出品者のカートが必要になった時点で別方式へ広げる順序が現実的です。

方式で変わる返金と残高

方式を決める前に、返金とチャージバックの負担先も確かめます。デスティネーション支払いと支払いと送金別方式では、返金額も不審請求の金額と手数料もプラットフォーム残高から引かれます。デスティネーション支払いでは、返金時にreverse_transferをtrueにすると、送った売上を連結アカウントから取り戻せます。既定では出品者が受け取った売上は残り、マイナス分はプラットフォームが負担します。不審請求の申し立てを受けた後の反証は、Stripe不審請求の反証資料の記事で扱っています。

この負担先は、規約や出品者向けの説明文にも書く事柄です。コードだけで決めず、人が判断してからClaude Codeに渡してください。

計画モードで仕様を固めてからコードを書かせる

Claude Codeには、ファイルを読んで計画だけを出し、承認までは編集しない計画モードがあります。claude --permission-mode planで起動するか、セッション中にShift+Tabで切り替えます。Connectのように後から直しにくい設計は、この段階で人が読んで止める価値があります。

claude --permission-mode plan

最初の指示には、方式の選択、保存する状態、テストの置き場所を入れます。次のような形が一例です。

Stripe Connectのマーケットプレイスを実装する計画を出して。
- 支払い方式はデスティネーション支払い(1取引1出品者)
- 出品者の状態はDBのseller_accountsに保存し、更新はaccount.updatedのみ
- 戻りURLの到達は完了と見なさない
- 金額計算は純粋関数に切り出し、単体テストを先に書く
実装はまだしない。変更するファイルと順序を一覧にして。

計画を読んで方向が合っていれば承認し、実装に進みます。承認の前に確かめるのは、金額計算が支払い処理と別の関数になっているか、状態の更新元が1か所に集まっているか、の2点です。

守らせる規約はCLAUDE.mdに置く

テストモードのキーだけを使わせるpermissionsの切り方は、Stripeサブスク同期の記事にある設定がそのまま使えます。その上に、Connect固有の規約をCLAUDE.mdに足しておくと、セッションをまたいで守らせられます。次は一例です。

## Stripe Connect
- 秘密鍵は環境変数から読む。コードにも例にも書かない
- 出品者の売れる状態は seller_accounts.charges_enabled と payouts_enabled で判定
- 状態を更新してよいのは account.updated の受信処理だけ
- 金額はすべて整数(最小通貨単位)。浮動小数点を使わない
- 手数料率の変更は lib/fees.ts のみ。テストを必ず更新する

出品者のオンボーディング — 完了の判定を戻りURLに頼らない

出品者の登録は、Stripeがホストする登録フローに送るのが最小の実装です。流れは、連結アカウントを作る、アカウントリンクを作って出品者をリダイレクトする、戻ってきたら状態を確かめる、の3段です。

手順

出品者を登録する流れ

  1. 1

    連結アカウントを作る

    controllerプロパティでダッシュボードの種類や手数料・損失の負担者を指定して作成します。

  2. 2

    アカウントリンクで登録画面へ送る

    連結アカウントのIDにrefresh_urlとreturn_urlを添えてアカウントリンクを作り、そのurlへ出品者をリダイレクトします。リンクは使い捨てで、訪問済みや期限切れだと無効になるため、更新URLで作り直します。

  3. 3

    戻ってきたら状態を確かめる

    return_urlへの到達は、情報がすべて集まったことを意味しません。フローに入って出たことだけを意味します。完了かどうかはアカウントのrequirementsで確かめます。

「戻りURLに来たら完了」という実装は、Claude Codeが書きやすい誤りです。保存して後で処理する選択肢が登録画面にあるため、途中離脱でもreturn_urlに戻ります。

account.updatedで状態を保存する

Stripeは、account.updatedのイベントを受けてアプリ側で状態をキャッシュする方法を案内しています。ここでは、判定を1つの関数に集めます。

// lib/seller-status.ts(例示。フィールド名はStripeのAccountオブジェクトに従う)
import type Stripe from "stripe";
 
export type SellerStatus =
  | "onboarding_needed"
  | "restricted"
  | "ready";
 
export function toSellerStatus(account: Stripe.Account): SellerStatus {
  const due = account.requirements?.currently_due ?? [];
  const pastDue = account.requirements?.past_due ?? [];
  if (pastDue.length > 0) return "restricted";
  if (due.length > 0) {
    return "onboarding_needed";
  }
  return account.charges_enabled && account.payouts_enabled
    ? "ready"
    : "restricted";
}

charges_enabledとpayouts_enabledは、支払いと入金が有効かどうかを表す値です。currently_dueに項目が残っているアカウントは、期限までに登録フローへ送り返します。期限が来てcurrently_dueが残っていると、該当する機能が無効になり、項目はpast_dueへ移ります。そのため、past_dueが空でないアカウントは、商品を購入可能にせず、出品者に再登録を促す扱いが安全です。

受信側は、署名検証済みのイベントからdata.objectのアカウントを読み、toSellerStatusの結果だけをDBに書きます。イベントの順序は保証されないので、古いイベントで新しい状態を上書きしないよう、書く前にAPIでアカウントを取得し直す構成にすると上書きを防げます。この点の詳しい作りは冪等受信の記事と同じです。

Webhookのスコープを取り違えない

Connectのwebhookには、「Your account」と「Connected accounts」の2つのスコープがあります。連結アカウントのaccount.updatedはConnected accountsスコープで届き、プラットフォーム自身のリソースのイベントはYour accountスコープで届きます。エンドポイントのスコープを間違えると、出品者の状態が一向に更新されません。

ローカルでの確認には、Stripe CLIを使います。スコープごとに転送の指定が違います。

# 連結アカウント側のイベントを転送
stripe listen --forward-connect-to localhost:4242/webhooks/connect
 
# 別の端末で、連結アカウントとしてイベントを発生させる
stripe trigger --stripe-account acct_xxx account.updated

livemodeの値も確認します。Connectの本番環境のwebhook URLは、本番とテスト両方のイベントを受信するためです。

デスティネーション支払いで分配する

出品者がreadyのときだけ購入できるようにし、Checkoutセッションに送金先と手数料を渡します。

// lib/checkout.ts(例示)
import Stripe from "stripe";
import { platformFee } from "./fees";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function createCheckout(
  sellerAccountId: string,
  amount: number,
  currency: string,
) {
  return stripe.checkout.sessions.create({
    mode: "payment",
    line_items: [{
      price_data: {
        currency,
        unit_amount: amount,
        product_data: { name: "商品名" },
      },
      quantity: 1,
    }],
    payment_intent_data: {
      application_fee_amount: platformFee(amount),
      transfer_data: { destination: sellerAccountId },
    },
    success_url: "https://example.com/success",
    cancel_url: "https://example.com/cancel",
  });
}

transfer_data[destination]がデスティネーション支払いの印で、application_fee_amountがプラットフォームの取り分です。application_fee_amountの上限は取引額です。

Stripeの例では、10.00 USDの支払いにapplication_fee_amountが1.23 USDのとき、全額が連結アカウントの保留残高に入り、そこから1.23 USDがプラットフォームへ送られます。さらにStripe手数料(例では0.59 USD)がプラットフォームの残高から引かれます。手数料から決済手数料を引いた差額が、実際の取り分です。手数料率を決めるときは、この引かれ方を含めて検算してください。

もう一つの指定方法がtransfer_data[amount]です。送金額を直接指定する形で、プラットフォーム手数料を引いた値を自分で渡します。手数料の計算を一箇所に集めるなら、こちらは使わずapplication_fee_amountに統一すると、テストが書きやすくなります。

非同期決済で送金がスキップされる場合

ACHやSEPAデビットのような非同期の決済手段では、承認から資金の確定まで時間が空きます。その間に送金先が必要な機能を失ったり閉鎖されたりすると、送金はスキップされ、資金はプラットフォームの残高に残ります。検出するにはcharge.updatedを購読し、Chargeのtransfer_dataがnullになっていないかを見ます。購読を足し忘れやすいイベントなので、指示に入れておきます。

複数の出品者に分けるなら支払いと送金別方式

カートに複数の出品者が入る場合は、支払いを作り、そのあとで出品者ごとに送金を作ります。支払いと送金を結び付けるのがtransfer_groupとsource_transactionです。

// lib/transfers.ts(例示)
import Stripe from "stripe";
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
 
export async function payoutToSellers(
  chargeId: string,
  orderId: string,
  splits: { accountId: string; amount: number }[],
  currency: string,
) {
  for (const s of splits) {
    await stripe.transfers.create({
      amount: s.amount,
      currency,
      destination: s.accountId,
      transfer_group: orderId,
      source_transaction: chargeId,
    });
  }
}

source_transactionを指定しないと、金額がプラットフォームの利用可能残高を超えた時点で送金リクエストが失敗します。Stripeは失敗した送金を自動では再試行しません。指定すれば、リクエストは成功し、支払いの資金が利用可能になるまで送金の実行が待たれます。自動入金のスケジュールも、source_transactionのない送金に支障を出す場合があります。

transfer_groupは関連するオブジェクトを識別するだけで、挙動は変えません。資金が使えるようになる前の送金を防ぐ役目はsource_transactionが担います。送金額の合計が支払い額を超えないかは、Stripeが止めてくれません。ここは自分のテストで守ります。

金額計算をテストで固める

Claude Codeに任せたいのは、テストを先に書かせて実装を合わせさせる反復です。最初に書かせるのは、金額を動かす純粋関数のテストです。

// lib/fees.test.ts(例示)
import { describe, expect, it } from "vitest";
import { platformFee, splitAmount } from "./fees";
 
describe("platformFee", () => {
  it("手数料は取引額を超えない", () => {
    expect(platformFee(50)).toBeLessThanOrEqual(50);
  });
  it("整数で返る", () => {
    expect(Number.isInteger(platformFee(1999))).toBe(true);
  });
});
 
describe("splitAmount", () => {
  it("分配の合計は支払い額を超えない", () => {
    const parts = splitAmount(10000, [0.6, 0.4]);
    expect(parts.reduce((a, b) => a + b, 0)).toBeLessThanOrEqual(10000);
  });
});

指示は「テストを実行して、失敗したら実装を直して」という形で足ります。Claude Codeの公式の手順も、テストの足場を作らせ、意味のあるケースを足させ、実行して失敗を直させる順序です。端数の扱いやゼロ円の取引のような境界値は、自分から挙げさせると漏れが減ります。

実行した出力を見せてもらうと、確認が速くなります。「通りました」の一言ではなく、コマンドと出力を求めてください。

npx vitest run lib/fees.test.ts lib/seller-status.test.ts

Stripeのテスト用トークンで出品者の状態を動かす

状態遷移のテストには、Stripeが用意するトリガー用のテストカードが使えます。tok_visa_triggerNextRequirementsは次に期限が来る要件を今すぐ必要な状態に変え、tok_visa_triggerChargeBlockは支払いのブロック、tok_visa_triggerPayoutBlockは入金のブロックを起こします。使うには、これらのカードをon_behalf_ofを設定したConnectの決済か、連結アカウントでのダイレクト支払いで使う必要があります。

payouts_enabledがfalseに落ちたときに、DB上の状態がrestrictedへ変わり、購入ボタンが止まるか。ここがマーケットプレイスの事故に直結するため、toSellerStatusに偽のAccountオブジェクトを渡す単体テストと、このトリガーを使った結合テストの両方で確かめます。

つまずきやすい点

落とし穴

Connected accountsスコープのエンドポイントがない

Your accountスコープだけを作ると、連結アカウントのaccount.updatedが届きません。ワークベンチでEvents fromがどちらかを確かめてください。

よくある質問

Accounts v2を使う場合、イベントは変わりますか

Accounts v2ではv2.core.account.*のイベントが加わります。v2のAccountは、v1とv2の両方のイベントを発生させ、スコープも異なります。連結アカウントが引き金のとき、v2のイベントはYour accountスコープで、v1のイベントはConnected accountsスコープで届きます。購読するイベント系統を決めてから、エンドポイントを作ってください。

ダイレクト支払いは選べませんか

連結アカウントが顧客と直接取引し、顧客がプラットフォームの存在に気づかない構成ではダイレクト支払いが向きます。購入者がマーケットプレイスに支払い、領収書にもマーケットプレイス名が出る構成なら、この記事の2方式が対象です。

参照した一次ソース

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

  1. docs.stripe.com/connect
  2. docs.stripe.com/connect/charges
  3. docs.stripe.com/connect/hosted-onboarding
  4. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn