付款與方案

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 WebhookPOST /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訂閱週期,例如每月或每年
priceIdStripe 中對應的 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 Price

Hobby、Startup 與 Enterprise 是頁面顯示的商業方案等級,不是在設定中額外包住月繳、年繳方案的一層物件。增加計費週期時,應在 plans 中新增對應方案。

首頁價格區塊預設以 saavo_starter 為主要產品。如果只是以範本建立自己的 SaaS,較簡單的做法是保留這個 Product ID,只替換產品名稱、方案、價格與使用權益。

所有類似下列內容的占位值:

price_hobby_monthly_replace_me

都必須在正式測試付款前,換成 Stripe 中實際存在的 Price ID。

範例中的方案名稱、說明與 Feature 也應全部換成自己的產品內容。

新增方案的完整流程,請參考:

新增 Stripe 方案

選擇訂閱或單次購買

預設範例全部採用訂閱。

訂閱適合持續提供服務的產品,例如:

$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/stripe

CLI 啟動後,會輸出一個臨時 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_me Price 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 頁面顯示付款成功」更可靠。

常見問題

接下來

依接下來要開發的功能,可以繼續閱讀:

大多數產品完成本章後,就不需要再修改付款系統本身。

接下來,可以直接依目前使用者的購買與授權結果,將實際需要收費的業務功能串接到產品中。