Biz Setup Sepay Payment — 為 Next.js 提供端到端 VietQR 支付基礎設施
此技能為使用 Sepay(越南 VietQR 支付網關)的 Next.js 登陸頁面建立完整的支付基礎設施。完成後:客戶填寫表單 → 建立訂單 → 掃描 QR 碼 → 轉帳 → Sepay Webhook 回呼 → 驗證 + 查詢潛在客戶 + 觸發 Email/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 個階段)
第 0 階段:偵測 Next.js 技術棧 + 詢問提供者(KV / Supabase)— 閘道
↓
第 1 階段:引導使用者設定 Sepay 帳戶 — 手動閘道,等待使用者完成
↓
第 2A 階段(如果選擇 KV):設定 Vercel KV(安裝套件 + 環境變數 + KV 命名空間)
第 2B 階段(如果選擇 Supabase):設定 Supabase(建立專案 + 遷移 SQL + service_role)
↓
第 3 階段:建立 lib/leads-store.ts(重新匯出的包裝層)+ lib/leads-{kv|supabase}.ts(實作)
↓
第 4 階段:建立 /api/checkout 路由(表單 → 訂單 → 儲存)
↓
第 5 階段:建立 /api/sepay-webhook 路由(驗證 + 查詢 + 副作用佔位符)
↓
第 6 階段:將 VietQR 嵌入登陸頁面(選擇 1 種 UX 模式)
↓
第 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 Function?」 — 等待使用者。
如果同時存在 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 儀表板一鍵完成)
✓ 原生 TTL — 無需擔心專案暫停
✗ 免費方案僅 30K 命令/月 → 約 500 筆訂單/月
✗ 沒有 SQL — 管理員過濾複雜且困難
2. Supabase Free (Postgres) — 建議使用
✓ 最寬裕的免費方案(500MB 資料庫 + 5GB 頻寬,無限查詢)
✓ Postgres = 強大的 SQL 查詢,Supabase Studio UI 美觀,方便檢視資料
✓ Type-safe TypeScript 自動生成
✓ 設定 5 分鐘(控制台 + 貼上 2 個環境變數)
⚠ 專案在靜止 7 天後會自動暫停 → 我會連接 Vercel Cron 每 6 天 ping `/api/health` 來解決
⚠ 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
- 完成個人資料:姓名 + 身分證號碼/居留證號碼(個人)或統一編號(公司)
步驟 2 — 連結銀行帳戶
儀表板 → 銀行帳戶 → 新增帳戶:
- 選擇銀行(Vietcombank, Techcombank, MB, ACB, VPBank, BIDV, VietinBank, TPBank, ...)
- 輸入帳號 + 戶名(需與銀行應用程式一致)
- 自動驗證(透過銀行 API)或手動驗證(轉帳 1,000-10,000 越南盾,並附上 Sepay 提供的驗證碼)
步驟 3 — 取得 API 金鑰
儀表板 → 設定 → API & Webhook → 建立新的 API 金鑰:
- 名稱:
landing-page-prod - 權限:
webhook:read - 複製金鑰(僅顯示一次)
步驟 4 — 設定 Webhook URL
同一頁面 → Webhook → 新增 Webhook:
- URL:
https://yourdomain.vn/api/sepay-webhook(部署後生效) - 驗證:API 金鑰 → 貼上剛建立的金鑰
- 事件:
transaction:incoming - 儲存
⚠️ 本地開發:使用 ngrok 將
localhost:3000暴露給 Sepay 進行測試。
步驟 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 = Upstash Redis 託管服務。每月免費 30K 命令。
2A.1 建立 KV 命名空間
Vercel 儀表板 → 專案 → Storage → Create Database → KV (Redis) → 名稱 sepay-leads → 建立。
自動產生 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 時)
請閱讀 references/supabase-setup.md 取得包含 6 個步驟的完整詳細指南。摘要:
2B.1 建立 Supabase 帳戶 + 專案
- https://supabase.com → Start your project → 使用 GitHub 登入
- New project:
- Name:
sepay-leads-<品牌名稱> - Database Password:點擊 Generate → 儲存
- Region:⚠️ Southeast Asia (Singapore)(預設美東地區延遲 250ms+)
- Pricing:Free
- Name:
- 等待約 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_rolesecret(不是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 天 ping /api/health。
技能會建立 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
如果倉庫的 .git/config 包含 GitHub 遠端,技能預設選擇選項 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 每日刪除。
之後更換提供者 = 修改 1 行 leads-store.ts(不要碰 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' — 提供者無關。
流程:
客戶端 POST /api/checkout { name, phone, email, productName, amount }
↓
伺服器:
1. 驗證(越南電話格式、電子郵件正規表達式、金額 > 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 }
↓
客戶端:顯示 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
↓
伺服器:
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 在非 200 回應時會使用費氏數列重試 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 | 重新導向到專用結帳頁面 |
| C. 嵌入式區塊 | 高信任度 / 長期產品 | 頁面內直接顯示 QR 碼(低轉換) |
技能會詢問使用者的偏好,然後根據選擇的範本建立元件。
第 7 階段 — 測試計劃
5 級測試,從單元到生產環境。每次測試後都提示使用者確認:
級別 1 — 儲存測試
node -e "
const store = require('./lib/leads-store');
store.createLead({ name: '測試客戶', phone: '0912345678', email: '[email protected]', productName: 'KHÓA HỌC PRO', amount: 500000 })
.then(r => console.log('建立 OK:', r.orderId))
.then(() => store.getLeadByOrderId('DH000001'))
.then(l => console.log('查詢 OK:', l?.name))
.catch(e => console.error('FAIL:', e));
"
級別 2 — 結帳流程測試
curl -X POST http://localhost:3000/api/checkout \
-H 'Content-Type: application/json' \
-d '{ "name":"Nguyễn Văn A", "phone":"0912345678", "email":"[email protected]", "productName":"KHÓA HỌC", "amount":500000 }'
期望回應:{ orderId: "DH000001", qrUrl: "...", ... }
級別 3 — Webhook 測試
# 模擬 Sepay webhook(從實際交易中獲取 payload)
curl -X POST http://localhost:3000/api/sepay-webhook \
-H 'Authorization: Apikey {SEPAY_WEBHOOK_API_KEY}' \
-H 'Content-Type: application/json' \
-d '{
"id": 12345,
"transferType": "in",
"content": "DH000001",
"amount": 500000,
"transactionDate": "2024-01-15T10:30:00Z"
}'
期望回應:200 + 資料庫中 leads 的 status 欄位變更為 paid。
級別 4 — 生產環境部署測試
# 部署到 Vercel
npx vercel --prod
git push origin main
# 測試生產環境結帳
curl -X POST https://yourdomain.vercel.app/api/checkout ...
級別 5 — Cron ping(僅 Supabase)
預設 GitHub Actions:在工作流程執行後,檢查 https://yourdomain.vercel.app/api/health 是否返回 200。
環境變數摘要
| 變數 | 必要 | 階段 | 來源 |
|---|---|---|---|
SEPAY_WEBHOOK_API_KEY | ✓ | 1 | Sepay 儀表板 |
SEPAY_BANK_ACCOUNT_NUMBER | ✓ | 1 | 使用者銀行 |
SEPAY_BANK_NAME | ✓ | 1 | 使用者銀行 |
SEPAY_ACCOUNT_NAME | ✓ | 1 | 使用者銀行 |
KV_URL | KV 需要 | 2A | Vercel KV |
KV_REST_API_URL | KV 需要 | 2A | Vercel KV |
KV_REST_API_TOKEN | KV 需要 | 2A | Vercel KV |
SUPABASE_URL | Supabase 需要 | 2B | Supabase API 設定 |
SUPABASE_SERVICE_ROLE_KEY | Supabase 需要 | 2B | Supabase API 設定 |
連接到其他技能
完成此技能後,使用者可以連接通知:
biz-email-setup— 新增lead-paid事件到/api/sepay-webhook中的 try/catch 迴圈biz-telegram-payment-notify— 新增 Telegram 機器人發送付款通知biz-sendgrid/biz-resend— 發送發票電子郵件