為業務功能串接付費權益
串接業務能力、方案、Stripe Checkout 和權益檢查,並驗證訂閱與單次購買流程。
前兩篇建立了工作區和個人收藏 API。這一篇要將收藏功能改成購買後才能使用,藉此走過「設定方案 → 完成付款 → 取得權益 → 存取業務功能」的完整流程。
這裡先實作月繳訂閱,最後說明如何新增單次購買方案。兩種方案都授予同一種布林權益,業務 API 只判斷是否具備該能力,不直接判斷使用者購買了哪個 Stripe Price。
開始前請先完成 Stripe 設定,確認測試環境的付款驗證資訊和 Webhook 都已串接。本文中的 Price ID 都是占位值,需要替換成你自己測試環境中的實際 ID。
一、釐清三項設定之間的關係
本例使用下列三個識別字:
| 識別字 | 位置 | 意義 |
|---|---|---|
savedLinks.use | config/capability.ts | 業務 API 要檢查的能力 |
saved_links_access | config/entitlements.ts | 包含該能力的權益 |
saved_links.monthly | config/products.ts | 授予該權益的產品和方案 |
能力是業務判斷的單位,權益將能力組合起來,方案則描述購買後會取得什麼。後續新增年繳或其他銷售方式時,可以繼續授予相同權益,不必修改業務 API。
這與權限與使用權益中的角色、能力、權益模型一致。本例使用布林能力,不涉及次數扣減。需要額度時,請繼續參考設計方案與串接使用權益。
二、新增顯示文案
在語言檔案的根物件中合併 recipes.savedLinks。如果根物件下已有 recipes,請繼續合併它的子項目。
先在 locales/en.json 新增:
"recipes": {
"savedLinks": {
"name": "Saved links",
"description": "Save and browse your personal links.",
"monthly": "Monthly access",
"month": "/month",
"oneTime": "One-time access"
}
}接著在其他已啟用的語言檔案中新增相同結構。目前的三種語言可以採用下列文案:
| 鍵的最後一段 | 簡體中文 | 繁體中文 |
|---|---|---|
name | 收藏链接 | 收藏連結 |
description | 保存并浏览你的个人收藏链接。 | 儲存並瀏覽你的個人收藏連結。 |
monthly | 按月使用 | 按月使用 |
month | /月 | /月 |
oneTime | 一次性购买 | 單次購買 |
下方設定都會引用這些資源鍵,同時保留 value,供設定展示等既有呼叫端使用。使用者介面的翻譯由資源鍵提供,英文 value 應與英文資源保持一致。
三、定義能力與權益
將下方的 savedLinks 合併到 config/capability.ts 既有的 websiteCapabilityDefinitions 物件中。該檔案已匯入 CapabilityType,沿用既有匯入即可。
savedLinks: {
use: {
type: CapabilityType.Boolean,
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
},
},再將下方的 saved_links_access 合併到 config/entitlements.ts 的 websiteEntitlementDefinitions:
saved_links_access: {
name: { value: 'Saved links', key: 'recipes.savedLinks.name' },
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
capabilities: ['savedLinks.use'],
},保留兩個檔案末尾既有的 as const satisfies ... 和型別匯出。專案會從這些定義推導可用的識別字,鍵名寫錯時,應在型別檢查階段就能發現。
四、新增月繳產品
為了讓範例獨立於範本原有的 saavo_starter 示範方案,請將下方的 saved_links 合併到 config/products.ts 的 websiteProductDefinitions 根物件中:
saved_links: {
name: { value: 'Saved links', key: 'recipes.savedLinks.name' },
description: { value: 'Save and browse your personal links.', key: 'recipes.savedLinks.description' },
plans: {
monthly: {
type: 'recurring',
interval: 'month',
intervalCount: 1,
priceId: 'price_saved_links_monthly_replace_me',
salePrice: '$9',
title: { value: 'Monthly access', key: 'recipes.savedLinks.monthly' },
billingLabel: { value: '/month', key: 'recipes.savedLinks.month' },
allowRepurchase: false,
access: {
entitlements: [{
target: 'saved_links_access',
config: {
kind: 'boolean',
priority: 100,
},
}],
},
},
},
},本例假設對應的 Stripe Price 為每月 9 美元。salePrice 是頁面顯示文字,實際收費金額、幣別和週期由付款平台上的 Price 決定,兩邊需要手動核對一致。
一個 Plan 對應一個 Price。日後新增年繳時,在 plans 下新增 yearly,分別填入年繳 Price、interval: 'year' 和年繳顯示價格,不要將多個週期的價格放進同一個 Plan。
這裡沒有授予通用的 premium 角色,因為收藏功能只依賴自己的權益。購買其他產品不應因此自動取得收藏功能,是否共用權益由產品規則決定。
五、發起測試購買
先重新啟動開發伺服器,讓設定更新生效。在已登入的本機網站主控台中執行:
const response = await fetch('/api/payments/checkout/saved_links/monthly', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const result = await response.json();
console.log(response.status, result);
if (result.success && result.data.type === 'redirect') {
window.location.assign(result.data.url);
}這一步呼叫既有的 Checkout API,不需要另外撰寫付款 API。參數中的 saved_links 和 monthly 必須與設定鍵一致,瀏覽器不提交金額、Price ID 或要發放的權益。
這段主控台程式碼用來驗收購買流程。正式的購買按鈕應沿用既有的付款呼叫方式,處理載入、失敗和重複點擊,並從語言資源讀取文案。新增設定也不代表首頁會自動出現符合產品設計的購買區域,仍需要檢查目前的購買元件如何讀取和展示產品目錄。
付款成功返回網站後,請等待 Webhook 和後續事件處理完成,再驗證權益。成功頁面只是瀏覽器返回的位置,不能在成功頁面直接發放權益給使用者。
六、保護收藏 API
在上一篇教學建立的 src/api/saved-links/index.ts 中新增匯入:
import { authz } from '@/authz';分別在建立和清單的 handler 中,緊接 authenticatedGuard 的失敗處理之後、呼叫業務 Service 之前,加入:
if (!(await authz.hasEntitlementCapability(c, 'savedLinks.use'))) {
return c.json({
success: false,
code: gResultCode.authEntitlementDenied,
}, 403);
}本例將建立和讀取都視為付費功能。因此,訂閱失效後,已儲存的資料仍在資料庫中,但 API 不再允許讀取。如果產品約定允許使用者繼續查看歷史資料,只在建立入口檢查權益即可,同時修改頁面說明和驗證的預期結果。
頁面可以依相同判斷顯示購買入口,但 API 中的檢查必須保留。如果其他入口也能呼叫這項業務功能,例如背景工作或另一個 API,也需要在對應的可信任入口執行適合它的授權檢查。
付費權益無法取代資源擁有權。收藏查詢中的 user_id 限制仍須保留,付費使用者也只能存取自己的資料。
七、新增單次購買方案
如果產品確實需要單次購買,請將下方的 one_time 合併到 saved_links.plans,並綁定一個單次購買的 Price:
one_time: {
type: 'one-time',
priceId: 'price_saved_links_one_time_replace_me',
salePrice: '$99',
title: { value: 'One-time access', key: 'recipes.savedLinks.oneTime' },
allowRepurchase: false,
access: {
entitlements: [{
target: 'saved_links_access',
config: {
kind: 'boolean',
priority: 100,
},
}],
},
},對應的測試 API 改為:
POST /api/payments/checkout/saved_links/one_time單次購買方案不填寫 interval 或 intervalCount。它表達的是收費方式,權益是否長期有效,仍取決於授予設定和後續業務規則,不能只憑 one-time 就在行銷文案中寫成「永久有效」。
allowRepurchase: false 用來限制已完成購買後再次購買,並不會鎖住付款完成前建立的多個 Checkout Session。介面仍須防止連續點擊,也不要將這個欄位當成付款請求的等冪保證。
如果同時銷售月繳和單次購買方案,要先釐清已購買單次方案的使用者是否仍應看到訂閱入口。這項商業規則需要在購買介面和伺服器端的購買規則中一致實作,本例不額外加入跨方案的互斥規則。可以先只上線一種方案,再擴充第二種。
八、驗證完整流程
使用測試帳號和測試付款環境逐項確認:
| 情境 | 檢查結果 |
|---|---|
| 尚未登入就呼叫收藏 API | 回傳 401 |
| 已登入但沒有收藏權益 | 回傳 403 |
| 取消 Checkout,尚未完成付款 | 不會取得權益 |
| 月繳付款成功,且 Webhook 處理完成 | 可以建立、讀取自己的收藏 |
| 使用者手動開啟付款成功頁面 | 不會因此取得權益 |
| 訂閱設定為週期結束時取消 | 不將「已設定取消」誤認為立即失效,依有效期間驗證 |
| 訂閱實際結束,狀態同步完成 | 月繳權益失效,沒有其他有效來源時,API 回傳 403 |
| 單次購買成功 | 取得設定中的權益,業務檢查方式不變 |
| 重複收到同一個付款事件 | 檢查既有的付款和事件處理紀錄,沒有重複的業務結果 |
退款紀錄與權益撤銷是兩個業務動作。目前的流程不能直接理解成「退款後一定會自動撤銷所有權益」,發布前應依你的退款規則,另外核對並驗證處理方式,詳見付款與方案。
如果顯示付款成功卻仍回傳 403,請依序檢查 Price 所屬環境、Webhook 是否送達、事件處理紀錄、方案的 access 和能力鍵名。不要略過權益檢查來掩蓋同步問題。
完成設定與 API 修改後,執行 npm run lint、npm run typecheck 和 npm run build,再進行上述付款驗收。型別檢查可以找出設定鍵名的問題,實際測試付款才能驗證回呼和權益流程。