#5870:把 Team billing 的席位数量交给 Stripe,同时让 workspace pool 可重算
今日锚点 #5870 是这条线从“workspace 能买 seat、pool 能付款”继续向前的一步:它把 seat-count mutation 从本地 PATCH 改成 Stripe hosted confirmation,并把 #5842 的 webhook-driven pool funding 改为消…
source_automation: voyager-merged-pr run_date: 2026-08-11 anchor_pr_number: 5870 pr_number: 5870 pr_title: “feat(billing): let Stripe’s portal change how many seats a workspace pays for” pr_url: https://github.com/adastralab-ai/voyager/pull/5870 ↗ author: “Shawn Hu / huxwfun” merged_at: “2026-08-11T03:00:15Z” modules:
- backend/base/db/migration/000111_credit_buckets_funded_allotment.down.sql
- backend/base/db/migration/000111_credit_buckets_funded_allotment.up.sql
- backend/base/infra/stripe/modules/stripe/portal.tf
- backend/base/infra/stripe/modules/stripe/products.tf
- backend/go/apps/api/configs/default.yml
- backend/go/apps/api/configs/dev.yml
- backend/go/apps/api/contract_test/billing/endpoint/team_checkout_test.go
- backend/go/apps/api/contract_test/billing/endpoint/team_seats_test.go
- backend/go/apps/api/contract_test/billing/webhook/team_pool_test.go
- backend/go/apps/api/contract_test/billing/webhook/team_subscription_row_test.go
- backend/go/apps/api/contract_test/billing/webhook/team_routing_test.go
- backend/go/apps/api/contract_test/credits/e2e/pool_derived_test.go
- backend/go/apps/api/contract_test/testutil/capture.go
- backend/go/apps/api/handler/workspace_billing.go
- backend/go/internal/billing/model.go
- backend/go/internal/billing/reconcile.go
- backend/go/internal/billing/service.go
- backend/go/internal/billing/webhook.go
- backend/go/internal/billing/workspace_billing.go
- backend/go/internal/billing/workspace_checkout_test.go
- backend/go/internal/credits/model.go
- backend/go/internal/credits/module.go
- backend/go/internal/credits/pool.go
- backend/go/internal/credits/repo.go
- backend/go/internal/credits/service.go
- backend/go/pkg/gen/api/gin.gen.go
- backend/go/pkg/gen/api/types.gen.go
- backend/go/pkg/gin/middleware/breaking-changes.json
- backend/workers/agent/src/gen/api/index.ts
- backend/workers/agent/src/gen/api/sdk.gen.ts
- backend/workers/agent/src/gen/api/types.gen.ts
- idl/openapispecs/api.yml
- idl/typespec/services/billing.tsp
- packages/site/src/gen/api/@tanstack/react-query.gen.ts
- packages/site/src/gen/api/index.ts
- packages/site/src/gen/api/sdk.gen.ts
- packages/site/src/gen/api/transformers.gen.ts
- packages/site/src/gen/api/types.gen.ts
- packages/site/src/gen/api/zod.gen.ts files_changed: 39 learning_tags:
- workspace-billing
- stripe-customer-portal
- seat-count
- stripe-webhook
- derived-credit-pool
- funded-allotment
- billing-period
- idempotent-upsert
- postgres-concurrency
- cross-layer-api
- generated-contract
- failure-recovery
- integration-testing
- rollout-readiness business_line: “Team billing 的 seat count、workspace pool 账期推导与 credits 付款边界” related_prs: [5817, 5820, 5821, 5833, 5842] line_stage: “PlanTeam 移除与 seat entitlement -> workspace checkout/webhook -> event-driven pool funding -> Stripe portal seat count -> 消费边界按订阅推导并以 funded_allotment 幂等补齐” open_questions:
- “AssignSeat、RevokeSeat、UpdateSeatLimit 仍是显式 not implemented;没有真实 seat assignment,就没有生产成员可以通过 ResolveFunding 触达这个 pool。”
- “staging/prod 的 team price_ids 与 team_portal_configuration_id 仍为空;代码会明确拒绝,但 Stripe products、portal 配置、旧对象归档和 live apply 尚未形成可复查的部署证据。”
- “Stripe 按剩余账期收新增席位的 proration,但 pool 按新增席位发整月额度;每席最多多拿一个月,是否接受这个可量化套利需要产品/财务确认。”
- “000111 的旧 pool backfill 只能用 balance 近似 funded_allotment;已消费的旧 pool 会被低估,迁移锁与线上 credit debit 并发需要部署窗口验证。”
- “Stripe subscription 仍限制为一种 seat kind,数据结构却保留多 price-key 求和能力;未来支持混买时,portal flow、seat assignment 和 pool allotment 需要一起重审。”
- “GET credits 与 Agent/task debit 都可能触发 pool funding,读路径因此会写 credit_buckets;需要确认这种 read-triggered materialization 的审计、延迟和重试语义。”
- “测试覆盖 fake Stripe、真实 Postgres 和 HTTP contract,但没有部署级 checkout -> webhook/sync -> seat assignment -> pool derivation -> debit/refund -> browser gate 链路。” feishu_doc_url: null
业务线概览#
这条业务线解决两个互相牵制的问题:
- workspace 购买的 seat 数量要能改变,而且付款、proration、3DS 和减席位到期处理不应该由 Voyager 重写一遍;
- workspace pool 不能依赖某一次 webhook 才“发出来”,否则事件丢失会永久丢 credits;但它又必须在 seat 数、账期和付款状态变化时保持幂等、可并发重算。
当前业务线已经把责任拆到三个边界:
- billing 负责 workspace 的 Stripe customer、subscription row、seat price 映射、seat holder 资格和 Stripe portal URL;
- credits 负责 seat 类型对应的月度 allotment、pool bucket 的原子补齐、单一付款方 debit 和按 debit bucket refund;
- TypeSpec 是 HTTP 契约源头,Go、Agent worker SDK、site SDK 和 breaking-changes map 都由这次契约变更同步生成。
今日锚点 #5870 ↗ 是这条线从“workspace 能买 seat、pool 能付款”继续向前的一步:它把 seat-count mutation 从本地 PATCH 改成 Stripe hosted confirmation,并把 #5842 的 webhook-driven pool funding 改为消费前/读取时按订阅推导。需要保持边界意识:这次合并没有实现成员分配、前端 Team billing 面板或部署后的 Stripe 配置,因此它是账务和运行时基础,不是完整 Team 产品闭环。
今日锚点#
- 标题:feat(billing): let Stripe’s portal change how many seats a workspace pays for ↗
- 编号:#5870
- 作者:Shawn Hu / huxwfun
- merge 时间:2026-08-11 03:00:15 UTC,即 Australia/Melbourne 2026-08-11 13:00:15
- merge commit:923920bb6c5937c67292f81be9244522953fe76e
- 主线证据:merge commit 923920bb6c5937c67292f81be9244522953fe76e 已由 git merge-base —is-ancestor 验证为 origin/main 的祖先;本次刷新后的 origin/main 为 a2092616c6cdc113a25069c049e937a8a88d12f3。
- 选择理由:2026-08-11 Melbourne 窗口内有两个未作为锚点学习过的 PR。#5870 修改 39 个文件,贯穿 TypeSpec、Stripe Terraform、Go billing/credits、迁移和真实 contract/e2e 测试,能自然串起至少五个有代码关系的 PR;#5869 只有 credit-ledger PostHog view 的两文件 analytics 变更,无法在今天窗口内自然形成同等完整的业务线。
演进时间线#
| 阶段 | PR | 改变的层 | 代码证据与意义 |
|---|---|---|---|
| 从个人 Plan 中移除 Team | #5817 ↗,2026-08-10 00:27 Melbourne | TypeSpec、billing model、entitlement | 删除 PlanTeam;PlanForPriceKey 不再把 team price 当成个人 plan,seat price 改由 SeatTypeForPriceKey 读取。seat 是 workspace 购买的能力来源,不是个人 plan ladder 的一个 rung。 |
| seat 授予能力 | #5820 ↗,2026-08-10 10:43 Melbourne | billing entitlement/runtime | 个人 entitlement 作为基线,再按 active Basic/Pro seat 做字段级 widen;GUEST 不算有效成员。它回答“能做什么”,但不回答“这次操作谁付款”。 |
| workspace pool 进入运行时 payer 选择 | #5821 ↗,2026-08-10 13:09 Melbourne | billing、credits、task、agent、ledger | ResolveFunding 绑定操作 workspace;pool 只有能覆盖整笔 task 才成为 payer,否则整笔回落个人;refund 按原 debit 的 bucket_id 回退。#5821 是上一份学习报告的锚点。 |
| workspace 购买与 webhook 路由 | #5833 ↗,2026-08-10 19:28 Melbourne | Stripe checkout、workspace subscription、webhook | 独立 workspace Stripe customer,checkout 在 subscription metadata 写 workspaceId,webhook 按 metadata 更新 workspace_subscriptions。AssignSeat、pool seeding、seat count mutation 当时仍未完成。 |
| 首版事件驱动 pool funding | #5842 ↗,2026-08-10 22:13 Melbourne | webhook、credits pool/repository | subscription event 更新 workspace row 后直接调用 credits.SyncWorkspacePool;period boundary 的 SQL upsert 防止同一账期重复覆盖,但 pool 仍把 webhook 当成唯一注资触发器。 |
| Stripe hosted seat-count 与可重算 pool | #5870 ↗,2026-08-11 13:00 Melbourne | Stripe portal、TypeSpec/生成契约、billing、credits、migration、tests | 用 seatCountPortal 生成预填数量的 Stripe confirmation,不在 Voyager 里改 subscription;WorkspacePortal/sync 负责行状态新鲜度;webhook 只更新 row;credits 在 pool 被读/花前按 row 推导,并以 funded_allotment 做高水位幂等补齐。 |
前置的 #5815(stop PlanResolution leaking the subscription row)已经在上一份 #5821 报告中作为 formal related PR 阅读过;本次不把它重复计入 related_prs,而是直接沿用它建立的“个人 billing facts 与 capability/funding 分离”边界。
这条线的演进不是简单地把旧接口换成新接口,而是把两个易丢失的外部事实分别放回正确的权威边界:
- 金钱和 3DS 由 Stripe hosted portal 负责;
- pool credits 由本地持久化的 subscription snapshot 在消费边界按状态函数推导。
当前架构与数据流#
flowchart LR
A["Workspace checkout"] --> B["Stripe workspace customer"]
B --> C["subscription metadata: workspaceId"]
C --> D["webhook or /billing/sync"]
D --> E["workspace_subscriptions: stripe_items + period"]
E --> F["active seat + workspace membership"]
F --> G["ResolveFunding(user, workspace)"]
G --> H["credits read / task debit / Agent turn debit"]
H --> I["ensurePoolFunded"]
I --> J["funded_allotment atomic upsert"]
J --> K["choose one payer"]
K --> L["credit_buckets + credit_transactions"]
L --> M["refund by debit.bucket_id"]plaintext- 购买入口。 #5833 的 WorkspaceCheckout 使用 workspace 自己的 Stripe customer,先在本地写 pending workspace subscription,再创建 checkout session,并通过 subscription_data.metadata 写入 workspaceId。#5870 把初次 checkout 输入从 seats 数组收成一个 seatType + count,因为 portal 对多 line-item subscription 不提供可编辑确认页。
- Stripe 侧 seat-count 入口。 #5870 新增 POST /api/workspaces/{id}/billing/seats/portal。handler 只做 owner 鉴权、body binding 和 service 转发;service 先检查 team portal configuration,再按本地记录的 subscription id retrieve Stripe 当前 subscription,从真实 subscription line item 找到唯一 recognized seat item,最后把目标 count 放入 subscription_update_confirm flow_data。它不向 Stripe subscription 发 POST,因此本地没有重复实现收费、proration 或 3DS。
- 状态同步。 owner 从 Stripe 页面返回时,POST /billing/sync 按 workspace 保存的 subscription id retrieve Stripe,并复用 applyWorkspaceSubscription 写回 status、stripe_items、subscription snapshot 和 period end。它不创建或补 pool,因为 credits 会在下一次 pool read/debit 时从同一行推导。普通 WorkspacePortal 会尽力先读 Stripe 更新本地 row,但如果 Stripe retrieve 失败仍打开 portal;seatCountPortal 则必须拿到 live item,读失败会返回错误。这两个不同的失败策略有明确测试覆盖。
- webhook。 subscription event 仍通过 subscription metadata 路由到 workspace row,并用 UpdateWorkspaceSubscriptionIfCurrent 的 SQL 条件防止旧 subscription 覆盖新 subscription。#5870 删除 billing service 对 credits 的 WorkspacePool setter 和 SyncWorkspacePool 调用;HandleWebhook 仍记录 audit 并返回 200,事件丢失不再等价于 credits 永久丢失。invoice.paid 只保留通知/审计职责,不再触发 pool funding。
- pool 推导。 billing.poolFunding 从 workspace subscription 的 stripe_items 计算 seat counts,从保存的 Stripe JSON snapshot 读取 period start,从 current_period_end 读取 period end。credits.ensurePoolFunded 先把 seat counts 转成每种 SeatType 的 monthly allotment,再检查当前 bucket 的 last_reset_period_start 与 funded_allotment;只有新账期或 allotment 增长才写入。
- 并发 debit。 credits 的 pool read 先无锁判断;没有刚需的请求不锁全 workspace。需要 funding 时,FundPoolForPeriod 在同一个 debit transaction 中完成,再用 FOR UPDATE 复读 pool,确认余额仍能覆盖整笔成本。一笔 task 不拆成 personal + pool 两个 payer;Agent turn 保留 #5821 的 post-hoc residue 规则。debit 的 bucket_id 是 refund 的唯一资金归属来源。
- 当前 API/前端边界。 TypeSpec、OpenAPI 和 generated Go/Agent/site clients 都有变化,breaking-changes.json 也同步更新;但本 PR 没有修改 packages/site 的业务 UI,也没有修改 backend/workers/agent 的 runtime gate,仅更新了生成客户端。因此合并后“接口已生成”不能推导出“用户面板已可操作”。
关键代码#
以下行号按本次刷新后 origin/main 的 merge 后文件标注;generated 文件只作为契约产物,不重复展开。
1. Plan 与 seat type 不再混成一个计费阶梯#
PR #5817,backend/go/internal/billing/model.go:59-105;PR #5820,backend/go/internal/billing/entitlements.go:21-55
var seatEntitlements = map[SeatType]Entitlements{
SeatTypeBasic: defaultEntitlements[PlanBasic],
SeatTypePro: defaultEntitlements[PlanPro],
}
func EntitlementsForSeatType(t SeatType) Entitlements {
if e, ok := seatEntitlements[t]; ok {
return e
}
return defaultEntitlements[PlanFree]
}plaintext#5817 让 PlanForPriceKey 不再把 team price 映射到 PlanTeam;#5820 再把 seat 的能力按字段并入个人 entitlement。这个拆分是 #5870 能把 pool allotment 按 SeatType 计算的前提:seat 的价格、能力、付款方和个人 plan 不再互相冒充。
2. checkout 把 workspace 身份写进 subscription,而不是只写 checkout session#
PR #5833,backend/go/internal/billing/workspace_billing.go:183-194
session, err := s.stripeClient.API.V1CheckoutSessions.Create(ctx, &stripe.CheckoutSessionCreateParams{
Customer: stripe.String(persisted.StripeCustomerID),
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
LineItems: []*stripe.CheckoutSessionCreateLineItemParams{{
Price: stripe.String(priceID),
Quantity: stripe.Int64(int64(in.Count)),
}},
SubscriptionData: &stripe.CheckoutSessionCreateSubscriptionDataParams{
Metadata: map[string]string{MetadataWorkspaceID: workspaceID.String()},
},
})plaintext这是事件路由的真正边界。#5870 的 pool derivation 读取的是被 webhook/sync 写回的 workspace row;如果 workspaceId 只存在 checkout session,subscription event 无法稳定找到 workspace,也就无法建立 seat counts 和 billing period。
3. seat-count mutation 只生成 Stripe confirmation,不直接改订阅#
PR #5870,backend/go/internal/billing/workspace_billing.go:263-312;handler diff
live, err := s.stripeClient.API.V1Subscriptions.Retrieve(ctx, *sub.StripeSubscriptionID, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to read the team subscription before opening the portal")
}
item, err := soleSeatItem(live, s.stripeClient.Config.PriceKeyForID)
if err != nil {
return nil, err
}
FlowData: &stripe.BillingPortalSessionCreateFlowDataParams{
Type: stripe.String(string(stripe.BillingPortalSessionFlowTypeSubscriptionUpdateConfirm)),
SubscriptionUpdateConfirm: &stripe.BillingPortalSessionCreateFlowDataSubscriptionUpdateConfirmParams{
Subscription: stripe.String(live.ID),
Items: []*stripe.BillingPortalSessionCreateFlowDataSubscriptionUpdateConfirmItemParams{{
ID: stripe.String(item.ItemID),
Quantity: stripe.Int64(int64(count)),
}},
},
},plaintextservice 从 Stripe live subscription 找 item,而不是信任可能落后的 stripe_items copy;这避免 owner 刚改完数量、webhook 尚未到达时,confirmation 指向已被替换的旧 line item。测试还断言 endpoint 没有 POST /v1/subscriptions/{id}。
4. webhook 只保持 subscription row,pool funding 由消费边界负责#
PR #5842 对比 #5870,backend/go/internal/billing/webhook.go:304-348;#5870 diff hunk
func (s *service) applyWorkspaceSubscriptionEvent(ctx context.Context, sub *stripe.Subscription, workspaceID uuid.UUID) error {
applied, err := s.applyWorkspaceSubscription(ctx, workspaceID, sub)
if err != nil {
return err
}
if !applied {
return errSkipStaleSubscription
}
return nil
}plaintext#5842 在这里继续调用 fundWorkspacePool -> credits.SyncWorkspacePool;#5870 删除了这条跨模块 setter。现在 webhook 的职责是把 Stripe 事实落到 workspace_subscriptions,sync 也复用同一个 apply 函数,pool 不再依赖事件是否成功到达。
5. funded_allotment 让“按状态推导”既可重试又不重复发放#
PR #5870,backend/go/internal/credits/pool.go:27-45;backend/go/internal/credits/repo.go:98-147;migration 000111
allotment, err := poolAllotment(pool.Seats)
if err != nil {
return false, err
}
if !poolFundingDue(buckets, allotment, pool.PeriodStart) {
return false, nil
}
if err := s.repo.FundPoolForPeriod(
ctx, tx, pool.WorkspaceID, allotment, pool.PeriodStart, pool.PeriodEnd,
); err != nil {
return false, err
}plaintextSQL upsert 的关键不是“每次读都覆盖 balance”,而是:
balance = CASE
WHEN credit_buckets.last_reset_period_start < EXCLUDED.last_reset_period_start
THEN EXCLUDED.balance
ELSE credit_buckets.balance
+ (EXCLUDED.funded_allotment - credit_buckets.funded_allotment)
END,
funded_allotment = EXCLUDED.funded_allotment
WHERE credit_buckets.last_reset_period_start < EXCLUDED.last_reset_period_start
OR (credit_buckets.last_reset_period_start = EXCLUDED.last_reset_period_start
AND credit_buckets.funded_allotment < EXCLUDED.funded_allotment)plaintext新账期覆盖旧余额;同账期新增 seat 只补 allotment 差额;旧账期或重复读不写。这个条件和 debit transaction 一起承担并发一致性,避免两个成员同时把同一增量发两遍。
6. 测试把外部时序和账务不变量拆开验证#
PR #5870,backend/go/apps/api/contract_test/credits/e2e/pool_derived_test.go:80-214;backend/go/apps/api/contract_test/billing/endpoint/team_seats_test.go:80-233
// no webhook has been delivered here
view := readCredits(t, c)
require.Len(t, view.Pools, 1)
assert.Equal(t, 2*proSeatMonthly, view.Pools[0].TotalAvailable)
// same period, more seats: add the difference and only once
setPool(t, wsID, 500)
givenSeatOnPaidWorkspace(t, ..., 3, start, end)
assert.Equal(t, 500+proSeatMonthly, view.Pools[0].TotalAvailable)plaintext同一个 e2e 文件还覆盖新账期重置、past_due 不发放、同账期减席位不减少 pool;billing endpoint contract 则覆盖 confirmation 不写 subscription、多 line-item 返回 400、sync 读到 Stripe 新数量、portal 读回取消状态,以及 Stripe 读取失败时仍允许普通 portal 打开。代码测试证明这些边界在 fake Stripe + Postgres + HTTP contract 中成立,不证明真实 Stripe portal 或部署拓扑已经验证。
工程取舍#
1. 外部事件不再是 credits 的唯一事实来源#
#5842 的事件驱动方案把 pool funding 放在 webhook 成功路径上:事件丢失、事件处理返回 audit-only 200 或服务短暂不可用,都可能让 pool 永久停留在旧值。#5870 把 pool balance 变成 subscription row、账期和 allotment 的派生状态,下一次 credits read/debit 可以重算;丢 webhook 的代价从“永久少发 credits”降为“本地 row 暂时不新鲜”。
这不是无条件修复:如果 webhook/sync 从未成功写入 workspace_subscriptions,credits 仍拿不到 seat 数和 period;因此 portal 返回后的 sync、subscription metadata 路由和 row freshness 仍是必要前置。
2. Stripe 负责财务交互,Voyager 负责业务事实#
Terraform 把原 Team product 拆成 Team Basic Seat 与 Team Pro Seat。原因有两层:
- 同一个 product 下两个同周期 price 会被 Stripe portal 配置拒绝;
- 多 line-item subscription 不会渲染可编辑的 subscription update 页面。
因此 checkout 只允许一种 seat kind,portal 的 subscription_update 同时允许 quantity 和 price,新增席位 always_invoice,减席位由 schedule_at_period_end 的 decreasing_item_amount 延迟到期末。代码数据模型仍按 price key 保存并按 SeatType 求和,为未来混买保留了数据表达能力,但当前购买流程没有开放它。
好处是收费、proration、3DS 和减席位保护落在 Stripe;代价是产品流程暂时放弃混买,且 Terraform 的 schedule_at_period_end 通过 local-exec 写入,必须在 live apply 后人工核验和归档旧 Stripe objects。
3. funded_allotment 是高水位,而不是余额快照#
pool 的 balance 会被成员消耗,所以不能用 balance 判断“这个账期已经发过多少”。000111 新增 funded_allotment,并给已有 workspace pool 用 balance 回填。对旧 pool 来说,已消费部分无法从旧 schema 精确恢复,迁移选择“低估而不是从零重复发放”;这符合不重复发放的安全方向,但需要在真实旧数据上验证。
SQL 条件把 period、funded_allotment 和 balance 更新放进一个 upsert,避免先读 funded_allotment、再写 balance 的 check-then-act race。credits 还在 ordinary spend 前先无锁读、必要时在锁内复读,减少所有 workspace 成员争用同一 pool row 的成本。
4. API 契约是一次性切换,生成产物同步变更#
TypeSpec 删除旧的 updateSeatCount PATCH,新增 seatCountPortal POST 和 sync POST,并把 checkout body 从 seat 数组改成单一 seatType/count。Go handler、OpenAPI、Go/Agent/site generated clients 和 breaking-changes map 同步变化;这符合仓库“一次 release 一起切换”的契约策略,没有保留双写或旧 endpoint 兼容层。
但生成 client 不是用户流程实现。#5870 没有 site UI、Admin、Agent worker runtime 或部署级 browser spec 的业务变更,所以当前最可靠的结论是“后端契约和账务边界已合入”,不是“Team seat 管理已经可从产品 UI 使用”。
5. 测试策略覆盖了最容易被代理指标掩盖的边界#
测试没有只检查 handler 返回 200:
- seat-count endpoint 断言 Stripe subscription 没有被本地 POST 修改;
- 多 line-item subscription 在生成无效 portal URL 前被 400 拒绝;
- 普通 portal 在 Stripe 不可读时仍可作为 cancel/修卡逃生口;
- pool 测试从没有 pool row 的 subscription fixture 开始,验证首次 credits read 会 materialize;
- mutation 验证覆盖关闭 lazy funding、把增量改成覆盖、删除 SQL guard 和把 portal refresh 改成 hard failure 等错误方向。
这些是高价值的契约信号;仍然只是 fake Stripe + 本地 Postgres/HTTP 的验证,不能替代真实 portal、Terraform apply 和浏览器回归。
和最近学习记录的关系#
上一份 2026-08-10 #5821 报告的核心结论是:workspace pool 已能成为一次操作的单一 payer,并按 debit bucket refund,但购买、seat assignment、pool seeding 尚未闭合。#5870 对这三个 gap 的处理是不对称的:
- 它补上了 seat count 的 Stripe payment surface 和 subscription row 的 refresh;
- 它用 derived funding 替代了 #5842 的 webhook seeding,降低丢事件的永久损失;
- 它没有实现 AssignSeat/RevokeSeat,也没有让 UI 把购买数量转换成成员 seat,因此 #5821 的 ResolveFunding 仍然是“具备代码路径但缺生产资格输入”。
换句话说,今天新增的是 billing truth 和 pool materialization 阶段,不是一次新的 debit/refund 语义。#5821 的单一付款方和 ledger attribution 仍是 #5870 必须保持的下游约束。
我会怎么吸收#
- 把不可可靠重放的外部事件降级为状态刷新,而不是把业务结果绑定在事件上。 先持久化足够的 Stripe snapshot,再在消费边界按状态函数补齐,丢事件最多影响 freshness。
- 同一份资格数据在不同业务问题上使用不同错误策略。 entitlement 可以 fail-open 避免全站被偶发读取故障锁死;funding 必须 fail-closed,不能在不确定时把公司消费记到员工个人余额。
- 财务交互优先复用 provider-hosted flow。 本地只生成带 live item 的确认页,避免重写 proration、3DS、退款和账期延迟规则。
- 把“已发放量”与“当前余额”分开建模。 任何会被消费的配额都不能用余额作为幂等 watermark;应使用单调高水位并让 predicate 与 write 在同一 SQL statement 中完成。
- 测试失败策略和副作用边界,而不只测 happy path。 特别是“不得本地写 Stripe”“Stripe 读失败仍可进入 portal”“重复读不重复发”“旧账期不能回写新账期”这些不可见但高代价的约束。
边界、风险与未解问题#
- 资格链未闭合。 AssignSeat、RevokeSeat、UpdateSeatLimit 仍返回 not implemented;workspace_seats 没有真实产品入口,不能把 pool derivation 的通过误读为成员可用。
- 部署状态未闭合。 PR body 说明 dev Stripe apply 已完成,但 staging/prod price mapping 和 team portal configuration 为空,相关 endpoint 会显式报配置缺失;live product/archive、portal schedule 和迁移部署尚缺独立证据。
- 按月额度与按日收费不一致。 Stripe 对中途加 seat 按剩余天数收费,credits 对新增 seat 按整月补齐;这是“可推导且不丢额度”的换价,可能在临近续费时产生最多一个月/席位的额度套利。
- 旧 pool 回填是近似。 000111 从旧 balance 回填 funded_allotment,无法知道旧版本已经发过且已消费的精确 allotment;需要真实历史数据和锁等待观察。
- 购买流程与数据模型不对称。 当前 subscription 强制一种 seat kind,但 seatItems/seatCountsFrom 对多个 price key 求和;未来开放混买时,portal 可编辑性和 assignment semantics 不能只改 Terraform。
- 读路径有写副作用。 credits view 和 debit 都可能 materialize pool;需要明确审计、请求重试和故障时返回旧余额的可观测性。
- 端到端覆盖缺失。 目前没有真实 Stripe hosted portal、webhook/sync 竞态、seat assignment、Agent gate 和浏览器 UI 串联的部署级验证;generated site client 变更也没有对应业务 UI。
- 并发边界仍需线上验证。 SQL upsert 和 bucket lock 解决了代码层的主要 race,但 subscription 更新、pool materialization、seat revocation 与同一 turn 的 ResolveFunding 之间仍可能跨事务错位。
候选说明#
- 今日 #5870:选中。 它是 Melbourne 当天合并、未在 study-log 作为锚点学习过的 PR;代码同时触及 Stripe portal、billing row、credits pool、migration、contract/e2e tests,可用 #5817、#5820、#5821、#5833、#5842 组成有共同模块和数据流证据的演进线。
- 今日 #5869:未选。 它只新增 PostHog credit ledger view,和本线有“credit ledger”词面关系,但无法自然串起 seat payment、pool derivation 或 runtime payer 的至少两个实质阶段;强行加入会把 analytics 视角与 billing 主线混在一起。
- 昨日 #5821:未重新作为锚点。 它已在 2026-08-10 学习过,本次只作为最近记录和下游约束引用;今天有合适的新锚点,因此没有扩大到更早窗口。
GitHub 文档#
- 报告文件:outputs/voyager-daily-pr-study/2026-08-11-pr-5870-stripe-portal-seat-count-pool-derivation.md
- Voyager PR:https://github.com/adastralab-ai/voyager/pull/5870 ↗
- GitHub commit(报告首次写入):aba4bb748193a3184638a9f94e4e1b26b5b40347