Biz Setup Sepay Payment — エンドツーエンドのVietQR決済インフラ for Next.js
このスキルは、Next.jsランディングページ向けにSepay(ベトナムのVietQR決済ゲートウェイ)を使用した完全な決済インフラを構築します。完了後:顧客がフォームに入力 → 注文作成 → QRスキャン → 銀行振込 → Sepay Webhook受信 → 検証 + リードルックアップ + メール/Telegramトリガー。すべて自動化され、管理者による手動確認は不要です。
理念: Sepayはベトナム向けの最も抵抗の少ないパスです — ネイティブのVietQR + 銀行API、顧客は何も登録する必要がなく、既存の銀行アプリでQRをスキャンするだけ。手数料1.5〜2%はStripe(4〜5%)より安く、決済はT+1、月100件未満のランディングページには無料枠が適しています。
リードストアの理由: Sepay Webhookは
content(顧客が振込時に貼り付けたテキスト)と金額のみを送信します。Sepayは顧客が誰かを知りません。contentをリード{name, phone, email, product, amount}にマッピングするために、ローカルのリードストアが必要です。
2つのリードストアオプション — ユーザーはフェーズ0で選択:
- Vercel KV (Upstash Redis) — セットアップ2分、ネイティブTTL、無料枠月30Kコマンド → 月500件未満
- Supabase Free (Postgres) — セットアップ5分、SQLクエリ + Studio UI、無料枠は約50倍広い → 月2000件未満。推奨。
APIルート(/api/checkout、/api/sepay-webhook、/api/admin/leads)はlib/leads-store.tsの薄い再エクスポートを介してプロバイダーに依存しません。後でプロバイダーを変更するには、1行を変更するだけです。
このスキルはユーザーに代わってSepayアカウントを登録しません(KYC事業者 + 銀行が必要です)。スキルはユーザーをステップバイステップでガイドし、その後すべてのコードを配線します。
このスキルを使うタイミング
- ユーザーがデジタル製品/コース/コーチングをローンチし、オンラインで支払いを受け取る必要がある場合。
- ユーザーがすでにNext.jsランディングページ(
ui-ux-pro-maxなどで作成)を持っており、決済フローを追加したい場合。 - ユーザーが
biz-email-setupまたはbiz-telegram-payment-notifyを配線する準備をしているが、まだ決済インフラがない場合 → このスキルが前提条件となります。
以下の場合はこのスキルを使用しないでください:
- ユーザーがStripe/PayPal(国際カード決済)を使用している場合 → 別のユースケース。
- ユーザーが手動の銀行振込 + 自分で確認を受け入れる場合(低ボリューム、推奨しません)。
- ユーザーがすでに決済インフラを持っており、通知のみが不足している場合 → 直接
/biz-email-setupまたは/biz-telegram-payment-notifyへ。
ワークフロー概要(7フェーズ)
Phase 0: Next.jsスタックの検出 + プロバイダー(KV / Supabase)の選択 — ゲート
↓
Phase 1: ユーザーのSepayアカウントセットアップをガイド — 手動ゲート、ユーザーの完了を待つ
↓
Phase 2A (KVの場合): Vercel KVのセットアップ(パッケージインストール + 環境変数 + KVネームスペース)
Phase 2B (Supabaseの場合): Supabaseのセットアップ(プロジェクト作成 + マイグレーションSQL + service_role)
↓
Phase 3: lib/leads-store.ts (再エクスポートラッパー) + lib/leads-{kv|supabase}.ts (実装) のスキャフォールド
↓
Phase 4: /api/checkoutルートのスキャフォールド(フォーム → 注文 → 保存)
↓
Phase 5: /api/sepay-webhookルートのスキャフォールド(検証 + ルックアップ + サイドエフェクトプレースホルダー)
↓
Phase 6: ランディングページへのVietQR埋め込み(3つのUXパターンから1つ選択)
↓
Phase 7: 5段階のテスト計画(ストア → チェックアウト → Webhook → 本番 → (Supabaseのみ) cron ping)
フェーズ0 + 1にはユーザーを待つゲートがあります。フェーズ2/3/4/5/6ではスキルが能動的に動作します。
フェーズ0 — Next.jsスタックの検出 + プロバイダーの選択
0.1 スタックの検出
test -f next.config.js -o -f next.config.mjs -o -f next.config.ts && echo "Next.js: ✓"
test -d app && echo "Router: App"
test -d pages && echo "Router: Pages"
test -f tsconfig.json && echo "TS: ✓"
test -f pnpm-lock.yaml && echo "pkg: pnpm" || test -f yarn.lock && echo "pkg: yarn" || echo "pkg: npm"
Next.jsでない場合: 停止。報告:「このスキルは現在Next.jsのみをサポートしています。現在のスタックは[X]です。Vite/静的 + Vercel関数にロジックを移植しますか?」 — ユーザーを待つ。
app/とpages/の両方がある場合: ユーザーにどちらを使用するか尋ねる。
0.2 既存インフラの確認
ls app/api/ pages/api/ lib/ 2>/dev/null
grep -E '"@vercel/kv"|"@supabase/supabase-js"|"@upstash/redis"' package.json
grep -E "SEPAY|KV_REST|SUPABASE_" .env.local 2>/dev/null
0.3 ユーザーにプロバイダーを尋ねる
お客様がNext.js [App/Pages] Router + TypeScript + [pnpm/yarn/npm]を使用していることを検出しました。
リードストアをどこに保存しますか?
1. Vercel KV (Upstash Redis)
✓ セットアップが速い(2分、Vercelダッシュボードで1クリック)
✓ ネイティブTTL — プロジェクトが一時停止する心配なし
✗ 無料枠は月30Kコマンドのみ → 月約500件
✗ SQLがない — 管理フィルターが複雑で難しい
2. Supabase Free (Postgres) — 推奨
✓ 無料枠が最も広い(500MB DB + 5GB帯域幅、クエリ無制限)
✓ Postgres = 強力なSQLクエリ、Supabase Studio UIでデータを美しく表示
✓ タイプセーフなTypeScript自動生成
✓ セットアップ5分(コンソール + 2つの環境変数を貼り付け)
⚠ 7日間無操作後、プロジェクトが自動一時停止 → Vercel Cronで`/api/health`に6日ごとにpingを送信して修正
⚠ TTLはpg_cronで手動(マイグレーションSQLで既に配線済み)
→ 月2000件未満に適しており、管理機能の拡張が容易
1と2のどちらを選びますか?
ゲート: ユーザーが選択するまでフェーズ1に進まない。デフォルトはSupabaseを提案するが、明示的に選択させる。
ユーザーが選択したら、計画を要約する:
**[Vercel KV / Supabase]を選択されました。以下の手順を実行します:
- Sepayアカウントのセットアップ方法を案内(5〜10分)
- 選択したプロバイダーに従ってリードストアをセットアップ
- コードを配線:lib/leads-store.ts + /api/checkout + /api/sepay-webhook + VietQR埋め込み
- テスト
フェーズ1に進む準備はできていますか?**
フェーズ1 — ユーザーのSepayアカウントセットアップをガイド
完全なガイドについてはreferences/sepay-account-setup.mdを参照。ユーザー向けに5つのステップを要約:
ステップ1 — my.sepay.vnに登録
- https://my.sepay.vn にアクセス → 登録
- 電話番号、メール、パスワードを入力 → OTP確認
- プロフィール完了:氏名 + CCCD/CMND(個人)または Tax Code(法人)
ステップ2 — 銀行口座をリンク
ダッシュボード → 銀行口座 → 口座を追加:
- 銀行を選択(Vietcombank、Techcombank、MB、ACB、VPBank、BIDV、VietinBank、TPBankなど)
- 口座番号 + 口座名義人(銀行アプリと一致)を入力
- 自動確認(銀行API経由)または手動確認(Sepayが指定する確認コードで1,000〜10,000VNDを送金)
ステップ3 — APIキーを取得
ダッシュボード → 設定 → API & Webhook → 新しいAPIキーを作成:
- 名前:
landing-page-prod - 権限:
webhook:read - キーをコピー(1回のみ表示)
ステップ4 — Webhook URLを設定
同じページ → Webhook → Webhookを追加:
- URL:
https://yourdomain.vn/api/sepay-webhook(デプロイ後に有効) - 認証:API Key → 作成したキーを貼り付け
- イベント:
transaction:incoming - 保存
⚠️ ローカル開発:Sepayテストのためにngrokを使用して
localhost:3000を公開。
ステップ5 — スキルに貼り付け
SEPAY_WEBHOOK_API_KEY=sk_xxxxxxxxxxxxxxxxxxxx
SEPAY_BANK_ACCOUNT_NUMBER=1023456789
SEPAY_BANK_NAME=Vietcombank
SEPAY_ACCOUNT_NAME=NGUYEN VAN A
ゲート: ユーザーが十分に貼り付けるまでフェーズ2に進まない。
フェーズ2A — Vercel KVのセットアップ(フェーズ0でユーザーがKVを選択した場合のみ)
Vercel KV = managed Upstash Redis。無料枠は月30Kコマンド。
2A.1 KVネームスペースを作成
Vercelダッシュボード → プロジェクト → Storage → Create Database → KV (Redis) → 名前sepay-leads → Create。
4つの環境変数が自動生成される:
KV_URL=...
KV_REST_API_URL=...
KV_REST_API_TOKEN=...
KV_REST_API_READ_ONLY_TOKEN=...
Connect to Projectをクリック → Vercelが環境変数を注入(Production + Preview + Development)。
2A.2 環境変数をローカルにプル
npm i -g vercel # まだの場合
vercel link
vercel env pull .env.local
2A.3 パッケージをインストール
pnpm add @vercel/kv # または yarn / npm install
2A.4 確認
node -e "
const { kv } = require('@vercel/kv');
kv.set('test', 'hello').then(() => kv.get('test')).then(v => {
console.log('KV OK:', v);
return kv.del('test');
});
"
KV OK: helloが出力されることを期待。フェーズ3へ。
フェーズ2B — Supabase Freeのセットアップ(フェーズ0でユーザーがSupabaseを選択した場合のみ)
完全な6ステップの詳細ガイドについてはreferences/supabase-setup.mdを参照。要約:
2B.1 Supabaseアカウントとプロジェクトを作成
- https://supabase.com → Start your project → GitHubでログイン
- New project:
- 名前:
sepay-leads-<brand> - Database Password:Generateをクリック → 保存
- リージョン:⚠️ 東南アジア (シンガポール)(デフォルトのus-eastはレイテンシ250ms以上)
- 料金プラン:Free
- 名前:
- プロビジョニングに約2分待つ
2B.2 マイグレーションSQLを実行
サイドバー → SQL Editor → New query → templates/supabase-migration.sqlの内容を貼り付け → Run。
4つのテーブル + インデックス + next_order_id()関数 + RLS有効化 + pg_cron毎日のクリーンアップジョブを作成。
出力を確認:4つのテーブル(leads/phone_index/order_counter/webhook_dedup)+ 1つのpg_cronジョブ + next_order_id()が「DH000001」を返す。
カウンターをリセット(確認によりカウンターが増加したため):
UPDATE order_counter SET current_value = 0 WHERE id = 1;
DELETE FROM leads WHERE order_id = 'DH000001';
2B.3 API認証情報を取得
Project Settings → API:
- Project URLをコピー(
https://xxxxx.supabase.co) service_roleシークレットをコピー(anonキーではない — service_roleはRLSをバイパスし、サーバー側で使用)
2B.4 環境変数を貼り付け
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJI...
スキルが自動確認:
- URLが
^https://[a-z0-9]+\.supabase\.co$に一致するか - キーがJWTをデコードし、
role: "service_role"を確認(anonではない)
間違っている場合 → エラーを報告し、再貼り付けを求める。
2B.5 パッケージをインストール
pnpm add @supabase/supabase-js
2B.6 Vercel Cron pingを配線(一時停止防止)
Supabase Freeは7日間の無操作後、自動一時停止。回避策:6日ごとに/api/healthにpingを送信。
スキルがapp/api/health/route.ts(templates/api-health-route.tsから)をスキャフォールドし、vercel.jsonに以下を追加:
{
"crons": [
{ "path": "/api/health", "schedule": "0 3 */6 * *" }
]
}
⚠️ Vercel Cron Hobbyは月2 cronに制限。*/6 daysパターン = 月約5回 → クォータ超過。オプション:
- Vercel Pro(月20ドル)— 無制限のcron
- GitHub Actionsワークフロー
.github/workflows/keep-alive.yml(無料)— リポジトリがGitHubの場合、スキルがスキャフォールド - cron-job.org(無料)— 手動セットアップ、URL
/api/healthを貼り付け
スキルはデフォルトで、リポジトリにGitHubリモートがある.git/configがある場合、オプション2(GitHub Actions)を選択。それ以外の場合は、オプション3の手順を出力。
2B.7 確認
node -e "
const { createClient } = require('@supabase/supabase-js');
const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
sb.from('order_counter').select('current_value').eq('id', 1).single().then(r => {
if (r.error) { console.error('FAIL:', r.error.message); process.exit(1); }
console.log('Supabase OK. current_value =', r.data.current_value);
});
"
Supabase OK. current_value = 0が出力されることを期待。フェーズ3へ。
フェーズ3 — lib/leads-store.ts + 実装のスキャフォールド
プロバイダー抽象化:APIルートはlib/leads-store.tsからインポートし、基盤がKVかPostgresか認識しない。
KVが選択された場合(フェーズ0)
2つのファイルをスキャフォールド:
lib/leads-store.ts←templates/lib-leads-store-kv.tsをコピー(1行export * from './leads-kv')lib/leads-kv.ts←templates/lib-leads-kv.tsをコピー(KV実装、約160行)
Supabaseが選択された場合
3つのファイルをスキャフォールド:
lib/leads-store.ts←templates/lib-leads-store-supabase.tsをコピー(Supabaseを再エクスポート)lib/leads-supabase.ts←templates/lib-leads-supabase.tsをコピー(Postgres実装 + 管理者向けlistLeads)lib/supabase-admin.ts←templates/lib-supabase-admin.tsをコピー(service_roleでキャッシュされた管理者クライアントシングルトン)
共通APIサーフェス(両方の実装):
createLead(input) → { orderId, lead }— アトミックID + 挿入 + セカンダリインデックスgetLeadByOrderId(orderId) → Lead | nullgetLeadByPhone(phone) → Lead | null— フォールバックルックアップfindPendingLeadByAmountAndTime(amount, start, end) → Lead[]— 戦略3 WebhookマッチングmarkLeadPaid(orderId, payment) → Lead | nullisTransactionProcessed(sepayId) → boolean— 重複排除markTransactionProcessed(sepayId) → void
TTL:保留中7日 / 完了済み90日。KVはex:パラメータ、Supabaseはexpire_atフィールド + pg_cron毎日の削除。
後でプロバイダーを変更するには、leads-store.tsの1行を変更するだけ(APIコードには触れない)。
詳細な根拠についてはreferences/leads-kv-schema.md(KV)またはreferences/supabase-leads-schema.md(Supabase)を参照。
フェーズ4 — /api/checkoutルートのスキャフォールド
templates/api-checkout-app-router.ts(App Router)またはtemplates/api-checkout-pages-router.ts(Pages Router)を読み、対応するパスにコピー。
両方のテンプレートはimport { createLead } from '@/lib/leads-store'を使用 — プロバイダーに依存しない。
フロー:
Client POST /api/checkout { name, phone, email, productName, amount }
↓
Server:
1. 検証(電話VN形式、メール正規表現、amount > 0)
2. createLead() → { orderId: "DH000123" }
3. Sepay QR URLを生成:https://qr.sepay.vn/img?acc=X&bank=Y&amount=Z&des=DH000123&template=compact
4. { orderId, amount, bankInfo, content, qrUrl }を返す
↓
Client: QRモーダルを表示 または /checkout/DH000123にリダイレクト
フェーズ5 — /api/sepay-webhookルートのスキャフォールド
templates/api-sepay-webhook-app-router.tsを読み、app/api/sepay-webhook/route.tsにコピー。
テンプレートはimport ... from '@/lib/leads-store'を使用 — プロバイダーに依存しない。
フロー:
Sepay POST /api/sepay-webhook
Authorization: Apikey {SEPAY_WEBHOOK_API_KEY}
Body: SepayWebhookPayload
↓
Server:
1. timingSafeEqualで認証を確認 — タイミング攻撃対策
2. 早期重複排除:isTransactionProcessed(payload.id) → 早期に200を返す
3. 最初に重複排除を記録 — 処理が失敗しても冪等
4. transferType === 'in'でフィルタリング(送金を除外)
5. マルチ戦略マッチング:
a. content → order_id(正規表現 /DH\d+/)
b. content → phone(正規表現 /0\d{9}/)→ getLeadByPhone
c. amount + タイムスタンプ ±30分 → findPendingLeadByAmountAndTime(長さが1の場合のみ受け入れ)
6. 過少支払いは拒否、過剰支払いは受け入れ
7. markLeadPaid(orderId, payment) → ストアを更新
8. try/catchループでサイドエフェクトをファンアウト(Promise.allは使用しない)
9. 常に200を返す(内部エラーでも) — Sepayはnon-200の場合、Fibonacci再試行を7回実行
サイドエフェクトのプレースホルダーは、後でbiz-email-setup + biz-telegram-payment-notifyを配線するためのもの。
重複排除の根拠と再試行動作についてはreferences/sepay-webhook-flow.mdを参照。
フェーズ6 — ランディングページへのVietQR埋め込み
3つのUXパターン。ユーザーに1つ選択してもらう:
| パターン | いつ使うか | UX |
|---|---|---|
| A. モーダルポップアップ | 単一製品 / 衝動買い | フォーム送信 → 元のページにQRモーダルポップアップ |
| B. /checkout/[orderId]ページ | 複数製品 / URL共有が必要 |
…[翻訳のため省略]
フェーズ7 — 5段階のテスト計画
以下の5つのレベルを順番に実行:
レベル1 — ストア単体テスト(1分)
# KV
node -e "
const { createLead, getLeadByOrderId, markLeadPaid } = require('./lib/leads-kv');
createLead({ name:'Test', phone:'0987654321', email:'[email protected]', product:'Test', amount:100000 })
.then(r => { console.log('Order:', r.orderId); return getLeadByOrderId(r.orderId); })
.then(r => { console.log('Get:', r ? r.name : 'NOT FOUND'); return markLeadPaid(r.orderId, { sepayId: 'test123', amount: 100000 }); })
.then(r => { console.log('Paid:', r.name); });
"
# Supabase
node -e "
const { createLead, getLeadByOrderId, markLeadPaid } = require('./lib/leads-supabase');
// ... 同様
"
レベル2 — チェックアウトAPIテスト(1分)
curl -X POST http://localhost:3000/api/checkout \
-H 'Content-Type: application/json' \
-d '{"name":"Test","phone":"0987654321","email":"[email protected]","productName":"Test","amount":100000}' | jq .
出力を確認:orderId、qrUrl、contentを含む。
レベル3 — Webhookシミュレーションテスト(2分)
templates/sepay-webhook-test-payload.jsonを読み込む。以下を実行:
# contentにorder_idが含まれる場合
curl -X POST http://localhost:3000/api/sepay-webhook \
-H 'Authorization: Apikey YOUR_SEPAY_WEBHOOK_API_KEY' \
-H 'Content-Type: application/json' \
-d @- << 'EOF'
{
"id": "test-webhook-1",
"transferType": "in",
"content": "DH000001",
"amount": 100000,
"transactionDate": "$(date -u +'%Y-%m-%d %H:%M:%S')"
}
EOF
出力が200 + matched: trueであることを確認。次に、重複排除をテスト:
curl ... -d '{"id":"test-webhook-1",...}' # 重複 → 200 + dedup:true
レベル4 — 本番デプロイテスト(5分)
vercel deploy --prodでデプロイ。以下を確認:
- チェックアウトエンドポイントが期待通りに応答する
- Webhookエンドポイントが
https://yourdomain.vercel.app/api/sepay-webhookでアクセス可能 - ngrokでローカルテストした場合:本番URLでWebhookを再設定
レベル5 — (Supabaseのみ)Cron pingテスト(1分)
curl https://yourdomain.vercel.app/api/health
ステータス200 + DB接続成功を確認。vercel.jsonのcron設定を確認:
cat vercel.json | grep -A3 crons
スキルの制限と引き継ぎメモ
| 項目 | 制限 | 回避策 |
|---|---|---|
| 顧客UI | スキルはエンドユーザー向け支払いUIを構築しない | 既存のランディングページで動作するよう設計 |
| Sepay登録 | できません。ユーザー手動 | ガイド提供 |
| クレジットカード | Sepayはベトナム銀行のみ(一部国際カード対応) | Stripe用の別スキルが必要 |
| 多通貨 | ベトナムドンのみ | 変換は別途処理 |
| Stripeのようなサブスクリプション | なし。一括支払いのみ | 必要な場合はStripe用スキルを使用 |
引き継ぎ(次の開発者へ):
- 本番URL:
https://yourdomain.vercel.app(ユーザーの実際のドメインに置き換え) - SepayダッシュボードのWebhook URLは必ずライブデプロイを指すようにすること
- 重複排除テーブル
webhook_dedupは24時間後に自動クリーンアップ - リードストアプロバイダーを切り替えるには、
lib/leads-store.tsのimport行を1行変更
付録A — テンプレートファイルリファレンス
| テンプレート | パス | 説明 |
|---|---|---|
templates/supabase-migration.sql | SQL Editor | 4つのテーブル + pg_cronでマイグレーション |
templates/api-health-route.ts | app/api/health/route.ts | 自動一時停止防止用ヘルスチェックエンドポイント |
templates/lib-leads-store-kv.ts | lib/leads-store.ts | KVプロバイダー再エクスポート(1行) |
templates/lib-leads-store-supabase.ts | lib/leads-store.ts | Supabaseプロバイダー再エクスポート(1行) |
templates/lib-leads-kv.ts | lib/leads-kv.ts | KV完全実装(160行) |
templates/lib-leads-supabase.ts | lib/leads-supabase.ts | Postgres完全実装 + リスト(250行) |
templates/lib-supabase-admin.ts | lib/supabase-admin.ts | 管理クライアントシングルトン |
templates/api-checkout-app-router.ts | app/api/checkout/route.ts | App Routerルート |
templates/api-checkout-pages-router.ts | pages/api/checkout.ts | Pages Routerルート |
templates/api-sepay-webhook-app-router.ts | app/api/sepay-webhook/route.ts | Webhook App Routerルート |
templates/sepay-webhook-test-payload.json | — | Webhookテスト用ペイロードサンプル |
付録B — 環境変数クイックリファレンス
# Sepay
SEPAY_WEBHOOK_API_KEY=sk_...
SEPAY_BANK_ACCOUNT_NUMBER=1023456789
SEPAY_BANK_NAME=Vietcombank
SEPAY_ACCOUNT_NAME=NGUYEN VAN A
# Vercel KV(フェーズ0でKVを選択した場合)
KV_URL=...
KV_REST_API_URL=...
KV_REST_API_TOKEN=...
KV_REST_API_READ_ONLY_TOKEN=...
# Supabase(フェーズ0でSupabaseを選択した場合)
SUPABASE_URL=https://xxxxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJI...
重要:すべての.env.local + Vercelで設定済みか確認すること。