Joye Dev

Back

#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

业务线概览#

这条业务线解决两个互相牵制的问题:

  1. workspace 购买的 seat 数量要能改变,而且付款、proration、3DS 和减席位到期处理不应该由 Voyager 重写一遍;
  2. 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 MelbourneTypeSpec、billing model、entitlement删除 PlanTeam;PlanForPriceKey 不再把 team price 当成个人 plan,seat price 改由 SeatTypeForPriceKey 读取。seat 是 workspace 购买的能力来源,不是个人 plan ladder 的一个 rung。
seat 授予能力#5820,2026-08-10 10:43 Melbournebilling entitlement/runtime个人 entitlement 作为基线,再按 active Basic/Pro seat 做字段级 widen;GUEST 不算有效成员。它回答“能做什么”,但不回答“这次操作谁付款”。
workspace pool 进入运行时 payer 选择#5821,2026-08-10 13:09 Melbournebilling、credits、task、agent、ledgerResolveFunding 绑定操作 workspace;pool 只有能覆盖整笔 task 才成为 payer,否则整笔回落个人;refund 按原 debit 的 bucket_id 回退。#5821 是上一份学习报告的锚点。
workspace 购买与 webhook 路由#5833,2026-08-10 19:28 MelbourneStripe 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 Melbournewebhook、credits pool/repositorysubscription event 更新 workspace row 后直接调用 credits.SyncWorkspacePool;period boundary 的 SQL upsert 防止同一账期重复覆盖,但 pool 仍把 webhook 当成唯一注资触发器。
Stripe hosted seat-count 与可重算 pool#5870,2026-08-11 13:00 MelbourneStripe 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
  1. 购买入口。 #5833 的 WorkspaceCheckout 使用 workspace 自己的 Stripe customer,先在本地写 pending workspace subscription,再创建 checkout session,并通过 subscription_data.metadata 写入 workspaceId。#5870 把初次 checkout 输入从 seats 数组收成一个 seatType + count,因为 portal 对多 line-item subscription 不提供可编辑确认页。
  2. 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。
  3. 状态同步。 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,读失败会返回错误。这两个不同的失败策略有明确测试覆盖。
  4. 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。
  5. 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 增长才写入。
  6. 并发 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 的唯一资金归属来源。
  7. 当前 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)),
        }},
    },
},
plaintext

service 从 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
}
plaintext

SQL 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 必须保持的下游约束。

我会怎么吸收#

  1. 把不可可靠重放的外部事件降级为状态刷新,而不是把业务结果绑定在事件上。 先持久化足够的 Stripe snapshot,再在消费边界按状态函数补齐,丢事件最多影响 freshness。
  2. 同一份资格数据在不同业务问题上使用不同错误策略。 entitlement 可以 fail-open 避免全站被偶发读取故障锁死;funding 必须 fail-closed,不能在不确定时把公司消费记到员工个人余额。
  3. 财务交互优先复用 provider-hosted flow。 本地只生成带 live item 的确认页,避免重写 proration、3DS、退款和账期延迟规则。
  4. 把“已发放量”与“当前余额”分开建模。 任何会被消费的配额都不能用余额作为幂等 watermark;应使用单调高水位并让 predicate 与 write 在同一 SQL statement 中完成。
  5. 测试失败策略和副作用边界,而不只测 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 文档#

🗂️ 这是知识库中的🔬 研究。

内容可能仍在补充或修订中。

← Back