登入與金流:收得到錢才是產品
做完前兩課之後,你手上有一個驗證過需求、選好技術棧的 AI SaaS 雛形。這時候最常出現一種幻覺:「等功能做完再來想金流」。這是個陷阱——金流和登入不是最後一哩,它們是整個產品的地基。所有的方案判斷、使用量限制、UI 狀態、API 保護,全部從「這個使用者是誰、他付了多少錢」出發。越晚做,越難插進去。
第二個陷阱是以為金流很簡單,直接抄教學貼上去就好。但 2026 年台灣做 SaaS 有幾個現實你必須先知道:Stripe 在台灣的申請限制、藍新/綠界和 Stripe 的適用場景差異、webhook 的狀態同步機制——這些細節搞錯,輕則使用者付完錢沒開通,重則一個月後自動續費失敗你渾然不知。這堂課把這些坑都翻出來,在你踩到之前。
這堂學什麼
- Supabase Auth 完整設定:email/password + Google OAuth,搭配 Next.js SSR 的 PKCE 流程
- 訂閱資料庫 schema 設計:users / subscriptions 兩張表如何關聯、欄位怎麼規劃
- 台灣 Stripe 現況:為什麼直接申請行不通、替代方案(藍新、綠界)優缺點比較
- Stripe 訂閱流程:建 Product & Price、產生 Checkout Session、Portal 讓使用者自己管訂閱
- Webhook 處理訂閱狀態:哪些事件必聽、如何驗簽名、狀態機怎麼設計
- 存取控制中介層:每個 API route 怎麼判斷「這個人有沒有資格用這個功能」
觀念一:三層門檻,缺一不可
金流串接不是「在結帳頁加個付款按鈕」這麼簡單。它背後是三層機制環環相扣:

Auth 層:使用者是誰。Supabase Auth 負責。沒有穩固的身分識別,後面兩層都是空中樓閣。
Billing 層:這個人付了多少錢、付的是哪個方案、什麼時候到期。Stripe 或台灣金流負責,結果同步進你自己的資料庫(不能每次都打 API 問 Stripe,太慢也有 rate limit 風險)。
Access 層:這個人能用什麼功能。Next.js middleware 或每個 API route 的開頭判斷。判斷依據來自你資料庫裡的 subscription status,不是每次去問 Stripe。
三層缺一層,一定出問題。最常見的死法是:Auth 做好了,Billing 也串了,結果 Access 層沒寫——付費使用者和免費使用者用同一套功能,錢白收了。
觀念二:台灣的 Stripe 現實
在寫任何程式之前,先把這件事說清楚:2026 年 Stripe 官方並不支援台灣本地申請。Stripe 的支援國家清單裡沒有台灣,這意味著你無法以台灣個人或公司的身分直接開 Stripe 帳號。
你有幾條路:
路線 A:設立海外公司(長期做 SaaS 的正解)。最常見是設美國 LLC 或香港公司,再用公司名義申請 Stripe。費用從一萬台幣起跳,流程約兩到四週。如果你打算認真做、長期收美金,這是最乾淨的做法。Stripe 手續費約 2.9% + $0.30/筆(美金),訂閱扣款自動、API 完整度最高。
路線 B:藍新金流(NewebPay)或綠界(ECPay)——台灣本地解。台灣公司或個人工作室可以直接申請。支援信用卡定期定額(相當於 Stripe 的 subscription),也支援 ATM 轉帳、超商代碼等本地付款方式。費率:綠界信用卡 2.75%、藍新 2.8%,撥款週期 7~14 天(比 Stripe 慢)。缺點是 API 文件較繁瑣,沒有 Stripe 那種開發者體驗;定期定額的 webhook 機制也沒 Stripe 完整。
路線 C:雙軌。先用藍新/綠界讓台灣用戶付款,未來設海外公司後加上 Stripe 收美金用戶。這是從台灣市場驗證、再走向國際的常見路徑。

本課以 Stripe 為主示範實作邏輯(webhook 設計、狀態機、middleware),這套思路換到藍新或綠界也完全適用。
觀念三:方案與額度模型設計
在寫任何程式碼之前,先把你的定價模型設計清楚。2026 年 AI SaaS 市場超過六成的產品採用「基礎訂閱 + 用量超額計費」的混合模型。純訂閱制最簡單,但使用者用量差異大的 AI 產品很容易賠錢;純用量計費讓使用者無法預測費用,轉換率低。
建議新手從三方案模型出發:

資料庫裡的方案用一個 plan 字串欄位(free/pro/team)加上 ai_calls_used 和 ai_calls_limit 兩個整數欄位,邏輯清楚又好查。不要一開始就設計太複雜的 Credit 系統,先讓「月費 → 開功能」跑通再說。
手把手實戰
步驟一:Supabase Auth 設定(email + Google OAuth)
先到 supabase.com 建一個新專案。建好後進 Dashboard。
Email 登入:左側選「Authentication → Providers → Email」,確認已啟用。「Confirm email」建議開啟——使用者要點信才能登入,避免亂填 email 造成後續金流問題。
Google OAuth:先到 Google Cloud Console 建一個 OAuth 2.0 用戶端 ID(類型選「Web 應用程式」),在「已授權的重新導向 URI」填入:
https://<你的-supabase-project-id>.supabase.co/auth/v1/callback
拿到 Client ID 和 Client Secret 後,回 Supabase Dashboard → Authentication → Providers → Google,貼上去啟用。
前端呼叫 Google 登入只要兩行(以 @supabase/ssr 為例):
// app/login/actions.ts
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'
export async function signInWithGoogle() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (data.url) redirect(data.url)
}
Next.js App Router 搭配 Supabase 必須用 PKCE 流程(不是 implicit flow),原因是 SSR 環境不能讓 token 出現在 URL 裡——@supabase/ssr 套件已經幫你處理好了,用它就對了。另外需要建 /auth/callback 這個 route 來交換 code:
// app/auth/callback/route.ts
import { createClient } from '@/utils/supabase/server'
import { NextResponse } from 'next/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
if (code) {
const supabase = await createClient()
await supabase.auth.exchangeCodeForSession(code)
}
return NextResponse.redirect(`${origin}/dashboard`)
}
步驟二:訂閱資料庫 schema
在 Supabase SQL Editor 執行以下建表語句。這兩張表是整個金流系統的骨幹:
-- 使用者訂閱資料表
create table public.subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade not null,
stripe_customer_id text unique,
stripe_subscription_id text unique,
plan text not null default 'free', -- 'free' | 'pro' | 'team'
status text not null default 'active', -- 'active' | 'past_due' | 'canceled'
current_period_end timestamptz, -- 這個訂閱週期什麼時候到期
ai_calls_used integer not null default 0,
ai_calls_limit integer not null default 50, -- free 方案預設 50 次/月
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- 每次 AI 呼叫的用量紀錄(選做,方便日後分析)
create table public.usage_logs (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade not null,
feature text not null, -- 哪個功能
tokens_used integer default 0,
created_at timestamptz default now()
);
-- RLS:使用者只能看到自己的資料
alter table public.subscriptions enable row level security;
create policy "Users can view own subscription"
on public.subscriptions for select
using (auth.uid() = user_id);
注意幾件事:
stripe_customer_id和stripe_subscription_id是和 Stripe 的接點,webhook 收到事件時靠這兩個欄位找到對應的使用者紀錄current_period_end讓你不需要每次都打 Stripe API,本地就能判斷訂閱是否有效- Row Level Security 必須開,不然使用者可以改別人的訂閱狀態
步驟三:Stripe 建立 Product 與 Price
登入 Stripe Dashboard → Products → Add product。
建一個「Pro Plan」產品,定價模型選「Recurring」(訂閱),設定月費金額(例如 $9.99 美元或 299 台幣),Billing period 選 Monthly。記下 Price ID(格式像 price_1AbcDef...),後面程式碼會用到。
如果你有年費方案,同一個 Product 底下再加一個 Price,Billing period 改 Yearly 並給個折扣。一個 Product 多個 Price 是 Stripe 的標準做法。
裝好 Stripe SDK:
npm install stripe @stripe/stripe-js
設定環境變數:
# .env.local
STRIPE_SECRET_KEY=sk_test_... # 從 Stripe Dashboard → Developers → API Keys 拿
STRIPE_WEBHOOK_SECRET=whsec_... # 稍後設定 webhook 後才有
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
步驟四:建立 Checkout Session API
使用者點「升級方案」按鈕時,前端打這支 API,後端產生 Stripe Checkout Session 並回傳結帳網址:
// app/api/billing/checkout/route.ts
import Stripe from 'stripe'
import { createClient } from '@/utils/supabase/server'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId } = await request.json()
// 取出或建立 Stripe Customer
const { data: sub } = await supabase
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.single()
let customerId = sub?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { supabase_user_id: user.id },
})
customerId = customer.id
// 把 customer id 存進資料庫
await supabase.from('subscriptions').upsert({
user_id: user.id,
stripe_customer_id: customerId,
}, { onConflict: 'user_id' })
}
const session = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
mode: 'subscription',
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/pricing`,
})
return NextResponse.json({ url: session.url })
}
前端對應的按鈕:
// components/UpgradeButton.tsx
'use client'
export function UpgradeButton({ priceId }: { priceId: string }) {
async function handleUpgrade() {
const res = await fetch('/api/billing/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await res.json()
window.location.href = url // 轉到 Stripe Checkout 頁
}
return <button onClick={handleUpgrade}>升級 Pro 方案</button>
}

步驟五:Webhook 處理訂閱狀態(最關鍵的一步)
Webhook 是整個金流系統最容易出錯的地方。先在 Stripe Dashboard → Developers → Webhooks 新增 Endpoint,URL 填 https://你的網域/api/billing/webhook,事件選:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
拿到 Webhook signing secret(whsec_...)填進 .env.local。
// app/api/billing/webhook/route.ts
import Stripe from 'stripe'
import { createClient } from '@/utils/supabase/server'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// ⚠️ 必須 export config 關閉 body parser,Stripe 驗簽名需要原始 body
export const config = { api: { bodyParser: false } }
export async function POST(request: Request) {
const body = await request.text()
const signature = (await headers()).get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch {
// 簽名錯誤:不是 Stripe 發來的,拒絕
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
const supabase = await createClient()
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const customerId = session.customer as string
const subscriptionId = session.subscription as string
// 取出訂閱詳情
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
await syncSubscription(supabase, customerId, subscription)
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const customerId = subscription.customer as string
await syncSubscription(supabase, customerId, subscription)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
// 付款失敗:把 status 改成 past_due,可以寄通知信
await supabase
.from('subscriptions')
.update({ status: 'past_due' })
.eq('stripe_customer_id', invoice.customer as string)
break
}
}
return NextResponse.json({ received: true })
}
async function syncSubscription(
supabase: ReturnType<typeof createClient> extends Promise<infer T> ? T : never,
customerId: string,
subscription: Stripe.Subscription
) {
// 從 Price ID 判斷方案
const priceId = subscription.items.data[0].price.id
const plan = priceId === process.env.STRIPE_PRO_PRICE_ID ? 'pro' : 'free'
const limit = plan === 'pro' ? 1000 : 50
await supabase
.from('subscriptions')
.update({
stripe_subscription_id: subscription.id,
plan,
status: subscription.status, // 'active' | 'past_due' | 'canceled' | ...
current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
ai_calls_limit: limit,
updated_at: new Date().toISOString(),
})
.eq('stripe_customer_id', customerId)
}

步驟六:存取控制中介層
Webhook 把訂閱狀態同步進資料庫之後,最後一塊拼圖是保護你的 AI 功能 API。每次使用者呼叫 AI 功能前,先檢查他有沒有資格用、有沒有額度剩:
// utils/checkAccess.ts
import { createClient } from '@/utils/supabase/server'
import { NextResponse } from 'next/server'
export async function checkAccess(requiredPlan: 'free' | 'pro' | 'team' = 'free') {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { allowed: false, response: NextResponse.json({ error: '請先登入' }, { status: 401 }) }
}
const { data: sub } = await supabase
.from('subscriptions')
.select('plan, status, ai_calls_used, ai_calls_limit')
.eq('user_id', user.id)
.single()
// 沒有訂閱紀錄:當 free 方案處理
const plan = sub?.plan ?? 'free'
const status = sub?.status ?? 'active'
const used = sub?.ai_calls_used ?? 0
const limit = sub?.ai_calls_limit ?? 50
// 訂閱逾期或取消
if (status === 'canceled' || status === 'past_due') {
return { allowed: false, response: NextResponse.json({ error: '訂閱已到期,請更新付款方式' }, { status: 403 }) }
}
// 方案不夠高
const planHierarchy = { free: 0, pro: 1, team: 2 }
if (planHierarchy[plan as keyof typeof planHierarchy] < planHierarchy[requiredPlan]) {
return { allowed: false, response: NextResponse.json({ error: '需要升級方案才能使用此功能' }, { status: 403 }) }
}
// 額度用完
if (used >= limit) {
return { allowed: false, response: NextResponse.json({ error: 'AI 額度已用完,請等下個月或升級方案' }, { status: 429 }) }
}
return { allowed: true, userId: user.id, plan, used, limit }
}
在 AI 功能的 API route 裡使用:
// app/api/ai/summarize/route.ts
import { checkAccess } from '@/utils/checkAccess'
import { createClient } from '@/utils/supabase/server'
export async function POST(request: Request) {
const access = await checkAccess('free') // 這個功能 free 方案也能用
if (!access.allowed) return access.response!
// --- 在這裡執行真正的 AI 邏輯 ---
const { text } = await request.json()
// ... 呼叫 Claude API 或 OpenAI ...
const result = '...'
// 扣除額度
const supabase = await createClient()
await supabase
.from('subscriptions')
.update({ ai_calls_used: access.used + 1 })
.eq('user_id', access.userId)
return Response.json({ result })
}
到這裡,可以真實收費的最小流程就完整了:使用者登入 → 選方案 → Stripe Checkout 付款 → Webhook 同步狀態 → API 依方案開放功能。
常見坑
坑 1:Webhook 沒驗簽名,任何人都能偽造事件
症狀:訂閱狀態被亂改,或有人送假的 checkout.session.completed 事件讓自己免費升級 Pro。
解法:一定要用 stripe.webhooks.constructEvent(body, signature, secret) 驗簽名,失敗就回 400 拒絕。有兩個細節容易忘:一是 Next.js App Router 的 route handler 要用 request.text() 拿原始 body,不能用 request.json()(JSON 解析會破壞簽名所需的原始位元組);二是 .env.local 裡的 STRIPE_WEBHOOK_SECRET 要用 Stripe Dashboard 的 Webhook endpoint 專屬 secret(whsec_...),不是你的 API secret key。
坑 2:只監聽 checkout.session.completed,一個月後用戶全部失效
這是最常見的新手死法,而且症狀會在一個月後才爆出來。checkout.session.completed 只在使用者完成第一次結帳時觸發一次;之後每個月自動續費觸發的是 invoice.paid。如果你只聽第一個事件,資料庫裡的 current_period_end 永遠停在第一次付款,一個月後系統判斷到期,使用者就被強制降回免費方案。
解法:一定要同時監聽:
checkout.session.completed → 首次付款,建立訂閱紀錄
customer.subscription.updated → 方案變更、續費、降級
customer.subscription.deleted → 使用者取消
invoice.payment_failed → 付款失敗,標記 past_due
坑 3:Stripe 台灣帳號問題,申請到一半才發現
症狀:填完 Stripe 申請表到最後一步,被要求提供一個「Supported country」的銀行帳戶,台灣的帳戶直接被拒。
根本原因:Stripe 的支援國家清單不包含台灣,台灣的銀行帳戶無法作為撥款帳戶。
解法:如前面觀念二所說,三條路選一條。如果你是個人工作室、還沒設海外公司,現階段改用綠界或藍新;如果你打算做國際市場、收美金,就得去設 LLC 或香港公司再申請。不要等到「功能都做完了才來處理金流」,這件事必須在技術棧選定時就確認。
坑 4:subscription status 沒隨月份重設 ai_calls_used
症狀:Pro 用戶上個月的額度用完後,到了新的計費週期還是顯示「額度已用完」,但他明明已經繳了這個月的錢。
解法:在 customer.subscription.updated 的 webhook handler 裡,判斷如果 current_period_end 有更新(代表進入新週期),就把 ai_calls_used 歸零:
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription
const newPeriodEnd = new Date(subscription.current_period_end * 1000).toISOString()
// 若新週期結束時間 > 現有紀錄,代表已續費,重設用量
await supabase
.from('subscriptions')
.update({
current_period_end: newPeriodEnd,
ai_calls_used: 0, // 重設!
status: subscription.status,
})
.eq('stripe_subscription_id', subscription.id)
break
}
坑 5:本地開發無法收到 Stripe Webhook
症狀:本機跑 npm run dev,結帳後 Stripe 打的 webhook 打到 localhost,但 Stripe 是從網際網路打過來的,當然連不到你的電腦。
解法:用 Stripe CLI 把 webhook 轉到本機:
# 先安裝 Stripe CLI
brew install stripe/stripe-cli/stripe
# 登入並啟動 webhook 轉發
stripe login
stripe listen --forward-to localhost:3000/api/billing/webhook
這個指令跑起來後,Stripe CLI 會印出一個暫時的 webhook secret(whsec_...),把它貼到 .env.local 裡的 STRIPE_WEBHOOK_SECRET,這樣本機才能正確驗簽名。注意這個 secret 和 Dashboard 上的不同,部署到正式環境時要換回 Dashboard 的那個。
作業
- 完整跑一遍登入流程:用 email 和 Google OAuth 各登入一次,確認 Supabase Dashboard 的 Authentication → Users 頁有出現這兩筆使用者紀錄。
- 在 Stripe Dashboard 建立 Pro Plan 產品和月費 Price,用測試模式跑一次完整的結帳流程(用 Stripe 測試卡號
4242 4242 4242 4242,日期填未來任意、CVV 隨意三位),確認 webhook handler 把 subscription status 更新進 Supabase。 - 在你的 AI 功能 API route 裡加上
checkAccess()呼叫,用免費方案的帳號測試:超過 50 次額度後,API 應該回傳 429 並附上「AI 額度已用完」的訊息。 - 選做:如果你打算做台灣市場,研究藍新金流的「信用卡定期定額」API 文件(developers.newebpay.com),列出它和 Stripe 在 webhook 設計上的三個主要差異。
下一課預告
登入和金流做好之後,你的 SaaS 已經是一個可以真實收費的產品了。但大部分 AI SaaS 倒下去的原因不是收不到錢,而是 token 費用把利潤吃光。下一課(第 4 課:AI 功能設計——別讓 token 吃掉利潤)會進入 AI 功能層:怎麼設計 prompt 讓成本可預測、用 streaming 改善使用者體驗、在哪些地方加 cache 大幅降低 API 費用,以及如何設計 token budget——讓你的利潤率從開始就被保護好。