為業務功能串接付費權益

串接業務能力、方案、Stripe Checkout 和權益檢查,並驗證訂閱與單次購買流程。

前兩篇建立了工作區和個人收藏 API。這一篇要將收藏功能改成購買後才能使用,藉此走過「設定方案 → 完成付款 → 取得權益 → 存取業務功能」的完整流程。

這裡先實作月繳訂閱,最後說明如何新增單次購買方案。兩種方案都授予同一種布林權益,業務 API 只判斷是否具備該能力,不直接判斷使用者購買了哪個 Stripe Price。

開始前請先完成 Stripe 設定,確認測試環境的付款驗證資訊和 Webhook 都已串接。本文中的 Price ID 都是占位值,需要替換成你自己測試環境中的實際 ID。

一、釐清三項設定之間的關係

本例使用下列三個識別字:

識別字位置意義
savedLinks.useconfig/capability.ts業務 API 要檢查的能力
saved_links_accessconfig/entitlements.ts包含該能力的權益
saved_links.monthlyconfig/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 和後續事件處理完成,再驗證權益。成功頁面只是瀏覽器返回的位置,不能在成功頁面直接發放權益給使用者。

在上一篇教學建立的 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,再進行上述付款驗收。型別檢查可以找出設定鍵名的問題,實際測試付款才能驗證回呼和權益流程。