付款與方案
Stripe Checkout、訂閱、單次購買、帳單入口與付款生命週期。定義產品目錄並串接 Stripe 後即可開始收費,不必重新實作付款系統。
Saavo 範本內建完整的計費流程,包括產品目錄、Stripe Checkout、訂閱與單次購買、Webhook 同步、帳單入口、已購產品與付款紀錄,以及購買後自動授予或撤回角色與使用權益。
開發自己的產品時,通常不必重新實作付款系統。主要工作是定義要銷售的方案與價格,設定 Stripe 和 Webhook,再於業務頁面與 API 中,依使用者已取得的權益決定可以使用哪些功能。
既有功能
範本初始化後,即可使用下列計費功能:
| 功能 | 預設入口 | 預設狀態 |
|---|---|---|
| 價格展示 | 首頁 #pricing | 顯示範例訂閱方案 |
| 發起結帳 | POST /api/payments/checkout/:productId/:planId | 已實作,需要登入 |
| 訂閱 | 範例產品 saavo_starter | 已設定月繳與年繳範例 |
| 買斷 | 同一套 Checkout | 已支援,預設範例沒有 Lifetime 方案 |
| 帳單入口 | POST /api/payments/stripe/billing | 已購買的使用者可以開啟 |
| Stripe Webhook | POST /api/webhooks/stripe | 已實作 |
| 已購產品 | /account | 已登入的使用者可查看 |
| 付款紀錄 | /account/payments | 已登入的使用者可查看 |
| 訂閱管理 | /dashboard/payments/subscriptions | 管理員可存取 |
| 單次訂單管理 | /dashboard/payments/purchases | 管理員可存取 |
付款成功後,Saavo 會依方案中的 access 設定,自動授予角色與使用權益。訂閱更新、取消或結束時,對應授權也會隨付款生命週期同步調整。
目前範本刻意不提供退款 API。Saavo 的定位是同步部分狀態,不是作為管理第三方付款系統的代理,因此退款這類敏感操作應在第三方付款系統中完成。如果退款後需要撤回使用者的權益與角色,記得到後台手動撤回。
目前 Saavo 範本只會同步第三方的退款結果,主要是為了方便查詢與統計。預設範本並未實作這類查詢與統計功能,如果業務需要,請自行實作相關程式碼。
開發業務功能時,不要另外建立一套 Checkout,也不要直接在業務程式碼中呼叫 Stripe SDK、自行驗證 Webhook 簽章,或在付款成功頁面手動寫入權益與角色。
業務程式碼應使用 Saavo 已產生的購買與授權結果。
先體驗購買流程
修改產品目錄前,建議先看看範本已提供的價格與購買介面。啟動開發伺服器後開啟首頁,導覽列中的價格入口會帶你前往 #pricing。

預設範例產品 saavo_starter 提供 Hobby、Startup 與 Enterprise 三種方案,並支援切換月繳與年繳:
Hobby $19 / 月 或 $190 / 年
Startup $49 / 月 或 $490 / 年 (推薦)
Enterprise $149 / 月 或 $1,490 / 年這些價格來自 config/products.ts 中的 salePrice,只用於頁面顯示。
實際扣款金額、幣別與計費週期由 Stripe Price 決定,因此正式上線前,應確認頁面顯示的價格與 Stripe 中的實際價格一致。
提示
這不是系統強制要求的行為,但使用者購買產品時,通常會期待最初看到的價格就是最後需要支付的價格。
尚未登入的使用者按下購買按鈕時,系統會先要求登入。Saavo 預設不支援匿名購買,因為目前的計費系統需要透過使用者的電子郵件地址,關聯第三方平台的客戶帳戶。
登入後,帳號中心已提供計費相關功能,可以直接查看已購產品、付款紀錄,並開啟 Stripe 帳單入口:
/account 已購產品
/account/payments 付款紀錄與 Stripe 帳單入口
管理員也可以透過下列頁面查看訂閱與單次購買紀錄:
/dashboard/payments/subscriptions 訂閱
/dashboard/payments/purchases 單次購買
提示
預設產品目錄中的 Price ID 仍是 *_replace_me 占位值。設定 Stripe 金鑰並換成實際的 Price ID 之前,價格頁面可以正常顯示,但無法完成 Checkout。這是範本的預設狀態,不代表計費模組發生錯誤。
完成 Stripe 設定後,一次正常的購買流程大致如下:
使用者選擇方案
↓
完成登入
↓
進入 Stripe Checkout
↓
完成付款
↓
Saavo 同步付款結果
↓
依方案的 access 授予角色與使用權益
↓
使用者開始使用付費功能使用者日後取消訂閱、更換付款方式或查看發票,都可以透過 Stripe Billing Portal 完成,不必自行實作另一套帳單管理後台。
設定產品與計費方式
預設產品目錄主要用來展示範本功能。
正式開發自己的產品時,需要依實際業務決定要賣什麼、如何收費,以及使用者付款後可以取得什麼。
定義銷售內容
產品目錄定義於:
config/products.ts範本預先設定了訂閱產品 saavo_starter,月繳與年繳分別定義為獨立方案。每個方案都透過 priceId 對應一個 Stripe Price。
常用欄位包括:
| 欄位 | 意義 |
|---|---|
type | 'recurring' 表示訂閱,'one-time' 表示單次購買 |
interval / intervalCount | 訂閱週期,例如每月或每年 |
priceId | Stripe 中對應的 Price ID |
salePrice | 頁面顯示價格 |
title / description / features | 價格卡片的顯示內容 |
default | 是否為預設方案 |
recommended | 是否顯示推薦標記 |
allowRepurchase | 已購買的使用者是否可以再次購買 |
access.roles | 購買後授予的角色 |
access.entitlements | 購買後授予的使用權益 |
設定中的 plans 採平面結構,每個方案直接宣告計費週期與 priceId:
saavo_starter
└── plans
├── license Hobby 月繳 → 一個 Stripe Price
├── hobby_yearly Hobby 年繳 → 一個 Stripe Price
├── startup_monthly Startup 月繳 → 一個 Stripe Price
├── startup_yearly Startup 年繳 → 一個 Stripe Price
├── enterprise_monthly Enterprise 月繳 → 一個 Stripe Price
└── enterprise_yearly Enterprise 年繳 → 一個 Stripe PriceHobby、Startup 與 Enterprise 是頁面顯示的商業方案等級,不是在設定中額外包住月繳、年繳方案的一層物件。增加計費週期時,應在 plans 中新增對應方案。
首頁價格區塊預設以 saavo_starter 為主要產品。如果只是以範本建立自己的 SaaS,較簡單的做法是保留這個 Product ID,只替換產品名稱、方案、價格與使用權益。
所有類似下列內容的占位值:
price_hobby_monthly_replace_me都必須在正式測試付款前,換成 Stripe 中實際存在的 Price ID。
範例中的方案名稱、說明與 Feature 也應全部換成自己的產品內容。
新增方案的完整流程,請參考:
選擇訂閱或單次購買
預設範例全部採用訂閱。
訂閱適合持續提供服務的產品,例如:
$19 / 月
$190 / 年如果產品提供買斷、License 或 Lifetime,也可以建立單次購買方案:
lifetime: {
type: 'one-time',
priceId: 'price_your_test_or_live_id',
allowRepurchase: false,
salePrice: '$199',
title: {
value: 'Lifetime',
},
access: {
roles: ['premium'],
entitlements: [
{
target: 'starter_download',
config: {
kind: 'boolean',
priority: 100,
description: 'Lifetime access',
},
},
],
},
},單次購買與訂閱使用同一套 Checkout,主要差異由方案的 type 決定。
請注意,預設首頁的價格元件主要以月繳與年繳訂閱為設計基礎,不會自動顯示 one-time 方案。
如果需要 Lifetime 方案,可以在自己的業務頁面加入獨立的購買入口。
單次購買紀錄會顯示於:
/dashboard/payments/purchases完整範例請參考:
訂閱方案也可以設定試用期間,例如:
trialPeriod: '14 days',試用結束後的預設行為由 config/payment.ts 控制:
subscriptionTrialEndBehavior: 'cancel',確認購買後提供的內容
計費系統負責確認:
使用者是否已完成購買?
方案中的 access 則決定:
使用者購買後可以取得什麼?
例如,方案可以設定為:
access: {
roles: ['premium'],
entitlements: [
{
target: 'starter_download',
config: {
kind: 'boolean',
priority: 100,
},
},
],
},購買成功後,Saavo 會依這裡的設定,自動授予對應的 Role 與 Entitlement。
整體關係如下:
使用者購買方案
↓
方案的 access
├── roles
└── entitlements
↓
能力
↓
開放業務功能實際的付費功能,通常應透過 Entitlement 或 Capability 判斷使用者是否有存取權限,不要只判斷是否擁有名為 premium 的角色。
依實際業務需要,可以使用多層且彼此獨立的判斷。例如,依使用者角色決定可以存取哪些頁面與功能,依使用權益判斷是否有某些操作權限,或依目前的能力決定是否允許執行特定操作。
這些判斷看起來有一定關聯,但你可以依業務需求選擇其中一種或多種。大多數情況不需要判斷 Capability,只檢查角色與使用權益即可。如果多個角色或權益提供同一種業務能力,也可以透過 Capability 統一檢查。
如果方案需要限制使用次數、額度或點數(Credits),就需要設定 Numeric Entitlement,並在業務操作成功後消耗對應額度。
詳細說明請參考:
設定付款後的重新導向頁面
範本預設在 Stripe Checkout 成功後返回 /account,關閉帳單入口後返回首頁:
stripe: {
checkout: {
successRedirectPath: '/account',
fallbackRedirectPath: '/',
},
billingPortal: {
returnRedirectPath: '/',
fallbackRedirectPath: '/',
},
}實際的 SaaS 產品通常會讓使用者在付款成功後,直接進入應用程式。
例如,產品的主要介面位於 /app:
stripe: {
checkout: {
successRedirectPath: '/app',
fallbackRedirectPath: '/',
},
billingPortal: {
returnRedirectPath: '/account/payments',
fallbackRedirectPath: '/',
},
}調整後:
Checkout 成功 → /app
Checkout 無法繼續 → /
離開帳單入口 → /account/payments建議在產品初始化時及早修改這些路徑,避免使用者付款後仍返回範本的預設頁面。
調整 Stripe Checkout
Checkout 的部分行為可以在 config/payment.ts 中調整:
stripe: {
enableAutomaticTax: true,
enablePromoCodes: true,
enableTaxIdCollection: false,
enableTermsOfServiceConsent: false,
enableInvoiceCreation: false,
}如果希望 Stripe 自動計算適用的稅額,可以維持:
enableAutomaticTax: true如果希望使用者能在 Checkout 輸入優惠碼:
enablePromoCodes: true如果產品面向企業,需要收集 Tax ID:
enableTaxIdCollection: true如果需要使用者在付款前確認服務條款:
enableTermsOfServiceConsent: true單次購買若希望 Stripe 同時建立 Invoice,可以啟用:
enableInvoiceCreation: true訂閱本身已透過 Invoice 計費,通常不需要依賴這項設定。
方案也可以預先設定 Stripe Coupon。設定方案層級的 Coupon 後,Checkout 會自動套用對應優惠。沒有預設 Coupon 時,可以透過 enablePromoCodes 允許使用者自行輸入優惠碼。若需要透過專屬連結,為不同管道顯示並套用不同 Coupon,請參考動態優惠碼。
準備 Stripe 服務
範本已內建計費邏輯,但實際扣款仍需要 Stripe 帳戶、金鑰、Product、Price 與 Webhook。
建議先使用 Stripe Test Mode 完整測試流程,再切換至 Live Mode。
設定 Stripe 金鑰
本機開發至少需要設定:
STRIPE_CONNECTION_ID=stripe-test
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...其中:
STRIPE_SECRET_KEY 是 Stripe Secret Key。開發與測試環境使用 Test Mode 金鑰,正式環境必須改用 Live Mode 金鑰。
STRIPE_WEBHOOK_SECRET 是 Webhook Endpoint 的簽署金鑰,必須與目前實際接收事件的端點對應。
STRIPE_CONNECTION_ID 用來區分不同的 Stripe 連線與環境。
例如:
本機/測試環境 → stripe-test
正式環境 → stripe-live正式環境上線後,不建議任意修改 STRIPE_CONNECTION_ID,否則相同的 Stripe 物件可能被辨識為另一組連線資料。
修改 .env 後,需要重新啟動開發伺服器。
在 Stripe 中建立 Product 與 Price
進入 Stripe Dashboard,並切換至 Test Mode。
依照 config/products.ts 定義的方案,建立對應的 Product 與 Price。
接著將每一個:
priceId: 'price_xxx_replace_me'換成實際的 Stripe Price ID。
兩者的關係如下:
config/products.ts
plan.priceId
↓
Stripe Price ID
↓
Stripe Checkout
↓
Webhook
↓
比對本機方案
↓
授予 access因此,priceId 必須精確對應目前方案使用的 Stripe Price。
priceId、Stripe 帳戶與 Test / Live Mode 也必須一致。
例如,本機使用:
STRIPE_SECRET_KEY=sk_test_...那麼 priceId 也必須屬於同一個 Stripe 帳戶的 Test Mode。
頁面中的 salePrice 不會影響 Stripe 的實際扣款金額。
因此,上線前應檢查:
頁面的 salePrice
≈
Stripe Price避免使用者在價格頁面看到 $19,進入 Checkout 後卻顯示不同價格。
設定 Webhook 回呼
Saavo 的 Stripe Webhook 端點為:
POST /api/webhooks/stripe正式環境的對應位址例如:
https://your-domain.com/api/webhooks/stripe需要在 Stripe Dashboard 建立 Webhook Endpoint,並訂閱 Saavo 使用的付款生命週期事件。
建立完成後,將該端點對應的 Signing Secret 寫入:
STRIPE_WEBHOOK_SECRET=whsec_...正式環境需要使用正式的 HTTPS 網域,以及在 Live Mode 建立的 Webhook Endpoint 與 Signing Secret。
完整的正式環境回呼位址,請參考:
在本機測試付款
Stripe 無法直接存取你的本機開發伺服器,因此在本機測試 Webhook 時,需要將事件轉送到本機。
建議使用 Stripe CLI:
stripe listen --forward-to localhost:5173/api/webhooks/stripeCLI 啟動後,會輸出一個臨時 Webhook Secret:
whsec_...將它寫入本機設定:
STRIPE_WEBHOOK_SECRET=whsec_...接著重新啟動開發伺服器。
如果本機開發伺服器使用的連接埠不是 5173,請將指令中的連接埠改成實際使用的值。
完成設定後,可以使用 Stripe Test Mode 提供的測試付款方式,完整購買一次。
本機測試時,除了確認 Checkout 是否付款成功,也應繼續確認:
Checkout 成功
↓
收到 Webhook
↓
產生本機購買紀錄
↓
授予 access
↓
可以使用付費功能如果 Checkout 已成功,但沒有正常收到 Webhook,本機通常不會產生完整的購買與授權結果。
在業務功能中使用付款結果
完成產品目錄與 Stripe 設定後,計費系統即可開始運作。
接下來,業務程式碼的重點是使用計費系統已產生的授權結果,不必繼續操作 Stripe。
基本原則是:
不要在業務程式碼中直接呼叫 Stripe SDK,也不要自行查詢付款資料表,或在 Checkout 成功頁面手動授予使用權益。
Saavo 已透過 Checkout、Webhook 與付款生命週期,處理購買、同步、授權與撤回授權。
業務程式碼只需要判斷:
目前使用者是否擁有執行這項功能所需的權限?
保護付費功能
例如,某個下載功能只允許已購買對應方案的使用者使用,可以直接檢查 Entitlement:
const allowed = await authz.hasEntitlement(
c,
'starter_download',
);
if (!allowed) {
return c.redirect(
getFullPath('/#pricing', locale),
);
}也可以檢查 Capability:
const allowed = await authz.hasEntitlementCapability(
c,
'starter.download',
);購買後的授權與訂閱結束後的撤回,都由付款生命週期處理,因此不需要在 Checkout 成功頁面手動新增權限資料。
API 也應在伺服器端完成檢查:
if (!await authz.hasEntitlement(c, 'starter.download')) {
return c.json(
{
success: false,
code: gResultCode.authPermissionDenied,
error: 'api.auth.permissionDenied',
},
403,
);
}常見的請求流程如下:
請求
↓
authenticatedGuard()
↓
authContext
↓
authz.hasEntitlement()
↓
業務邏輯身分驗證負責判斷:
目前使用者是誰?
計費與使用權益系統則進一步判斷:
目前使用者擁有哪些角色與使用權益?可以執行哪些操作?
完整範例請參考:
判斷使用者是否已購買
有時業務功能不是要判斷使用者目前是否擁有某項能力,只是想知道:
這位使用者是否已購買過目前的方案?
可以使用:
await authz.hasPurchasedProduct(
c,
productId,
planId,
);它與權益檢查的用途不同。
對訂閱方案而言,主要用來判斷目前是否存在對應方案的有效購買關係。
對單次購買方案而言,則用來判斷使用者是否已完成過對應的購買。
因此:
是否允許使用付費功能
→ 檢查 Entitlement / Capability
是否允許再次購買同一個方案
→ 檢查 hasPurchasedProduct()當 allowRepurchase: false 時,Checkout 也會使用類似邏輯避免重複購買。
在業務頁面發起購買
首頁價格區塊已串接完整的購買流程。如果需要在業務頁面新增購買按鈕,請繼續使用統一的 Checkout API:
POST /api/payments/checkout/:productId/:planId前端只需要傳入:
productId
planId例如:
async function buy(productId: string, planId: string) {
const result = await postService<{ type: 'redirect'; url: string }>(
`/api/payments/checkout/${productId}/${planId}`,
);
if (!result.success) {
return;
}
if (result.data.type === 'redirect') {
replace(result.data.url);
}
}如果使用者尚未登入,可以先開啟登入彈出視窗:
const { isLoggedIn } = useCurrentUser();
if (!isLoggedIn) {
setLoginModalOpen(true);
return;
}伺服器端會依 productId 與 planId 找到對應方案,再依方案類型建立 Subscription Checkout 或單次購買的 Payment Checkout。
請注意,前端的登入狀態與按鈕狀態只能用來改善使用者體驗。涉及付費功能的存取權限時,仍必須在伺服器端檢查 Entitlement 或 Capability。
提示
盡量不要將 Stripe Price ID 當成業務參數,直接提供給前端。
在帳號中心管理帳單
Saavo 已在 /account 提供帳號層級的計費資訊,不必重新實作一套 Billing Settings。
適合繼續放在帳號中心的內容包括:
- 已購產品
- 付款紀錄
- Stripe Billing Portal
- 發票與付款方式的管理入口
使用者可以透過 Billing Portal 取消訂閱、更換信用卡,或查看 Stripe 發票。
屬於特定 SaaS 產品的計費資訊,則應由業務頁面自行實作。
例如,AI 產品可能還需要:
目前剩餘額度
本月用量
升級方案
點數使用紀錄
工作區計費狀態這些內容較適合放在產品自己的 Dashboard。你也可以直接沿用並修改帳號中心的產品購買頁面,以符合業務需求。
可以用一個簡單的方式判斷:
與「這個帳號買過什麼、如何付款」有關的內容放在
/account,與「目前產品如何使用這些購買結果」有關的內容放在業務頁面。
上線檢查
收費系統是正式上線前必須完整驗證的基礎功能。
建議至少檢查下列流程:
-
config/products.ts中已沒有*_replace_mePrice ID。 - 價格頁面的方案名稱、價格、說明與 Feature 已換成自己的產品內容。
- 頁面顯示價格與 Stripe Price 一致。
- 每個方案的
access.roles/access.entitlements都對應實際的付費功能。 - 尚未登入的使用者按下購買按鈕時,會先被要求登入。
- 使用 Stripe Test Mode 可以完成訂閱購買。
- 如果有單次購買方案,也已完成一次完整測試。
- Checkout 成功後,可以正常收到 Webhook。
- 在
/account可以看到已購產品。 - 在
/account/payments可以看到付款紀錄,並開啟 Billing Portal。 - 購買後,對應的 Entitlement / Capability 已生效。
- 可以正常使用付費功能。
- 取消訂閱後,在訂閱實際結束時,會撤回對應權益。
- 管理員可以在 Dashboard 查看訂閱與單次購買紀錄。
- Checkout 成功路徑與 Billing Portal 返回路徑,已改為自己的產品位址。
- 正式環境使用 Live Mode 的 Stripe 金鑰。
- 正式環境使用獨立的
STRIPE_CONNECTION_ID。 - 正式環境的 Webhook 已改用正式 HTTPS 網域。
- 沒有混用 Test 與 Live 的 Secret Key、Webhook Secret、Price ID。
建議使用新註冊的一般使用者帳號,從註冊開始完整操作一次:
註冊
↓
登入
↓
購買
↓
Webhook
↓
取得使用權益
↓
使用付費功能
↓
進入 Billing Portal
↓
取消訂閱
↓
訂閱結束
↓
撤回使用權益這樣比只確認「Stripe 頁面顯示付款成功」更可靠。
常見問題
接下來
依接下來要開發的功能,可以繼續閱讀:
- 想了解實際產品如何設計收費方式 → WebpageToPDF 實作教學
- 想新增 Stripe 方案 → 新增 Stripe 方案
- 想新增 Lifetime 或單次購買 → 單次購買方案
- 想限制付費功能 → 付費後才能使用的功能
- 想實作次數限制或點數 → 設計方案與使用權益
- 想了解角色與使用權益 → 權限與使用權益
- 想設定聯盟行銷佣金 → 聯盟行銷計畫
- 想查詢付款資料表與欄位 → 資料庫
大多數產品完成本章後,就不需要再修改付款系統本身。
接下來,可以直接依目前使用者的購買與授權結果,將實際需要收費的業務功能串接到產品中。