自分の売上と、他人の売上は別物でした
個人開発で Stripe を使うようになって以来、決済といえば「自分の口座に入るお金」の話でした。買い切りでもサブスクリプションでも、失敗したときに困るのは私ひとりです。
マーケットプレイスは、そこが違いました。購入者が払った代金は一度こちらを通り、出品者へ渡ります。実装を誤れば、面識のない二人の間に立って信頼を壊すのは自分です。検証環境で手数料率の定数を書きながら、指が少し重くなったのを覚えています。
通常の Stripe 決済は、この三者間の資金の流れを想定していません。集めて、分けて、送る。その一連を引き受けるのが Stripe Connect です。
ここでは UI を Rork で生成し、バックエンドを Supabase Edge Functions に置き、資金の移動そのものは Stripe Connect に委ねる構成を扱います。動くコードだけでなく、テストカードでは一度も再現しなかった落とし穴——Webhook の重複配信、charges_enabled が false のままの出品者、JPY の端数——にも踏み込みます。
1. Stripe Connect の基礎知識:3つのアカウントタイプ
Stripe Connectには大きく3つのアカウントタイプがあります。マーケットプレイスの設計に大きく影響するため、まず整理しておきましょう。
Standard アカウント はStripeの標準的なダッシュボードを出品者に提供します。出品者が自分でStripeアカウントを持ち、管理します。Stripeのオンボーディング画面がそのまま使えるため、実装コストが最も低い選択肢です。ただし、プラットフォーム側からの詳細な制御には制約があります。
Express アカウント は最もよく使われるタイプで、Stripeがホストするシンプルなオンボーディング画面を出品者に表示します。KYC(本人確認)をStripeが代行するため、コンプライアンス上の負担を大幅に軽減できます。手数料はDestination Charges(送金先指定)方式と相性が良く、個人開発のマーケットプレイスにはExpressが最適解 です。
Custom アカウント はUI/UXを完全にカスタマイズできますが、KYC責任をプラットフォームが負うため、法的リスクとコストが高くなります。スタートアップ初期には不向きです。
2. Supabase によるバックエンド設計
RorkアプリのバックエンドにはSupabaseを使います。まず必要なテーブルを設計します。
テーブル設計
-- 出品者情報(Stripe Connectアカウント紐付け)
create table sellers (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth . users (id) not null ,
stripe_account_id text unique , -- Stripe Connect アカウントID (acct_xxxx)
onboarding_complete boolean default false,
charges_enabled boolean default false,
payouts_enabled boolean default false,
created_at timestamptz default now ()
);
-- 商品テーブル
create table products (
id uuid primary key default gen_random_uuid(),
seller_id uuid references sellers(id) not null ,
title text not null ,
description text ,
price integer not null , -- 価格(円)
currency text default 'jpy' ,
image_url text ,
status text default 'active' , -- active / sold / deleted
created_at timestamptz default now ()
);
-- 注文テーブル
create table orders (
id uuid primary key default gen_random_uuid(),
product_id uuid references products(id) not null ,
buyer_user_id uuid references auth . users (id) not null ,
seller_id uuid references sellers(id) not null ,
amount integer not null , -- 決済金額(円)
platform_fee integer not null , -- プラットフォーム手数料(円)
stripe_payment_intent_id text unique ,
stripe_transfer_id text ,
status text default 'pending' , -- pending / paid / shipped / completed / refunded
created_at timestamptz default now ()
);
Row Level Security (RLS) の設定
-- 出品者は自分のレコードのみ参照・更新可能
alter table sellers enable row level security ;
create policy "sellers_own_record" on sellers
for all using ( auth . uid () = user_id);
-- 商品は誰でも閲覧可能、変更は出品者のみ
alter table products enable row level security ;
create policy "products_read_all" on products
for select using ( status = 'active' );
create policy "products_write_own" on products
for all using (
seller_id in ( select id from sellers where user_id = auth . uid ())
);
-- 注文は売り手・買い手のみ参照可能
alter table orders enable row level security ;
create policy "orders_participant_only" on orders
for select using (
buyer_user_id = auth . uid () or
seller_id in ( select id from sellers where user_id = auth . uid ())
);
3. Stripe Connect アカウントの作成とオンボーディング
出品者がアプリ上でStripe Expressアカウントを作成し、本人確認を完了するフローを実装します。
バックエンドAPI(Supabase Edge Functions)
// supabase/functions/stripe-connect-onboard/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts" ;
import Stripe from "https://esm.sh/stripe@14.0.0" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
const stripe = new Stripe (Deno.env. get ( "STRIPE_SECRET_KEY" ) ! , {
apiVersion: "2024-06-20" ,
});
serve ( async ( req ) => {
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_SERVICE_ROLE_KEY" ) !
);
// ユーザー認証確認
const authHeader = req.headers. get ( "Authorization" ) ! ;
const { data : { user } } = await supabase.auth. getUser (
authHeader. replace ( "Bearer " , "" )
);
if ( ! user) return new Response ( "Unauthorized" , { status: 401 });
// 既存のStripe Connectアカウントを確認
const { data : existingSeller } = await supabase
. from ( "sellers" )
. select ( "stripe_account_id" )
. eq ( "user_id" , user.id)
. single ();
let accountId = existingSeller?.stripe_account_id;
// なければ新規作成
if ( ! accountId) {
const account = await stripe.accounts. create ({
type: "express" ,
country: "JP" ,
email: user.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: "individual" ,
});
accountId = account.id;
// Supabaseに保存
await supabase. from ( "sellers" ). upsert ({
user_id: user.id,
stripe_account_id: accountId,
});
}
// オンボーディングURL生成(Stripeのホストする本人確認画面)
const accountLink = await stripe.accountLinks. create ({
account: accountId,
refresh_url: `${ Deno . env . get ( "APP_URL" ) }/seller/onboarding?reauth=true` ,
return_url: `${ Deno . env . get ( "APP_URL" ) }/seller/onboarding?success=true` ,
type: "account_onboarding" ,
});
return new Response (
JSON . stringify ({ url: accountLink.url }),
{ headers: { "Content-Type" : "application/json" } }
);
});
Rorkアプリ側:オンボーディング画面
// Rork で生成するオンボーディング画面のコア実装
import React, { useState } from "react" ;
import { View, Text, TouchableOpacity, Alert } from "react-native" ;
import { WebView } from "react-native-webview" ;
import { supabase } from "@/lib/supabase" ;
export default function SellerOnboardingScreen () {
const [ isLoading , setIsLoading ] = useState ( false );
const [ onboardingUrl , setOnboardingUrl ] = useState < string | null >( null );
const [ isComplete , setIsComplete ] = useState ( false );
const startOnboarding = async () => {
setIsLoading ( true );
try {
const { data : { session } } = await supabase.auth. getSession ();
const response = await fetch (
`${ process . env . EXPO_PUBLIC_SUPABASE_URL }/functions/v1/stripe-connect-onboard` ,
{
method: "POST" ,
headers: {
Authorization: `Bearer ${ session ?. access_token }` ,
"Content-Type" : "application/json" ,
},
}
);
const { url } = await response. json ();
setOnboardingUrl (url);
} catch (error) {
Alert. alert ( "エラー" , "オンボーディングの開始に失敗しました" );
} finally {
setIsLoading ( false );
}
};
if (isComplete) {
return (
< View style = {{ flex : 1 , justifyContent : "center" , alignItems : "center" }} >
< Text style = {{ fontSize : 24 }} > ✅ </ Text >
< Text style = {{ fontSize : 18 , marginTop : 16 }} >
出品者登録が完了しました!
</ Text >
< Text style = {{ color : "#666" , marginTop : 8 }} >
審査完了後に販売を開始できます
</ Text >
</ View >
);
}
if (onboardingUrl) {
return (
< WebView
source = {{ uri : onboardingUrl }}
onNavigationStateChange = {(state) => {
// return_url に遷移したら完了とみなす
if (state.url. includes ( "success=true" )) {
setIsComplete ( true );
setOnboardingUrl ( null );
}
}}
/>
);
}
return (
< View style = {{ flex : 1 , padding : 24 , justifyContent : "center" }} >
< Text style = {{ fontSize : 22 , fontWeight : "bold" , marginBottom : 16 }} >
出品者として登録する
</ Text >
< Text style = {{ color : "#555" , marginBottom : 32 , lineHeight : 24 }} >
Stripe による本人確認( KYC )を完了すると、
商品を出品して売上を受け取ることができます。
確認作業は通常1〜2営業日で完了します。
</ Text >
< TouchableOpacity
onPress = {startOnboarding}
disabled = {isLoading}
style = {{
backgroundColor : "#5469D4" ,
padding : 16 ,
borderRadius : 8 ,
alignItems : "center" ,
}}
>
< Text style = {{ color : "#fff" , fontSize : 16 , fontWeight : "600" }} >
{ isLoading ? "準備中..." : "本人確認を開始する" }
</ Text >
</ TouchableOpacity >
</ View >
);
}
4. Destination Charges による決済と手数料分配
購入者が決済すると、Stripeが自動的に出品者への送金とプラットフォーム手数料の分配を処理します。
決済API(Supabase Edge Function)
// supabase/functions/create-marketplace-payment/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts" ;
import Stripe from "https://esm.sh/stripe@14.0.0" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
const stripe = new Stripe (Deno.env. get ( "STRIPE_SECRET_KEY" ) ! , {
apiVersion: "2024-06-20" ,
});
// プラットフォーム手数料率(10%)
const PLATFORM_FEE_RATE = 0.10 ;
serve ( async ( req ) => {
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_SERVICE_ROLE_KEY" ) !
);
const { productId } = await req. json ();
// ユーザー認証
const authHeader = req.headers. get ( "Authorization" ) ! ;
const { data : { user } } = await supabase.auth. getUser (
authHeader. replace ( "Bearer " , "" )
);
if ( ! user) return new Response ( "Unauthorized" , { status: 401 });
// 商品・出品者情報を取得
const { data : product } = await supabase
. from ( "products" )
. select ( "*, sellers(stripe_account_id, charges_enabled)" )
. eq ( "id" , productId)
. eq ( "status" , "active" )
. single ();
if ( ! product) {
return new Response ( "Product not found" , { status: 404 });
}
if ( ! product.sellers.charges_enabled) {
return new Response ( "Seller not ready to accept payments" , { status: 400 });
}
const amount = product.price; // 円
const platformFee = Math. floor (amount * PLATFORM_FEE_RATE );
// PaymentIntent を作成(Destination Charges方式)
const paymentIntent = await stripe.paymentIntents. create ({
amount,
currency: "jpy" ,
// 出品者のStripe Connectアカウントに送金
transfer_data: {
destination: product.sellers.stripe_account_id,
},
// プラットフォーム手数料(自動的にプラットフォームアカウントに残る)
application_fee_amount: platformFee,
metadata: {
product_id: productId,
buyer_user_id: user.id,
seller_id: product.seller_id,
},
});
// 注文レコード作成
await supabase. from ( "orders" ). insert ({
product_id: productId,
buyer_user_id: user.id,
seller_id: product.seller_id,
amount,
platform_fee: platformFee,
stripe_payment_intent_id: paymentIntent.id,
status: "pending" ,
});
return new Response (
JSON . stringify ({
clientSecret: paymentIntent.client_secret,
amount,
platformFee,
sellerReceives: amount - platformFee,
}),
{ headers: { "Content-Type" : "application/json" } }
);
});
Rorkアプリ側:購入フロー
// Rork の購入画面(Payment Sheet 使用)
import React, { useState } from "react" ;
import { View, Text, TouchableOpacity, Alert } from "react-native" ;
import { useStripe } from "@stripe/stripe-react-native" ;
import { supabase } from "@/lib/supabase" ;
interface ProductPurchaseProps {
productId : string ;
productTitle : string ;
price : number ;
}
export default function ProductPurchaseScreen ({
productId ,
productTitle ,
price ,
} : ProductPurchaseProps ) {
const { initPaymentSheet , presentPaymentSheet } = useStripe ();
const [ isLoading , setIsLoading ] = useState ( false );
const handlePurchase = async () => {
setIsLoading ( true );
try {
const { data : { session } } = await supabase.auth. getSession ();
// バックエンドからclientSecretを取得
const response = await fetch (
`${ process . env . EXPO_PUBLIC_SUPABASE_URL }/functions/v1/create-marketplace-payment` ,
{
method: "POST" ,
headers: {
Authorization: `Bearer ${ session ?. access_token }` ,
"Content-Type" : "application/json" ,
},
body: JSON . stringify ({ productId }),
}
);
const { clientSecret , amount , sellerReceives } = await response. json ();
// Payment Sheet の初期化
const { error : initError } = await initPaymentSheet ({
paymentIntentClientSecret: clientSecret,
merchantDisplayName: "Your Marketplace" ,
primaryButtonLabel: `¥${ amount . toLocaleString () } で購入する` ,
});
if (initError) throw new Error (initError.message);
// Payment Sheet を表示
const { error : presentError } = await presentPaymentSheet ();
if (presentError) {
if (presentError.code !== "Canceled" ) {
Alert. alert ( "決済エラー" , presentError.message);
}
return ;
}
// 決済成功
Alert. alert (
"購入完了" ,
`「${ productTitle }」の購入が完了しました。 \n 出品者への連絡をお待ちください。`
);
} catch (error) {
Alert. alert ( "エラー" , "購入処理中にエラーが発生しました" );
} finally {
setIsLoading ( false );
}
};
const platformFee = Math. floor (price * 0.10 );
return (
< View style = {{ padding : 24 }} >
< Text style = {{ fontSize : 20 , fontWeight : "bold" , marginBottom : 8 }} >
{ productTitle }
</ Text >
< View style = {{ backgroundColor : "#f8f9fa" , padding : 16 , borderRadius : 8 , marginBottom : 24 }} >
< Text style = {{ fontSize : 16 , marginBottom : 4 }} >
価格 : ¥{ price . toLocaleString ()}
</ Text >
< Text style = {{ fontSize : 13 , color : "#666" }} >
※ サービス手数料 ¥{platformFee.toLocaleString()} を含む
</ Text >
</ View >
< TouchableOpacity
onPress = {handlePurchase}
disabled = {isLoading}
style = {{
backgroundColor : "#00a651" ,
padding : 16 ,
borderRadius : 8 ,
alignItems : "center" ,
}}
>
< Text style = {{ color : "#fff" , fontSize : 16 , fontWeight : "600" }} >
{ isLoading ? "処理中..." : `¥${ price . toLocaleString () } で購入する` }
</ Text >
</ TouchableOpacity >
</ View >
);
}
5. Webhook 処理による注文ステータスの自動更新
Stripeから送られるWebhookイベントを処理して、注文ステータスを自動更新します。これが実装されていないと、決済完了後も注文が「pending」のままになってしまいます。
// supabase/functions/stripe-webhook/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts" ;
import Stripe from "https://esm.sh/stripe@14.0.0" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
const stripe = new Stripe (Deno.env. get ( "STRIPE_SECRET_KEY" ) ! , {
apiVersion: "2024-06-20" ,
httpClient: Stripe. createFetchHttpClient (),
});
const webhookSecret = Deno.env. get ( "STRIPE_WEBHOOK_SECRET" ) ! ;
serve ( async ( req ) => {
const body = await req. text ();
const signature = req.headers. get ( "stripe-signature" ) ! ;
let event : Stripe . Event ;
try {
// Deno/Cloudflare Workers 環境では非同期バージョンが必須
event = await stripe.webhooks. constructEventAsync (
body,
signature,
webhookSecret,
undefined ,
Stripe. createSubtleCryptoProvider ()
);
} catch (err) {
console. error ( "Webhook signature verification failed:" , err);
return new Response ( "Invalid signature" , { status: 400 });
}
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_SERVICE_ROLE_KEY" ) !
);
switch (event.type) {
case "payment_intent.succeeded" : {
const paymentIntent = event.data.object as Stripe . PaymentIntent ;
// 注文ステータスを paid に更新
await supabase
. from ( "orders" )
. update ({ status: "paid" })
. eq ( "stripe_payment_intent_id" , paymentIntent.id);
// 商品ステータスを sold に更新(1点物の場合)
const { data : order } = await supabase
. from ( "orders" )
. select ( "product_id" )
. eq ( "stripe_payment_intent_id" , paymentIntent.id)
. single ();
if (order) {
await supabase
. from ( "products" )
. update ({ status: "sold" })
. eq ( "id" , order.product_id);
}
break ;
}
case "payment_intent.payment_failed" : {
const paymentIntent = event.data.object as Stripe . PaymentIntent ;
await supabase
. from ( "orders" )
. update ({ status: "failed" })
. eq ( "stripe_payment_intent_id" , paymentIntent.id);
break ;
}
case "account.updated" : {
// 出品者アカウントの審査状況を更新
const account = event.data.object as Stripe . Account ;
await supabase
. from ( "sellers" )
. update ({
charges_enabled: account.charges_enabled,
payouts_enabled: account.payouts_enabled,
onboarding_complete: account.details_submitted,
})
. eq ( "stripe_account_id" , account.id);
break ;
}
default :
console. log ( `Unhandled event type: ${ event . type }` );
}
return new Response ( JSON . stringify ({ received: true }), {
headers: { "Content-Type" : "application/json" },
});
});
6. 出品者ダッシュボード:売上・送金状況の確認
出品者が自分の売上を確認できるダッシュボード画面を実装します。
// supabase/functions/seller-dashboard/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts" ;
import Stripe from "https://esm.sh/stripe@14.0.0" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
const stripe = new Stripe (Deno.env. get ( "STRIPE_SECRET_KEY" ) ! , {
apiVersion: "2024-06-20" ,
});
serve ( async ( req ) => {
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_SERVICE_ROLE_KEY" ) !
);
const authHeader = req.headers. get ( "Authorization" ) ! ;
const { data : { user } } = await supabase.auth. getUser (
authHeader. replace ( "Bearer " , "" )
);
if ( ! user) return new Response ( "Unauthorized" , { status: 401 });
const { data : seller } = await supabase
. from ( "sellers" )
. select ( "stripe_account_id" )
. eq ( "user_id" , user.id)
. single ();
if ( ! seller?.stripe_account_id) {
return new Response ( "Seller not found" , { status: 404 });
}
// Stripe Connect アカウントの残高を取得
const balance = await stripe.balance. retrieve ({
stripeAccount: seller.stripe_account_id,
});
// 最近の送金履歴
const payouts = await stripe.payouts. list (
{ limit: 5 },
{ stripeAccount: seller.stripe_account_id }
);
return new Response (
JSON . stringify ({
balance: {
available: balance.available[ 0 ]?.amount ?? 0 ,
pending: balance.pending[ 0 ]?.amount ?? 0 ,
currency: "jpy" ,
},
recentPayouts: payouts.data. map (( p ) => ({
amount: p.amount,
status: p.status,
arrivalDate: p.arrival_date,
})),
}),
{ headers: { "Content-Type" : "application/json" } }
);
});
7. 返金・紛争処理
マーケットプレイスでは返金対応も必要です。Stripe Connectの返金は通常の返金よりも少し複雑で、出品者への送金を取り消す reverse_transfer: true の指定が重要です。
// supabase/functions/process-refund/index.ts(管理者用)
import { serve } from "https://deno.land/std@0.168.0/http/server.ts" ;
import Stripe from "https://esm.sh/stripe@14.0.0" ;
import { createClient } from "https://esm.sh/@supabase/supabase-js@2" ;
const stripe = new Stripe (Deno.env. get ( "STRIPE_SECRET_KEY" ) ! , {
apiVersion: "2024-06-20" ,
});
serve ( async ( req ) => {
const { orderId , reason } = await req. json ();
const supabase = createClient (
Deno.env. get ( "SUPABASE_URL" ) ! ,
Deno.env. get ( "SUPABASE_SERVICE_ROLE_KEY" ) !
);
const { data : order } = await supabase
. from ( "orders" )
. select ( "*" )
. eq ( "id" , orderId)
. single ();
if ( ! order || order.status !== "paid" ) {
return new Response ( "Order not eligible for refund" , { status: 400 });
}
// Destination Charges の返金
const refund = await stripe.refunds. create ({
payment_intent: order.stripe_payment_intent_id,
reason: reason || "requested_by_customer" ,
reverse_transfer: true , // 出品者への送金を取り消す
refund_application_fee: true , // プラットフォーム手数料も返金
});
await supabase
. from ( "orders" )
. update ({ status: "refunded" })
. eq ( "id" , orderId);
// 商品を再出品可能に戻す
await supabase
. from ( "products" )
. update ({ status: "active" })
. eq ( "id" , order.product_id);
return new Response (
JSON . stringify ({ refundId: refund.id, status: refund.status }),
{ headers: { "Content-Type" : "application/json" } }
);
});
8. テストカードでは一度も再現しなかった3つの落とし穴
ここまでのコードは、テストモードでは気持ちよく通ります。私がつまずいたのは、いずれもテストカード(4242…)を何度叩いても現れないものでした。順に潰していきます。
落とし穴1:Webhook は同じイベントを二度届ける
Stripe の Webhook 配信は at-least-once です。こちらが 200 を返すのが遅れたとき、ネットワークが切れたとき、Stripe は同じ payment_intent.succeeded を再送します。前掲のハンドラは冪等ではないため、二度目の配信で商品ステータスを再び sold に上書きし、売上集計をイベント単位で持っている場合は二重計上になります。
対策はシンプルで、イベント ID を主キーに持つテーブルへ「先に書く」ことです。
create table processed_webhook_events (
event_id text primary key ,
event_type text not null ,
processed_at timestamptz not null default now ()
);
// stripe-webhook/index.ts の署名検証直後に挿入する
const { error : dupError } = await supabase
. from ( "processed_webhook_events" )
. insert ({ event_id: event.id, event_type: event.type });
if (dupError) {
// 主キー衝突 = 処理済み。200 を返して再送を止める
if (dupError.code === "23505" ) {
return new Response ( JSON . stringify ({ received: true , duplicate: true }), {
headers: { "Content-Type" : "application/json" },
});
}
// それ以外のDBエラーは 500 を返し、Stripe に再送させる
return new Response ( "DB error" , { status: 500 });
}
Webhook の受信設計そのものを掘り下げたい場合は、順序ずれや欠落の補正まで扱った Rork アプリの Stripe Webhook が本番でだけ取りこぼす も併せてご覧ください。
順序が肝心です。処理を終えてから記録すると、処理中にクラッシュした場合に「実行されたのに記録がない」状態が残ります。先に INSERT して所有権を取り、失敗したら 500 を返して Stripe の再送に委ねる。この形なら、重複も取りこぼしもどちらも防げます。
落とし穴2:account.updated を一度落とすと、出品者は永久に売れない
charges_enabled の更新を Webhook だけに頼ると、その1通が届かなかった出品者は審査を通過しているのにアプリ上は「準備中」のままです。本人には何も分かりません。決済 API 側で弾いているため、購入者にも「この出品者は受け取れません」としか出ない。
Webhook を「速い経路」、明示的な同期を「確実な経路」として二重化します。
// supabase/functions/sync-seller-status/index.ts
// オンボーディング画面から戻ったとき、および出品者ダッシュボード表示時に呼ぶ
const { data : seller } = await supabase
. from ( "sellers" )
. select ( "stripe_account_id" )
. eq ( "user_id" , user.id)
. single ();
const account = await stripe.accounts. retrieve (seller.stripe_account_id);
await supabase
. from ( "sellers" )
. update ({
charges_enabled: account.charges_enabled,
payouts_enabled: account.payouts_enabled,
onboarding_complete: account.details_submitted,
})
. eq ( "stripe_account_id" , account.id);
return new Response (
JSON . stringify ({
chargesEnabled: account.charges_enabled,
// 何が足りないのかを出品者本人に見せる
currentlyDue: account.requirements?.currently_due ?? [],
pastDue: account.requirements?.past_due ?? [],
disabledReason: account.requirements?.disabled_reason ?? null ,
}),
{ headers: { "Content-Type" : "application/json" } }
);
requirements.currently_due を画面に出すかどうかで、問い合わせの数がはっきり変わります。個人開発では、この問い合わせ対応こそ最も割ける時間の少ない資源です。「審査中です」とだけ表示していた頃は、出品者から見れば止まっているのか進んでいるのか判断できません。不足書類の項目名をそのまま並べるだけでも、自己解決できる方が増えます。
落とし穴3:JPY の端数は、部分返金で表に出る
application_fee_amount は整数でなければ Stripe が invalid_request_error を返します。JPY は最小通貨単位が1円のため、10% の手数料は Math.floor(amount * 0.10) のように必ず丸める必要があります。ここまでは前掲のコードで扱っています。
問題は部分返金です。refund_application_fee: true はプラットフォーム手数料を全額 返します。3,000 円の商品を 1,000 円だけ返金したのに、手数料 300 円が丸ごと戻る。返金のたびにこちらが赤字を積む構造です。
按分するなら、手数料の返金額を明示します。
// 部分返金:返金比率に応じて手数料も按分する
const refundAmount = 1000 ; // 返金する金額(円)
const orderAmount = order.amount; // 3000
const orderFee = order.platform_fee; // 300
// 端数はプラットフォーム側が飲む(出品者の受取を削らない)方向に丸める
const feeRefund = Math. floor ((orderFee * refundAmount) / orderAmount);
const refund = await stripe.refunds. create ({
payment_intent: order.stripe_payment_intent_id,
amount: refundAmount,
reason: "requested_by_customer" ,
reverse_transfer: true ,
refund_application_fee: false , // 全額返金を止める
});
// 手数料の按分返金は別 API で行う
if (feeRefund > 0 ) {
const charge = await stripe.charges. retrieve (refund.charge as string );
await stripe.applicationFees. createRefund (
charge.application_fee as string ,
{ amount: feeRefund }
);
}
Math.floor にしているのは、丸め誤差の1円を出品者ではなく自分が負担するためです。金額としては誤差でも、出品者から見れば「計算が合わない」は信用の問題になります。取り分の計算で迷ったときは、自分が損をする側に倒しておくと後で説明する手間が減ります。
9. 本番公開前のコンプライアンスチェックリスト
Stripe Connectを使ったマーケットプレイスを本番公開する前に、以下を必ず確認してください。
Stripe側の設定確認 として、まずStripeダッシュボードの「Connect設定」でプラットフォームの業種・説明・サポートメールアドレスを正確に登録します。Expressアカウントのオンボーディング画面にはプラットフォーム名とロゴが表示されるため、ブランドイメージに影響します。また「利用規約」ページでConnect利用規約を承認します(本番環境では必須)。
テスト環境での動作確認 では、テスト用カード(4242 4242 4242 4242)で購入フローが完了すること、Webhookがローカル環境で受信できること(stripe listen --forward-toを活用)、返金フローが正常に動作すること、account.updated Webhookで charges_enabled: true の受信とDB更新が動作することを確認します。
セキュリティ確認 として、Supabase RLSが全テーブルで有効化されていること、APIキーが環境変数で管理されソースコードに含まれていないこと、Webhook署名検証が有効であること、管理者専用エンドポイント(返金など)に適切な認証が設定されていることを確認します。
法的要件 については、日本でマーケットプレイスを運営する場合、資金決済法の規制対象になる可能性があります。Stripe ConnectのDestination Chargesを使う場合、資金の一時預かりが発生するため、資金移動業の登録が必要になるケースがあります。必ず法律の専門家への相談を行ってください。
10. 実装前によく迷う2点
出品者の審査が終わったことを、アプリ側はいつ知るのか
日本の個人出品者の場合、本人確認書類をアップロードしてから通常1〜3営業日で審査が完了します。アプリ側の判断材料は account.updated Webhook の charges_enabled ですが、前述のとおりこれ一本に頼ると取りこぼしたときに復旧手段がありません。Webhook で速報を受け、画面表示のたびに accounts.retrieve で照合する。この二段構えを最初から入れておくと、後から慌てて足す羽目になりません。
手数料率をいくつに置くか
フリマ型で10〜15%、スキルマーケット型で15〜20%が実勢です。ここで見落としやすいのが、決済手数料(日本の Stripe は 3.6%)が先に引かれた後の実質収益で考える必要がある点です。プラットフォーム手数料を10%に設定した場合、手元に残るのは概ね 6〜7% になります。少額商品ほどこの差が効くため、最低手数料額(例:50円)を併用する設計も検討に値します。
落ち着いて次に進むために
ここまでで、出品者のオンボーディング、Destination Charges による決済と手数料分配、Webhook の冪等化、部分返金時の手数料按分までが揃いました。
最初から全部を作る必要はありません。個人開発で進める場合はなおさらです。私の場合、まず「出品者1人・商品1点・返金なし」で通し、テストモードで決済が最後まで抜けることを確認してから、冪等化と同期処理を足しました。落とし穴として挙げた3つは、いずれも本番のトラフィックが来て初めて表に出るものです。順番としては、公開前の最後の週にまとめて入れるより、決済が通った直後に入れておく方が安全でした。
次の一歩としては、processed_webhook_events テーブルを作り、既存の Webhook ハンドラの署名検証直後に INSERT を1本足すところから始めてみてください。それだけで、二重計上という最も気づきにくい事故が消えます。
なお、他人の資金を預かる形態は、国や規模によって適用される規制が変わります。設計段階で専門家に相談しておくと、後から構造を作り直す負担を避けられます。
サブスクリプション型の Stripe 連携については Rork Max × Stripe サブスクリプションを本番運用へ 、バックエンド設計については Rork × Supabase 認証&リアルタイム機能実装ガイド が参考になります。
長い記事をここまでお読みいただき、ありがとうございました。私自身まだ検証の途中にある部分もありますので、実装の中で見つかった知見があれば、ぜひ共有していただけると嬉しいです。