第 11 課 · 收費:把用量變成帳單——Stripe 用量計費與發票

第 11 課 · 收費:把用量變成帳單——Stripe 用量計費與發票

月底了。你打開兩個分頁。

左邊那個分頁是你的產品後台:12 個「設計夥伴」(design partner)正在用你的競品情報週報。每週一凌晨三點,graph 準時醒來,抓料、去重、摘要、比對,只報 diff,起草、等分析師審一眼、送出。它記得上週(第 7 課)、單位經濟為正(第 9 課)、每個租戶資料互相隔離(第 10 課)。從第 1 課到第 10 課,你把一個真正的產品該有的骨架,一塊一塊焊上去了。它很完整,跑得很漂亮

右邊那個分頁是兩張帳單。OpenAI 這個月跟你收 $340。Stripe 這個月幫你收到的錢是……$0

因為你根本還沒接 Stripe

這 12 個設計夥伴每週準時收到有價值的情報、每週都在替他們省下半天的人力——而你,每週替他們墊付 LLM 的錢。這不是生意,是你自掏腰包的慈善。第 1 課那句話,此刻正指著你的鼻子:

demo 回答的是「模型能不能做到這件酷事一次」;產品回答的是「一個陌生人願不願意為它每個月付錢」。

你的東西已經好到值得付費了。這一課,我們補上讓「值得付費」變成「真的在付費」的最後一步:把用量變成帳單。

這一課回答三個問題:

  1. 怎麼把第 9 課算出的用量/成本、第 10 課的每租戶歸戶,接上 Stripe 的用量計費(metered billing),把「跑了幾次、報了幾個競品」變成可收的月費/用量費?
  2. 「計量 → 上報 → 開發票」這條路怎麼設計成可靠——不重複計費、對得起帳?而計量這段純邏輯,怎麼像 diff、去重那樣離線測
  3. 為什麼「能收費」是四件事的最後一角——補上它,你手上的東西才從 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 從 MemorySaverPostgres 一樣:

// 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 課那張租戶表meterNoderesolveTenant 讀的就是它):

// 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 用一個 Mapidentifier 去重、真版把同一個 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 假 StripeStubBillingSink,靠 identifier 去重)+記憶體版 ledger,零金鑰、零網路就把「不重複計費、對得起帳、只收交付價值」全部釘死在 CI 裡。真打 Stripe(stripeBillingSinkbilling.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 子站。)出貨在望了。