付了錢,App 卻說我是免費方案:訂閱狀態同步的雙路徑設計
訂閱制產品有一個最不能出錯的瞬間:用戶按下購買、Face ID 通過、錢扣了——然後他回到 App。
如果這時候畫面上還寫著「免費方案」,前面所有轉換漏斗的努力都白費了。他不會想「大概在同步吧」,他會想「我被騙了」。
這件事看起來像購買流程的問題,其實不是。購買在 App Store 那邊已經完成了,問題出在:你的伺服器什麼時候、用什麼依據,把這個用戶標成付費?
天真版架構,和它的三個死法
用 RevenueCat 的標準做法是接 webhook:它在購買、續訂、取消、退款時推事件給你,你更新資料庫。看起來一條路就夠了。
但如果你直接照事件內容寫資料庫,有三種死法:
| 死法 | 發生什麼 |
|---|---|
| 語意誤解 | CANCELLATION 只是「關閉自動續訂」,權益仍有效到期滿。照字面把用戶降級,等於沒收人家已付費的時間 |
| 亂序與重送 | 事件不保證順序、可能重複。先到的 RENEWAL 蓋掉後到的 EXPIRATION,或反過來 |
| 整條路徑掉了 | 匿名購買對不回帳號、你的伺服器剛好 5xx、事件在往你這裡的路上消失——用戶付了錢,你這邊什麼都沒發生 |
前兩種是寫錯資料,第三種是根本沒寫。三種的共同點:單靠「事件推過來」這一條路,狀態遲早會歪。
核心決定:事件是門鈴,不是包裹
我的 webhook handler 對事件內容只做一件事:找出「該同步哪個用戶」。tier 是什麼,一律回頭向 RevenueCat REST API 撈當下的 entitlements 重新算:
// 由「當下」的 entitlements 算 tier;expires_date null = lifetime
export function deriveTier(subscriber, now = Date.now()) {
const entitlements = subscriber?.entitlements ?? {};
const active = (name) => {
const ent = entitlements[name];
if (!ent) return false;
if (ent.expires_date == null) return true;
return Date.parse(ent.expires_date) > now;
};
if (active('premium')) return 'premium';
if (active('pro')) return 'pro';
return 'free';
}這個決定一次解掉前兩種死法:
亂序不再重要——不管事件用什麼順序到,每次都是「以此刻的真相為準」重算,同一個事件重送十次,寫進去的結果都一樣。整條更新是冪等的,這也讓錯誤處理變簡單:任何一步失敗就回 5xx,讓 RevenueCat 按 backoff 重送,重試不會造成重複扣權益或重複升級。
CANCELLATION 的語意也不用我操心——關掉自動續訂的用戶,entitlements 裡的 expires_date 還沒到,deriveTier 自然回傳付費 tier;到期那天 EXPIRATION 事件來敲門,重算自然變 free。我從頭到尾沒有寫過任何一行「if 事件是取消就降級」。
第二條路:reconcile,webhook 的補償路徑
但門鈴壞掉的情況(第三種死法)還在。最典型的是匿名購買:用戶還沒登入就先買了,事件裡的 app_user_id 是 RevenueCat 的匿名 id,對不回我資料庫裡的任何帳號。這時 webhook 只能放棄。
所以有第二條路,方向反過來——client 主動拉:
// POST /api/subscription/reconcile(帶 Supabase JWT)
// 以 JWT 的 uuid 為準,向 RevenueCat 撈當下權益回寫
export async function handleSubscriptionReconcile(request, user, env) {
const tier = await syncTierForUser(user.id, env);
return { data: { tier }, status: 200 };
}App 端在偵測到不一致時呼叫它——本機 SDK 說有權益、但 JWT 裡的 tier 是 free,就打一次 reconcile,然後 refreshSession() 換發新 JWT。畫面立刻正確。
注意這條路和 webhook 共用同一個 syncTierForUser:同一套「以 RevenueCat 當下權益為準」的邏輯,只是觸發者不同。一邊是 RevenueCat 敲門,一邊是用戶自己敲門,開門之後做的事完全一樣。這很重要——兩條路徑如果各寫一套邏輯,它們遲早會對同一個用戶算出不同答案。
這條補償路徑是雙向的:權益該有而沒有,補上;權益過期了本機還顯示有,降級。自癒不分方向。
為什麼 tier 要從 JWT 讀,不信 client 旗標
還有一個容易做錯的地方:伺服器端的功能閘門(誰能用進階 AI、誰有配額)依據什麼判斷 tier?
不能信 client 送上來的旗標——「我是 premium」這種欄位,改一行請求就能偽造。tier 必須在伺服器簽發的 JWT 裡,client 只能整顆 token 拿去用,改不了內容。
這帶出一個不明顯的細節:tier 要寫進兩個地方。
| 寫到哪 | 誰讀它 |
|---|---|
| profiles.subscription_tier(資料表) | JWT 簽發 hook 啟用時,發 token 讀這裡 |
| auth metadata 的 tier | hook 未啟用時 JWT 直接帶這裡;App 設定頁的方案顯示也讀這裡 |
為什麼兩處?因為 JWT 簽發鏈路有兩種配置狀態,兩種狀態讀的來源不同。只寫一處,在某一種配置下就是靜默的錯——而且是「有些用戶對、有些用戶錯」那種最難查的錯。
五個容易踩的小決定
整套做完,回頭看有五個小地方是文件不會告訴你、踩過才知道的:
一、對不回帳號的匿名購買,回 200 不是 5xx。回 5xx 它會無限重送,但這個事件永遠對不回帳號,重送一萬次也一樣。回 200 放它走,這筆購買交給 reconcile 補——用戶登入、SDK 把匿名 id alias 到帳號之後,reconcile 撈得到權益。
二、TRANSFER 事件要同步兩個人。權益從 A 帳號搬到 B 帳號時,A 要降、B 要升,漏掉任何一邊都是資料錯。
三、RevenueCat 的查詢 API 是 get-or-create。拿一個它沒看過的 id 去查,它會建一個空的 subscriber 回給你空權益。對真實用戶無害(uuid 已經 alias 過就回真權益),但別拿隨便的字串去打,會在 RevenueCat 那邊留一堆幽靈帳號。
四、webhook 驗證用 constant-time 比較。Authorization header 和 secret 的比對如果用 ===,比對時間會洩露前綴資訊。順帶容忍「Bearer <secret>」的常見後台設定寫法,省掉一次除錯來回。
五、資料列可能不存在。理論上註冊時就建了 profiles 列,但「理論上」不是保證——同步時查無此列就補建,否則之後某個環節的 COALESCE(NULL, 'free') 會把付費用戶靜默降級。
如果你也要做
把整套設計壓成四條原則:
一、事件只當觸發訊號,狀態永遠向權威來源重查。這一條買到冪等、亂序安全、重送安全。
二、推的路徑(webhook)一定要配一條拉的路徑(reconcile),兩條共用同一個同步函式。
三、伺服器端判斷 tier 只信 JWT,不信 client 旗標。
四、想清楚每種失敗要回什麼狀態碼:能靠重送救的回 5xx,重送也救不了的回 200 放行,交給另一條路徑。
用戶按下購買到畫面顯示付費方案,中間是一整條分散式系統。它不會因為你用了 RevenueCat 就消失,只是變得可以管理。
這套機制服務的產品:KOFNote