Merci の裏側 — Stripe Connect でチップ決済 SaaS を作る
Merci は、飲食店・個人事業主向けのチップ決済 SaaS です。お客様がスマホで QR コードを読み取り、店舗のスタッフさんに直接感謝の気持ちを送れる仕組みを提供しています。本稿では、Merci の裏側にある Stripe Connect の選定・設計・実装、そして本番運用 3 ヶ月で得られた学びを、実際にコードを書き審査申請書を書いた立場からまとめます。
なぜ Stripe Connect か
チップ決済 SaaS のような「プラットフォーム」で必須になるのが、「プラットフォームがお金を一時的に預かって、後で受取人(店舗)に送る」という Connect パターンです。自前でこれを実装すると、日本では資金決済法上の「資金移動業」または「収納代行」の整理が必要になり、業登録・供託金・帳簿義務が発生して個人事業レベルでは事実上不可能です。Stripe Connect を使うと、この資金の流れは Stripe が「決済代行」として担うため、Merci 自身は一切資金を保管しません。これが最大の理由でした。
技術的な代替案として、PayPay for Business の売上分配、Square の外部連携、あるいは Stripe の非 Connect(通常の Charges + 銀行振込で送金)も検討しましたが、いずれも「決済 → 受取人口座」のフローを自動化できるものは Stripe Connect しかありませんでした。Square は API での振込分配ができず、PayPay は API 提供範囲が狭く、通常 Stripe は資金を一度自社に受けてから送金する必要があり資金移動業に触れます。
Connect のアカウントタイプ 3 種比較
Stripe Connect には Standard / Express / Custom の 3 種類があります。Merci でどれを採るか、実装前に半日ほど比較しました。要点を表にまとめます。
- Standard:受取人(店舗)が自分で Stripe アカウントを持ち、Stripe ダッシュボードを Stripe から直接使う。KYC・法令対応・サポートすべて Stripe と店舗の間。プラットフォーム側の実装工数は最小(OAuth 連携のみ)、UX カスタマイズ度も最小、月額料金 0 円、審査は店舗自身が Stripe と直接やる。
- Express:受取人は Stripe アカウントを持つが、オンボーディングはプラットフォームの UI に埋め込まれた短縮フローで完結する。KYC は Stripe 責任、日本語 UI 提供あり、Stripe から Express 用の簡易ダッシュボード(残高・入金予定確認)も自動提供される。月額 0 円、実装工数は中。
- Custom:受取人には Stripe アカウントの存在すら見えない。すべての UI・KYC 収集・エラーハンドリングをプラットフォームが自前で作る。UX カスタマイズ度は最大、Stripe ダッシュボード提供なし、月額 $2/active account、実装工数は最大でコンプライアンス責務もプラットフォーム側。
Express を採用した具体的な理由 5 選
- 店舗さんは飲食業のプロで、会計 SaaS のプロではない。Standard の場合、店舗さんが Stripe から届く英語混じりの本人確認メールに対応できないケースが実際にありました(テスト導入店舗 3 件中 2 件で「これは詐欺メールですか?」と問い合わせ)。Express なら Merci 側でオンボーディング状況が可視化できるので、こちらから声がけできます。
- KYC を Stripe に完全に任せられる。運転免許証の裏面画像、法人の登記事項証明書、代表者の実質的支配者情報、口座名義の一致確認 —— 日本の KYC 要件は年々厳しくなっており、これを自前でやる(Custom)ROI が Merci の事業規模では合いません。
- 資金移動業・特商法対応も Stripe 側の枠組みで完結する。プラットフォーム側では「Merci はチップ決済の場を提供する」旨だけを書けばよく、資金の流れの主体は Stripe です。
- Express ダッシュボードが日本語 UI で提供される。店舗さんが自分の入金予定・振込履歴を確認するための管理画面を Stripe が自動生成してくれます。これを Merci 側で作ると、それだけで 2 週間の開発が消えていました。
- Standard より審査承認率が高い実感がありました。Standard は店舗さん自身が Stripe と契約するため、事業内容が Stripe の Restricted Business Categories に引っかかるかどうかを店舗さんが判断する必要があります。Express は Merci が「プラットフォームとしてこういう業種を扱う」と一括で Stripe に説明できるため、業種ごとの摩擦が減ります。
チャージ方式の使い分け — Merci はなぜ Destination charges か
Stripe Connect には決済処理の方式が 3 つあります。これは Connect を扱ううえで最初に理解しないと後戻りできない設計判断です。
- Direct charges:決済は完全に受取人(店舗)の Stripe アカウント上で発生する。プラットフォームは
application_fee_amountだけを受け取る。返金対応・お客様問い合わせは店舗が主体。 - Destination charges:決済はプラットフォームの Stripe アカウント上で発生し、その決済の
transfer_data[destination]に受取人アカウントを指定することで、Stripe が自動的に受取人へ送金する。返金・分析・問い合わせは Merci が主体。 - Separate Charges and Transfers:決済はプラットフォームで受け、あとで別トランザクションとして受取人へ transfer する。1 決済を複数の受取人に分配したい場合や、送金タイミングを遅らせたい場合に使う。
Merci は Destination charges を採用しました。理由は 3 つです。
- 返金対応を Merci 側で持ちたい。チップは金額こそ小さいですが、「押し間違えて 5,000 円送ってしまった」というお客様のサポートは決済プラットフォーム側で受けるべき(店舗さんに「返金してほしい」と直接クレームがいくと本業のオペレーションに影響します)。Destination charges なら Merci ダッシュボードから 1 クリックで返金できます。
- 手数料透明性を Merci が保証したい。Direct だと店舗ごとの Stripe 手数料が微妙に異なり(法人/個人・業種・実績で変わる)、「Merci に払った手数料はいくらか」を店舗さん向けに一貫した数字で出せません。Destination なら Merci の手数料テーブルで固定できます。
- 不正・チャージバック対応。カード会社からのチャージバック要求はプラットフォームの Stripe アカウントに来ます。Merci でまず受けて、必要に応じて店舗さんに確認する運用にしたかったので、Destination が自然でした。
プラットフォーム手数料の設計 — 実際の設定値と根拠
Merci の現在の手数料構造はこうなっています(2026-07 時点)。
- お客様がチップ 1,000 円を送る
- Stripe の決済手数料:3.6%(国内カード、Visa/Master/JCB/Amex)= 36 円
- Merci のプラットフォーム手数料:5%(チップ額に対して)= 50 円
- 店舗さんの手取り:1,000 − 36 − 50 = 914 円
コードで書くと、PaymentIntent 作成時にこうしています(実際のコードから抽出)。
const amount = 1000; // 円
const merciFeeRate = 0.05;
const applicationFeeAmount = Math.floor(amount * merciFeeRate);
await stripe.paymentIntents.create({ amount, currency: "jpy", application_fee_amount: applicationFeeAmount, transfer_data: {destination: shop.stripeAccountId }} );
ここで実装初期にハマった落とし穴を 2 つ共有します。
- Stripe 手数料は application_fee_amount には含まれない。
application_fee_amountはあくまで「プラットフォーム(Merci)の取り分」であり、Stripe の決済手数料はさらにその Merci 取り分から引かれます。つまり Merci が受け取るのは 50 円ではなく、50 − (50 × 0.036) ≈ 48.2 円です。ここを勘違いして「実効手数料 5% だ」と社内資料に書いて、あとで CFO 役から指摘されて直しました。 - JPY は最小単位= 1 円のためゼロ端数(
Math.floor)で問題ないが、USD の場合は cents 単位で application_fee_amount を渡す必要があります。Merci はまだ JPY 専用ですが、将来インバウンド観光客向けに USD/EUR チップを解放する際にリファクタが必要になるため、内部モデルは最初から「minor unit(最小単位)」で持つようにしました。
なお、手数料 5% という数字は、競合(一般的な決済 SaaS が 3〜8%)と Merci の粗利(Stripe 3.6% + Cloud Run + Supabase + サポート人件費)から逆算した数字です。3% にすると Merci 側の営業利益率がほぼゼロ、8% にすると店舗さんが「銀行振込でいいや」となる分岐点、というのが 3 ヶ月の値付け実験でわかった相場感でした。
Webhook の実装で実際に踏んだ地雷 3 つ
Stripe は決済イベントを Webhook(HTTPS POST)で通知します。ここは「動いてるから大丈夫」だと本番で必ず事故る領域で、Merci でも 3 つの地雷を踏みました。
① Next.js の bodyParser が署名検証を壊す
Stripe の Webhook はリクエストボディの生バイト列に対して署名を計算します。ところが Next.js(App Router 以前の Pages Router 時代)はデフォルトで JSON を parse してしまうため、req.body がオブジェクトになった状態で stripe.webhooks.constructEvent に渡すと SignatureVerificationError が確実に出ます。App Router では request.text() で生文字列を取得し、それを検証に渡す必要があります。ここでハマって半日溶かしました。教訓:Stripe のドキュメントの言語別サンプルを愚直に写経する。
② イベントの順序は保証されない
payment_intent.succeeded の Webhook が payment_intent.created より先に届くことが実際にありました。DB でイベント順序を前提にした状態遷移(created → processing → succeeded)を書いていると、順序の逆転で「succeeded を受け取ったけど該当決済が DB にない」というエラーが出ます。Merci ではイベントを受け取ったら 常に対象 PaymentIntent を Stripe API で fetch して最新状態を DB に upsert する方針に切り替えました。Webhook はあくまで「見にいくべきタイミング」の通知として扱う、というパターンです。
③ retry の指数バックオフとタイムアウト
Stripe は Webhook のレスポンスが 30 秒を超えるか 2xx 以外を返すと、指数バックオフで最大 3 日間 retry します。Merci 初期実装では Webhook 内で Slack 通知 + メール送信 + DB 書き込みを同期で走らせていたところ、外部 API 障害でメール送信が 40 秒かかった日に 1 決済につき 20 回以上 Webhook が再送される事故がありました。今は Webhook は「署名検証 → DB に生イベントを保存 → 202 で即返す」だけにして、後続処理は別ワーカーで非同期実行しています。
Idempotency Key の設計 — event.id だけでは足りない
「Webhook が複数回来ることがあるので event.id を保存して重複を検知する」というのは Stripe ドキュメントに書かれている基本パターンです。ただ、Merci の実装ではこれだけでは不十分でした。理由は 2 つあります。
- 1 つの決済ライフサイクルで複数の Webhook が同じ副作用を要求する。
charge.succeededとpayment_intent.succeededの両方が届き、どちらも「店舗残高を更新する」という副作用を持ちます。event.id で重複排除しても、別イベントとして両方処理されて店舗残高が二重加算されます。対策として、副作用ごとに ドメイン固有の idempotency key(例:「PaymentIntent ID + 副作用の種類(残高加算/メール送信/Slack 通知)」)を発行し、UNIQUE 制約で守っています。 - Separate Charges and Transfers に将来移行する場合、Charge と Transfer は別イベントで届き、それぞれに独立の副作用が発生します。event.id では単純に守れないため、Merci は「対象 Stripe オブジェクト ID + 副作用 kind」を主キーにしています。
Postgres での実装はこう書いています。
CREATE TABLE webhook_side_effects (
object_id text NOT NULL,
effect_kind text NOT NULL,
processed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (object_id, effect_kind)
);
そして副作用実行時はこう。
INSERT INTO webhook_side_effects (object_id, effect_kind) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *;
RETURNING が空なら「すでに処理済み → 副作用スキップ」、行が返れば「今回が初回 → 副作用実行」。トランザクション内で副作用と INSERT を同時に COMMIT することで、副作用の実行と重複排除の記録がアトミックになります。この設計にしてから、Webhook 起因の二重処理事故はゼロになりました。
本番稼働までにハマった審査系の話
Stripe には Restricted Business Categories(制限付き業種)があり、飲食業自体は問題ないのですが、Merci のようなプラットフォーム型は「プラットフォーム上で扱える業種の範囲」を Stripe 側の審査で決定されます。Merci の申請では以下を明記しました。
- 扱う受取人業種:飲食業、美容業、パーソナルサービス業(当初「あらゆる小売」と書いて差し戻された)
- 扱わない業種:アダルト、ギャンブル、寄付、投資関連、暗号資産関連
- プラットフォーム上での不正監視の仕組み:手動 KYC + 決済額異常検知の自動アラート
また、日本の資金決済法上の位置づけについて Stripe 側からも確認があり、「Merci は資金移動業者ではなく、Stripe を経由した決済代行スキームである」ことを弁護士メモ付きで提出しています。混同されがちな「収納代行」との違いは、収納代行が「本来受取人が受け取るべき代金を代わりに受け取る」のに対し、Merci スキームでは Stripe が決済主体であり Merci は資金の預かり主体ではない、という点です。
特商法上の記載についても、決済ページと利用規約に「販売事業者:各店舗名(Merci は決済プラットフォームを提供する)」「商品:任意チップ」「返品・返金:決済完了後 30 日以内、Merci 管理画面から申請」「事業者所在地・連絡先」を明記しました。二段階認証は店舗さん側の Stripe アカウントで強制に設定しています(Stripe 側の推奨に従い、任意にはしなかった)。
未成年ユーザー・従業員への配慮
飲食業でチップとなると、労働基準法上の位置づけを整理する必要がありました。日本ではチップは慣習化していないため、雇用契約書に「サービス料・チップの帰属」条項がない店舗さんがほとんどです。Merci では以下の資料を店舗さん向けに用意しています。
- チップは店舗の売上として計上する:スタッフさん個人への贈与にすると、税務上の贈与税・所得税の整理が煩雑になります。店舗の売上として計上し、店舗の判断でスタッフさんへのインセンティブ手当として支給するのが最もクリーンです。
- 雇用契約書に追記するテンプレ条項:「サービス提供に対して顧客より支払われるチップは店舗の売上に帰属し、就業規則に基づき従業員に配分することがある」旨の一文を用意。社労士監修。
- 未成年アルバイトへの配慮:Merci の管理画面から「未成年スタッフには個別トークページを作成しない」オプションを提供。顔写真掲載は保護者同意書テンプレートを配布。
運用 3 ヶ月で分かった KPI
本番稼働(2026-04)から 3 ヶ月分の実測値です。数字はぼかしていますがオーダー感は正確です。
- 決済承認率:97.2%(3D Secure 対応済 / 主な失敗要因は与信限度額と海外カード)
- 返金率:0.4%(お客様の押し間違い / 二重送信)
- チャージバック率:0.05%(Stripe 警戒閾値 1% の 1/20)
- 1 決済あたり平均チップ額:623 円(想定 500 円をやや上回る、リピーターがまとめて送る事例あり)
- Stripe 実効手数料率:3.61%(ほぼドキュメント通り、AMEX 比率が想定より低かった)
- Merci 実効手数料率:4.82%(application_fee にも Stripe 手数料がかかるため公称 5% から少し目減り)
- Webhook 処理成功率:99.98%(残り 0.02% は外部依存タイムアウトで retry 済)
感覚として一番の意外だったのは、返金率が想定 2% に対して 0.4% と大幅に低かったことです。UI 上で「送信前確認モーダル」を丁寧に作った効果だと考えています。逆に承認率 97.2% は業界平均(決済業界全体で 92〜95%)よりかなり高く、Merci が扱う金額帯が小さい(数百円〜数千円)ため与信でハネられにくいのが効いていそうです。
今後の改善課題
- Apple Pay / Google Pay 対応:現状カード直接入力のみで、モバイル決済の 1 タップ化ができていません。Stripe Payment Element を導入すれば実装は 2 日程度ですが、Domain Verification(Apple Pay 用)の店舗ごと展開フローを詰め切れておらず未着手。優先度:高。
- 複数店舗持ちの本部管理:チェーン店舗さんから「本部で全店舗の売上を横串で見たい」との要望あり。Stripe Connect の「Account Groups」機能ではなく、Merci 側で「本部アカウント → 店舗アカウント N 対応」の権限モデルを実装予定。
- 会計ソフト連携:freee / マネーフォワードクラウドへの CSV / API 連携。現状は月次で店舗さんが手動 CSV ダウンロードしている状態。freee は Public API がしっかりしているのでこちらから着手予定。
- スタッフ個人トーク(QR)ページの多言語化:訪日観光客チップ需要のため、まずは英語・繁体字・簡体字・韓国語。決済フロー自体は多通貨対応が必要(現状 JPY のみ)。
- Separate Charges and Transfers への部分移行:将来「サービス料 → 店舗、チップ → スタッフ個人(法人化した個人事業主)」のような分配モデルを導入する場合、Destination charges では表現できないため。設計だけ先行検討中。
まとめ
チップ決済 SaaS は、技術的にはシンプルに見えて、金融・税務・法務・UX の各領域で細部が重要な難しさがあります。Merci が本番稼働まで辿り着いた要因を 5 つに集約すると:
- Stripe Connect Express を採用し、KYC・法令対応・受取人ダッシュボードを Stripe に委譲したこと
- Destination charges を選び、返金・手数料透明性・不正対応の主導権を Merci が保持したこと
- application_fee_amount の計算式と Stripe 実効手数料の関係を早期に理解し、公称手数料と実効手数料の乖離を CFO 役と共有できたこと
- Webhook を「署名検証 → 生保存 → 202 → 非同期処理」パターンにし、副作用の idempotency をドメインごとに管理したこと
- Stripe 審査に対して「扱う業種」「扱わない業種」「不正監視」を先手で明記し、資金決済法上の位置づけを弁護士メモで補強したこと
これから同種のプラットフォーム型決済 SaaS を作る方の参考になれば幸いです。個別具体の相談(Connect アカウントタイプ選定、審査書き方、Webhook 設計)は Merci 公式サイトの問い合わせフォームからお気軽にどうぞ。