
Midday 订阅取消流程全解基于 Polar Webhook 的两阶段取消、降级与重激活机制【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday本文基于仓库内部文档 subscription-cancellation-flow.md 与 API 源码实现完整解析 Midday 中试用与付费订阅的取消机制Polar Webhook 事件生命周期subscription.canceled与subscription.revoked的语义区别、各事件处理器如何修改数据库状态、TRPC 取消/重激活 mutation 的调用链以及取消状态对银行同步、邮件营销、引导页跳转等下游系统的影响。读完后你可以掌握定时取消cancel at period end 到期降级这一 SaaS 计费模型的完整事件驱动实现并能对照源码理解其竞态处理与失败模式设计。Polar Webhook 生命周期不同取消方式触发不同事件序列Polar 会根据订阅被取消的方式发送不同的 Webhook 事件序列。理解这一点是整个取消流程的基石取消意图和取消执行是两个独立时刻分别由不同的事件承载。周期末取消Midday 的默认路径Midday 的cancelSubscriptionTRPC mutation 使用cancelAtPeriodEnd: true此时事件分为两个阶段阶段一 —— 用户点击取消时立即触发subscription.updated (status: active/trialing, cancel_at_period_end: true) subscription.canceled (status: active/trialing, cancel_at_period_end: true)此刻订阅在 Polar 侧仍然是 active/trialing用户尚未失去任何权益。这两个事件只是预约了取消cancellation scheduled而不是执行取消。阶段二 —— 计费周期结束或试用期结束时触发subscription.updated (status: canceled) subscription.revoked (status: canceled)这才是订阅真正终结的时刻权益被撤销计费停止。立即撤销管理员/商户强制取消三个事件一次性全部触发subscription.updated (status: canceled) subscription.canceled (status: canceled) subscription.revoked (status: canceled)重激活用户在周期结束前反悔subscription.updated (status: active/trialing, cancel_at_period_end: false) subscription.uncanceled这条序列在 Midday 中没有对应的处理器原因见下文风险与缓解章节。Webhook 处理器实现五个事件的职责划分所有 Polar 事件在 polarWebhookRouter 中通过一个switch (event.type)分发处理。在进入事件分发之前入口先用polar-sh/sdk/webhooks的validateEvent校验签名依赖POLAR_WEBHOOK_SECRET环境变量与webhook-id/webhook-timestamp/webhook-signature三个请求头若 SDK 无法解析事件类型但签名已通过则回退到原始 JSON 交给自己的 switch-case 处理见 polar/index.ts#L69-L86。事件处理失败时抛出 5xx让 Polar 的内置重试机制重新投递——这也是文档风险章节中Webhook 未送达缓解策略的落点。所有事件都通过event.data.metadata.teamId定位团队缺少该元数据时仅记录 warning 并跳过。各事件的具体行为如下。subscription.createdstatus: trialing仅在event.data.status trialing时生效从产品 ID 解析出plan设置subscriptionStatus: trialing并清空canceledAt见 polar/index.ts#L94-L120。该事件在用户于引导流程中完成带试用期的 Polar 结算后触发。subscription.active无条件将团队更新为plan取自产品、subscriptionStatus: active、清空canceledAtpolar/index.ts#L122-L146。该事件在试用转为付费或无试用直接订阅时触发同时也是取消后重新订阅的恢复入口见场景 6、7。subscription.canceled只记录意图不做降级这是整个设计中最关键的一处克制处理器只写入canceledAt时间戳不改动plan也不改动subscriptionStatuspolar/index.ts#L148-L172。源码注释明确写道subscription.canceled fires immediately when the user cancels -- the subscription is still active/trialing with cancel_at_period_end: true. Only record the cancellation intent; the actual plan downgrade happens on subscription.revoked when the period ends.按文档描述该事件还会触发两封邮件立即发送感谢成为客户哪里不满意的挽留邮件并调度 3 天后的跟进邮件跟进前用isTeamStillCanceled检查团队是否仍处于取消状态。对应的邮件处理器位于 cancellation-emails.ts 与 cancellation-email-followup.ts。subscription.revoked真正的终结点且区分 past_duerevoked处理器首先检查event.data.statuspolar/index.ts#L220-L265若status past_duePolar 出于向后兼容可能以 revoked 事件承载逾期状态保留当前 plan仅标记subscriptionStatus: past_due——用户在修复支付方式期间不丢失访问权否则视为订阅确定结束。按文档描述此处的标准行为是降级为plan: trial、清空subscriptionStatus、设置canceledAt。当前源码的一处演进值得注意从源码结构看仓库现版本已将 revoked 的默认分支改为wind-down 模式——不再将plan降级为trial而是保留团队当前 plan银行同步调度、insights 等依赖 plan 的能力因此对存量客户继续可用只清空subscriptionStatus并写入canceledAt日志为Team subscription revoked; plan preserved for wind-downpolar/index.ts#L244-L264。同理createCheckout mutation 当前会直接抛出FORBIDDEN: New subscriptions are no longer available.取消挽留邮件处理器也仅记录日志即跳过。可以推断项目已进入停止计费的收尾阶段文档描述的是完整的订阅生命周期实现两者对照阅读时需注意这一时间差。subscription.past_due仅设置subscriptionStatus: past_due不动 plan随后并发查询团队 owner 的联系方式与团队信息触发payment-issue邮件任务jobId: payment-issue-${teamId}保证幂等去重失败只记日志不阻断 Webhook 响应polar/index.ts#L174-L218。subscription.uncanceled未处理从源码结构看switch 语句中没有subscription.uncanceled分支全仓库对该事件名的唯一提及就是本文档。应用内的重激活走 TRPCreactivateSubscriptionmutation 直接改 Polar 状态并清库因此站内路径不受影响风险在于用户绕过 dashboard、直接在 Polar 客户门户重激活的场景见下文。端到端流程从点击取消到重新订阅文档给出的完整事件流如下结合源码中的调用链可以补全每一步的落点User clicks Cancel subscription in dashboard | v TRPC cancelSubscription mutation |-- Calls Polar API: subscriptions.update({ cancelAtPeriodEnd: true }) |-- Updates DB: team.canceledAt now | v Polar fires subscription.canceled IMMEDIATELY | v Webhook handler: |-- Sets canceledAt now (plan and subscriptionStatus left intact) |-- Sends cancellation outreach email schedules 3-day follow-up | v Users next page load: |-- Server fetches user.me - plan is still starter/pro |-- User keeps full access to dashboard |-- Billing page shows Reactivate subscription button |-- Copy: Your subscription/trial has been canceled and will end | at the end of your billing/trial period. | v [Days/weeks pass -- user has access through remaining period] | v Polar fires subscription.revoked (at actual period end) | v Webhook handler: |-- Sets plan trial |-- Sets subscriptionStatus null |-- Sets canceledAt now | v Users next page load: |-- Server fetches user.me - plan is trial, canceledAt is set |-- Layout skips onboarding redirect (canceledAt is set, so this | is a returning user, not a fresh signup) |-- TrialGuard shows UpgradeContent (Continue with Midday plans) |-- User can subscribe as a paying customer (no trial)TRPC 侧的实现细节billing.tsPolar 客户解析resolvePolarCustomer优先按externalId teamId查询 Polar 客户若该 externalId 属于同一用户的其他团队则回退到用团队邮箱team.email由订阅 Webhook 写入做customers.list精确匹配billing.ts#L16-L44。cancelSubscriptionbilling.ts#L249-L314列出客户订阅并按metadata.teamId过滤找到active / past_due / trialing状态的订阅已存在canceledAtPeriodEnd时幂等返回成功否则调用api.subscriptions.update写入cancelAtPeriodEnd: true以及用户填写的取消原因customerCancellationReason与评论customerCancellationComment最后更新team.canceledAt。reactivateSubscriptionbilling.ts#L316-L355找到cancelAtPeriodEnd: true且状态仍为active / past_due / trialing的订阅将其改回cancelAtPeriodEnd: false并清空数据库中的canceledAt。找不到时抛出NOT_FOUND。重激活之所以可行正因为 Polar 侧订阅在周期结束前始终是活跃状态。场景矩阵七种取消/恢复路径的状态演化场景 1试用用户在 14 天试用期内取消步骤之后的数据库状态用户权限注册并开启试用planstarter, statustrialing, canceledAtnull完整权限点击取消planstarter, statustrialing, canceledAtnow完整权限subscription.canceled触发planstarter, statustrialing, canceledAtnow完整权限剩余试用天数内不变完整权限试用结束subscription.revoked触发plantrial, statusnull, canceledAtnowUpgradeContent用户重新订阅付费无试用planstarter/pro, statusactive, canceledAtnull完整权限场景 2付费用户active在计费周期中途取消步骤之后的数据库状态用户权限活跃付费订阅planpro, statusactive, canceledAtnull完整权限点击取消planpro, statusactive, canceledAtnow完整权限subscription.canceled触发planpro, statusactive, canceledAtnow完整权限剩余计费周期内不变完整权限周期结束subscription.revoked触发plantrial, statusnull, canceledAtnowUpgradeContent场景 3商户/管理员在 Polar 侧立即撤销步骤之后的数据库状态用户权限管理员在 Polar 面板撤销----subscription.canceled触发canceledAtnowplan/status 不变完整权限短暂subscription.revoked触发几乎同时plantrial, statusnull, canceledAtnowUpgradeContent两个事件几乎同时到达由revoked处理器执行降级。强制撤销导致立即失去访问权符合预期。场景 4用户取消后在周期结束前重激活步骤之后的数据库状态用户权限点击取消planstarter, statustrialing, canceledAtnow完整权限subscription.canceled触发planstarter, statustrialing, canceledAtnow完整权限点击 ReactivateTRPC mutationplanstarter, statustrialing, canceledAtnull完整权限Polar: cancelAtPeriodEnd 重置为 false----场景 5支付失败past_duesubscription.past_due处理器设置subscriptionStatus: past_due但保留 plan用户在修复支付方式期间保持访问。若支付重试耗尽subscription.revoked将以status: past_due到达处理器走 past_due 分支保留 plan单独处理不执行降级。场景 6试用转付费正常路径无取消试用结束时 Polar 扣款subscription.active触发设置subscriptionStatus: active、plan 取自产品、清空canceledAt。与取消逻辑完全解耦。场景 7取消/撤销后重新订阅撤销后团队为plan: trial、canceledAt已设置用户看到带套餐选择的 UpgradeContent。Plans组件plans.tsx调用createCheckout时不携带requireTrial因此结算按常规付费订阅进行无试用期、立即扣款。成功后subscription.active触发恢复完整权限。注意用户无法再次获得免费试用因为试用资格明确要求canceledAt null。依赖系统canceledAt与 plan 如何驱动下游行为取消状态不是孤立的它通过plan/subscriptionStatus/canceledAt三个字段的组合影响多个子系统。银行同步资格check-team-eligibility.ts文档描述的判定为plan pro || plan starter取消后剩余周期内 plan 保持 starter/pro同步继续撤销后 plan 变为 trial同步资格回落到创建后 14 天窗口。该 14 天窗口的查询条件在 getTeamsWithBankConnections 中可见trial 团队需同时满足canceledAt为空且createdAt在最近 14 天内。需要说明的是当前仓库中isTeamEligibleForSync已简化为无条件返回true同样是 wind-down 期改动阅读时以文档描述的判定逻辑为准。邮件营销资格check-team-plan.tsshouldSendEmail经 Supabase 查询teams表后判定plan trial || subscription_status trialing。取消后剩余试用期内subscription_status仍为 trialing营销邮件按预期继续发送check-team-plan.ts#L3-L21。引导任务中的试用期即将结束邮件onboarding.ts第 12 天的明天试用结束邮件守卫条件为freshTeam?.subscription_status trialing !freshTeam.canceled_at试用期内取消的用户满足trialing但canceled_at非空第二个条件为 false邮件正确地不会发送——用户已经知道自己要离开无需再提醒即将扣费。引导重定向守卫layout.tsx/(sidebar)/layout.tsx)Layout 会将plan trial且在强制执行日期之后创建的新团队重定向到/onboarding?sstart-trial但仅当canceledAt为 null 时生效。已有过订阅历史canceledAt已设置的团队跳过该重定向落在 TrialGuard / UpgradeContentContinue with Midday 套餐选择页面可以付费重新订阅。计费设置页与文案manage-subscription.tsx、cancellation-dialog.tsxplan ! trial展示ManageSubscription卡片canceledAt已设置时提供 Reactivate 按钮plan trial展示Plans组件供重新订阅。关键文案区分试用与付费用户位置试用用户付费用户取消对话框Your trial will remain active until it ends. You wont be charged.Your plan will remain active until the end of your current billing period. You wont be charged again.订阅管理卡片已取消Your trial has been canceled and will end when your trial period expires. You wont be charged.Your subscription has been canceled and will end at the end of your billing period.关键设计决策与背后的工程理由为什么subscription.canceled不降级因为该事件在用户点击取消的瞬间触发而非周期结束时刻。若此时降级用户会在付费/试用余额未用完时提前失去权限。所以只记录取消意图canceledAtplan 与 subscriptionStatus 保持原样。为什么由subscription.revoked执行降级它才是订阅周期真正结束的信号此时将 plan 置为 trial、清空 subscriptionStatus 才是安全的状态迁移点。挽留邮件在subscription.canceled时立即发送感谢成为客户哪里不满意这类个性化挽留邮件在用户取消当下发送而不是周期结束时——此时用户的决策记忆还新鲜反馈质量最高。3 天跟进邮件发送前先调用 isTeamStillCanceled查询teams.canceledAt IS NOT NULL确认用户仍处于取消状态避免给已重激活的用户发错邮件。Layout 重定向守卫canceledAt/onboarding?sstart-trial重定向只适用于从未有过订阅的全新团队。canceledAt有值的团队是经历过完整引导的回头用户应看到升级页而非重新引导。重新订阅跳过试用UpgradeContent 与计费设置页共用的Plans组件调用createCheckout时不传requireTrial重订阅用户立即付费、没有第二次免费试用试用资格本身也显式要求canceledAt null双重防堵。风险与缓解subscription.revoked未送达若 Polar 投递失败团队会在付费期之后继续保有权限。缓解手段是 Polar 的 Webhook 内置重试且 API 侧对处理异常返回 5xx 以触发重试polar/index.ts#L272-L281。文档认为这是更安全的失败模式——暂时多给权限好过提前剥夺权限后续可增加定期 reconciliation 任务主动拉取 Polar 侧订阅状态做对账。subscription.uncanceled未处理用户若绕过 dashboard、直接在 Polar 客户门户重激活该事件会到达但无处理器canceledAt残留导致 dashboard 误显示 Reactivate 按钮。站内重激活路径TRPC mutation不受影响。这是一个已识别的已知边界。TRPC mutation 与 Webhook 之间的竞态cancelSubscriptionmutation 先调 Polar API、再更新canceledAtWebhook 理论上可能在这两步之间到达。但 mutation 与 Webhook 处理器对canceledAt的写入语义完全相同都置为当前时间因此无论处理顺序如何最终状态一致——一个刻意设计为幂等的并发窗口。相关源码文件索引文件职责apps/api/src/rest/routers/webhooks/polar/index.ts全部 Polar 订阅事件的签名校验与处理器apps/api/src/trpc/routers/billing.tscreateCheckout、cancelSubscription、reactivateSubscription、getActiveSubscription、getPortalUrl 等 mutationapps/api/src/schemas/billing.tsbilling 路由的 zod 输入 schemaapps/dashboard/src/app/[locale]/(app)/(sidebar)/layout.tsx含引导重定向守卫与 TrialGuard 包装的布局apps/dashboard/src/components/cancellation-dialog.tsx多步取消对话框含试用专属文案apps/dashboard/src/components/manage-subscription.tsx订阅卡片取消/重激活与状态文案apps/dashboard/src/components/plans.tsx套餐选择 Polar 结算不带 requireTrialpackages/db/src/queries/teams.ts团队查询含isTeamStillCanceled、银行同步资格查询packages/db/src/queries/users.ts返回含 subscriptionStatus 的团队数据apps/api/src/utils/check-team-eligibility.ts银行同步资格判定packages/jobs/src/utils/check-team-plan.ts基于 plan/status 的邮件资格判定packages/jobs/src/tasks/team/onboarding.ts引导任务与试用即将结束邮件守卫apps/worker/src/processors/teams/cancellation-emails.ts立即挽留邮件处理器apps/worker/src/processors/teams/cancellation-email-followup.ts3 天跟进邮件发送前重查取消状态注文档Files Involved一节中列出的trial-guard.tsx、upgrade-content.tsx、utils/trial.ts在当前仓库快照中未找到对应文件本文未引用TrialGuard / UpgradeContent 组件名仅作为文档中的概念保留。【免费下载链接】middayInvoicing, Time tracking, File reconciliation, Storage, Financial Overview your own Assistant made for Freelancers项目地址: https://gitcode.com/GitHub_Trending/mi/midday创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考