第 11 課 · 收費:把用量變成帳單——Stripe 用量計費與發票
月底了。你打開兩個分頁。
左邊那個分頁是你的產品後台:12 個「設計夥伴」(design partner)正在用你的競品情報週報。每週一凌晨三點,graph 準時醒來,抓料、去重、摘要、比對,只報 diff,起草、等分析師審一眼、送出。它記得上週(第 7 課)、單位經濟為正(第 9 課)、每個租戶資料互相隔離(第 10 課)。從第 1 課到第 10 課,你把一個真正的產品該有的骨架,一塊一塊焊上去了。它很完整,跑得很漂亮。
右邊那個分頁是兩張帳單。OpenAI 這個月跟你收 $340。Stripe 這個月幫你收到的錢是……$0。
因為你根本還沒接 Stripe。
這 12 個設計夥伴每週準時收到有價值的情報、每週都在替他們省下半天的人力——而你,每週替他們墊付 LLM 的錢。這不是生意,是你自掏腰包的慈善。第 1 課那句話,此刻正指著你的鼻子:
demo 回答的是「模型能不能做到這件酷事一次」;產品回答的是「一個陌生人願不願意為它每個月付錢」。
你的東西已經好到值得付費了。這一課,我們補上讓「值得付費」變成「真的在付費」的最後一步:把用量變成帳單。
這一課回答三個問題:
- 怎麼把第 9 課算出的用量/成本、第 10 課的每租戶歸戶,接上 Stripe 的用量計費(metered billing),把「跑了幾次、報了幾個競品」變成可收的月費/用量費?
- 「計量 → 上報 → 開發票」這條路怎麼設計成可靠——不重複計費、對得起帳?而計量這段純邏輯,怎麼像 diff、去重那樣離線測?
- 為什麼「能收費」是四件事的最後一角——補上它,你手上的東西才從 demo 變成一門生意(呼應第 1 課)?
💡 先把本課邊界釘死:這是用量計費(metered billing)/發票的定義專章。它站在兩根已經打好的地基上——第 9 課建立的成本/用量與單位經濟、第 10 課建立的多租戶歸戶,本課只引用、不重講。至於「怎麼把這一切包成一個能對外站著的 HTTP 服務」——自己包 Node HTTP server、CORS、金鑰管理、webhook 簽章驗證、觀測/tracing——那是第 12 課上線硬化的事,本課只接金流計量,不談部署。
還有一句對照第 1 課的分工:Stripe 內部怎麼把 meter event 聚合、怎麼算稅、怎麼生 PDF 發票、怎麼重試扣款,那是 Stripe 的事,我們不重刻。 就像我們不自己手刻 checkpointer(那是
ai-pipeline-from-scratch的事),我們也不自己手刻一個計費系統。你要蓋的不是金流商,是一個把自家用量誠實、可靠地餵給金流商的計量層。
一、能收費:四件事的最後一角,也是最後才拼得上的一角
先回答第三個問題,因為它決定了你看待這一課的態度。
第 1 課把「產品不是 demo」拆成四根柱子,你一路蓋到現在:
flowchart LR
P1["1. 全端<br/>control panel ✅ 第 1–6 課"]
P2["2. 控制節點與狀態<br/>記得上週、給得出 diff ✅ 第 7 課"]
P3["3. 控制預算<br/>單位經濟為正 ✅ 第 8–9 課"]
P4["4. 能收費<br/>把用量變帳單 ⬅ 就是這一課"]
P1 --- P2 --- P3 --- P4
「能收費」排在最後,不是隨便排的,是它天生只能排最後——因為它吃前面三根柱子的產出。停下來想三秒,你會發現要對一個客戶開出一張對得起帳的帳單,你得先有三樣東西,缺一樣都開不成:
- 你得先能穩定地跑出有價值的東西(柱子 1、2)——不然收什麼費?
- 你得先算得出每一次跑花了多少、值多少(柱子 3、第 9 課的單位經濟)——不然你不知道該收多少才有毛利,很可能定了個賣越多虧越多的價。
- 你得先能分清楚這次用量是「誰」的(第 10 課的多租戶歸戶)——不然你把 A 客戶的用量算到 B 客戶頭上,一張錯帳就是一次退訂加一則差評。
用量計費是這三根柱子的「兌現口」:它把「準時、便宜、歸戶清楚地跑出來的價值」,換成銀行帳戶裡的數字。這也是為什麼它必須最後做——你不可能對一個還算不清成本、還分不清租戶的系統收費。前十課不是在拖延收費,是在替收費打地基。
補上這一角,第 1 課那條主線就閉合了:
| 少了「能收費」 | 補上「能收費」 |
|---|---|
| 一個你自掏腰包養著的免費工具 | 一門有 MRR、單位經濟為正的生意 |
| 「哇酷」的 demo,朋友截圖轉發 | 陌生人每月刷卡、續訂的產品 |
| 成本你自己吸收,用越多你越虧 | 用量變帳單,用越多你賺越多 |
第 1 課還有一句話,這一課要讓它從比喻變成一行 API 呼叫:誰扛累活,誰收月費。 前十課我們把「記憶+比對+策展+控成本」這些累活從使用者身上接了過來、扛在後端。這一課,就是去把月費收回來——不再是一句口號,而是 stripe.billing.meterEvents.create(...)。
二、先想清楚賣什麼:月費 + 用量費,與那個叫「competitor-report」的計價單位
在碰任何 Stripe API 之前,先回答一個純產品問題:你到底按什麼收錢? 這個問題答錯,後面程式碼寫得再漂亮都是白搭。
我們的產品有兩種價值來源,剛好對上兩種收法:
- 有沒有訂閱這個服務——收月費(固定的訂閱費)。這是「你買了進場券」。
- 這期用了多少(跑了幾次、報了幾個競品)——收用量費,也就是這一課的主角用量計費(metered billing)。這是「你坐了幾趟」。
用量費需要一個計價單位:一個可以「數出來、按個數收錢」的東西。回到第 1 課那句 mustAnswer——「跑了幾次、報了幾個競品」——最自然的單位是:
一個 competitor-report = 在某一次 digest run 裡,成功報出的「一個競品」。
這個單位很妙,因為它同時吃住了「跑了幾次」和「報了幾個競品」兩個維度:
- 一週跑一次、追 3 個競品 → 這次產生 3 個 competitor-report。
- 一個月跑 4 次、每次 3 個 → 這個月累積 12 個 competitor-report。
- 客戶臨時多加一個競品到 4 個 → 這週變 4 個、這個月往上跳。
把一整個 billing period(計費週期,通常一個月)裡的 competitor-report 全加起來,就是這個客戶這期的用量。乘上單價,就是用量費。
這個單位要通過「單位經濟」這一關
這裡是第 9 課直接接上第 11 課的地方,也是第 1 課那句「能不能收費,一半取決於能不能控制成本」的兌現點。第 9 課已經幫你算出「跑一次、報一個競品」的單位成本(抓取 + 摘要 + diff 那幾次 LLM 呼叫的錢,扣掉快取與模型分流省下的)。用量計費要成立,你的單價必須穩穩壓過這個單位成本:
| 一個 competitor-report | 數字(示意) | 來源 |
|---|---|---|
| 單位成本 | ≈ $0.04 | 第 9 課算出來的(含模型分流、快取後的均攤) |
| metered 單價 | $0.50 | 你的定價決定 |
| 每個 competitor-report 的毛利 | $0.46(約 92%) | 兩者相減 |
只要這一列的毛利是正的,第 1 課那個「單位經濟為正」就在每一個計價單位上成立——客戶用越多,你賺越多,而不是賣越多虧越多。這就是為什麼柱子 3 非得排在柱子 4 前面:沒有第 9 課那個 $0.04,你根本不知道 $0.50 是賺是賠。
💡 實務上很少「純用量」或「純月費」,最常見的是月費(含額度)+ 超量用量費:例如月費 $149 包含每月 20 個 competitor-report,超過的部分才按 $0.50/個 計。好消息是——「含 20 個免費」這種額度與級距,是 Stripe 的 Price 設定能直接做的(tiered / graduated pricing),不必你自己寫程式去算額度。 你的程式只負責如實回報用了幾個,「前 20 個免費、之後多少錢」交給 Stripe 的定價規則。這是一條很值得記住的分工線:用量是你算的,定價是 Stripe 算的。 這一課的計量程式碼,從頭到尾只碰「用了幾個」,一次都不碰「一個多少錢」。
⚠️ 別把「純用量計費」當成唯一正解。純用量對你(成本對齊)很爽,但對客戶是帳單不可預測——月底才知道要付多少,很多 B2B 採購討厭這種不確定,會嚇跑人。反過來,純月費(吃到飽)對客戶很安心,但你會遇到那個追 200 個競品、把你單位經濟吃到翻負的重度用戶。「基礎月費 + 合理額度 + 超量才計量」通常是那個兩邊都能接受的中間點。 選哪種是定價策略、是產品/商業判斷,不是技術問題——這一課教你怎麼可靠地把用量算清楚並收上來,至於幾塊錢、含幾個額度,是你和你的市場之間的事。
三、計量 → 上報 → 開發票:兩個世界,一道 seam
現在看這條路的全貌。它跨越兩個世界,中間隔著一道你必須守好的接縫。
你的世界(你寫的程式)負責兩件事:
- 計量(metering):一次 digest run 跑完,把它換算成「這個租戶、這次、幾個 competitor-report」——一段純邏輯。
- 記帳(ledger):把每一筆計量寫進你自己的一張帳(billing ledger),這張帳是真理來源(source of truth)。
Stripe 的世界(你不重刻)負責另外兩件事:
- 聚合(aggregation):把你上報的一筆筆用量,按 billing period 累加起來。
- 開發票與收款(invoicing):期末把「固定月費 + 聚合出來的用量費」算成一張發票,自動向客戶的信用卡扣款。
兩個世界之間,靠一個叫 meter event(計量事件) 的東西傳話。這就是這一課的 seam——第 2 課我們把 seam 切在 LLM 上(純邏輯 vs 打 LLM 的呼叫);這一課,我們把同一把刀切在 Stripe 上:純計量邏輯在一邊,真打 Stripe 的呼叫在另一邊。原理一模一樣,純邏輯那半邊不需金鑰、能離線用 stub 測到爽。
flowchart LR
subgraph OURS["你的世界(你寫的程式,能離線測)"]
direction TB
RUN["digest run 跑完<br/>第 9 課的用量收尾"] --> METER["計量:算出 competitor-report 數<br/>純邏輯 · 決定性"]
METER --> LEDGER[("billing ledger<br/>真理來源")]
LEDGER --> FLUSH["上報:flush 到 Stripe<br/>帶決定性 identifier"]
end
subgraph STRIPE["Stripe 的世界(你不重刻)"]
direction TB
MEV["meter event"] --> AGG["按 billing period 聚合"]
AGG --> INV["期末:開發票、自動扣款"]
end
FLUSH -->|"meter event(這道 seam)"| MEV
在跳進程式碼前,先把 Stripe 的幾個名詞認清楚——它們不是我們框架的術語(那些第 2、7 課定義過了),是 Stripe 的詞,這裡定義一次、後面照用:
| Stripe 名詞 | 是什麼 | 在我們產品裡對應到 |
|---|---|---|
| Customer | 一個付費對象 | 一個租戶(第 10 課的 account)= 一個 Stripe Customer |
| Subscription | 綁在 Customer 上的訂閱,決定「每期收什麼」 | 這個客戶的訂閱,掛著兩個 Price |
| Price | 一條計價規則 | 兩條:一條固定的(月費)、一條 metered 的(吃 meter 的量) |
| Meter | 一個用量計數器,有個 event_name |
我們的 competitor_report 計數器 |
| Meter Event | 往 Meter 記一筆量的事件 | 就是那道 seam:帶著 customer、value、identifier |
| Invoice | 期末算出來的發票 | Stripe 自動生成、自動扣款,我們幾乎不用碰 |
看懂這張表,你就抓到分工了:我們負責產生正確的 Meter Event 並記進自己的帳;Stripe 負責把 Meter Event 聚合成 Invoice。 這一課的所有工程難度,都集中在「產生正確的 Meter Event」這半邊——而「正確」的意思,就是下一節的主題:不重複、對得起帳。
四、可靠的核心:冪等(不重複計費)+ 對帳(對得起帳)
這是整課最重要的一節。計量算錯數字只是尷尬,計量重複計費是會賠掉信任、甚至惹上糾紛的——沒有人能忍受「這個月被多收了一次」。所以我們花整整一節,把「不重複計費」做到滴水不漏。
先看它會怎麼壞
「上報一筆用量到 Stripe」這個動作,看似簡單,其實埋著一堆會讓你重複計費的地雷:
| 出錯情境 | 為什麼會重複計費 |
|---|---|
| 節點重試 / graph 恢復 | 第 6 課教過:被 interrupt() 的節點在 resume 時會重跑;一般的重試也會讓收尾節點跑第二次 |
| server 送出後、記錄前當機 | Stripe 已經收到了,但你還沒記下「已上報」→ 重開機後你以為沒送過,又送一次 |
| 打 Stripe timeout | 你的請求超時了,但你不知道 Stripe 到底收到沒——重送?可能雙倍。不送?可能漏帳 |
| 手動重跑某一週 | 修了個 bug,把 acme-corp 第 27 週重跑一次——這是同一筆帳,不該收兩次 |
這些情境有個共同點:同一筆邏輯上的用量,可能被送出去不只一次。 你沒辦法保證「每筆只送剛好一次」(分散式系統裡這幾乎做不到),所以正確的目標不是「剛好一次」,而是——
就算送很多次,也只算一次。 這個性質叫冪等(idempotent)。
兩層防線:你的 ledger 是真理,Stripe 的去重是安全網
我們用兩層冪等來達成它,而且分工清楚:
第一層(主力):你自己的 billing ledger,用「決定性冪等鑰匙」擋重複。
每一筆計量,我們先算出一把決定性(deterministic)的冪等鑰匙——「決定性」意思是同一筆邏輯用量,永遠算出同一把鑰匙。最自然的鑰匙就是這次 run 的身分:
idempotencyKey = `report:${runId}`
只要 runId 是穩定的(第 10 課/第 9 課給每次執行的那個 id),重跑同一次 run 就會算出同一把鑰匙。寫進 ledger 時用「這把鑰匙已存在就不寫」(SQL 的 INSERT ... ON CONFLICT DO NOTHING)——第二次寫,是個 no-op。你的帳裡,這筆用量永遠只有一列。
⚠️ 決定性鑰匙的關鍵,是它必須綁在「邏輯上的那筆用量」,而不是「這次程式執行」。 如果你用
crypto.randomUUID()當鑰匙,那每次重試都是一把新鑰匙,兩層防線瞬間全破——你會忠實地記兩筆、收兩次。所以runId要是排程時就定下、重試也不變的身分(例如「acme-corp 的第 27 週」=acme-corp:w27),不是每次invoke隨機生一個。這條紀律接續第 7 課的 thread:thread 是「哪條追蹤線」的穩定名牌,runId 是「哪一次執行」的穩定名牌。 邊界:如果你真的想一週跑兩次、各自收費,那它們就是兩筆不同的邏輯用量,該給不同的 runId——鑰匙決定性,不代表「一週只能有一筆」,代表「同一筆重跑幾次都只算一次」。
第二層(安全網):上報時,把同一把鑰匙當成 Stripe meter event 的 identifier。
Stripe 的 meter event 收一個 identifier 欄位,重複的 identifier 會被 Stripe 忽略。所以就算你的第一層因為某種你沒想到的原因漏了、把同一筆送了兩次到 Stripe,Stripe 這邊也會用 identifier 再擋一次。
⚠️ 但別把對帳的責任外包給 Stripe。 Stripe 用
identifier去重是安全網,不是真理來源——它的去重有時間窗(不是永遠記得每一個你送過的 id),你也不該讓「這個月到底該收多少」的最終答案,掛在金流商的一個去重快取上。真理來源永遠是你自己那張 ledger。 兩層的分工是:你的 ledger 保證「帳本身是對的」,Stripe 的identifier保證「就算你手滑多送一次也不出事」。 安全網是拿來兜底的,不是拿來當地板站的。
還有一條讓它穩的紀律:計量歸計量,上報歸上報,別綁死
看回第三節那張圖,你會發現我刻意把「寫 ledger」和「flush 到 Stripe」畫成兩步,中間斷開。這不是為了好看,是一條救命的設計:
digest run 跑完,先把帳記進自己的 ledger(本地、可靠、幾乎不會失敗);至於「推去 Stripe」,是解耦出去、可以稍後、可以重試的另一步。
為什麼要斷開?因為 Stripe 會有掛掉、變慢、timeout 的時候,而你絕不能讓金流商的一時不可用,拖垮你產品的核心工作。想想看:週一凌晨三點,digest 該送就得送——那是客戶付錢買的價值;如果因為「Stripe 那一刻剛好在維護、meter event 送不出去」就讓整個 run 失敗、週報沒寄出,那是本末倒置。
所以:送週報是絕不能失敗的正事,記帳寫本地 ledger 幾乎不會失敗,而「推去 Stripe」是可以慢慢補、慢慢重試的雜事。 本地 ledger 裡那些「還沒推去 Stripe」的列,就是一個待辦清單(做過後端的人會認得,這就是 outbox pattern 的形狀);一個定時任務把它們一列列 flush 出去,因為每列都帶決定性 identifier,這個 flush 跑多勤、重跑幾次都安全。
💡 一句話收束這一節的三條紀律:(1)鑰匙要決定性——同一筆用量永遠同一把鑰匙;(2)你的 ledger 是真理,Stripe 的
identifier是安全網;(3)計量寫本地要可靠,上報 Stripe 要解耦、可重試。 這三條加起來,就是「不重複計費、對得起帳」。下一節把它們變成能跑、能測的程式碼。
五、動手:計量是純邏輯,用 stub 假 Stripe 離線測
到你最熟悉的環節了——把上面的設計,切在 seam 上,讓純邏輯那半邊完全離線、無金鑰、可測到死。這一課的 stub 假的不是模型,是 Stripe:我們用一個記憶體版的假金流商頂替真 Stripe,就能在 CI、在飛機上、在一把金鑰都沒有的情況下,驗證「不重複計費、對得起帳」。
5.1 先擺出第 9、10 課的產出(只為對照,不重定義)
我們的計量吃兩樣上游的東西:第 9 課算好的用量、第 10 課的租戶歸戶。照第 4 課的慣例,把它們列出來只為讓後面程式碼有上下文——欄位名以那兩課為準:
// upstream.ts —— 第 9、10 課已建立,這裡列出只為對照。
// 第 9 課:一次 digest run 跑完的用量收尾。
export interface RunUsage {
runId: string; // 這次執行的穩定身分(例:"acme-corp:w27")——冪等鑰匙的來源
account: string; // 這次是「誰」的(第 10 課的租戶歸戶)
week: number; // 第幾週
competitorsReported: number; // ★ 這次「成功」報出的競品數(抓取失敗、被降級略過的不算)
llmCostUsd: number; // 第 9 課算出的這次 LLM 成本(拿來對照單位經濟,計費本身用不到)
}
// 第 10 課:租戶(account)→ Stripe Customer 的對應。
// 客戶第一次訂閱時,你建一個 Stripe Customer、把 id 存進第 10 課那張租戶表,之後查得到。
export interface TenantBilling {
account: string;
stripeCustomerId: string; // 例:"cus_ABC123"
}
💡 注意
competitorsReported只數成功報出的競品——抓取失敗、被第 4 課那個try/catch降級成「本週略過」的,不計費。這是「對得起帳」的一部分:你收的是交付出去的價值,不是你踩到的錯誤。 一個競品這週因為對方網站掛了而沒報成,你不該為它收客戶錢。這也正是為什麼計量值得寫成一段你親手掌控、測得到的純邏輯,而不是隨手competitors.length帶過——計費的每一個數字,都要對得起良心,也對得起客戶的信任。
5.2 純邏輯:把一次 run 換算成一筆帳
核心計量就一個純函式:吃 RunUsage + Stripe customer id,吐一筆 BillingRecord。它不碰網路、不碰 Stripe、不需要任何金鑰,而且是決定性的——同一次 run 永遠算出同一把冪等鑰匙。
// metering.ts —— 純邏輯:不碰網路、不碰 Stripe、不需要金鑰。
import type { RunUsage } from "./upstream";
// Stripe meter 的 event_name(第六節建 meter 時會用同一個字串)。
export const COMPETITOR_REPORT = "competitor_report";
// 我們記進自己 ledger 的一筆帳。
export interface BillingRecord {
idempotencyKey: string; // 決定性冪等鑰匙(主鍵、唯一)
account: string;
stripeCustomerId: string;
eventName: string; // = COMPETITOR_REPORT
quantity: number; // 這次報了幾個 competitor-report
runId: string;
week: number;
reportedAt: string | null; // null = 還沒 flush 到 Stripe(就是 outbox 的待辦標記)
}
/**
* 把一次 digest run 換算成一筆待計費的帳。
* - 純函式:同樣輸入永遠同樣輸出,可離線單元測試。
* - 決定性鑰匙:`report:${runId}`——重跑同一次 run,鑰匙不變 → 不會重複計費。
*/
export function meterRun(run: RunUsage, stripeCustomerId: string): BillingRecord {
return {
idempotencyKey: `report:${run.runId}`, // ★ 決定性:綁邏輯 run,不綁「這次執行」
account: run.account,
stripeCustomerId,
eventName: COMPETITOR_REPORT,
quantity: run.competitorsReported, // 只算成功報出的(見 5.1 的 💡)
runId: run.runId,
week: run.week,
reportedAt: null, // 剛計量完,還沒上報
};
}
// 對帳(reconciliation):也是純邏輯——把一疊 ledger 列,加成「已上報 / 待上報」總量。
export interface Reconciliation {
reportedUnits: number;
pendingUnits: number;
byCustomer: Record<string, { reported: number; pending: number }>;
}
export function reconcile(records: BillingRecord[]): Reconciliation {
const r: Reconciliation = { reportedUnits: 0, pendingUnits: 0, byCustomer: {} };
for (const rec of records) {
const slot = (r.byCustomer[rec.stripeCustomerId] ??= { reported: 0, pending: 0 });
if (rec.reportedAt) {
r.reportedUnits += rec.quantity;
slot.reported += rec.quantity;
} else {
r.pendingUnits += rec.quantity;
slot.pending += rec.quantity;
}
}
return r;
}
💡 對帳為什麼只加用量(units)、不加金額?因為第二節那條分工線:用量是你算的,定價是 Stripe 算的。 「這個客戶這期用了 12 個 competitor-report」是你該對得清清楚楚的;「12 個該收多少錢(含不含額度、有沒有級距折扣)」是 Stripe 的 Price 在算。所以「對得起帳」的具體含意是:你 ledger 裡的已上報 units,要等於 Stripe 那邊 meter 收到的 units。 金額對不對,是這個等式成立後、Stripe 自己保證的事。
5.3 seam:BillingSink 介面,與 stub 假 Stripe
把「真打 Stripe」藏在一個介面後面,graph 和 flush 邏輯只依賴這個介面、不直接依賴 stripe 這個 SDK。這就是 seam:
// billing-sink.ts —— seam:上報用量的介面。真實作打 Stripe,測試時換 stub。
export interface MeterEvent {
eventName: string;
identifier: string; // ★ 冪等安全網:重複的 identifier 會被 Stripe 忽略
stripeCustomerId: string;
value: number; // 這次上報的量
}
export interface BillingSink {
reportMeterEvent(e: MeterEvent): Promise<void>;
}
// stub 假 Stripe:記憶體版,模擬「用 identifier 去重」的真實行為。零金鑰、零網路。
export class StubBillingSink implements BillingSink {
readonly seen = new Map<string, MeterEvent>();
async reportMeterEvent(e: MeterEvent): Promise<void> {
if (this.seen.has(e.identifier)) return; // ← 模擬 Stripe 的 identifier 去重
this.seen.set(e.identifier, { ...e });
}
// 測試用:某個 customer 到目前為止被計了多少量(就像 Stripe 那邊的 meter 總數)。
totalFor(stripeCustomerId: string): number {
return [...this.seen.values()]
.filter((e) => e.stripeCustomerId === stripeCustomerId)
.reduce((sum, e) => sum + e.value, 0);
}
}
StubBillingSink 只做一件關鍵的事:用 identifier 去重——這正是真 Stripe 的行為。有了它,我們就能離線驗證「同一筆送兩次,Stripe 只算一次」,完全不用真的打 Stripe。
5.4 billing ledger:你的真理來源(記憶體版,可離線)
ledger 也藏在介面後面。這裡給一個記憶體版(跑測試、跑 demo 用),正式上線換成 Postgres 只換這個實作、其他不動——就像第 7 課的 checkpointer 從 MemorySaver 換 Postgres 一樣:
// ledger.ts —— 你的真理來源。記憶體版可離線;上線換 Postgres 只換這個實作。
import type { BillingRecord } from "./metering";
export interface BillingLedger {
record(rec: BillingRecord): Promise<void>; // 冪等寫入:同鑰匙只留一筆
pending(): Promise<BillingRecord[]>; // 還沒 flush 的(outbox 待辦)
markReported(idempotencyKey: string, at?: string): Promise<void>;
all(): Promise<BillingRecord[]>; // 對帳用
}
export class InMemoryLedger implements BillingLedger {
private rows = new Map<string, BillingRecord>();
async record(rec: BillingRecord): Promise<void> {
// ★ 第一層冪等:ON CONFLICT DO NOTHING——同一把鑰匙已存在,就當沒發生。
if (this.rows.has(rec.idempotencyKey)) return;
this.rows.set(rec.idempotencyKey, { ...rec });
}
async pending(): Promise<BillingRecord[]> {
return [...this.rows.values()].filter((r) => r.reportedAt === null);
}
async markReported(key: string, at = new Date().toISOString()): Promise<void> {
const row = this.rows.get(key);
if (row) row.reportedAt = at;
}
async all(): Promise<BillingRecord[]> {
return [...this.rows.values()];
}
}
💡 上線那張 Postgres 表,重點就在主鍵 + 那句 upsert:
CREATE TABLE billing_ledger ( idempotency_key TEXT PRIMARY KEY, -- ★ 唯一主鍵=第一層冪等的地基 account TEXT NOT NULL, stripe_customer_id TEXT NOT NULL, event_name TEXT NOT NULL, quantity INTEGER NOT NULL, run_id TEXT NOT NULL, week INTEGER NOT NULL, reported_at TIMESTAMPTZ -- NULL = 還沒 flush(outbox 待辦) ); -- 寫入時:INSERT ... ON CONFLICT (idempotency_key) DO NOTHING;語意跟記憶體版一模一樣,只是這次寫進資料庫、跨程序都在。這張表跟第 10 課那個接 Postgres 的 checkpointer 用同一個資料庫、按同一套多租戶邊界隔離——歸戶靠的就是每列都帶著
account。(怎麼接 Postgres、怎麼做租戶隔離是第 10 課;這裡只是把計費帳放進那個已經打好的地基。)
5.5 上報:flush(把 outbox 推去 Stripe,可重試)
flush 把 ledger 裡「還沒上報」的列,一列列送去 Stripe。關鍵在順序:先送 Stripe,成功了才把本地標成已上報。
// flush.ts —— 把 ledger 的待辦(outbox)推去 Stripe。可以跑多勤、重跑幾次都安全。
import type { BillingLedger } from "./ledger";
import type { BillingSink } from "./billing-sink";
export async function flushToStripe(
ledger: BillingLedger,
sink: BillingSink,
): Promise<number> {
const pending = await ledger.pending();
let flushed = 0;
for (const rec of pending) {
// 先送 Stripe(帶決定性 identifier),成功回來,才把本地標成已上報。
await sink.reportMeterEvent({
eventName: rec.eventName,
identifier: rec.idempotencyKey, // ★ 第二層冪等:Stripe 端也用它去重
stripeCustomerId: rec.stripeCustomerId,
value: rec.quantity,
});
await ledger.markReported(rec.idempotencyKey);
flushed++;
}
return flushed;
}
為什麼是「先送、再標」這個順序,不能反過來?想那個當機情境:如果你先標「已上報」、再送 Stripe,中間當機,那筆就永遠不會被送出去了——漏帳。 反過來「先送、再標」,若在「送出成功」和「標記」之間當機,那筆還是 pending,下次 flush 會再送一次——但因為帶著同一個 identifier,Stripe 直接去重,不會重複計費。這就是「at-least-once 送出 + 冪等接收端」:寧可多送、絕不漏送,靠冪等把多送的那幾次吸收掉。
5.6 接進 graph:一個 meter 收尾節點
計量在哪觸發?就在 graph 的最尾巴——run 送完週報之後,加一個 meter 節點,讓「這次 run」以替自己記一筆帳作為收尾。這很符合我們「大腦在後端 graph」的定位:一次執行的最後一件事,是誠實地登記自己用了多少。
// graph-meter-node.ts —— 加在第 6 課那張 digest graph 的最尾巴(send 之後)。
import type { LangGraphRunnableConfig } from "@langchain/langgraph";
import { meterRun } from "./metering";
import type { BillingLedger } from "./ledger";
import type { RunUsage, TenantBilling } from "./upstream";
// 這個節點只寫「自己的 ledger」,刻意不打 Stripe——上報是第 5.5 解耦出去的另一步。
export function makeMeterNode(deps: {
ledger: BillingLedger;
// 第 10 課的租戶表:account → Stripe customer。
resolveTenant: (account: string) => Promise<TenantBilling>;
}) {
return async function meterNode(
state: { runUsage: RunUsage }, // 第 9 課的收尾把 RunUsage 收在 state 裡
_config: LangGraphRunnableConfig,
): Promise<Partial<{ billed: boolean }>> {
const run = state.runUsage;
const tenant = await deps.resolveTenant(run.account); // 歸戶:這次是誰的
await deps.ledger.record(meterRun(run, tenant.stripeCustomerId)); // 純邏輯 + 冪等寫入
return { billed: true };
};
}
注意這個節點只寫本地 ledger、不碰 Stripe——這就是 5.4 那條「別讓金流商拖垮核心工作」的紀律落地:就算 Stripe 這一刻整個掛了,週報照樣送出、帳照樣記進本地 ledger,等 flush 稍後慢慢補推。graph 的核心工作(送出有價值的週報)和金流商的可用性,被乾淨地脫鉤了。
💡 flush 誰來觸發?可以在 run 收尾後順手跑一次,也可以擺一個每幾分鐘跑一次的小定時任務——因為 flush 是冪等的,跑多跑少都不會出錯。至於這個定時任務、以及整個服務怎麼包成能對外站著的東西(HTTP server、金鑰、觀測),是第 12 課的事,這裡不展開。
5.7 離線測:把「不重複計費、對得起帳」釘死
現在收割 seam 切乾淨的回報。以下測試一把金鑰都不用、一個網路都不打,卻把這一課最關鍵的可靠性性質全部釘死:
// metering.test.ts —— vitest 風格,離線,零金鑰、零網路。
import { describe, it, expect } from "vitest";
import { meterRun, reconcile, COMPETITOR_REPORT } from "./metering";
import { StubBillingSink } from "./billing-sink";
import { InMemoryLedger } from "./ledger";
import { flushToStripe } from "./flush";
import type { RunUsage } from "./upstream";
const runW27: RunUsage = {
runId: "acme-corp:w27", account: "acme-corp", week: 27,
competitorsReported: 3, llmCostUsd: 0.12,
};
describe("計量是純邏輯、決定性", () => {
it("同一次 run 永遠算出同一把冪等鑰匙", () => {
const a = meterRun(runW27, "cus_acme");
const b = meterRun(runW27, "cus_acme");
expect(a.idempotencyKey).toBe(b.idempotencyKey); // 決定性
expect(a.idempotencyKey).toBe("report:acme-corp:w27");
expect(a.quantity).toBe(3);
expect(a.eventName).toBe(COMPETITOR_REPORT);
});
});
describe("不重複計費", () => {
it("同一個 run 的計量寫兩次,ledger 只留一筆", async () => {
const ledger = new InMemoryLedger();
await ledger.record(meterRun(runW27, "cus_acme"));
await ledger.record(meterRun(runW27, "cus_acme")); // 重試/重跑又寫一次
expect(await ledger.all()).toHaveLength(1); // ★ 第一層冪等:只留一筆
});
it("同一個 identifier 送兩次 Stripe,只計一次(第二層安全網)", async () => {
const sink = new StubBillingSink();
const e = {
eventName: COMPETITOR_REPORT, identifier: "report:acme-corp:w27",
stripeCustomerId: "cus_acme", value: 3,
};
await sink.reportMeterEvent(e);
await sink.reportMeterEvent(e); // 手滑重送
expect(sink.totalFor("cus_acme")).toBe(3); // 不是 6
});
it("端到端重跑同一週:先跑一次、再整週重跑,Stripe 只計一次", async () => {
const ledger = new InMemoryLedger();
const sink = new StubBillingSink();
// 第一次跑:計量 → flush
await ledger.record(meterRun(runW27, "cus_acme"));
await flushToStripe(ledger, sink);
// 因為某個 bug,把 acme-corp 第 27 週整個重跑一次(同一邏輯 run,runId 不變)
await ledger.record(meterRun(runW27, "cus_acme"));
await flushToStripe(ledger, sink);
expect(await ledger.all()).toHaveLength(1); // ledger 只有一筆
expect(sink.totalFor("cus_acme")).toBe(3); // Stripe 只計 3,不是 6
});
it("送出後、標記前當機:重送同一 identifier 也不會重複計費", async () => {
const ledger = new InMemoryLedger();
const sink = new StubBillingSink();
await ledger.record(meterRun(runW27, "cus_acme"));
await flushToStripe(ledger, sink); // 正常送出一次
// 模擬「Stripe 收了、但 markReported 前當機」:下次 flush 會拿同一個 identifier 再推一次
await sink.reportMeterEvent({
eventName: COMPETITOR_REPORT, identifier: "report:acme-corp:w27",
stripeCustomerId: "cus_acme", value: 3,
});
expect(sink.totalFor("cus_acme")).toBe(3); // 冪等吸收掉重送,還是只算一次
});
});
describe("對得起帳(對帳)", () => {
it("ledger 的已上報 units,等於 Stripe 端看到的 units", async () => {
const ledger = new InMemoryLedger();
const sink = new StubBillingSink();
await ledger.record(meterRun(runW27, "cus_acme"));
await ledger.record(meterRun(
{ runId: "acme-corp:w28", account: "acme-corp", week: 28, competitorsReported: 4, llmCostUsd: 0.15 },
"cus_acme",
));
await flushToStripe(ledger, sink);
const recon = reconcile(await ledger.all());
expect(recon.reportedUnits).toBe(7); // 3 + 4
expect(recon.pendingUnits).toBe(0); // 都推出去了
expect(sink.totalFor("cus_acme")).toBe(recon.reportedUnits); // ★ 對得起帳:兩邊相等
});
it("只計成功報出的競品——被降級略過的不進帳", async () => {
// 這週 4 個競品,其中 1 個抓取失敗被略過 → 第 9 課的 competitorsReported 已經是 3。
const degraded: RunUsage = {
runId: "acme-corp:w29", account: "acme-corp", week: 29,
competitorsReported: 3, llmCostUsd: 0.10, // 交付 3 個,不是名單上的 4 個
};
const rec = meterRun(degraded, "cus_acme");
expect(rec.quantity).toBe(3); // 只收交付出去的價值
});
});
跑 npx vitest run,全綠。你剛剛在完全沒有 Stripe 金鑰的情況下,證明了這套計量:
- 決定性——同一次 run 永遠同一把鑰匙;
- 不重複計費——寫兩次、送兩次、重跑一週、送出後當機重送,每一種都只算一次;
- 對得起帳——你 ledger 的已上報 units 等於 Stripe 收到的 units;
- 誠實——只收交付出去的價值,降級略過的不進帳。
這正是把 seam 切在 Stripe 上的紅利:產品裡最不能出錯的那一段(計費),是全套系統裡最好測、最不用花錢跑、CI 裡就能守死的一塊。 真金流商會有 timeout、會維護、會改 API,但你的計量與對帳邏輯,永遠可以離線用 stub 測到滴水不漏。
六、真打 Stripe:把它接起來(示範,不進 CI)
離線把邏輯守死了,現在把 seam 的另一側接上真 Stripe——這一段需要金鑰、跑 Stripe 測試模式、不進 CI。
6.1 一次性設定:建 Meter、建兩個 Price、掛上 Subscription
這幾步通常在 Stripe Dashboard 點一點就好,這裡用 API 寫出來讓你看清楚對應關係(跑一次即可):
// setup-stripe.ts —— 一次性設定(示範,需要金鑰)。跑一次,或在 Dashboard 點好。
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function setup() {
// 1) 建一個 Meter:一個叫 competitor_report 的用量計數器。
// event_name 要跟 metering.ts 裡的 COMPETITOR_REPORT 完全一致。
const meter = await stripe.billing.meters.create({
display_name: "Competitor reports",
event_name: "competitor_report",
default_aggregation: { formula: "sum" }, // 一期內把 value 全加起來
customer_mapping: { type: "by_id", event_payload_key: "stripe_customer_id" },
value_settings: { event_payload_key: "value" },
});
// 2) metered Price:綁在這個 meter 上,決定「一個 competitor-report 多少錢」。
// (含不含額度、要不要級距,都在這裡設——用量計費你算,定價 Stripe 算。)
const meteredPrice = await stripe.prices.create({
currency: "usd",
unit_amount: 50, // $0.50 / 個
recurring: { interval: "month", usage_type: "metered", meter: meter.id },
product_data: { name: "競品情報週報 · 用量" },
});
// 3) 固定月費 Price:$149/月,跟用量無關。
const basePrice = await stripe.prices.create({
currency: "usd",
unit_amount: 14900, // $149
recurring: { interval: "month" },
product_data: { name: "競品情報週報 · 月費" },
});
console.log({ meter: meter.id, meteredPrice: meteredPrice.id, basePrice: basePrice.id });
}
setup();
客戶第一次訂閱時,你建一個 Customer、開一個掛著這兩個 Price 的 Subscription,然後把 customer.id 存進第 10 課那張租戶表(meterNode 裡 resolveTenant 讀的就是它):
// subscribe.ts —— 客戶點「開始訂閱」時(示範)。
async function subscribe(account: string, email: string) {
const customer = await stripe.customers.create({ email, metadata: { account } });
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: BASE_PRICE_ID }, { price: METERED_PRICE_ID }], // 月費 + 用量,同一張訂閱
});
// ★ 把 account → customer.id 存進第 10 課的租戶表,之後 meterNode 查得到。
await saveTenantBilling({ account, stripeCustomerId: customer.id });
}
6.2 真的 BillingSink:換掉 stub 就好
seam 切乾淨的最後一份回報:真 Stripe 版的 sink,就是實作同一個介面。graph、flush、測試一個字都不用改,只是把注入的 StubBillingSink 換成它:
// billing-sink.stripe.ts —— 真打 Stripe(示範,需要金鑰,不進 CI)。
import Stripe from "stripe";
import type { BillingSink, MeterEvent } from "./billing-sink";
export function stripeBillingSink(secretKey: string): BillingSink {
const stripe = new Stripe(secretKey);
return {
async reportMeterEvent(e: MeterEvent): Promise<void> {
await stripe.billing.meterEvents.create({
event_name: e.eventName,
identifier: e.identifier, // ★ 就是這裡:Stripe 用它去重(第二層冪等)
payload: {
stripe_customer_id: e.stripeCustomerId,
value: String(e.value), // meter event 的 payload 值是字串
},
});
},
};
}
對照一下 StubBillingSink:stub 用一個 Map 靠 identifier 去重、真版把同一個 identifier 交給 Stripe 去重。兩者對外行為一致——這就是為什麼你在 5.7 用 stub 驗證過的「不重複計費」,換上真 Stripe 依然成立。上線就是一行注入的差別:
// 離線 / CI:
const sink = new StubBillingSink();
// 上線(示範):
const sink = stripeBillingSink(process.env.STRIPE_SECRET_KEY!);
6.3 開發票這一段:其實你幾乎不用做事
meter event 送進去之後,「開發票」基本上是 Stripe 自動的:期末它把固定月費 + 這期 meter 聚合出來的用量,算成一張 Invoice、向客戶的卡自動扣款。你要做的,只是在扣款成功/失敗時知道一聲——靠 webhook:
invoice.paid→ 這個租戶付清了,繼續服務。invoice.payment_failed→ 扣款失敗,進入催款(dunning);連續失敗可以把這個租戶的 digest 暫停(在排程那層擋掉,不再替沒付錢的人燒 LLM)。
// 概念示意:webhook 收到扣款結果,更新租戶狀態。完整實作在第 12 課。
function onStripeWebhook(event: Stripe.Event) {
if (event.type === "invoice.payment_failed") {
const inv = event.data.object as Stripe.Invoice;
pauseTenantIfDelinquent(inv.customer as string); // 沒付錢就別再替他跑
}
}
⚠️ 這個 webhook 端點是外面打得到你的入口,一定要驗簽章(
stripe.webhooks.constructEvent用 webhook secret 驗),否則任何人偽造一個invoice.paid就能白嫖你的服務。但驗簽、開這個 HTTP 端點、管金鑰、CORS、觀測——這一整套「把服務對外站起來」的硬化,是第 12 課的事,本課點到為止:你只要知道發票與扣款是 Stripe 自動做的,你的責任是安全地聽它的結果、據此開關服務。
💡 你可能看過另一種舊寫法:
stripe.subscriptionItems.createUsageRecord(...)(usage records)。那是 Stripe 較舊的計量 API。本課用的 Billing Meters + Meter Events 是較新、Stripe 現在推薦的做法,identifier冪等也更乾淨。看到舊教學用 usage records 別緊張,概念(回報用量給金流商去聚合)是一樣的,只是原語換了。
6.4 控制面上讓客戶看見用量(順帶一提,大腦仍在後端)
客戶會想知道「我這個月用了多少、快超額了嗎」。這塊就放進第 5 課那個 control panel——但注意分工:用量的真相在後端(你的 ledger / reconcile),control panel 只是去後端讀一個數字、畫出來:
// 後端多一個唯讀端點:這個租戶這期用了多少(讀自己的 ledger,不打 Stripe)。
// 掛上 HTTP、加驗證是第 12 課;這裡只示意資料從哪來。
async function usageThisPeriod(account: string) {
const rows = (await ledger.all()).filter((r) => r.account === account);
const { byCustomer } = reconcile(rows);
return byCustomer; // { cus_xxx: { reported, pending } } → 前端畫成「本月已用 N 個」
}
這再一次守住第 1 課的定位:control panel 是後端 graph 的控制面,不是大腦。 它顯示用量、讓客戶點「升級方案」,但「這個月到底用了多少、該收多少」這個真相,始終長在後端那張 ledger 上——不在瀏覽器裡。
這一課建立了什麼
把這一課收攏成你能帶走的心智模型:
- 用量計費(metered billing)= 計量 → 上報 → 開發票,橫跨兩個世界:你的世界負責計量(純邏輯)+記進自己的 billing ledger(真理來源);Stripe 的世界負責聚合 meter event 並開發票、扣款。兩個世界的 seam 是 meter event。分工一句話:用量是你算的,定價是 Stripe 算的。
- 計價單位=competitor-report(一次 run 裡成功報出的一個競品),它同時吃住「跑了幾次、報了幾個競品」。單價必須壓過第 9 課的單位成本,第 1 課「單位經濟為正」才在每個計價單位上成立。收法通常是月費 + 額度 + 超量計量。
- 可靠 = 冪等 + 對帳。冪等靠兩層:(1) 你的 ledger 用決定性冪等鑰匙
report:${runId}(ON CONFLICT DO NOTHING)=主力、真理來源;(2) Stripe meter event 的identifier=安全網(有時間窗,別拿它當地板)。鑰匙必須綁邏輯 run、不綁「這次執行」,重跑同一週才不會重複計費。對帳=你 ledger 的已上報 units 等於 Stripe 收到的 units。 - 計量歸計量、上報歸上報:run 收尾在 graph 尾巴的
meter節點寫本地 ledger(幾乎不會失敗),「flush 去 Stripe」解耦成可重試的另一步(outbox)——絕不讓金流商的可用性拖垮送週報這件核心正事。flush 冪等,跑多勤都安全。 - 可離線測:計量(
meterRun)與對帳(reconcile)是純邏輯,坐在 seam 純計算這一邊;用 stub 假 Stripe(StubBillingSink,靠identifier去重)+記憶體版 ledger,零金鑰、零網路就把「不重複計費、對得起帳、只收交付價值」全部釘死在 CI 裡。真打 Stripe(stripeBillingSink、billing.meterEvents.create)只當示範、不進 CI,上線只換一行注入。 - 這是四件事的最後一角:能收費吃前三根柱子的產出(穩定跑出價值、算得清成本、分得清租戶),所以天生排最後;補上它,第 1 課那條「demo → 產品」「誰扛累活,誰收月費」的主線就閉合了——你手上的東西,從一個你自掏腰包的免費工具,變成一門單位經濟為正、有 MRR 的生意。
四根柱子,到這裡全立起來了:一個會記得上週、只報 diff,單位經濟為正、每個租戶隔離、而且真的能開發票收錢的產品。它在你的機器上、在測試裡,跑得漂亮。
只剩最後一個問題:它還沒真正「站」在網路上。 LangGraph.js 沒有內建 HTTP server(第 1 課就講在明處了),你這張會計量、會收 webhook 的 graph,現在還住在你的 npx tsx、你的測試裡。下一課(第 12 課)是收尾:我們親手把它包成一個能對外站著的 Node 服務——HTTP server、CORS、金鑰管理、webhook 簽章驗證、觀測/tracing——讓這四根立好的柱子,真的撐起一棟能對外營業的房子。(部署那一段回指 no-logic-trade 的 DevOps 子站。)出貨在望了。