@taskforcehq/taskforce 0.3.304 → 0.3.306

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (383) hide show
  1. package/README.md +140 -247
  2. package/dist/Taskforce.module.css +295 -89
  3. package/dist/TaskforceCore.js +378 -111
  4. package/dist/TaskforceCore.test.js +1221 -107
  5. package/dist/assets/fonts/CourierPrime-Bold.woff2 +0 -0
  6. package/dist/assets/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  7. package/dist/assets/fonts/CourierPrime-Italic.woff2 +0 -0
  8. package/dist/assets/fonts/CourierPrime-Regular.woff2 +0 -0
  9. package/dist/assets/fonts/Noir_medium.woff2 +0 -0
  10. package/dist/assets/fonts/Noir_regular.woff2 +0 -0
  11. package/dist/assets/fonts/RussoOne-Regular.woff2 +0 -0
  12. package/dist/assets/fonts/SpecialElite-Regular.woff2 +0 -0
  13. package/dist/compat/workspaceSyncCompat.js +52 -2
  14. package/dist/compat/workspaceSyncCompat.test.js +38 -1
  15. package/dist/components/context/ContextAttachmentManager.d.ts +14 -0
  16. package/dist/components/context/ContextAttachmentManager.js +549 -0
  17. package/dist/components/features/AgentsModule.d.ts +10 -1
  18. package/dist/components/features/AgentsModule.js +260 -15
  19. package/dist/components/features/AgentsModule.test.js +255 -5
  20. package/dist/components/features/DocumentIndex.d.ts +1 -1
  21. package/dist/components/features/DocumentIndex.js +1 -1
  22. package/dist/components/features/DocumentViewer.d.ts +6 -2
  23. package/dist/components/features/DocumentViewer.js +90 -10
  24. package/dist/components/features/DocumentWorkspace.d.ts +5 -22
  25. package/dist/components/features/DocumentWorkspace.js +12 -155
  26. package/dist/components/features/DocumentWorkspace.test.js +226 -6
  27. package/dist/components/features/TaskSettings.js +29 -3
  28. package/dist/components/features/TaskSettings.test.js +42 -1
  29. package/dist/components/features/documentWorkspaceModel.d.ts +24 -0
  30. package/dist/components/features/documentWorkspaceModel.js +57 -0
  31. package/dist/components/features/useDocumentWorkspaceController.d.ts +34 -0
  32. package/dist/components/features/useDocumentWorkspaceController.js +113 -0
  33. package/dist/components/task/TaskCard.js +19 -28
  34. package/dist/components/task/TaskCard.test.js +38 -3
  35. package/dist/components/task/TaskContextUpload.js +4 -526
  36. package/dist/components/task/TaskForm.d.ts +8 -8
  37. package/dist/components/task/TaskForm.js +362 -230
  38. package/dist/components/task/TaskForm.test.js +558 -381
  39. package/dist/components/task/TaskKanban.d.ts +2 -1
  40. package/dist/components/task/TaskKanban.js +96 -14
  41. package/dist/components/task/TaskKanban.test.js +36 -0
  42. package/dist/components/ui/EditableAvatarButton.d.ts +18 -0
  43. package/dist/components/ui/EditableAvatarButton.js +18 -0
  44. package/dist/components/views/PlanComparisonPage.d.ts +9 -1
  45. package/dist/components/views/PlanComparisonPage.js +70 -17
  46. package/dist/components/views/PlanComparisonPage.test.js +289 -41
  47. package/dist/components/views/PlansPage.js +239 -106
  48. package/dist/components/views/PlansPage.test.js +593 -42
  49. package/dist/components/views/StandaloneLayout.js +310 -554
  50. package/dist/components/views/panels/FilterToolbar.test.js +6 -4
  51. package/dist/components/views/panels/PlanningDrawer.d.ts +8 -1
  52. package/dist/components/views/panels/PlanningDrawer.js +18 -11
  53. package/dist/components/views/panels/PlanningDrawer.test.d.ts +1 -0
  54. package/dist/components/views/panels/PlanningDrawer.test.js +141 -0
  55. package/dist/components/views/planningScope.d.ts +20 -0
  56. package/dist/components/views/planningScope.js +25 -0
  57. package/dist/components/views/planningScope.test.d.ts +1 -0
  58. package/dist/components/views/planningScope.test.js +60 -0
  59. package/dist/components/views/standalone/modals/AccountHubModal.d.ts +27 -14
  60. package/dist/components/views/standalone/modals/AccountHubModal.js +87 -15
  61. package/dist/components/views/standalone/modals/AccountHubModal.test.js +42 -2
  62. package/dist/components/views/standalone/modals/AvatarImageManagerModal.d.ts +20 -0
  63. package/dist/components/views/standalone/modals/AvatarImageManagerModal.js +127 -0
  64. package/dist/components/views/standalone/modals/EditProfileModal.d.ts +2 -9
  65. package/dist/components/views/standalone/modals/EditProfileModal.js +3 -2
  66. package/dist/components/views/standalone/modals/SyncEnableWarningModal.d.ts +9 -0
  67. package/dist/components/views/standalone/modals/SyncEnableWarningModal.js +6 -0
  68. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +1 -0
  69. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +24 -0
  70. package/dist/components/views/standalone/modals/SyncStatusModal.d.ts +2 -1
  71. package/dist/components/views/standalone/modals/SyncStatusModal.js +2 -2
  72. package/dist/config/envSchema.js +1 -1
  73. package/dist/core/AiProfileService.d.ts +45 -3
  74. package/dist/core/AiProfileService.js +774 -63
  75. package/dist/core/AiProfileService.test.js +22 -2
  76. package/dist/core/AiProfiles.test.js +761 -2
  77. package/dist/core/AttachmentLinkService.js +57 -15
  78. package/dist/core/AuthIdentityService.d.ts +107 -0
  79. package/dist/core/AuthIdentityService.js +284 -0
  80. package/dist/core/AuthTokenService.d.ts +4 -0
  81. package/dist/core/AuthTokenService.js +34 -6
  82. package/dist/core/AuthTokenService.test.d.ts +1 -0
  83. package/dist/core/AuthTokenService.test.js +78 -0
  84. package/dist/core/EntitlementsPolicy.test.js +77 -15
  85. package/dist/core/GlobalSettingsService.js +115 -6
  86. package/dist/core/PlanEntitlementService.d.ts +30 -3
  87. package/dist/core/PlanEntitlementService.js +454 -93
  88. package/dist/core/PlanFeatureCatalog.test.d.ts +1 -0
  89. package/dist/core/PlanFeatureCatalog.test.js +84 -0
  90. package/dist/core/PlanVersionPolicy.test.js +54 -28
  91. package/dist/core/PlanningEntities.test.js +52 -0
  92. package/dist/core/SignupMonetizationSettings.test.js +241 -44
  93. package/dist/core/SyncReconciliationService.js +2 -65
  94. package/dist/core/SystemAdmin.test.js +244 -88
  95. package/dist/core/TaskAttachmentsCanonical.test.js +51 -1
  96. package/dist/core/TaskTaxonomyValidation.test.js +53 -0
  97. package/dist/core/Taskforce.d.ts +228 -14
  98. package/dist/core/Taskforce.js +814 -201
  99. package/dist/core/Taskforce.listTasksSlim.test.d.ts +1 -0
  100. package/dist/core/Taskforce.listTasksSlim.test.js +91 -0
  101. package/dist/core/UserProfileAvatarDrafts.test.js +30 -3
  102. package/dist/core/WorkspaceLifecycleService.js +0 -5
  103. package/dist/core/WorkspacePermissions.test.js +25 -1
  104. package/dist/core/WorkspacePlanMode.test.js +128 -27
  105. package/dist/core/shared.d.ts +3 -1
  106. package/dist/core/shared.js +16 -1
  107. package/dist/core/types.d.ts +68 -2
  108. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +26 -1
  109. package/dist/hooks/sync/auth.js +3 -1
  110. package/dist/hooks/sync/bootstrap.js +2 -0
  111. package/dist/hooks/sync/controlPlane.d.ts +37 -0
  112. package/dist/hooks/sync/controlPlane.js +78 -0
  113. package/dist/hooks/sync/controlPlane.test.d.ts +1 -0
  114. package/dist/hooks/sync/controlPlane.test.js +121 -0
  115. package/dist/hooks/sync/lifecyclePolicy.d.ts +45 -0
  116. package/dist/hooks/sync/lifecyclePolicy.js +93 -0
  117. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +1 -0
  118. package/dist/hooks/sync/lifecyclePolicy.test.js +124 -0
  119. package/dist/hooks/sync/orchestratorShared.d.ts +10 -1
  120. package/dist/hooks/sync/orchestratorShared.js +68 -47
  121. package/dist/hooks/sync/orchestratorShared.test.js +50 -1
  122. package/dist/hooks/sync/pullLifecycle.d.ts +31 -0
  123. package/dist/hooks/sync/pullLifecycle.js +24 -0
  124. package/dist/hooks/sync/pullLifecycle.test.d.ts +1 -0
  125. package/dist/hooks/sync/pullLifecycle.test.js +80 -0
  126. package/dist/hooks/sync/recovery.d.ts +16 -2
  127. package/dist/hooks/sync/recovery.js +171 -53
  128. package/dist/hooks/sync/recovery.test.d.ts +1 -0
  129. package/dist/hooks/sync/recovery.test.js +292 -0
  130. package/dist/hooks/sync/transfers.d.ts +16 -3
  131. package/dist/hooks/sync/transfers.js +186 -65
  132. package/dist/hooks/sync/transfers.test.d.ts +1 -0
  133. package/dist/hooks/sync/transfers.test.js +396 -0
  134. package/dist/hooks/sync/useRecentSyncEvents.d.ts +21 -0
  135. package/dist/hooks/sync/useRecentSyncEvents.js +92 -0
  136. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +1 -0
  137. package/dist/hooks/sync/useRecentSyncEvents.test.js +124 -0
  138. package/dist/hooks/sync/useSyncStatusActions.d.ts +30 -0
  139. package/dist/hooks/sync/useSyncStatusActions.js +88 -0
  140. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +1 -0
  141. package/dist/hooks/sync/useSyncStatusActions.test.js +133 -0
  142. package/dist/hooks/sync/useSyncStatusControls.d.ts +25 -0
  143. package/dist/hooks/sync/useSyncStatusControls.js +60 -0
  144. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +1 -0
  145. package/dist/hooks/sync/useSyncStatusControls.test.js +88 -0
  146. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +12 -0
  147. package/dist/hooks/useSyncOrchestrator.d.ts +16 -6
  148. package/dist/hooks/useSyncOrchestrator.js +424 -175
  149. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +487 -10
  150. package/dist/hooks/useTaskData.js +8 -3
  151. package/dist/hooks/useTaskData.test.js +45 -0
  152. package/dist/hooks/useTaskMutations.d.ts +2 -1
  153. package/dist/hooks/useTaskMutations.js +12 -1
  154. package/dist/hooks/useTaskMutations.test.js +30 -1
  155. package/dist/hooks/useTaskforce.d.ts +65 -4
  156. package/dist/hooks/useTaskforce.js +564 -243
  157. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +1 -0
  158. package/dist/hooks/useTaskforce.runtime-routing.test.js +152 -0
  159. package/dist/hooks/useTaskforce.sync-behavior.test.js +2454 -208
  160. package/dist/hooks/useWorkspaceSyncController.d.ts +4 -1
  161. package/dist/hooks/useWorkspaceSyncController.js +17 -2
  162. package/dist/hooks/useWorkspaceSyncController.test.js +1 -0
  163. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.d.ts +16 -0
  164. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +316 -35
  165. package/dist/localization/locales/en-US.d.ts +0 -1
  166. package/dist/localization/locales/en-US.js +0 -1
  167. package/dist/localization/locales/es-419.js +0 -1
  168. package/dist/localization/locales/pt-BR.js +0 -1
  169. package/dist/mcp/adminWorkspaceRegistrar.d.ts +2 -0
  170. package/dist/mcp/adminWorkspaceRegistrar.js +154 -0
  171. package/dist/mcp/collaborationRegistrar.d.ts +2 -0
  172. package/dist/mcp/collaborationRegistrar.js +71 -0
  173. package/dist/mcp/documentAssetRegistrar.d.ts +2 -0
  174. package/dist/mcp/documentAssetRegistrar.js +346 -0
  175. package/dist/mcp/runtime.d.ts +2 -0
  176. package/dist/mcp/runtime.js +2433 -3534
  177. package/dist/mcp/runtime.test.js +440 -2
  178. package/dist/mcp/taskPlanningRegistrar.d.ts +2 -0
  179. package/dist/mcp/taskPlanningRegistrar.js +418 -0
  180. package/dist/mcp/toolCatalog.d.ts +35 -0
  181. package/dist/mcp/toolCatalog.js +3 -0
  182. package/dist/migrations/taskSchemaMigrations.d.ts +1 -1
  183. package/dist/migrations/taskSchemaMigrations.js +171 -61
  184. package/dist/migrations/taskSchemaMigrations.test.js +15 -0
  185. package/dist/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  186. package/dist/resources/templates/workflows/collaborate.yaml +1 -1
  187. package/dist/resources/templates/workflows/evaluate.yaml +2 -2
  188. package/dist/resources/templates/workflows/plan.yaml +1 -1
  189. package/dist/resources/templates/workflows/review.yaml +2 -2
  190. package/dist/server/annotatedAttachmentsRoutes.test.js +3 -3
  191. package/dist/server/auth/providers/apple.d.ts +33 -0
  192. package/dist/server/auth/providers/apple.js +82 -0
  193. package/dist/server/auth/providers/appleClientSecret.d.ts +37 -0
  194. package/dist/server/auth/providers/appleClientSecret.js +65 -0
  195. package/dist/server/auth/providers/github.d.ts +26 -0
  196. package/dist/server/auth/providers/github.js +86 -0
  197. package/dist/server/auth/providers/google.d.ts +21 -0
  198. package/dist/server/auth/providers/google.js +56 -0
  199. package/dist/server/auth/providers/types.d.ts +21 -0
  200. package/dist/server/auth/providers/types.js +1 -0
  201. package/dist/server/auth.d.ts +2 -0
  202. package/dist/server/auth.js +6 -3
  203. package/dist/server/documentReviewRoutes.test.js +36 -3
  204. package/dist/server/index.cookieProxy.test.js +1 -0
  205. package/dist/server/index.d.ts +12 -0
  206. package/dist/server/index.js +120 -9
  207. package/dist/server/index.rateLimit.test.js +3 -0
  208. package/dist/server/index.test.js +106 -3
  209. package/dist/server/routes/admin.d.ts +4 -5
  210. package/dist/server/routes/admin.js +410 -80
  211. package/dist/server/routes/annotatedAttachments.d.ts +1 -1
  212. package/dist/server/routes/annotatedAttachments.js +1 -1
  213. package/dist/server/routes/auth.d.ts +16 -6
  214. package/dist/server/routes/auth.js +1015 -118
  215. package/dist/server/routes/authSupport.d.ts +30 -0
  216. package/dist/server/routes/authSupport.js +81 -0
  217. package/dist/server/routes/billing.d.ts +1 -1
  218. package/dist/server/routes/billing.js +276 -34
  219. package/dist/server/routes/billing.test.js +760 -52
  220. package/dist/server/routes/documentReviews.d.ts +1 -1
  221. package/dist/server/routes/documentReviews.js +1 -1
  222. package/dist/server/routes/documents.d.ts +5 -2
  223. package/dist/server/routes/documents.js +242 -74
  224. package/dist/server/routes/primitives.d.ts +11 -0
  225. package/dist/server/routes/primitives.js +73 -0
  226. package/dist/server/routes/resources.d.ts +1 -1
  227. package/dist/server/routes/resources.js +1 -1
  228. package/dist/server/routes/shared.d.ts +29 -3
  229. package/dist/server/routes/shared.js +69 -4
  230. package/dist/server/routes/sync.d.ts +1 -1
  231. package/dist/server/routes/sync.integration.test.js +437 -39
  232. package/dist/server/routes/sync.js +62 -606
  233. package/dist/server/routes/syncAuxRoutes.d.ts +15 -0
  234. package/dist/server/routes/syncAuxRoutes.js +91 -0
  235. package/dist/server/routes/syncAuxRoutes.test.d.ts +1 -0
  236. package/dist/server/routes/syncAuxRoutes.test.js +158 -0
  237. package/dist/server/routes/syncPullApplyRoutes.d.ts +36 -0
  238. package/dist/server/routes/syncPullApplyRoutes.js +204 -0
  239. package/dist/server/routes/syncPushRoutes.d.ts +24 -0
  240. package/dist/server/routes/syncPushRoutes.js +212 -0
  241. package/dist/server/routes/syncRouteGuards.d.ts +36 -0
  242. package/dist/server/routes/syncRouteGuards.js +67 -0
  243. package/dist/server/routes/syncRouteGuards.test.d.ts +1 -0
  244. package/dist/server/routes/syncRouteGuards.test.js +94 -0
  245. package/dist/server/routes/syncRouteTypes.d.ts +13 -0
  246. package/dist/server/routes/syncRouteTypes.js +1 -0
  247. package/dist/server/routes/syncSnapshotRoutes.d.ts +66 -0
  248. package/dist/server/routes/syncSnapshotRoutes.js +180 -0
  249. package/dist/server/routes/tasks.d.ts +1 -1
  250. package/dist/server/routes/tasks.js +44 -2
  251. package/dist/server/routes/workspaces.d.ts +1 -1
  252. package/dist/server/routes/workspaces.js +37 -6
  253. package/dist/server/routes.d.ts +2 -9
  254. package/dist/server/routes.js +136 -165
  255. package/dist/server/routes.test.js +2457 -162
  256. package/dist/services/workflowExportSnapshots.test.js +2 -0
  257. package/dist/shared/aiProfileSeatScope.d.ts +5 -0
  258. package/dist/shared/aiProfileSeatScope.js +21 -0
  259. package/dist/shared/passwordPolicy.d.ts +13 -0
  260. package/dist/shared/passwordPolicy.js +84 -0
  261. package/dist/shared/passwordPolicy.test.d.ts +1 -0
  262. package/dist/shared/passwordPolicy.test.js +34 -0
  263. package/dist/storage/workspaceAssetStore.d.ts +15 -3
  264. package/dist/storage/workspaceAssetStore.js +132 -44
  265. package/dist/storage/workspaceAssetStore.test.js +93 -6
  266. package/dist/styles/fonts.css +70 -0
  267. package/dist/sync/collaborationSyncPayload.d.ts +6 -1
  268. package/dist/sync/collaborationSyncPayload.js +17 -11
  269. package/dist/sync/collaborationSyncPayload.test.js +13 -1
  270. package/dist/sync/contentSyncState.d.ts +4 -8
  271. package/dist/sync/contentSyncState.js +33 -77
  272. package/dist/sync/syncApplyHandlers.d.ts +7 -0
  273. package/dist/sync/syncApplyHandlers.js +28 -13
  274. package/dist/sync/syncApplyHandlers.test.js +72 -3
  275. package/dist/sync/syncFieldSemantics.d.ts +12 -0
  276. package/dist/sync/syncFieldSemantics.js +55 -0
  277. package/dist/sync/syncResourceAdapters.d.ts +44 -0
  278. package/dist/sync/syncResourceAdapters.js +77 -0
  279. package/dist/sync/syncService.d.ts +6 -0
  280. package/dist/sync/syncService.js +60 -0
  281. package/dist/sync/syncService.test.js +71 -3
  282. package/dist/sync/taskSyncChangeKey.d.ts +2 -0
  283. package/dist/sync/taskSyncChangeKey.js +143 -0
  284. package/dist/sync/taskSyncPayload.js +3 -8
  285. package/dist/sync/workspacePullFeed.js +4 -3
  286. package/dist/sync/workspacePullFeed.test.js +43 -0
  287. package/dist/sync/workspaceRepair.js +5 -2
  288. package/dist/sync/workspaceSyncModel.d.ts +48 -0
  289. package/dist/sync/workspaceSyncModel.js +195 -31
  290. package/dist/sync/workspaceSyncModel.test.js +325 -12
  291. package/dist/sync/workspaceSyncState.d.ts +19 -0
  292. package/dist/sync/workspaceSyncState.js +3 -1
  293. package/dist/sync/workspaceSyncSurface.js +3 -8
  294. package/dist/types.d.ts +17 -0
  295. package/dist/ui/assets/AgentsModule-DrLawwNl.js +1 -0
  296. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-BlS-cqnl.js → AnnotatedAttachmentWorkspace-Z9_whf7F.js} +1 -1
  297. package/dist/ui/assets/CourierPrime-Bold-BuLj_dpw.woff2 +0 -0
  298. package/dist/ui/assets/CourierPrime-BoldItalic-BrK2pUkZ.woff2 +0 -0
  299. package/dist/ui/assets/CourierPrime-Italic-DFUZK5Ey.woff2 +0 -0
  300. package/dist/ui/assets/CourierPrime-Regular-BsKphzVf.woff2 +0 -0
  301. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +252 -0
  302. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +1 -0
  303. package/dist/ui/assets/{InitiativesModule-BjR6iBf9.js → InitiativesModule-9E6ParrY.js} +1 -1
  304. package/dist/ui/assets/Noir_medium-bxQwKGzB.woff2 +0 -0
  305. package/dist/ui/assets/Noir_regular-ojf2kxlG.woff2 +0 -0
  306. package/dist/ui/assets/PlansPage-CvfnMiWq.css +1 -0
  307. package/dist/ui/assets/PlansPage-DRXscYxH.js +1 -0
  308. package/dist/ui/assets/RussoOne-Regular-Cu_qq_qC.woff2 +0 -0
  309. package/dist/ui/assets/SpecialElite-Regular-Di6gSvAS.woff2 +0 -0
  310. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +9 -0
  311. package/dist/ui/assets/{WorkflowsModule-DnFqdUME.js → WorkflowsModule-BA7jcEpH.js} +1 -1
  312. package/dist/ui/assets/index-9eT8e0Lu.css +1 -0
  313. package/dist/ui/assets/index-C6k75jr8.js +6 -0
  314. package/dist/ui/assets/{vendor-icons-I7ZwG1z_.js → vendor-icons-DuEd_65c.js} +1 -1
  315. package/dist/ui/fonts/CourierPrime-Bold.ttf +0 -0
  316. package/dist/ui/fonts/CourierPrime-Bold.woff2 +0 -0
  317. package/dist/ui/fonts/CourierPrime-BoldItalic.ttf +0 -0
  318. package/dist/ui/fonts/CourierPrime-BoldItalic.woff2 +0 -0
  319. package/dist/ui/fonts/CourierPrime-Italic.ttf +0 -0
  320. package/dist/ui/fonts/CourierPrime-Italic.woff2 +0 -0
  321. package/dist/ui/fonts/CourierPrime-Regular.ttf +0 -0
  322. package/dist/ui/fonts/CourierPrime-Regular.woff2 +0 -0
  323. package/dist/ui/fonts/Noir_medium.otf +0 -0
  324. package/dist/ui/fonts/Noir_medium.woff2 +0 -0
  325. package/dist/ui/fonts/Noir_regular.otf +0 -0
  326. package/dist/ui/fonts/Noir_regular.woff2 +0 -0
  327. package/dist/ui/fonts/RussoOne-Regular.woff2 +0 -0
  328. package/dist/ui/fonts/SpecialElite-Regular.ttf +0 -0
  329. package/dist/ui/fonts/SpecialElite-Regular.woff2 +0 -0
  330. package/dist/ui/index.html +3 -3
  331. package/dist/utils/accountProfileSummaryCache.d.ts +5 -0
  332. package/dist/utils/assignees.d.ts +2 -0
  333. package/dist/utils/assignees.js +2 -0
  334. package/dist/utils/avatarUpload.d.ts +27 -0
  335. package/dist/utils/avatarUpload.js +132 -0
  336. package/dist/utils/avatarUpload.test.d.ts +1 -0
  337. package/dist/utils/avatarUpload.test.js +40 -0
  338. package/dist/utils/bootstrapTrace.d.ts +7 -0
  339. package/dist/utils/bootstrapTrace.js +29 -0
  340. package/dist/utils/commercialLifecyclePresentation.d.ts +19 -0
  341. package/dist/utils/commercialLifecyclePresentation.js +199 -0
  342. package/dist/utils/contextAssetEvents.d.ts +2 -0
  343. package/dist/utils/syncEventDetails.d.ts +3 -0
  344. package/dist/utils/syncEventDetails.js +64 -0
  345. package/dist/utils/syncEventDetails.test.d.ts +1 -0
  346. package/dist/utils/syncEventDetails.test.js +23 -0
  347. package/dist/utils/syncEventPresentation.d.ts +15 -0
  348. package/dist/utils/syncEventPresentation.js +129 -0
  349. package/dist/utils/syncEventPresentation.test.d.ts +1 -0
  350. package/dist/utils/syncEventPresentation.test.js +64 -0
  351. package/dist/utils/syncStatusPresentation.d.ts +72 -0
  352. package/dist/utils/syncStatusPresentation.js +217 -0
  353. package/dist/utils/syncStatusPresentation.test.d.ts +1 -0
  354. package/dist/utils/syncStatusPresentation.test.js +164 -0
  355. package/dist/utils/taskActivity.d.ts +2 -0
  356. package/dist/utils/taskActivity.js +228 -22
  357. package/dist/utils/taskActivity.test.js +119 -8
  358. package/dist/utils/workspaceSyncPresentation.d.ts +4 -0
  359. package/dist/utils/workspaceSyncPresentation.js +68 -2
  360. package/dist/utils/workspaceSyncPresentation.test.js +36 -1
  361. package/package.json +10 -2
  362. package/scripts/check-cloud-mcp.mjs +83 -0
  363. package/scripts/export-sqlite-to-postgres.mjs +7 -9
  364. package/scripts/playwright-auth.sh +5 -1
  365. package/scripts/run-billing-performance-smoke.sh +24 -0
  366. package/scripts/run-e2e-realtime.sh +17 -3
  367. package/scripts/run-smoke.sh +17 -5
  368. package/scripts/run-soak.sh +17 -5
  369. package/src/resources/templates/workflow-sources/workflowDocs.mjs +6 -6
  370. package/src/resources/templates/workflows/collaborate.yaml +1 -1
  371. package/src/resources/templates/workflows/evaluate.yaml +2 -2
  372. package/src/resources/templates/workflows/plan.yaml +1 -1
  373. package/src/resources/templates/workflows/review.yaml +2 -2
  374. package/dist/ui/assets/AgentsModule-BLWVbuKP.js +0 -1
  375. package/dist/ui/assets/DocumentWorkspace-BH_6DF6x.js +0 -250
  376. package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +0 -1
  377. package/dist/ui/assets/PlansPage-C2dd2n1X.css +0 -1
  378. package/dist/ui/assets/PlansPage-CSDQ4sLa.js +0 -1
  379. package/dist/ui/assets/RussoOne-Regular-C3BxZIj7.ttf +0 -0
  380. package/dist/ui/assets/TaskSettings-CEDy34Jo.js +0 -9
  381. package/dist/ui/assets/index-DXUdtH1B.js +0 -6
  382. package/dist/ui/assets/index-Ii0B0rTO.css +0 -1
  383. package/scripts/migrate-planning-model.ts +0 -233
@@ -180,6 +180,16 @@ function setupBillingSchema(db) {
180
180
  created_at TEXT NOT NULL,
181
181
  updated_at TEXT NOT NULL
182
182
  );
183
+ CREATE TABLE plan_feature_matrix (
184
+ tenant_id TEXT NOT NULL,
185
+ plan_version_id TEXT NOT NULL,
186
+ feature_key TEXT NOT NULL,
187
+ access TEXT NOT NULL DEFAULT 'disabled',
188
+ config_json TEXT NOT NULL DEFAULT '{}',
189
+ created_at TEXT NOT NULL,
190
+ updated_at TEXT NOT NULL,
191
+ PRIMARY KEY (tenant_id, plan_version_id, feature_key)
192
+ );
183
193
  CREATE TABLE plan_catalog (
184
194
  plan_id TEXT PRIMARY KEY,
185
195
  tenant_id TEXT NOT NULL,
@@ -188,6 +198,16 @@ function setupBillingSchema(db) {
188
198
  created_at TEXT NOT NULL,
189
199
  updated_at TEXT NOT NULL
190
200
  );
201
+ CREATE TABLE plan_feature_catalog_overrides (
202
+ tenant_id TEXT NOT NULL,
203
+ feature_key TEXT NOT NULL,
204
+ public_label TEXT,
205
+ public_description TEXT,
206
+ public_display_order INTEGER,
207
+ created_at TEXT NOT NULL,
208
+ updated_at TEXT NOT NULL,
209
+ PRIMARY KEY (tenant_id, feature_key)
210
+ );
191
211
  CREATE TABLE account_entitlements (
192
212
  account_id TEXT PRIMARY KEY,
193
213
  tenant_id TEXT NOT NULL,
@@ -208,6 +228,32 @@ function setupBillingSchema(db) {
208
228
  );
209
229
  `);
210
230
  }
231
+ function readMockPlanVersionFeatures(db, tenantId, planVersionId) {
232
+ try {
233
+ return db.prepare(`
234
+ SELECT feature_key, access, config_json
235
+ FROM plan_feature_matrix
236
+ WHERE tenant_id = ? AND plan_version_id = ?
237
+ `).all(tenantId, planVersionId).reduce((acc, row) => {
238
+ const featureKey = String(row?.feature_key || '').trim();
239
+ if (!featureKey)
240
+ return acc;
241
+ acc[featureKey] = {
242
+ featureKey,
243
+ access: String(row?.access || '').trim(),
244
+ config: row?.config_json ? JSON.parse(String(row.config_json)) : {}
245
+ };
246
+ return acc;
247
+ }, {});
248
+ }
249
+ catch (error) {
250
+ const message = String(error?.message || '');
251
+ if (message.toLowerCase().includes('no such table: plan_feature_matrix')) {
252
+ return {};
253
+ }
254
+ throw error;
255
+ }
256
+ }
211
257
  function buildBillingRouteHandlers(db, opts) {
212
258
  const routes = new Map();
213
259
  const tenantId = 'default';
@@ -288,12 +334,69 @@ function buildBillingRouteHandlers(db, opts) {
288
334
  seatLimit: row?.seat_limit === null || row?.seat_limit === undefined ? null : Number(row.seat_limit),
289
335
  trialDays: Number.isFinite(Number(metadata?.trialDays)) ? Number(metadata.trialDays) : 0,
290
336
  metadata,
291
- features: {},
337
+ features: readMockPlanVersionFeatures(db, tenantId, String(row?.plan_version_id || '')),
292
338
  createdAt: '',
293
339
  updatedAt: ''
294
340
  };
295
341
  });
296
342
  },
343
+ listPlanFeatureCatalog: () => {
344
+ const overrides = db.prepare(`
345
+ SELECT feature_key, public_label, public_description, public_display_order
346
+ FROM plan_feature_catalog_overrides
347
+ WHERE tenant_id = ?
348
+ `).all(tenantId);
349
+ const overrideMap = new Map(overrides.map((row) => [
350
+ String(row.feature_key || '').trim(),
351
+ {
352
+ publicLabel: row.public_label ?? null,
353
+ publicDescription: row.public_description ?? null,
354
+ publicDisplayOrder: row.public_display_order ?? null
355
+ }
356
+ ]));
357
+ return [
358
+ {
359
+ featureKey: 'collaboration.team_management',
360
+ label: 'Team Management',
361
+ description: 'Allow managing members, invites, roles, and collaboration settings.',
362
+ publicLabel: overrideMap.get('collaboration.team_management')?.publicLabel ?? null,
363
+ publicDescription: overrideMap.get('collaboration.team_management')?.publicDescription ?? null,
364
+ publicDisplayOrder: overrideMap.get('collaboration.team_management')?.publicDisplayOrder ?? 40,
365
+ allowedAccessModes: ['enabled', 'disabled'],
366
+ configTemplate: {}
367
+ },
368
+ {
369
+ featureKey: 'context.uploads',
370
+ label: 'Document Cloud Storage',
371
+ description: 'Allow cloud document storage with an optional storage allowance in MB.',
372
+ publicLabel: overrideMap.get('context.uploads')?.publicLabel ?? null,
373
+ publicDescription: overrideMap.get('context.uploads')?.publicDescription ?? null,
374
+ publicDisplayOrder: overrideMap.get('context.uploads')?.publicDisplayOrder ?? 30,
375
+ allowedAccessModes: ['enabled', 'disabled'],
376
+ configTemplate: { storageLimitMb: 1024 }
377
+ },
378
+ {
379
+ featureKey: 'workspace.workspaces',
380
+ label: 'Workspaces',
381
+ description: 'Allow multiple workspaces with a configurable workspace limit.',
382
+ publicLabel: overrideMap.get('workspace.workspaces')?.publicLabel ?? null,
383
+ publicDescription: overrideMap.get('workspace.workspaces')?.publicDescription ?? null,
384
+ publicDisplayOrder: overrideMap.get('workspace.workspaces')?.publicDisplayOrder ?? 10,
385
+ allowedAccessModes: ['enabled', 'disabled'],
386
+ configTemplate: { maxWorkspaces: 1 }
387
+ },
388
+ {
389
+ featureKey: 'collaboration.ai_profiles',
390
+ label: 'AI Profiles',
391
+ description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
392
+ publicLabel: overrideMap.get('collaboration.ai_profiles')?.publicLabel ?? null,
393
+ publicDescription: overrideMap.get('collaboration.ai_profiles')?.publicDescription ?? null,
394
+ publicDisplayOrder: overrideMap.get('collaboration.ai_profiles')?.publicDisplayOrder ?? 20,
395
+ allowedAccessModes: ['enabled', 'limited', 'disabled'],
396
+ configTemplate: { maxAiProfiles: 1 }
397
+ }
398
+ ];
399
+ },
297
400
  getAccountEntitlement: () => {
298
401
  const entitlement = opts?.accessStatus?.entitlement;
299
402
  return {
@@ -691,6 +794,72 @@ describe('Billing Webhook Routes', () => {
691
794
  `).get();
692
795
  expect(String(failure?.reason || '')).toContain('no longer current for this account');
693
796
  });
797
+ it('allows authoritative subscription webhooks to recover accounts after the local checkout expired', async () => {
798
+ const now = new Date().toISOString();
799
+ db.prepare(`
800
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
801
+ VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
802
+ `).run(now, now);
803
+ db.prepare(`
804
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
805
+ VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
806
+ `).run(now, now);
807
+ db.prepare(`
808
+ INSERT INTO user_billing (
809
+ user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at
810
+ ) VALUES ('u1', 'default', 'cus_123', 'pending_plan_selection', ?, ?)
811
+ `).run(now, now);
812
+ db.prepare(`
813
+ INSERT INTO account_entitlements (
814
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
815
+ ) VALUES ('u1', 'default', 'team-v1', 'pending_plan_selection', ?, NULL, 'checkout-expired', ?, ?)
816
+ `).run(now, now, now);
817
+ mockConstructEvent.mockReturnValue({
818
+ id: 'evt_checkout_expired_recovery',
819
+ type: 'customer.subscription.updated',
820
+ data: {
821
+ object: {
822
+ id: 'sub_123',
823
+ customer: 'cus_123',
824
+ status: 'active',
825
+ trial_end: null,
826
+ metadata: { planVersionId: 'team-v1' },
827
+ items: {
828
+ data: [{
829
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
830
+ price: { id: 'price_team_month', recurring: { interval: 'month' } }
831
+ }]
832
+ }
833
+ }
834
+ }
835
+ });
836
+ const req = createMockReq('{"id":"evt_checkout_expired_recovery"}', {
837
+ 'x-taskforce-runtime-mode': 'cloud',
838
+ 'stripe-signature': 'sig'
839
+ });
840
+ const res = createMockRes();
841
+ await webhookHandler(req, res, {});
842
+ expect(res.statusCode).toBe(200);
843
+ const entitlement = db.prepare(`
844
+ SELECT plan_version_id, entitlement_state, updated_by
845
+ FROM account_entitlements
846
+ WHERE account_id = 'u1' AND tenant_id = 'default'
847
+ LIMIT 1
848
+ `).get();
849
+ expect(entitlement).toEqual(expect.objectContaining({
850
+ plan_version_id: 'team-v1',
851
+ entitlement_state: 'active',
852
+ updated_by: 'stripe-webhook'
853
+ }));
854
+ const failure = db.prepare(`
855
+ SELECT reason
856
+ FROM stripe_webhook_failures
857
+ WHERE event_id = 'evt_checkout_expired_recovery'
858
+ ORDER BY created_at DESC
859
+ LIMIT 1
860
+ `).get();
861
+ expect(failure).toBeUndefined();
862
+ });
694
863
  it('ignores stale subscription webhooks for active accounts when a newer checkout attempt is open', async () => {
695
864
  const now = new Date().toISOString();
696
865
  db.prepare(`
@@ -821,6 +990,9 @@ describe('Billing Webhook Routes', () => {
821
990
  const firstCall = mockCheckoutCreate.mock.calls[0];
822
991
  const args = (Array.isArray(firstCall) ? firstCall[0] : {});
823
992
  expect(String(args.success_url || '')).toContain('/?screen=plans&checkout=success');
993
+ expect(String(args.success_url || '')).toContain('planId=collab');
994
+ expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
995
+ expect(String(args.success_url || '')).toContain('interval=month');
824
996
  expect(String(args.cancel_url || '')).toContain('/?screen=plans&checkout=cancel');
825
997
  expect(String(args.cancel_url || '')).toContain('planId=collab');
826
998
  expect(String(args.cancel_url || '')).toContain('planVersionId=collab-v1');
@@ -1133,6 +1305,9 @@ describe('Billing Webhook Routes', () => {
1133
1305
  expect(res.statusCode).toBe(200);
1134
1306
  const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1135
1307
  expect(String(args.success_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=success');
1308
+ expect(String(args.success_url || '')).toContain('planId=collab');
1309
+ expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1310
+ expect(String(args.success_url || '')).toContain('interval=month');
1136
1311
  expect(String(args.cancel_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=cancel');
1137
1312
  expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1138
1313
  checkoutState: 'checkout',
@@ -1167,6 +1342,9 @@ describe('Billing Webhook Routes', () => {
1167
1342
  expect(res.statusCode).toBe(200);
1168
1343
  const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1169
1344
  expect(String(args.success_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=success');
1345
+ expect(String(args.success_url || '')).toContain('planId=collab');
1346
+ expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1347
+ expect(String(args.success_url || '')).toContain('interval=month');
1170
1348
  });
1171
1349
  it('builds direct cloud checkout return URLs from the actual request protocol when forwarded proto is absent', async () => {
1172
1350
  const now = new Date().toISOString();
@@ -1192,6 +1370,9 @@ describe('Billing Webhook Routes', () => {
1192
1370
  expect(res.statusCode).toBe(200);
1193
1371
  const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1194
1372
  expect(String(args.success_url || '')).toContain('http://127.0.0.1:4173/?screen=plans&checkout=success');
1373
+ expect(String(args.success_url || '')).toContain('planId=collab');
1374
+ expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1375
+ expect(String(args.success_url || '')).toContain('interval=month');
1195
1376
  expect(String(args.cancel_url || '')).toContain('http://127.0.0.1:4173/?screen=plans&checkout=cancel');
1196
1377
  });
1197
1378
  it('applies a free plan immediately without creating a Stripe checkout session', async () => {
@@ -1415,7 +1596,7 @@ describe('Billing Webhook Routes', () => {
1415
1596
  stripe_subscription_id: 'sub_email_match'
1416
1597
  }));
1417
1598
  });
1418
- it('updates the existing paid subscription instead of creating a second checkout', async () => {
1599
+ it('updates the existing paid subscription and keeps canonical entitlement aligned with the new plan', async () => {
1419
1600
  const now = new Date().toISOString();
1420
1601
  db.prepare(`
1421
1602
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1476,12 +1657,26 @@ describe('Billing Webhook Routes', () => {
1476
1657
  planVersionId: 'team-v1'
1477
1658
  }));
1478
1659
  const entitlement = db.prepare(`
1479
- SELECT plan_version_id
1660
+ SELECT plan_version_id, entitlement_state, updated_by
1480
1661
  FROM account_entitlements
1481
1662
  WHERE tenant_id = 'default' AND account_id = 'u1'
1482
1663
  LIMIT 1
1483
1664
  `).get();
1484
- expect(entitlement?.plan_version_id).toBe('team-v1');
1665
+ expect(entitlement).toEqual(expect.objectContaining({
1666
+ plan_version_id: 'team-v1',
1667
+ entitlement_state: 'active',
1668
+ updated_by: 'checkout-confirm'
1669
+ }));
1670
+ const billingRow = db.prepare(`
1671
+ SELECT stripe_price_id, billing_interval
1672
+ FROM user_billing
1673
+ WHERE tenant_id = 'default' AND user_id = 'u1'
1674
+ LIMIT 1
1675
+ `).get();
1676
+ expect(billingRow).toEqual(expect.objectContaining({
1677
+ stripe_price_id: 'price_team_month',
1678
+ billing_interval: 'month'
1679
+ }));
1485
1680
  });
1486
1681
  it('updates the existing subscription when only the interval changes on the same plan', async () => {
1487
1682
  const now = new Date().toISOString();
@@ -1544,7 +1739,7 @@ describe('Billing Webhook Routes', () => {
1544
1739
  planVersionId: 'pro-v1'
1545
1740
  }));
1546
1741
  });
1547
- it('confirms a successful checkout session and updates the entitlement immediately', async () => {
1742
+ it('confirms a successful checkout session, activates canonical entitlement, and closes the checkout attempt', async () => {
1548
1743
  const now = new Date().toISOString();
1549
1744
  db.prepare(`
1550
1745
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1554,6 +1749,11 @@ describe('Billing Webhook Routes', () => {
1554
1749
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1555
1750
  VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1556
1751
  `).run(now, now);
1752
+ db.prepare(`
1753
+ INSERT INTO billing_checkout_attempts (
1754
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1755
+ ) VALUES ('cs_success_123', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1756
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1557
1757
  const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1558
1758
  expect(confirmHandler).toBeTypeOf('function');
1559
1759
  const req = createMockReq(JSON.stringify({
@@ -1570,14 +1770,36 @@ describe('Billing Webhook Routes', () => {
1570
1770
  }));
1571
1771
  expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
1572
1772
  const entitlement = db.prepare(`
1573
- SELECT plan_version_id, entitlement_state
1773
+ SELECT plan_version_id, entitlement_state, updated_by
1574
1774
  FROM account_entitlements
1575
1775
  WHERE tenant_id = 'default' AND account_id = 'u1'
1576
1776
  LIMIT 1
1577
1777
  `).get();
1578
1778
  expect(entitlement).toEqual(expect.objectContaining({
1579
1779
  plan_version_id: 'pro-v1',
1580
- entitlement_state: 'active'
1780
+ entitlement_state: 'active',
1781
+ updated_by: 'checkout-confirm'
1782
+ }));
1783
+ const billingRow = db.prepare(`
1784
+ SELECT stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status
1785
+ FROM user_billing
1786
+ WHERE tenant_id = 'default' AND user_id = 'u1'
1787
+ LIMIT 1
1788
+ `).get();
1789
+ expect(billingRow).toEqual(expect.objectContaining({
1790
+ stripe_customer_id: 'cus_123',
1791
+ stripe_subscription_id: 'sub_123',
1792
+ stripe_price_id: 'price_pro_month',
1793
+ stripe_status: 'active'
1794
+ }));
1795
+ const attempt = db.prepare(`
1796
+ SELECT status
1797
+ FROM billing_checkout_attempts
1798
+ WHERE tenant_id = 'default' AND session_id = 'cs_success_123'
1799
+ LIMIT 1
1800
+ `).get();
1801
+ expect(attempt).toEqual(expect.objectContaining({
1802
+ status: 'confirmed'
1581
1803
  }));
1582
1804
  });
1583
1805
  it('does not activate entitlement when the checkout session is still incomplete', async () => {
@@ -1631,6 +1853,268 @@ describe('Billing Webhook Routes', () => {
1631
1853
  updated_by: 'checkout-start'
1632
1854
  }));
1633
1855
  });
1856
+ it('keeps checkout confirmation retryable until Stripe attaches the subscription to the completed session', async () => {
1857
+ const now = new Date().toISOString();
1858
+ db.prepare(`
1859
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1860
+ VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
1861
+ `).run(now, now);
1862
+ db.prepare(`
1863
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1864
+ VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
1865
+ `).run(now, now);
1866
+ db.prepare(`
1867
+ INSERT INTO account_entitlements (
1868
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1869
+ ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
1870
+ `).run(now, now, now);
1871
+ db.prepare(`
1872
+ INSERT INTO billing_checkout_attempts (
1873
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1874
+ ) VALUES ('cs_pending_subscription', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1875
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1876
+ mockCheckoutRetrieve.mockResolvedValueOnce({
1877
+ id: 'cs_pending_subscription',
1878
+ mode: 'subscription',
1879
+ status: 'complete',
1880
+ payment_status: 'paid',
1881
+ customer: 'cus_123',
1882
+ subscription: null,
1883
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1884
+ });
1885
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1886
+ const req = createMockReq(JSON.stringify({
1887
+ sessionId: 'cs_pending_subscription'
1888
+ }), {
1889
+ 'x-taskforce-runtime-mode': 'cloud',
1890
+ host: 'app.taskforcehq.ai'
1891
+ });
1892
+ const res = createMockRes();
1893
+ await confirmHandler(req, res, {});
1894
+ expect(res.statusCode).toBe(409);
1895
+ expect(JSON.parse(res.body)).toEqual({
1896
+ error: 'Checkout session is still finalizing your subscription.',
1897
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
1898
+ });
1899
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
1900
+ const entitlement = db.prepare(`
1901
+ SELECT plan_version_id, entitlement_state
1902
+ FROM account_entitlements
1903
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1904
+ LIMIT 1
1905
+ `).get();
1906
+ expect(entitlement).toEqual(expect.objectContaining({
1907
+ plan_version_id: 'free-v1',
1908
+ entitlement_state: 'active'
1909
+ }));
1910
+ const attempt = db.prepare(`
1911
+ SELECT status
1912
+ FROM billing_checkout_attempts
1913
+ WHERE tenant_id = 'default' AND session_id = 'cs_pending_subscription'
1914
+ LIMIT 1
1915
+ `).get();
1916
+ expect(attempt).toEqual(expect.objectContaining({
1917
+ status: 'open'
1918
+ }));
1919
+ });
1920
+ it('keeps checkout confirmation retryable until the subscription settles into an active commercial state', async () => {
1921
+ const now = new Date().toISOString();
1922
+ db.prepare(`
1923
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1924
+ VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
1925
+ `).run(now, now);
1926
+ db.prepare(`
1927
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1928
+ VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
1929
+ `).run(now, now);
1930
+ db.prepare(`
1931
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1932
+ VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1933
+ `).run(now, now);
1934
+ db.prepare(`
1935
+ INSERT INTO user_billing (
1936
+ user_id, tenant_id, tier, entitlement_state, created_at, updated_at
1937
+ ) VALUES ('u1', 'default', 'free', 'active', ?, ?)
1938
+ `).run(now, now);
1939
+ db.prepare(`
1940
+ INSERT INTO account_entitlements (
1941
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1942
+ ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
1943
+ `).run(now, now, now);
1944
+ db.prepare(`
1945
+ INSERT INTO billing_checkout_attempts (
1946
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1947
+ ) VALUES ('cs_wait_for_active', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1948
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1949
+ mockCheckoutRetrieve.mockResolvedValueOnce({
1950
+ id: 'cs_wait_for_active',
1951
+ mode: 'subscription',
1952
+ status: 'complete',
1953
+ payment_status: 'paid',
1954
+ customer: 'cus_123',
1955
+ subscription: 'sub_123',
1956
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1957
+ });
1958
+ mockSubscriptionRetrieve.mockResolvedValueOnce({
1959
+ id: 'sub_123',
1960
+ customer: 'cus_123',
1961
+ status: 'incomplete',
1962
+ trial_end: null,
1963
+ metadata: { userId: 'u1', planVersionId: 'pro-v1' },
1964
+ items: {
1965
+ data: [{
1966
+ id: 'si_123',
1967
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
1968
+ price: { id: 'price_pro_month', recurring: { interval: 'month' } }
1969
+ }]
1970
+ }
1971
+ });
1972
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1973
+ const req = createMockReq(JSON.stringify({
1974
+ sessionId: 'cs_wait_for_active'
1975
+ }), {
1976
+ 'x-taskforce-runtime-mode': 'cloud',
1977
+ host: 'app.taskforcehq.ai'
1978
+ });
1979
+ const res = createMockRes();
1980
+ await confirmHandler(req, res, {});
1981
+ expect(res.statusCode).toBe(409);
1982
+ expect(JSON.parse(res.body)).toEqual({
1983
+ error: 'Checkout session is still finalizing your plan.',
1984
+ code: 'CHECKOUT_SESSION_INCOMPLETE'
1985
+ });
1986
+ const entitlement = db.prepare(`
1987
+ SELECT plan_version_id, entitlement_state
1988
+ FROM account_entitlements
1989
+ WHERE tenant_id = 'default' AND account_id = 'u1'
1990
+ LIMIT 1
1991
+ `).get();
1992
+ expect(entitlement).toEqual(expect.objectContaining({
1993
+ plan_version_id: 'free-v1',
1994
+ entitlement_state: 'active'
1995
+ }));
1996
+ const attempt = db.prepare(`
1997
+ SELECT status
1998
+ FROM billing_checkout_attempts
1999
+ WHERE tenant_id = 'default' AND session_id = 'cs_wait_for_active'
2000
+ LIMIT 1
2001
+ `).get();
2002
+ expect(attempt).toEqual(expect.objectContaining({
2003
+ status: 'open'
2004
+ }));
2005
+ });
2006
+ it('does not mutate billing mirror or canonical entitlement when the purchased plan version is still unresolved', async () => {
2007
+ const now = new Date().toISOString();
2008
+ db.prepare(`
2009
+ INSERT INTO billing_checkout_attempts (
2010
+ session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
2011
+ ) VALUES ('cs_plan_unresolved', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
2012
+ `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
2013
+ db.prepare(`
2014
+ INSERT INTO account_entitlements (
2015
+ account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
2016
+ ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'seed', ?, ?)
2017
+ `).run(now, now, now);
2018
+ db.prepare(`
2019
+ INSERT INTO user_billing (
2020
+ user_id, tenant_id, stripe_customer_id, stripe_subscription_id, tier, entitlement_state, created_at, updated_at
2021
+ ) VALUES ('u1', 'default', 'cus_existing', 'sub_existing', 'free', 'active', ?, ?)
2022
+ `).run(now, now);
2023
+ mockCheckoutRetrieve.mockResolvedValueOnce({
2024
+ id: 'cs_plan_unresolved',
2025
+ mode: 'subscription',
2026
+ status: 'complete',
2027
+ payment_status: 'paid',
2028
+ customer: 'cus_123',
2029
+ subscription: 'sub_unmapped',
2030
+ metadata: { userId: 'u1' }
2031
+ });
2032
+ mockSubscriptionRetrieve.mockResolvedValueOnce({
2033
+ id: 'sub_unmapped',
2034
+ customer: 'cus_123',
2035
+ status: 'active',
2036
+ trial_end: null,
2037
+ metadata: { userId: 'u1' },
2038
+ items: {
2039
+ data: [{
2040
+ id: 'si_unmapped',
2041
+ current_period_end: Math.floor(Date.now() / 1000) + 86400,
2042
+ price: { id: 'price_unmapped', recurring: { interval: 'month' } }
2043
+ }]
2044
+ }
2045
+ });
2046
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2047
+ const req = createMockReq(JSON.stringify({
2048
+ sessionId: 'cs_plan_unresolved'
2049
+ }), {
2050
+ 'x-taskforce-runtime-mode': 'cloud',
2051
+ host: 'app.taskforcehq.ai'
2052
+ });
2053
+ const res = createMockRes();
2054
+ await confirmHandler(req, res, {});
2055
+ expect(res.statusCode).toBe(409);
2056
+ expect(JSON.parse(res.body)).toEqual({
2057
+ error: 'Checkout completed, but the purchased plan could not be resolved yet.',
2058
+ code: 'CHECKOUT_PLAN_VERSION_UNRESOLVED'
2059
+ });
2060
+ const entitlement = db.prepare(`
2061
+ SELECT plan_version_id, entitlement_state
2062
+ FROM account_entitlements
2063
+ WHERE tenant_id = 'default' AND account_id = 'u1'
2064
+ LIMIT 1
2065
+ `).get();
2066
+ expect(entitlement).toEqual(expect.objectContaining({
2067
+ plan_version_id: 'free-v1',
2068
+ entitlement_state: 'active'
2069
+ }));
2070
+ const billingRow = db.prepare(`
2071
+ SELECT stripe_customer_id, stripe_subscription_id, tier, entitlement_state
2072
+ FROM user_billing
2073
+ WHERE tenant_id = 'default' AND user_id = 'u1'
2074
+ LIMIT 1
2075
+ `).get();
2076
+ expect(billingRow).toEqual(expect.objectContaining({
2077
+ stripe_customer_id: 'cus_existing',
2078
+ stripe_subscription_id: 'sub_existing',
2079
+ tier: 'free',
2080
+ entitlement_state: 'active'
2081
+ }));
2082
+ const attempt = db.prepare(`
2083
+ SELECT status
2084
+ FROM billing_checkout_attempts
2085
+ WHERE tenant_id = 'default' AND session_id = 'cs_plan_unresolved'
2086
+ LIMIT 1
2087
+ `).get();
2088
+ expect(attempt).toEqual(expect.objectContaining({
2089
+ status: 'open'
2090
+ }));
2091
+ });
2092
+ it('rejects checkout confirmation when the session belongs to a different account', async () => {
2093
+ mockCheckoutRetrieve.mockResolvedValueOnce({
2094
+ id: 'cs_wrong_account',
2095
+ mode: 'subscription',
2096
+ status: 'complete',
2097
+ payment_status: 'paid',
2098
+ customer: 'cus_other',
2099
+ subscription: 'sub_other',
2100
+ metadata: { userId: 'u2' }
2101
+ });
2102
+ const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2103
+ const req = createMockReq(JSON.stringify({
2104
+ sessionId: 'cs_wrong_account'
2105
+ }), {
2106
+ 'x-taskforce-runtime-mode': 'cloud',
2107
+ host: 'app.taskforcehq.ai'
2108
+ });
2109
+ const res = createMockRes();
2110
+ await confirmHandler(req, res, {});
2111
+ expect(res.statusCode).toBe(403);
2112
+ expect(JSON.parse(res.body)).toEqual({
2113
+ error: 'Checkout session does not belong to the current account.',
2114
+ code: 'CHECKOUT_SESSION_ACCOUNT_MISMATCH'
2115
+ });
2116
+ expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
2117
+ });
1634
2118
  it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
1635
2119
  const now = new Date().toISOString();
1636
2120
  db.prepare(`
@@ -1928,11 +2412,27 @@ describe('Billing Webhook Routes', () => {
1928
2412
  `).get();
1929
2413
  expect(purged).toBeUndefined();
1930
2414
  });
1931
- it('lists stripe price options filtered by interval for system admin', async () => {
2415
+ it('lists active Stripe price options for the requested interval', async () => {
1932
2416
  mockPricesList.mockResolvedValueOnce({
1933
2417
  data: [
1934
- { id: 'price_month_1', active: true, currency: 'usd', unit_amount: 1200, recurring: { interval: 'month' }, nickname: 'Monthly', product: 'prod_1' },
1935
- { id: 'price_year_1', active: true, currency: 'usd', unit_amount: 12000, recurring: { interval: 'year' }, nickname: 'Yearly', product: 'prod_1' }
2418
+ {
2419
+ id: 'price_month_1',
2420
+ active: true,
2421
+ currency: 'usd',
2422
+ unit_amount: 1900,
2423
+ nickname: 'Monthly Core',
2424
+ recurring: { interval: 'month' },
2425
+ product: { id: 'prod_core', name: 'Core' }
2426
+ },
2427
+ {
2428
+ id: 'price_year_1',
2429
+ active: true,
2430
+ currency: 'usd',
2431
+ unit_amount: 19000,
2432
+ nickname: 'Yearly Core',
2433
+ recurring: { interval: 'year' },
2434
+ product: { id: 'prod_core', name: 'Core' }
2435
+ }
1936
2436
  ],
1937
2437
  has_more: false
1938
2438
  });
@@ -1946,14 +2446,19 @@ describe('Billing Webhook Routes', () => {
1946
2446
  await route(req, res, {});
1947
2447
  expect(res.statusCode).toBe(200);
1948
2448
  const body = JSON.parse(res.body);
1949
- expect(Array.isArray(body.options)).toBe(true);
1950
- expect(body.options.length).toBe(1);
1951
- expect(body.options[0]).toEqual(expect.objectContaining({
1952
- stripePriceId: 'price_month_1',
1953
- interval: 'month'
1954
- }));
2449
+ expect(body.options).toEqual([
2450
+ expect.objectContaining({
2451
+ stripePriceId: 'price_month_1',
2452
+ interval: 'month',
2453
+ unitAmount: 1900,
2454
+ currency: 'usd',
2455
+ nickname: 'Monthly Core',
2456
+ productId: 'prod_core',
2457
+ productName: 'Core'
2458
+ })
2459
+ ]);
1955
2460
  });
1956
- it('keeps active manual price mappings during Stripe import when the price is still active but lacks plan metadata', async () => {
2461
+ it('keeps active imported price mappings during Stripe import when the Stripe price is still active but lacks plan metadata', async () => {
1957
2462
  const now = new Date().toISOString();
1958
2463
  db.prepare(`
1959
2464
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -1992,7 +2497,7 @@ describe('Billing Webhook Routes', () => {
1992
2497
  `).get();
1993
2498
  expect(row.active).toBe(1);
1994
2499
  });
1995
- it('upserts price mappings and deactivates prior active mapping for same version interval', async () => {
2500
+ it('upserts manual Stripe price mappings and deactivates the prior active mapping', async () => {
1996
2501
  const now = new Date().toISOString();
1997
2502
  db.prepare(`
1998
2503
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2000,13 +2505,13 @@ describe('Billing Webhook Routes', () => {
2000
2505
  `).run(now, now);
2001
2506
  db.prepare(`
2002
2507
  INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2003
- VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1100, 'usd', ?, ?)
2508
+ VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1500, 'usd', ?, ?)
2004
2509
  `).run(now, now);
2005
2510
  mockPriceRetrieve.mockResolvedValueOnce({
2006
2511
  id: 'price_new',
2007
2512
  active: true,
2008
2513
  currency: 'usd',
2009
- unit_amount: 1500,
2514
+ unit_amount: 2400,
2010
2515
  recurring: { interval: 'month' }
2011
2516
  });
2012
2517
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
@@ -2020,18 +2525,28 @@ describe('Billing Webhook Routes', () => {
2020
2525
  const upsertRes = createMockRes();
2021
2526
  await upsertRoute(upsertReq, upsertRes, {});
2022
2527
  expect(upsertRes.statusCode).toBe(200);
2528
+ const body = JSON.parse(upsertRes.body);
2529
+ expect(body.mapping).toEqual(expect.objectContaining({
2530
+ plan_version_id: 'collab-v1',
2531
+ billing_interval: 'month',
2532
+ stripe_price_id: 'price_new',
2533
+ pricing_type: 'stripe',
2534
+ active: 1,
2535
+ unit_amount: 2400,
2536
+ currency: 'usd'
2537
+ }));
2023
2538
  const rows = db.prepare(`
2024
- SELECT stripe_price_id, active
2539
+ SELECT stripe_price_id, active, unit_amount
2025
2540
  FROM billing_price_mappings
2026
2541
  WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
2027
2542
  ORDER BY stripe_price_id ASC
2028
2543
  `).all();
2029
2544
  expect(rows).toEqual([
2030
- { stripe_price_id: 'price_new', active: 1 },
2031
- { stripe_price_id: 'price_old', active: 0 }
2545
+ { stripe_price_id: 'price_new', active: 1, unit_amount: 2400 },
2546
+ { stripe_price_id: 'price_old', active: 0, unit_amount: 1500 }
2032
2547
  ]);
2033
2548
  });
2034
- it('allows the same stripe price id to be assigned to multiple plan versions', async () => {
2549
+ it('rejects manual reuse of a Stripe price id across multiple plan versions', async () => {
2035
2550
  const now = new Date().toISOString();
2036
2551
  db.prepare(`
2037
2552
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2041,13 +2556,6 @@ describe('Billing Webhook Routes', () => {
2041
2556
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2042
2557
  VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2043
2558
  `).run(now, now);
2044
- mockPriceRetrieve.mockResolvedValue({
2045
- id: 'price_shared',
2046
- active: true,
2047
- currency: 'usd',
2048
- unit_amount: 1900,
2049
- recurring: { interval: 'month' }
2050
- });
2051
2559
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2052
2560
  const firstReq = createMockReq(JSON.stringify({
2053
2561
  planVersionId: 'collab-v1',
@@ -2066,19 +2574,12 @@ describe('Billing Webhook Routes', () => {
2066
2574
  }), { 'x-taskforce-runtime-mode': 'cloud' });
2067
2575
  const secondRes = createMockRes();
2068
2576
  await upsertRoute(secondReq, secondRes, {});
2069
- expect(secondRes.statusCode).toBe(200);
2070
- const rows = db.prepare(`
2071
- SELECT plan_version_id, stripe_price_id, active
2072
- FROM billing_price_mappings
2073
- WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
2074
- ORDER BY plan_version_id ASC
2075
- `).all();
2076
- expect(rows).toEqual([
2077
- { plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
2078
- { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2079
- ]);
2577
+ expect(secondRes.statusCode).toBe(409);
2578
+ expect(JSON.parse(secondRes.body)).toEqual(expect.objectContaining({
2579
+ code: 'BILLING_PRICE_ALREADY_ASSIGNED'
2580
+ }));
2080
2581
  });
2081
- it('keeps reused mappings for other plan versions during Stripe import', async () => {
2582
+ it('keeps reused active mappings during Stripe import when the shared Stripe price is still active', async () => {
2082
2583
  const now = new Date().toISOString();
2083
2584
  db.prepare(`
2084
2585
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
@@ -2125,12 +2626,16 @@ describe('Billing Webhook Routes', () => {
2125
2626
  { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2126
2627
  ]);
2127
2628
  });
2128
- it('upserts explicit free pricing without requiring a Stripe price id', async () => {
2629
+ it('supports manual free price mappings and deactivation', async () => {
2129
2630
  const now = new Date().toISOString();
2130
2631
  db.prepare(`
2131
2632
  INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2132
2633
  VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
2133
2634
  `).run(now, now);
2635
+ db.prepare(`
2636
+ INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2637
+ VALUES ('default', 'starter-v1', 'month', 'price_old', 'stripe', 1, 1500, 'usd', ?, ?)
2638
+ `).run(now, now);
2134
2639
  const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2135
2640
  expect(upsertRoute).toBeTypeOf('function');
2136
2641
  const upsertReq = createMockReq(JSON.stringify({
@@ -2142,16 +2647,40 @@ describe('Billing Webhook Routes', () => {
2142
2647
  const upsertRes = createMockRes();
2143
2648
  await upsertRoute(upsertReq, upsertRes, {});
2144
2649
  expect(upsertRes.statusCode).toBe(200);
2145
- const row = db.prepare(`
2146
- SELECT stripe_price_id, pricing_type, active, unit_amount
2650
+ expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2651
+ mapping: expect.objectContaining({
2652
+ plan_version_id: 'starter-v1',
2653
+ billing_interval: 'month',
2654
+ stripe_price_id: 'free:starter-v1:month',
2655
+ pricing_type: 'free',
2656
+ active: 1,
2657
+ unit_amount: 0,
2658
+ currency: null
2659
+ })
2660
+ }));
2661
+ const deactivateRoute = routeHandlers.get('POST /api/taskforce/admin/billing/price-mappings/deactivate');
2662
+ expect(deactivateRoute).toBeTypeOf('function');
2663
+ const deactivateReq = createMockReq(JSON.stringify({
2664
+ planVersionId: 'starter-v1',
2665
+ interval: 'month'
2666
+ }), { 'x-taskforce-runtime-mode': 'cloud' });
2667
+ const deactivateRes = createMockRes();
2668
+ await deactivateRoute(deactivateReq, deactivateRes, {});
2669
+ expect(deactivateRes.statusCode).toBe(200);
2670
+ expect(JSON.parse(deactivateRes.body)).toEqual(expect.objectContaining({
2671
+ success: true,
2672
+ changedRows: 2
2673
+ }));
2674
+ const rows = db.prepare(`
2675
+ SELECT stripe_price_id, pricing_type, active
2147
2676
  FROM billing_price_mappings
2148
2677
  WHERE tenant_id = 'default' AND plan_version_id = 'starter-v1' AND billing_interval = 'month'
2149
- LIMIT 1
2150
- `).get();
2151
- expect(row.stripe_price_id).toBe('free:starter-v1:month');
2152
- expect(row.pricing_type).toBe('free');
2153
- expect(row.active).toBe(1);
2154
- expect(row.unit_amount).toBe(0);
2678
+ ORDER BY stripe_price_id ASC
2679
+ `).all();
2680
+ expect(rows).toEqual([
2681
+ { stripe_price_id: 'free:starter-v1:month', pricing_type: 'free', active: 0 },
2682
+ { stripe_price_id: 'price_old', pricing_type: 'stripe', active: 0 }
2683
+ ]);
2155
2684
  });
2156
2685
  it('rejects webhook failure diagnostics for non-admin', async () => {
2157
2686
  const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
@@ -2229,6 +2758,185 @@ describe('Billing Webhook Routes', () => {
2229
2758
  expect(freePlan.pricing.year).toBeNull();
2230
2759
  expect(proPlan.defaultVersion.trialDays).toBe(0);
2231
2760
  });
2761
+ it('invalidates cached public pricing when billing mappings change', async () => {
2762
+ const now = new Date().toISOString();
2763
+ routeHandlers = buildBillingRouteHandlers(db);
2764
+ const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2765
+ expect(pricingRoute).toBeTypeOf('function');
2766
+ db.prepare(`
2767
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2768
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2769
+ `).run(now, now);
2770
+ db.prepare(`
2771
+ INSERT INTO billing_price_mappings (
2772
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2773
+ ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2774
+ `).run(now, now);
2775
+ const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2776
+ const firstRes = createMockRes();
2777
+ await pricingRoute(firstReq, firstRes, {});
2778
+ expect(firstRes.statusCode).toBe(200);
2779
+ const firstBody = JSON.parse(firstRes.body);
2780
+ expect(firstBody.plans.find((p) => p.planId === 'pro')?.pricing?.month?.unitAmount).toBe(1200);
2781
+ const updatedAt = new Date(Date.now() + 1000).toISOString();
2782
+ db.prepare(`
2783
+ UPDATE billing_price_mappings
2784
+ SET unit_amount = 2400, updated_at = ?
2785
+ WHERE tenant_id = 'default' AND plan_version_id = 'pro-v1' AND billing_interval = 'month'
2786
+ `).run(updatedAt);
2787
+ const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2788
+ const secondRes = createMockRes();
2789
+ await pricingRoute(secondReq, secondRes, {});
2790
+ expect(secondRes.statusCode).toBe(200);
2791
+ const secondBody = JSON.parse(secondRes.body);
2792
+ expect(secondBody.plans.find((p) => p.planId === 'pro')?.pricing?.month?.unitAmount).toBe(2400);
2793
+ });
2794
+ it('invalidates cached public pricing when plan features change', async () => {
2795
+ const now = new Date().toISOString();
2796
+ routeHandlers = buildBillingRouteHandlers(db);
2797
+ const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2798
+ expect(pricingRoute).toBeTypeOf('function');
2799
+ db.exec(`
2800
+ CREATE TABLE IF NOT EXISTS plan_feature_matrix (
2801
+ tenant_id TEXT NOT NULL,
2802
+ plan_version_id TEXT NOT NULL,
2803
+ feature_key TEXT NOT NULL,
2804
+ access TEXT NOT NULL,
2805
+ config_json TEXT,
2806
+ created_at TEXT NOT NULL,
2807
+ updated_at TEXT NOT NULL,
2808
+ PRIMARY KEY (tenant_id, plan_version_id, feature_key)
2809
+ )
2810
+ `);
2811
+ db.prepare(`
2812
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2813
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2814
+ `).run(now, now);
2815
+ db.prepare(`
2816
+ INSERT INTO billing_price_mappings (
2817
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2818
+ ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2819
+ `).run(now, now);
2820
+ db.prepare(`
2821
+ INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
2822
+ VALUES ('default', 'pro-v1', 'ai.chat', 'enabled', '{}', ?, ?)
2823
+ `).run(now, now);
2824
+ const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2825
+ const firstRes = createMockRes();
2826
+ await pricingRoute(firstReq, firstRes, {});
2827
+ expect(firstRes.statusCode).toBe(200);
2828
+ const firstBody = JSON.parse(firstRes.body);
2829
+ expect(firstBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
2830
+ expect.objectContaining({ featureKey: 'ai.chat' })
2831
+ ]);
2832
+ const updatedAt = new Date(Date.now() + 1000).toISOString();
2833
+ db.prepare(`
2834
+ UPDATE plan_feature_matrix
2835
+ SET access = 'disabled', updated_at = ?
2836
+ WHERE tenant_id = 'default' AND plan_version_id = 'pro-v1' AND feature_key = 'ai.chat'
2837
+ `).run(updatedAt);
2838
+ const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2839
+ const secondRes = createMockRes();
2840
+ await pricingRoute(secondReq, secondRes, {});
2841
+ expect(secondRes.statusCode).toBe(200);
2842
+ const secondBody = JSON.parse(secondRes.body);
2843
+ expect(secondBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([]);
2844
+ });
2845
+ it('includes public feature copy and invalidates cached public pricing when catalog overrides change', async () => {
2846
+ const now = new Date().toISOString();
2847
+ routeHandlers = buildBillingRouteHandlers(db);
2848
+ const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2849
+ expect(pricingRoute).toBeTypeOf('function');
2850
+ db.prepare(`
2851
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2852
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2853
+ `).run(now, now);
2854
+ db.prepare(`
2855
+ INSERT INTO billing_price_mappings (
2856
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2857
+ ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2858
+ `).run(now, now);
2859
+ db.prepare(`
2860
+ INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
2861
+ VALUES ('default', 'pro-v1', 'collaboration.ai_profiles', 'enabled', '{}', ?, ?)
2862
+ `).run(now, now);
2863
+ db.prepare(`
2864
+ INSERT INTO plan_feature_catalog_overrides (tenant_id, feature_key, public_label, public_description, created_at, updated_at)
2865
+ VALUES ('default', 'collaboration.ai_profiles', 'AI teammates', 'Add reusable AI teammates to your workspace.', ?, ?)
2866
+ `).run(now, now);
2867
+ const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2868
+ const firstRes = createMockRes();
2869
+ await pricingRoute(firstReq, firstRes, {});
2870
+ expect(firstRes.statusCode).toBe(200);
2871
+ const firstBody = JSON.parse(firstRes.body);
2872
+ expect(firstBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
2873
+ expect.objectContaining({
2874
+ featureKey: 'collaboration.ai_profiles',
2875
+ label: 'AI Profiles',
2876
+ publicLabel: 'AI teammates',
2877
+ publicDescription: 'Add reusable AI teammates to your workspace.'
2878
+ })
2879
+ ]);
2880
+ const updatedAt = new Date(Date.now() + 1000).toISOString();
2881
+ db.prepare(`
2882
+ UPDATE plan_feature_catalog_overrides
2883
+ SET public_label = 'AI copilots', updated_at = ?
2884
+ WHERE tenant_id = 'default' AND feature_key = 'collaboration.ai_profiles'
2885
+ `).run(updatedAt);
2886
+ const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2887
+ const secondRes = createMockRes();
2888
+ await pricingRoute(secondReq, secondRes, {});
2889
+ expect(secondRes.statusCode).toBe(200);
2890
+ const secondBody = JSON.parse(secondRes.body);
2891
+ expect(secondBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
2892
+ expect.objectContaining({
2893
+ featureKey: 'collaboration.ai_profiles',
2894
+ publicLabel: 'AI copilots'
2895
+ })
2896
+ ]);
2897
+ });
2898
+ it('orders public pricing features by catalog display order', async () => {
2899
+ const now = new Date().toISOString();
2900
+ routeHandlers = buildBillingRouteHandlers(db);
2901
+ const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2902
+ expect(pricingRoute).toBeTypeOf('function');
2903
+ db.prepare(`
2904
+ INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, seat_limit, metadata_json, created_at, updated_at)
2905
+ VALUES ('pro-v1', 'default', 'pro', 1, 1, 5, '{}', ?, ?)
2906
+ `).run(now, now);
2907
+ db.prepare(`
2908
+ INSERT INTO billing_price_mappings (
2909
+ tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2910
+ ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1800, 'usd', ?, ?)
2911
+ `).run(now, now);
2912
+ for (const feature of [
2913
+ { key: 'collaboration.team_management', access: 'enabled', config: '{}' },
2914
+ { key: 'collaboration.invites', access: 'enabled', config: '{}' },
2915
+ { key: 'collaboration.seat_limit', access: 'limited', config: '{"seat_limit":5}' },
2916
+ { key: 'context.uploads', access: 'enabled', config: '{"storageLimitMb":24}' },
2917
+ { key: 'workspace.workspaces', access: 'enabled', config: '{"maxWorkspaces":1}' },
2918
+ { key: 'collaboration.ai_profiles', access: 'limited', config: '{"maxAiProfiles":4}' }
2919
+ ]) {
2920
+ db.prepare(`
2921
+ INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
2922
+ VALUES ('default', 'pro-v1', ?, ?, ?, ?, ?)
2923
+ `).run(feature.key, feature.access, feature.config, now, now);
2924
+ }
2925
+ const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2926
+ const res = createMockRes();
2927
+ await pricingRoute(req, res, {});
2928
+ expect(res.statusCode).toBe(200);
2929
+ const body = JSON.parse(res.body);
2930
+ const orderedPlan = body.plans.find((plan) => plan.planId === 'pro');
2931
+ expect(orderedPlan.features.map((feature) => feature.featureKey)).toEqual([
2932
+ 'workspace.workspaces',
2933
+ 'collaboration.ai_profiles',
2934
+ 'context.uploads',
2935
+ 'collaboration.team_management'
2936
+ ]);
2937
+ expect(orderedPlan.features.map((feature) => feature.featureKey)).not.toContain('collaboration.invites');
2938
+ expect(orderedPlan.features.map((feature) => feature.featureKey)).not.toContain('collaboration.seat_limit');
2939
+ });
2232
2940
  it('returns the pricing environment derived from the cloud host', async () => {
2233
2941
  routeHandlers = buildBillingRouteHandlers(db);
2234
2942
  const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');