@taskforcehq/taskforce 0.3.309 → 0.3.313

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 (470) hide show
  1. package/README.md +89 -127
  2. package/dist/TaskforceCore.js +1 -1
  3. package/dist/components/context/ContextAttachmentManager.js +4 -3
  4. package/dist/components/features/InitiativesModule.js +1 -1
  5. package/dist/components/features/TaskSettings.js +1 -1
  6. package/dist/components/ui/DisclosureSectionHeader.d.ts +12 -0
  7. package/dist/components/ui/DisclosureSectionHeader.js +8 -0
  8. package/dist/components/views/PlanComparisonPage.js +1 -1
  9. package/dist/components/views/StandaloneLayout.js +18 -4
  10. package/dist/components/views/panels/PlanningDrawer.js +2 -1
  11. package/dist/components/views/standalone/modals/AccountHubModal.js +1 -1
  12. package/dist/components/views/standalone/modals/HelpModal.d.ts +3 -1
  13. package/dist/components/views/standalone/modals/HelpModal.js +17 -2
  14. package/dist/core/PlanEntitlementService.d.ts +2 -0
  15. package/dist/core/PlanEntitlementService.js +57 -4
  16. package/dist/hooks/useFilterSort.js +1 -1
  17. package/dist/hooks/useTaskforce.js +2 -2
  18. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +5 -2
  19. package/dist/localization/locales/en-US.js +1 -1
  20. package/dist/localization/locales/es-419.js +1 -1
  21. package/dist/localization/locales/pt-BR.js +1 -1
  22. package/dist/mcp/runtime.js +49 -14
  23. package/dist/server/routes/billing.js +0 -1
  24. package/dist/ui/assets/AgentsModule-BULqIo8e.js +1 -0
  25. package/dist/ui/assets/{AnnotatedAttachmentWorkspace-CCckzWYZ.js → AnnotatedAttachmentWorkspace-DHr4rHK9.js} +1 -1
  26. package/dist/ui/assets/ContextAttachmentManager-DoMe2OpY.js +3 -0
  27. package/dist/ui/assets/{DocumentWorkspace-BVaWPA25.js → DocumentWorkspace-DumBWhvb.js} +1 -1
  28. package/dist/ui/assets/{EntityActivityTimeline-DSj0ThaK.js → EntityActivityTimeline-sHk8Nzbq.js} +1 -1
  29. package/dist/ui/assets/{InitiativesModule-AxMkWtxH.js → InitiativesModule-3X9UNzHh.js} +1 -1
  30. package/dist/ui/assets/PlansPage-C3A3EYaV.js +1 -0
  31. package/dist/ui/assets/PlansPage-DeAzWYTb.css +1 -0
  32. package/dist/ui/assets/{TaskContextUpload-Dw4rTF4i.js → TaskContextUpload-C8G7pWkX.js} +1 -1
  33. package/dist/ui/assets/{TaskSettings-f2ga42_V.js → TaskSettings-DM1o9Wla.js} +2 -2
  34. package/dist/ui/assets/{WorkflowsModule-E4UL3mov.js → WorkflowsModule-CW0crzvD.js} +1 -1
  35. package/dist/ui/assets/documentReferences-BZB3nMRb.js +1 -0
  36. package/dist/ui/assets/index-BWFQ9mGn.js +5 -0
  37. package/dist/ui/assets/{index-BvAbqx5Q.css → index-DcK_8tuE.css} +1 -1
  38. package/dist/ui/assets/{vendor-icons-B32hGuF2.js → vendor-icons-CLnehDTw.js} +1 -1
  39. package/dist/ui/index.html +3 -3
  40. package/dist/utils/supportContact.d.ts +9 -0
  41. package/dist/utils/supportContact.js +32 -0
  42. package/package.json +12 -4
  43. package/scripts/postinstall.js +4 -3
  44. package/dist/TaskforceCore.CategoryFallback.test.d.ts +0 -1
  45. package/dist/TaskforceCore.CategoryFallback.test.js +0 -101
  46. package/dist/TaskforceCore.filter-persistence.test.d.ts +0 -1
  47. package/dist/TaskforceCore.filter-persistence.test.js +0 -402
  48. package/dist/TaskforceCore.routing.test.d.ts +0 -1
  49. package/dist/TaskforceCore.routing.test.js +0 -311
  50. package/dist/TaskforceCore.test.d.ts +0 -1
  51. package/dist/TaskforceCore.test.js +0 -7910
  52. package/dist/TaskforceWidget.test.d.ts +0 -1
  53. package/dist/TaskforceWidget.test.js +0 -92
  54. package/dist/UnsavedChangesRelaxed.test.d.ts +0 -1
  55. package/dist/UnsavedChangesRelaxed.test.js +0 -16
  56. package/dist/__tests__/localizationCatalogs.test.d.ts +0 -1
  57. package/dist/__tests__/localizationCatalogs.test.js +0 -54
  58. package/dist/__tests__/workflowSimulation.test.d.ts +0 -1
  59. package/dist/__tests__/workflowSimulation.test.js +0 -113
  60. package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.test.d.ts +0 -1
  61. package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.test.js +0 -278
  62. package/dist/annotatedAttachments/payload.test.d.ts +0 -1
  63. package/dist/annotatedAttachments/payload.test.js +0 -67
  64. package/dist/compat/workspaceSyncCompat.test.d.ts +0 -1
  65. package/dist/compat/workspaceSyncCompat.test.js +0 -124
  66. package/dist/components/features/AgentsModule.test.d.ts +0 -1
  67. package/dist/components/features/AgentsModule.test.js +0 -601
  68. package/dist/components/features/AnnotatedAttachmentWorkspace.test.d.ts +0 -1
  69. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +0 -2883
  70. package/dist/components/features/DocumentWorkspace.test.d.ts +0 -1
  71. package/dist/components/features/DocumentWorkspace.test.js +0 -1439
  72. package/dist/components/features/TaskSettings.test.d.ts +0 -1
  73. package/dist/components/features/TaskSettings.test.js +0 -1674
  74. package/dist/components/features/settings/TaxonomyLibraryManager.test.d.ts +0 -1
  75. package/dist/components/features/settings/TaxonomyLibraryManager.test.js +0 -141
  76. package/dist/components/task/TaskActionHeader.test.d.ts +0 -1
  77. package/dist/components/task/TaskActionHeader.test.js +0 -91
  78. package/dist/components/task/TaskCard.test.d.ts +0 -1
  79. package/dist/components/task/TaskCard.test.js +0 -513
  80. package/dist/components/task/TaskContextUpload.test.d.ts +0 -1
  81. package/dist/components/task/TaskContextUpload.test.js +0 -506
  82. package/dist/components/task/TaskForm.test.d.ts +0 -1
  83. package/dist/components/task/TaskForm.test.js +0 -946
  84. package/dist/components/task/TaskKanban.empty-column-drop.test.d.ts +0 -1
  85. package/dist/components/task/TaskKanban.empty-column-drop.test.js +0 -66
  86. package/dist/components/task/TaskKanban.planning-drop.test.d.ts +0 -1
  87. package/dist/components/task/TaskKanban.planning-drop.test.js +0 -93
  88. package/dist/components/task/TaskKanban.test.d.ts +0 -1
  89. package/dist/components/task/TaskKanban.test.js +0 -99
  90. package/dist/components/task/TaskList.test.d.ts +0 -1
  91. package/dist/components/task/TaskList.test.js +0 -473
  92. package/dist/components/task/TaskStatusActions.test.d.ts +0 -1
  93. package/dist/components/task/TaskStatusActions.test.js +0 -54
  94. package/dist/components/task/taskKanbanDropRules.test.d.ts +0 -1
  95. package/dist/components/task/taskKanbanDropRules.test.js +0 -114
  96. package/dist/components/ui/LevelSelector.test.d.ts +0 -1
  97. package/dist/components/ui/LevelSelector.test.js +0 -55
  98. package/dist/components/ui/Markdown.test.d.ts +0 -1
  99. package/dist/components/ui/Markdown.test.js +0 -38
  100. package/dist/components/ui/MultiSelectFilter.test.d.ts +0 -1
  101. package/dist/components/ui/MultiSelectFilter.test.js +0 -74
  102. package/dist/components/ui/TaxonomyDropdown.test.d.ts +0 -1
  103. package/dist/components/ui/TaxonomyDropdown.test.js +0 -130
  104. package/dist/components/ui/TopNoticeLayer.test.d.ts +0 -1
  105. package/dist/components/ui/TopNoticeLayer.test.js +0 -25
  106. package/dist/components/views/AppShellHeader.test.d.ts +0 -1
  107. package/dist/components/views/AppShellHeader.test.js +0 -42
  108. package/dist/components/views/PlanComparisonPage.test.d.ts +0 -1
  109. package/dist/components/views/PlanComparisonPage.test.js +0 -757
  110. package/dist/components/views/PlansPage.test.d.ts +0 -1
  111. package/dist/components/views/PlansPage.test.js +0 -710
  112. package/dist/components/views/chrome/WorkspaceChromeSections.test.d.ts +0 -1
  113. package/dist/components/views/chrome/WorkspaceChromeSections.test.js +0 -54
  114. package/dist/components/views/chrome/workspaceChrome.test.d.ts +0 -1
  115. package/dist/components/views/chrome/workspaceChrome.test.js +0 -52
  116. package/dist/components/views/panels/DocumentFilterToolbar.test.d.ts +0 -1
  117. package/dist/components/views/panels/DocumentFilterToolbar.test.js +0 -19
  118. package/dist/components/views/panels/FilterToolbar.test.d.ts +0 -1
  119. package/dist/components/views/panels/FilterToolbar.test.js +0 -47
  120. package/dist/components/views/panels/PlanningDrawer.test.d.ts +0 -1
  121. package/dist/components/views/panels/PlanningDrawer.test.js +0 -529
  122. package/dist/components/views/planningScope.test.d.ts +0 -1
  123. package/dist/components/views/planningScope.test.js +0 -60
  124. package/dist/components/views/standalone/modals/AccountHubModal.test.d.ts +0 -1
  125. package/dist/components/views/standalone/modals/AccountHubModal.test.js +0 -66
  126. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.d.ts +0 -1
  127. package/dist/components/views/standalone/modals/SyncEnableWarningModal.test.js +0 -24
  128. package/dist/components/views/teamManagementAccess.test.d.ts +0 -1
  129. package/dist/components/views/teamManagementAccess.test.js +0 -44
  130. package/dist/config/deployment.test.d.ts +0 -1
  131. package/dist/config/deployment.test.js +0 -90
  132. package/dist/config/envSchema.test.d.ts +0 -1
  133. package/dist/config/envSchema.test.js +0 -125
  134. package/dist/core/AiProfileService.test.d.ts +0 -1
  135. package/dist/core/AiProfileService.test.js +0 -184
  136. package/dist/core/AiProfiles.test.d.ts +0 -1
  137. package/dist/core/AiProfiles.test.js +0 -1873
  138. package/dist/core/AuthTokenService.test.d.ts +0 -1
  139. package/dist/core/AuthTokenService.test.js +0 -78
  140. package/dist/core/BulkUpdateFieldsPreview.test.d.ts +0 -1
  141. package/dist/core/BulkUpdateFieldsPreview.test.js +0 -62
  142. package/dist/core/CompletedAt.test.d.ts +0 -1
  143. package/dist/core/CompletedAt.test.js +0 -44
  144. package/dist/core/CreatedBy.test.d.ts +0 -1
  145. package/dist/core/CreatedBy.test.js +0 -188
  146. package/dist/core/DeletedTaskTrash.test.d.ts +0 -1
  147. package/dist/core/DeletedTaskTrash.test.js +0 -104
  148. package/dist/core/EntitlementsPolicy.test.d.ts +0 -1
  149. package/dist/core/EntitlementsPolicy.test.js +0 -280
  150. package/dist/core/GlobalSettingsService.test.d.ts +0 -1
  151. package/dist/core/GlobalSettingsService.test.js +0 -45
  152. package/dist/core/JsonBackupRuntime.test.d.ts +0 -1
  153. package/dist/core/JsonBackupRuntime.test.js +0 -155
  154. package/dist/core/PlanFeatureCatalog.test.d.ts +0 -1
  155. package/dist/core/PlanFeatureCatalog.test.js +0 -139
  156. package/dist/core/PlanVersionPolicy.test.d.ts +0 -1
  157. package/dist/core/PlanVersionPolicy.test.js +0 -204
  158. package/dist/core/PlanningEntities.test.d.ts +0 -1
  159. package/dist/core/PlanningEntities.test.js +0 -509
  160. package/dist/core/ScheduleFields.test.d.ts +0 -1
  161. package/dist/core/ScheduleFields.test.js +0 -193
  162. package/dist/core/SetupState.test.d.ts +0 -1
  163. package/dist/core/SetupState.test.js +0 -113
  164. package/dist/core/SignupMonetizationSettings.test.d.ts +0 -1
  165. package/dist/core/SignupMonetizationSettings.test.js +0 -515
  166. package/dist/core/Stats.test.d.ts +0 -1
  167. package/dist/core/Stats.test.js +0 -36
  168. package/dist/core/SystemAdmin.test.d.ts +0 -1
  169. package/dist/core/SystemAdmin.test.js +0 -1106
  170. package/dist/core/TaskActivitySync.test.d.ts +0 -1
  171. package/dist/core/TaskActivitySync.test.js +0 -90
  172. package/dist/core/TaskAttachmentsCanonical.test.d.ts +0 -1
  173. package/dist/core/TaskAttachmentsCanonical.test.js +0 -356
  174. package/dist/core/TaskAuditTimeline.test.d.ts +0 -1
  175. package/dist/core/TaskAuditTimeline.test.js +0 -319
  176. package/dist/core/TaskTaxonomyValidation.test.d.ts +0 -1
  177. package/dist/core/TaskTaxonomyValidation.test.js +0 -440
  178. package/dist/core/Taskforce.ids.test.d.ts +0 -1
  179. package/dist/core/Taskforce.ids.test.js +0 -611
  180. package/dist/core/Taskforce.listTasksSlim.test.d.ts +0 -1
  181. package/dist/core/Taskforce.listTasksSlim.test.js +0 -91
  182. package/dist/core/Taskforce.mcpAuth.test.d.ts +0 -1
  183. package/dist/core/Taskforce.mcpAuth.test.js +0 -450
  184. package/dist/core/Taskforce.realtimeMutation.test.d.ts +0 -1
  185. package/dist/core/Taskforce.realtimeMutation.test.js +0 -131
  186. package/dist/core/UserProfileAvatarDrafts.test.d.ts +0 -1
  187. package/dist/core/UserProfileAvatarDrafts.test.js +0 -138
  188. package/dist/core/WorkspaceDelete.test.d.ts +0 -1
  189. package/dist/core/WorkspaceDelete.test.js +0 -159
  190. package/dist/core/WorkspacePermissions.test.d.ts +0 -1
  191. package/dist/core/WorkspacePermissions.test.js +0 -270
  192. package/dist/core/WorkspacePlanMode.test.d.ts +0 -1
  193. package/dist/core/WorkspacePlanMode.test.js +0 -383
  194. package/dist/core/WorkspaceSyncSettings.test.d.ts +0 -1
  195. package/dist/core/WorkspaceSyncSettings.test.js +0 -65
  196. package/dist/core/__tests__/TaskAttachmentsCanonical.test.d.ts +0 -1
  197. package/dist/core/__tests__/TaskAttachmentsCanonical.test.js +0 -212
  198. package/dist/documentReviews/DocumentReviewSessionStore.test.d.ts +0 -1
  199. package/dist/documentReviews/DocumentReviewSessionStore.test.js +0 -127
  200. package/dist/hooks/authGuardPolicy.test.d.ts +0 -1
  201. package/dist/hooks/authGuardPolicy.test.js +0 -76
  202. package/dist/hooks/bootstrapTimeouts.test.d.ts +0 -1
  203. package/dist/hooks/bootstrapTimeouts.test.js +0 -12
  204. package/dist/hooks/sync/controlPlane.test.d.ts +0 -1
  205. package/dist/hooks/sync/controlPlane.test.js +0 -121
  206. package/dist/hooks/sync/lifecyclePolicy.test.d.ts +0 -1
  207. package/dist/hooks/sync/lifecyclePolicy.test.js +0 -124
  208. package/dist/hooks/sync/orchestratorShared.test.d.ts +0 -1
  209. package/dist/hooks/sync/orchestratorShared.test.js +0 -98
  210. package/dist/hooks/sync/pullLifecycle.test.d.ts +0 -1
  211. package/dist/hooks/sync/pullLifecycle.test.js +0 -80
  212. package/dist/hooks/sync/recovery.test.d.ts +0 -1
  213. package/dist/hooks/sync/recovery.test.js +0 -292
  214. package/dist/hooks/sync/transfers.test.d.ts +0 -1
  215. package/dist/hooks/sync/transfers.test.js +0 -928
  216. package/dist/hooks/sync/useRecentSyncEvents.test.d.ts +0 -1
  217. package/dist/hooks/sync/useRecentSyncEvents.test.js +0 -124
  218. package/dist/hooks/sync/useSyncStatusActions.test.d.ts +0 -1
  219. package/dist/hooks/sync/useSyncStatusActions.test.js +0 -133
  220. package/dist/hooks/sync/useSyncStatusControls.test.d.ts +0 -1
  221. package/dist/hooks/sync/useSyncStatusControls.test.js +0 -88
  222. package/dist/hooks/useFilterSort.test.d.ts +0 -1
  223. package/dist/hooks/useFilterSort.test.js +0 -419
  224. package/dist/hooks/useRealtimeSync.test.d.ts +0 -1
  225. package/dist/hooks/useRealtimeSync.test.js +0 -311
  226. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.d.ts +0 -1
  227. package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +0 -54
  228. package/dist/hooks/useSyncOrchestrator.context-assets.test.d.ts +0 -1
  229. package/dist/hooks/useSyncOrchestrator.context-assets.test.js +0 -228
  230. package/dist/hooks/useSyncOrchestrator.retry-closure.test.d.ts +0 -1
  231. package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +0 -2027
  232. package/dist/hooks/useTaskData.test.d.ts +0 -1
  233. package/dist/hooks/useTaskData.test.js +0 -291
  234. package/dist/hooks/useTaskMutations.test.d.ts +0 -1
  235. package/dist/hooks/useTaskMutations.test.js +0 -256
  236. package/dist/hooks/useTaskforce.contract.test.d.ts +0 -1
  237. package/dist/hooks/useTaskforce.contract.test.js +0 -7
  238. package/dist/hooks/useTaskforce.priority-filters.test.d.ts +0 -1
  239. package/dist/hooks/useTaskforce.priority-filters.test.js +0 -28
  240. package/dist/hooks/useTaskforce.runtime-routing.test.d.ts +0 -1
  241. package/dist/hooks/useTaskforce.runtime-routing.test.js +0 -246
  242. package/dist/hooks/useTaskforce.sync-behavior.test.d.ts +0 -1
  243. package/dist/hooks/useTaskforce.sync-behavior.test.js +0 -3644
  244. package/dist/hooks/useWorkspaceSyncController.test.d.ts +0 -1
  245. package/dist/hooks/useWorkspaceSyncController.test.js +0 -192
  246. package/dist/hooks/workspace/useTaskforceApiRouting.test.d.ts +0 -1
  247. package/dist/hooks/workspace/useTaskforceApiRouting.test.js +0 -77
  248. package/dist/hooks/workspace/workspaceLocalState.test.d.ts +0 -1
  249. package/dist/hooks/workspace/workspaceLocalState.test.js +0 -59
  250. package/dist/localization/localizationCatalogs.test.d.ts +0 -1
  251. package/dist/localization/localizationCatalogs.test.js +0 -37
  252. package/dist/mcp/aiProfileBootstrap.test.d.ts +0 -1
  253. package/dist/mcp/aiProfileBootstrap.test.js +0 -68
  254. package/dist/mcp/canonicalAssetHelpers.test.d.ts +0 -1
  255. package/dist/mcp/canonicalAssetHelpers.test.js +0 -67
  256. package/dist/mcp/clientIntegrations.test.d.ts +0 -1
  257. package/dist/mcp/clientIntegrations.test.js +0 -219
  258. package/dist/mcp/optionResolution.test.d.ts +0 -1
  259. package/dist/mcp/optionResolution.test.js +0 -43
  260. package/dist/mcp/runtime.test.d.ts +0 -1
  261. package/dist/mcp/runtime.test.js +0 -5120
  262. package/dist/mcp/structuredCommentValidation.test.d.ts +0 -1
  263. package/dist/mcp/structuredCommentValidation.test.js +0 -31
  264. package/dist/mcp/structuredDocValidation.test.d.ts +0 -1
  265. package/dist/mcp/structuredDocValidation.test.js +0 -35
  266. package/dist/mcp/toolRegistry.test.d.ts +0 -1
  267. package/dist/mcp/toolRegistry.test.js +0 -34
  268. package/dist/migrations/billingSchemaParity.test.d.ts +0 -1
  269. package/dist/migrations/billingSchemaParity.test.js +0 -160
  270. package/dist/migrations/taskSchemaMigrations.test.d.ts +0 -1
  271. package/dist/migrations/taskSchemaMigrations.test.js +0 -225
  272. package/dist/qaOpen.test.d.ts +0 -1
  273. package/dist/qaOpen.test.js +0 -56
  274. package/dist/releaseVersion.test.d.ts +0 -1
  275. package/dist/releaseVersion.test.js +0 -32
  276. package/dist/runtime/appGates.test.d.ts +0 -1
  277. package/dist/runtime/appGates.test.js +0 -42
  278. package/dist/server/aiProfileAvatarAssets.test.d.ts +0 -1
  279. package/dist/server/aiProfileAvatarAssets.test.js +0 -213
  280. package/dist/server/annotatedAttachmentsRoutes.test.d.ts +0 -1
  281. package/dist/server/annotatedAttachmentsRoutes.test.js +0 -532
  282. package/dist/server/auth.test.d.ts +0 -1
  283. package/dist/server/auth.test.js +0 -131
  284. package/dist/server/buildInfo.test.d.ts +0 -1
  285. package/dist/server/buildInfo.test.js +0 -29
  286. package/dist/server/cors.test.d.ts +0 -1
  287. package/dist/server/cors.test.js +0 -77
  288. package/dist/server/documentReviewRoutes.test.d.ts +0 -1
  289. package/dist/server/documentReviewRoutes.test.js +0 -190
  290. package/dist/server/email.test.d.ts +0 -1
  291. package/dist/server/email.test.js +0 -113
  292. package/dist/server/index.cookieProxy.test.d.ts +0 -1
  293. package/dist/server/index.cookieProxy.test.js +0 -67
  294. package/dist/server/index.rateLimit.test.d.ts +0 -1
  295. package/dist/server/index.rateLimit.test.js +0 -22
  296. package/dist/server/index.test.d.ts +0 -1
  297. package/dist/server/index.test.js +0 -283
  298. package/dist/server/rateLimit.test.d.ts +0 -1
  299. package/dist/server/rateLimit.test.js +0 -107
  300. package/dist/server/realtimeSync.auth.test.d.ts +0 -1
  301. package/dist/server/realtimeSync.auth.test.js +0 -62
  302. package/dist/server/realtimeSync.replay.test.d.ts +0 -1
  303. package/dist/server/realtimeSync.replay.test.js +0 -192
  304. package/dist/server/realtimeSync.workspaceIsolation.test.d.ts +0 -1
  305. package/dist/server/realtimeSync.workspaceIsolation.test.js +0 -101
  306. package/dist/server/routes/billing.test.d.ts +0 -1
  307. package/dist/server/routes/billing.test.js +0 -3455
  308. package/dist/server/routes/shared.test.d.ts +0 -1
  309. package/dist/server/routes/shared.test.js +0 -32
  310. package/dist/server/routes/sync.integration.test.d.ts +0 -1
  311. package/dist/server/routes/sync.integration.test.js +0 -2932
  312. package/dist/server/routes/syncAuxRoutes.test.d.ts +0 -1
  313. package/dist/server/routes/syncAuxRoutes.test.js +0 -158
  314. package/dist/server/routes/syncRouteGuards.test.d.ts +0 -1
  315. package/dist/server/routes/syncRouteGuards.test.js +0 -94
  316. package/dist/server/routes.test.d.ts +0 -1
  317. package/dist/server/routes.test.js +0 -15612
  318. package/dist/server/runtimeMode.test.d.ts +0 -1
  319. package/dist/server/runtimeMode.test.js +0 -26
  320. package/dist/server/securityHeaders.test.d.ts +0 -1
  321. package/dist/server/securityHeaders.test.js +0 -70
  322. package/dist/services/templateEngine.test.d.ts +0 -1
  323. package/dist/services/templateEngine.test.js +0 -150
  324. package/dist/services/templates.test.d.ts +0 -1
  325. package/dist/services/templates.test.js +0 -45
  326. package/dist/services/workflowDocumentFixtures.test.d.ts +0 -1
  327. package/dist/services/workflowDocumentFixtures.test.js +0 -78
  328. package/dist/services/workflowExportMatrix.test.d.ts +0 -1
  329. package/dist/services/workflowExportMatrix.test.js +0 -113
  330. package/dist/services/workflowExportSnapshots.test.d.ts +0 -1
  331. package/dist/services/workflowExportSnapshots.test.js +0 -30
  332. package/dist/services/workflowGeneration.test.d.ts +0 -1
  333. package/dist/services/workflowGeneration.test.js +0 -12
  334. package/dist/services/workflowSimulation.test.d.ts +0 -1
  335. package/dist/services/workflowSimulation.test.js +0 -113
  336. package/dist/services/workflowV2Policy.test.d.ts +0 -1
  337. package/dist/services/workflowV2Policy.test.js +0 -80
  338. package/dist/shared/customTaxonomyLibrary.test.d.ts +0 -1
  339. package/dist/shared/customTaxonomyLibrary.test.js +0 -20
  340. package/dist/shared/passwordPolicy.test.d.ts +0 -1
  341. package/dist/shared/passwordPolicy.test.js +0 -34
  342. package/dist/shared/runtimeContract.test.d.ts +0 -1
  343. package/dist/shared/runtimeContract.test.js +0 -23
  344. package/dist/shared/taxonomyLibrary.test.d.ts +0 -1
  345. package/dist/shared/taxonomyLibrary.test.js +0 -26
  346. package/dist/shared/taxonomyState.test.d.ts +0 -1
  347. package/dist/shared/taxonomyState.test.js +0 -71
  348. package/dist/soak/preflight.cli.d.ts +0 -1
  349. package/dist/soak/preflight.cli.js +0 -28
  350. package/dist/soak/preflight.d.ts +0 -26
  351. package/dist/soak/preflight.js +0 -113
  352. package/dist/soak/preflight.test.d.ts +0 -1
  353. package/dist/soak/preflight.test.js +0 -39
  354. package/dist/storage/objectStorageClient.test.d.ts +0 -1
  355. package/dist/storage/objectStorageClient.test.js +0 -82
  356. package/dist/storage/objectStorageConfig.test.d.ts +0 -1
  357. package/dist/storage/objectStorageConfig.test.js +0 -50
  358. package/dist/storage/postgresAdapter.test.d.ts +0 -1
  359. package/dist/storage/postgresAdapter.test.js +0 -29
  360. package/dist/storage/workspaceAssetStore.test.d.ts +0 -1
  361. package/dist/storage/workspaceAssetStore.test.js +0 -588
  362. package/dist/sync/cloudSyncApi.test.d.ts +0 -1
  363. package/dist/sync/cloudSyncApi.test.js +0 -111
  364. package/dist/sync/collaborationSyncPayload.test.d.ts +0 -1
  365. package/dist/sync/collaborationSyncPayload.test.js +0 -125
  366. package/dist/sync/contentSyncPayload.test.d.ts +0 -1
  367. package/dist/sync/contentSyncPayload.test.js +0 -143
  368. package/dist/sync/encryptionService.test.d.ts +0 -1
  369. package/dist/sync/encryptionService.test.js +0 -52
  370. package/dist/sync/planningSyncPayload.test.d.ts +0 -1
  371. package/dist/sync/planningSyncPayload.test.js +0 -92
  372. package/dist/sync/realtimeFeature.test.d.ts +0 -1
  373. package/dist/sync/realtimeFeature.test.js +0 -30
  374. package/dist/sync/realtimeReconcile.test.d.ts +0 -1
  375. package/dist/sync/realtimeReconcile.test.js +0 -43
  376. package/dist/sync/syncApplyHandlers.test.d.ts +0 -1
  377. package/dist/sync/syncApplyHandlers.test.js +0 -626
  378. package/dist/sync/syncService.test.d.ts +0 -1
  379. package/dist/sync/syncService.test.js +0 -903
  380. package/dist/sync/taskSyncPayload.test.d.ts +0 -1
  381. package/dist/sync/taskSyncPayload.test.js +0 -54
  382. package/dist/sync/workspacePullFeed.test.d.ts +0 -1
  383. package/dist/sync/workspacePullFeed.test.js +0 -438
  384. package/dist/sync/workspaceSyncModel.test.d.ts +0 -1
  385. package/dist/sync/workspaceSyncModel.test.js +0 -1442
  386. package/dist/test/setup.d.ts +0 -1
  387. package/dist/test/setup.js +0 -15
  388. package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +0 -1
  389. package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +0 -3
  390. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +0 -1
  391. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +0 -1
  392. package/dist/ui/assets/documentReferences-BOUcJm6-.js +0 -1
  393. package/dist/ui/assets/index-MAHKvFqp.js +0 -4
  394. package/dist/utils/accountProfileSummaryCache.test.d.ts +0 -1
  395. package/dist/utils/accountProfileSummaryCache.test.js +0 -63
  396. package/dist/utils/annotatedAttachments.test.d.ts +0 -1
  397. package/dist/utils/annotatedAttachments.test.js +0 -108
  398. package/dist/utils/assignees.test.d.ts +0 -1
  399. package/dist/utils/assignees.test.js +0 -25
  400. package/dist/utils/avatarUpload.test.d.ts +0 -1
  401. package/dist/utils/avatarUpload.test.js +0 -40
  402. package/dist/utils/canonicalAssetKind.test.d.ts +0 -1
  403. package/dist/utils/canonicalAssetKind.test.js +0 -24
  404. package/dist/utils/coalescedRefresh.test.d.ts +0 -1
  405. package/dist/utils/coalescedRefresh.test.js +0 -30
  406. package/dist/utils/constants.icons.test.d.ts +0 -1
  407. package/dist/utils/constants.icons.test.js +0 -41
  408. package/dist/utils/planParser.test.d.ts +0 -1
  409. package/dist/utils/planParser.test.js +0 -183
  410. package/dist/utils/planningReferences.test.d.ts +0 -1
  411. package/dist/utils/planningReferences.test.js +0 -17
  412. package/dist/utils/syncEventDetails.test.d.ts +0 -1
  413. package/dist/utils/syncEventDetails.test.js +0 -23
  414. package/dist/utils/syncEventPresentation.test.d.ts +0 -1
  415. package/dist/utils/syncEventPresentation.test.js +0 -64
  416. package/dist/utils/syncStatusPresentation.test.d.ts +0 -1
  417. package/dist/utils/syncStatusPresentation.test.js +0 -164
  418. package/dist/utils/taskActivity.test.d.ts +0 -1
  419. package/dist/utils/taskActivity.test.js +0 -218
  420. package/dist/utils/taskDraftDefaults.test.d.ts +0 -1
  421. package/dist/utils/taskDraftDefaults.test.js +0 -34
  422. package/dist/utils/taskDragZones.test.d.ts +0 -1
  423. package/dist/utils/taskDragZones.test.js +0 -14
  424. package/dist/utils/taskNormalization.test.d.ts +0 -1
  425. package/dist/utils/taskNormalization.test.js +0 -158
  426. package/dist/utils/taskSearch.test.d.ts +0 -1
  427. package/dist/utils/taskSearch.test.js +0 -69
  428. package/dist/utils/taskUtils.test.d.ts +0 -1
  429. package/dist/utils/taskUtils.test.js +0 -19
  430. package/dist/utils/taskforceApiClient.test.d.ts +0 -1
  431. package/dist/utils/taskforceApiClient.test.js +0 -44
  432. package/dist/utils/taxonomyFormDefaults.test.d.ts +0 -1
  433. package/dist/utils/taxonomyFormDefaults.test.js +0 -63
  434. package/dist/utils/uiNotice.test.d.ts +0 -1
  435. package/dist/utils/uiNotice.test.js +0 -18
  436. package/dist/utils/workspaceIdentity.test.d.ts +0 -1
  437. package/dist/utils/workspaceIdentity.test.js +0 -61
  438. package/dist/utils/workspaceLifecycle.test.d.ts +0 -1
  439. package/dist/utils/workspaceLifecycle.test.js +0 -64
  440. package/dist/utils/workspaceSyncPresentation.test.d.ts +0 -1
  441. package/dist/utils/workspaceSyncPresentation.test.js +0 -71
  442. package/scripts/bootstrap-playwright-auth.mjs +0 -155
  443. package/scripts/check-cloud-mcp.mjs +0 -83
  444. package/scripts/check-compat-boundaries.mjs +0 -41
  445. package/scripts/check-env.ts +0 -31
  446. package/scripts/check-localization-strings.mjs +0 -49
  447. package/scripts/check-runtime-boundaries.mjs +0 -44
  448. package/scripts/debug-global.mjs +0 -34
  449. package/scripts/debug-login.mjs +0 -32
  450. package/scripts/debug-server.mjs +0 -36
  451. package/scripts/deploy-prod.sh +0 -41
  452. package/scripts/deploy-staging.sh +0 -23
  453. package/scripts/export-sqlite-to-postgres.mjs +0 -123
  454. package/scripts/generate-env-example.ts +0 -8
  455. package/scripts/mcp-stdio-adapter.mjs +0 -160
  456. package/scripts/migrate-task-data-assets.mjs +0 -408
  457. package/scripts/playwright-auth.sh +0 -19
  458. package/scripts/qa-open-lib.mjs +0 -175
  459. package/scripts/qa-open-lib.mjs.d.ts +0 -49
  460. package/scripts/qa-open.mjs +0 -352
  461. package/scripts/release-version-lib.mjs +0 -88
  462. package/scripts/release-version.mjs +0 -72
  463. package/scripts/run-billing-performance-smoke.sh +0 -24
  464. package/scripts/run-dev-proxy.sh +0 -51
  465. package/scripts/run-e2e-realtime.sh +0 -30
  466. package/scripts/run-smoke.sh +0 -35
  467. package/scripts/run-soak.sh +0 -41
  468. package/scripts/test-global.js +0 -143
  469. package/scripts/update-homebrew-formula.sh +0 -108
  470. package/scripts/with-esbuild.sh +0 -11
@@ -1,3455 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
- import { Readable } from 'stream';
3
- import Database from 'better-sqlite3';
4
- import { registerBillingRoutes } from './billing';
5
- const mockConstructEvent = vi.fn();
6
- const mockPricesList = vi.fn(async () => ({ data: [], has_more: false }));
7
- const mockPriceRetrieve = vi.fn(async (id) => ({ id, active: true, currency: 'usd', unit_amount: 1500, recurring: { interval: 'month' } }));
8
- const mockCheckoutCreate = vi.fn(async () => ({
9
- id: 'cs_test',
10
- url: 'https://example.test/checkout',
11
- expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
12
- }));
13
- const mockCheckoutRetrieve = vi.fn(async (id) => ({
14
- id,
15
- mode: 'subscription',
16
- status: 'complete',
17
- payment_status: 'paid',
18
- customer: 'cus_123',
19
- subscription: 'sub_123',
20
- metadata: { userId: 'u1' }
21
- }));
22
- const mockPortalCreate = vi.fn(async () => ({ id: 'bps_test', url: 'https://example.test/portal' }));
23
- const mockSubscriptionCancel = vi.fn(async () => ({ id: 'sub_test', status: 'canceled' }));
24
- const mockSubscriptionList = vi.fn(async () => ({ data: [] }));
25
- const mockCustomersList = vi.fn(async () => ({ data: [] }));
26
- const mockSubscriptionUpdate = vi.fn(async (id, input) => ({
27
- id,
28
- customer: 'cus_123',
29
- status: 'active',
30
- trial_end: null,
31
- metadata: {
32
- userId: 'u1',
33
- planVersionId: String(input?.metadata?.planVersionId || 'pro-v1')
34
- },
35
- items: {
36
- data: [{
37
- id: 'si_123',
38
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
39
- price: { id: String(input?.items?.[0]?.price || 'price_pro_month'), recurring: { interval: 'month' } }
40
- }]
41
- }
42
- }));
43
- const mockSubscriptionRetrieve = vi.fn(async (id) => ({
44
- id,
45
- customer: 'cus_123',
46
- status: 'active',
47
- trial_end: null,
48
- metadata: { userId: 'u1', planVersionId: 'pro-v1' },
49
- items: {
50
- data: [{
51
- id: 'si_123',
52
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
53
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
54
- }]
55
- }
56
- }));
57
- vi.mock('stripe', () => ({
58
- default: class StripeMock {
59
- webhooks = {
60
- constructEvent: mockConstructEvent
61
- };
62
- prices = {
63
- list: mockPricesList,
64
- retrieve: mockPriceRetrieve
65
- };
66
- checkout = {
67
- sessions: {
68
- create: mockCheckoutCreate,
69
- retrieve: mockCheckoutRetrieve
70
- }
71
- };
72
- billingPortal = {
73
- sessions: {
74
- create: mockPortalCreate
75
- }
76
- };
77
- customers = {
78
- list: mockCustomersList
79
- };
80
- subscriptions = {
81
- cancel: mockSubscriptionCancel,
82
- retrieve: mockSubscriptionRetrieve,
83
- update: mockSubscriptionUpdate,
84
- list: mockSubscriptionList
85
- };
86
- constructor(_apiKey) { }
87
- }
88
- }));
89
- function createMockReq(body, headers = {}) {
90
- const req = Readable.from([Buffer.from(body, 'utf8')]);
91
- req.headers = headers;
92
- return req;
93
- }
94
- function createMockRes() {
95
- const response = {
96
- statusCode: 200,
97
- headers: {},
98
- body: '',
99
- writeHead: vi.fn((statusCode, headers) => {
100
- response.statusCode = statusCode;
101
- response.headers = headers || {};
102
- return response;
103
- }),
104
- end: vi.fn((payload) => {
105
- response.body = String(payload || '');
106
- return response;
107
- })
108
- };
109
- return response;
110
- }
111
- function setupBillingSchema(db) {
112
- db.exec(`
113
- CREATE TABLE user_billing (
114
- user_id TEXT PRIMARY KEY,
115
- tenant_id TEXT NOT NULL,
116
- stripe_customer_id TEXT,
117
- stripe_subscription_id TEXT,
118
- stripe_price_id TEXT,
119
- stripe_status TEXT,
120
- tier TEXT NOT NULL DEFAULT '',
121
- billing_interval TEXT,
122
- entitlement_state TEXT NOT NULL DEFAULT 'active',
123
- trial_end TEXT,
124
- current_period_end TEXT,
125
- effective_until TEXT,
126
- created_at TEXT NOT NULL,
127
- updated_at TEXT NOT NULL
128
- );
129
- CREATE TABLE stripe_webhook_events (
130
- event_id TEXT PRIMARY KEY,
131
- tenant_id TEXT NOT NULL,
132
- event_type TEXT NOT NULL,
133
- processed_at TEXT NOT NULL
134
- );
135
- CREATE TABLE stripe_webhook_failures (
136
- id TEXT PRIMARY KEY,
137
- tenant_id TEXT NOT NULL,
138
- event_id TEXT NOT NULL,
139
- event_type TEXT,
140
- reason TEXT NOT NULL,
141
- payload_excerpt TEXT,
142
- purge_after TEXT NOT NULL,
143
- created_at TEXT NOT NULL
144
- );
145
- CREATE TABLE billing_checkout_attempts (
146
- session_id TEXT PRIMARY KEY,
147
- tenant_id TEXT NOT NULL,
148
- user_id TEXT NOT NULL,
149
- plan_version_id TEXT NOT NULL,
150
- billing_interval TEXT,
151
- status TEXT NOT NULL DEFAULT 'open',
152
- expires_at TEXT,
153
- created_at TEXT NOT NULL,
154
- updated_at TEXT NOT NULL
155
- );
156
- CREATE TABLE billing_price_mappings (
157
- tenant_id TEXT NOT NULL,
158
- plan_version_id TEXT NOT NULL,
159
- billing_interval TEXT NOT NULL,
160
- pricing_audience TEXT NOT NULL DEFAULT 'public',
161
- stripe_price_id TEXT NOT NULL,
162
- pricing_type TEXT NOT NULL DEFAULT 'stripe',
163
- active INTEGER NOT NULL DEFAULT 1,
164
- unit_amount INTEGER,
165
- currency TEXT,
166
- created_at TEXT NOT NULL,
167
- updated_at TEXT NOT NULL,
168
- PRIMARY KEY (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id)
169
- );
170
- CREATE UNIQUE INDEX idx_billing_price_mappings_active_interval
171
- ON billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience)
172
- WHERE active = 1;
173
- CREATE TABLE plan_versions (
174
- plan_version_id TEXT PRIMARY KEY,
175
- tenant_id TEXT NOT NULL,
176
- plan_id TEXT NOT NULL,
177
- version_number INTEGER NOT NULL,
178
- is_default INTEGER NOT NULL DEFAULT 0,
179
- seat_limit INTEGER,
180
- metadata_json TEXT NOT NULL DEFAULT '{}',
181
- created_at TEXT NOT NULL,
182
- updated_at TEXT NOT NULL
183
- );
184
- CREATE TABLE plan_feature_matrix (
185
- tenant_id TEXT NOT NULL,
186
- plan_version_id TEXT NOT NULL,
187
- feature_key TEXT NOT NULL,
188
- access TEXT NOT NULL DEFAULT 'disabled',
189
- config_json TEXT NOT NULL DEFAULT '{}',
190
- created_at TEXT NOT NULL,
191
- updated_at TEXT NOT NULL,
192
- PRIMARY KEY (tenant_id, plan_version_id, feature_key)
193
- );
194
- CREATE TABLE plan_catalog (
195
- plan_id TEXT PRIMARY KEY,
196
- tenant_id TEXT NOT NULL,
197
- display_name TEXT NOT NULL,
198
- status TEXT NOT NULL DEFAULT 'active',
199
- created_at TEXT NOT NULL,
200
- updated_at TEXT NOT NULL
201
- );
202
- CREATE TABLE plan_feature_catalog_overrides (
203
- tenant_id TEXT NOT NULL,
204
- feature_key TEXT NOT NULL,
205
- public_label TEXT,
206
- public_description TEXT,
207
- public_display_order INTEGER,
208
- created_at TEXT NOT NULL,
209
- updated_at TEXT NOT NULL,
210
- PRIMARY KEY (tenant_id, feature_key)
211
- );
212
- CREATE TABLE account_entitlements (
213
- account_id TEXT PRIMARY KEY,
214
- tenant_id TEXT NOT NULL,
215
- plan_version_id TEXT NOT NULL,
216
- entitlement_state TEXT NOT NULL DEFAULT 'active',
217
- effective_at TEXT,
218
- effective_until TEXT,
219
- updated_by TEXT,
220
- created_at TEXT NOT NULL,
221
- updated_at TEXT NOT NULL
222
- );
223
- CREATE TABLE system_settings (
224
- tenant_id TEXT NOT NULL,
225
- settings_key TEXT NOT NULL,
226
- payload TEXT,
227
- updated_at TEXT NOT NULL,
228
- PRIMARY KEY (tenant_id, settings_key)
229
- );
230
- `);
231
- }
232
- function readMockPlanVersionFeatures(db, tenantId, planVersionId) {
233
- try {
234
- return db.prepare(`
235
- SELECT feature_key, access, config_json
236
- FROM plan_feature_matrix
237
- WHERE tenant_id = ? AND plan_version_id = ?
238
- `).all(tenantId, planVersionId).reduce((acc, row) => {
239
- const featureKey = String(row?.feature_key || '').trim();
240
- if (!featureKey)
241
- return acc;
242
- acc[featureKey] = {
243
- featureKey,
244
- access: String(row?.access || '').trim(),
245
- config: row?.config_json ? JSON.parse(String(row.config_json)) : {}
246
- };
247
- return acc;
248
- }, {});
249
- }
250
- catch (error) {
251
- const message = String(error?.message || '');
252
- if (message.toLowerCase().includes('no such table: plan_feature_matrix')) {
253
- return {};
254
- }
255
- throw error;
256
- }
257
- }
258
- function buildBillingRouteHandlers(db, opts) {
259
- const routes = new Map();
260
- const tenantId = 'default';
261
- const siteSettings = opts?.siteSettings || {};
262
- const resolvePlanIdForVersion = (planVersionIdRaw) => {
263
- const planVersionId = String(planVersionIdRaw || '').trim();
264
- if (!planVersionId)
265
- return '';
266
- const row = db.prepare(`
267
- SELECT plan_id
268
- FROM plan_versions
269
- WHERE tenant_id = ? AND plan_version_id = ?
270
- LIMIT 1
271
- `).get(tenantId, planVersionId);
272
- return String(row?.plan_id || '').trim();
273
- };
274
- const core = {
275
- getDatabaseAdapter: () => db,
276
- getTenantId: () => tenantId,
277
- createUserBillingRecord: (userId) => {
278
- const now = new Date().toISOString();
279
- db.prepare(`
280
- INSERT INTO user_billing (
281
- user_id, tenant_id, tier, entitlement_state, created_at, updated_at
282
- ) VALUES (?, ?, '', 'active', ?, ?)
283
- ON CONFLICT (user_id) DO NOTHING
284
- `).run(userId, tenantId, now, now);
285
- },
286
- transitionCanonicalCommercialState: (input) => {
287
- const now = new Date().toISOString();
288
- const planVersionId = String(input.planVersionId || '').trim();
289
- const effectiveAt = input.effectiveAt || now;
290
- const effectiveUntil = input.effectiveUntil || null;
291
- db.prepare(`
292
- INSERT INTO account_entitlements (
293
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
294
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
295
- ON CONFLICT (account_id) DO UPDATE SET
296
- plan_version_id = excluded.plan_version_id,
297
- entitlement_state = excluded.entitlement_state,
298
- effective_at = excluded.effective_at,
299
- effective_until = excluded.effective_until,
300
- updated_by = excluded.updated_by,
301
- updated_at = excluded.updated_at
302
- `).run(input.accountId, tenantId, planVersionId, input.state, effectiveAt, effectiveUntil, input.updatedBy, input.createdAt || now, input.updatedAt || now);
303
- const planId = typeof input.billingMirrorPlanId === 'string'
304
- ? input.billingMirrorPlanId
305
- : resolvePlanIdForVersion(planVersionId);
306
- db.prepare(`
307
- INSERT INTO user_billing (
308
- user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
309
- ) VALUES (?, ?, ?, ?, ?, ?, ?)
310
- ON CONFLICT (user_id) DO UPDATE SET
311
- tier = excluded.tier,
312
- entitlement_state = excluded.entitlement_state,
313
- effective_until = excluded.effective_until,
314
- updated_at = excluded.updated_at
315
- `).run(input.accountId, tenantId, planId, input.state, effectiveUntil, now, now);
316
- },
317
- listPlanCatalog: () => [
318
- { planId: 'pro', displayName: 'Pro Plan', status: 'active', createdAt: '', updatedAt: '' },
319
- { planId: 'inactive-plan', displayName: 'Inactive', status: 'archived', createdAt: '', updatedAt: '' },
320
- { planId: 'empty-plan', displayName: 'Empty', status: 'active', createdAt: '', updatedAt: '' },
321
- { planId: 'starter', displayName: 'Starter', status: 'active', createdAt: '', updatedAt: '' }
322
- ],
323
- listPlanVersions: (planId) => {
324
- return db.prepare(`
325
- SELECT plan_version_id, plan_id, version_number, is_default, seat_limit, metadata_json
326
- FROM plan_versions
327
- WHERE tenant_id = ? AND plan_id = ?
328
- ORDER BY version_number ASC
329
- `).all(tenantId, planId).map((row) => {
330
- const metadata = row?.metadata_json ? JSON.parse(String(row.metadata_json)) : {};
331
- return {
332
- planVersionId: String(row?.plan_version_id || ''),
333
- planId: String(row?.plan_id || ''),
334
- versionNumber: Number(row?.version_number || 1),
335
- isDefault: Number(row?.is_default || 0) === 1,
336
- seatLimit: row?.seat_limit === null || row?.seat_limit === undefined ? null : Number(row.seat_limit),
337
- trialDays: Number.isFinite(Number(metadata?.trialDays)) ? Number(metadata.trialDays) : 0,
338
- metadata,
339
- features: readMockPlanVersionFeatures(db, tenantId, String(row?.plan_version_id || '')),
340
- createdAt: '',
341
- updatedAt: ''
342
- };
343
- });
344
- },
345
- listPlanFeatureCatalog: () => {
346
- const overrides = db.prepare(`
347
- SELECT feature_key, public_label, public_description, public_display_order
348
- FROM plan_feature_catalog_overrides
349
- WHERE tenant_id = ?
350
- `).all(tenantId);
351
- const overrideMap = new Map(overrides.map((row) => [
352
- String(row.feature_key || '').trim(),
353
- {
354
- publicLabel: row.public_label ?? null,
355
- publicDescription: row.public_description ?? null,
356
- publicDisplayOrder: row.public_display_order ?? null
357
- }
358
- ]));
359
- return [
360
- {
361
- featureKey: 'collaboration.team_management',
362
- label: 'Team Management',
363
- description: 'Allow managing members, invites, roles, and collaboration settings.',
364
- publicLabel: overrideMap.get('collaboration.team_management')?.publicLabel ?? null,
365
- publicDescription: overrideMap.get('collaboration.team_management')?.publicDescription ?? null,
366
- publicDisplayOrder: overrideMap.get('collaboration.team_management')?.publicDisplayOrder ?? 40,
367
- allowedAccessModes: ['enabled', 'disabled'],
368
- configTemplate: {}
369
- },
370
- {
371
- featureKey: 'context.uploads',
372
- label: 'Document Cloud Storage',
373
- description: 'Allow cloud document storage with an optional storage allowance in MB.',
374
- publicLabel: overrideMap.get('context.uploads')?.publicLabel ?? null,
375
- publicDescription: overrideMap.get('context.uploads')?.publicDescription ?? null,
376
- publicDisplayOrder: overrideMap.get('context.uploads')?.publicDisplayOrder ?? 30,
377
- allowedAccessModes: ['enabled', 'disabled'],
378
- configTemplate: { storageLimitMb: 1024 }
379
- },
380
- {
381
- featureKey: 'workspace.workspaces',
382
- label: 'Workspaces',
383
- description: 'Allow multiple workspaces with a configurable workspace limit.',
384
- publicLabel: overrideMap.get('workspace.workspaces')?.publicLabel ?? null,
385
- publicDescription: overrideMap.get('workspace.workspaces')?.publicDescription ?? null,
386
- publicDisplayOrder: overrideMap.get('workspace.workspaces')?.publicDisplayOrder ?? 10,
387
- allowedAccessModes: ['enabled', 'disabled'],
388
- configTemplate: { maxWorkspaces: 1 }
389
- },
390
- {
391
- featureKey: 'collaboration.ai_profiles',
392
- label: 'AI Profiles',
393
- description: 'Allow registered AI collaborator profiles with an optional per-workspace limit.',
394
- publicLabel: overrideMap.get('collaboration.ai_profiles')?.publicLabel ?? null,
395
- publicDescription: overrideMap.get('collaboration.ai_profiles')?.publicDescription ?? null,
396
- publicDisplayOrder: overrideMap.get('collaboration.ai_profiles')?.publicDisplayOrder ?? 20,
397
- allowedAccessModes: ['enabled', 'limited', 'disabled'],
398
- configTemplate: { maxAiProfiles: 1 }
399
- }
400
- ];
401
- },
402
- getAccountEntitlement: () => {
403
- const entitlement = opts?.accessStatus?.entitlement;
404
- return {
405
- accountId: String(entitlement?.accountId || 'u1'),
406
- planVersionId: String(entitlement?.planVersionId || 'pro-v1'),
407
- planId: String(entitlement?.planId || 'pro'),
408
- state: String(entitlement?.state || 'active'),
409
- effectiveAt: entitlement?.effectiveAt ?? null,
410
- effectiveUntil: entitlement?.effectiveUntil ?? null,
411
- updatedBy: null,
412
- createdAt: '',
413
- updatedAt: ''
414
- };
415
- },
416
- getAccountAccessStatus: () => opts?.accessStatus || ({
417
- gate: 'ok',
418
- hasEntitlement: true,
419
- canAccessApp: true,
420
- canAccessPlans: true,
421
- planSelectionRequired: false,
422
- message: null,
423
- entitlement: {
424
- accountId: 'u1',
425
- planVersionId: 'pro-v1',
426
- planId: 'pro',
427
- state: 'active',
428
- effectiveAt: null,
429
- effectiveUntil: null
430
- }
431
- }),
432
- getBillingMirrorProfileForPlanVersion: (planVersionId) => ({
433
- planId: planVersionId.startsWith('pro') ? 'pro' : 'starter',
434
- entitlementState: 'active'
435
- }),
436
- getGlobalSettings: () => ({
437
- site: siteSettings
438
- }),
439
- resolveUserIdentity: () => opts?.userIdentity === undefined
440
- ? {
441
- userId: 'u1',
442
- email: 'owner@example.com',
443
- systemRole: 'user',
444
- betaAccess: true
445
- }
446
- : opts.userIdentity,
447
- updateAccountEntitlement: vi.fn().mockReturnValue({
448
- accountId: 'u1',
449
- planVersionId: 'starter-v1',
450
- planId: 'starter',
451
- state: 'active',
452
- effectiveAt: null,
453
- effectiveUntil: null
454
- })
455
- };
456
- registerBillingRoutes({
457
- addRoute: (method, path, handler) => routes.set(`${method} ${path}`, handler),
458
- core,
459
- context: {
460
- getRuntimeMode: () => 'cloud'
461
- },
462
- ensureAuthenticatedUser: () => true,
463
- ensureAdminRole: () => opts?.isAdmin !== false,
464
- requestUserId: () => 'u1',
465
- resolveErrorStatusCode: (_error, fallback = 500) => fallback,
466
- auditAdminAction: () => { }
467
- });
468
- return routes;
469
- }
470
- describe('Billing Webhook Routes', () => {
471
- const originalEnv = { ...process.env };
472
- let db;
473
- let webhookHandler;
474
- let routeHandlers;
475
- beforeEach(() => {
476
- vi.clearAllMocks();
477
- db = new Database(':memory:');
478
- setupBillingSchema(db);
479
- routeHandlers = buildBillingRouteHandlers(db);
480
- webhookHandler = routeHandlers.get('POST /api/taskforce/billing/webhook');
481
- process.env.TASKFORCE_BILLING_ENABLED = 'true';
482
- process.env.TASKFORCE_STRIPE_SECRET_KEY = 'sk_test_123';
483
- process.env.TASKFORCE_STRIPE_WEBHOOK_SECRET = 'whsec_123';
484
- });
485
- afterEach(() => {
486
- process.env = { ...originalEnv };
487
- db.close();
488
- });
489
- it('returns 400 for invalid webhook signature', async () => {
490
- mockConstructEvent.mockImplementation(() => {
491
- throw new Error('invalid signature');
492
- });
493
- const req = createMockReq('{"id":"evt_bad"}', {
494
- 'x-taskforce-runtime-mode': 'cloud',
495
- 'stripe-signature': 'bad_signature'
496
- });
497
- const res = createMockRes();
498
- await webhookHandler(req, res, {});
499
- expect(res.statusCode).toBe(400);
500
- });
501
- it('returns 200 no-op for duplicate event id', async () => {
502
- const now = new Date().toISOString();
503
- db.prepare(`
504
- INSERT INTO stripe_webhook_events (event_id, tenant_id, event_type, processed_at)
505
- VALUES (?, 'default', ?, ?)
506
- `).run('evt_dupe', 'invoice.paid', now);
507
- mockConstructEvent.mockReturnValue({
508
- id: 'evt_dupe',
509
- type: 'invoice.paid',
510
- data: {
511
- object: {
512
- customer: 'cus_1',
513
- lines: { data: [{ price: { id: 'price_1', recurring: { interval: 'month' } } }] }
514
- }
515
- }
516
- });
517
- const req = createMockReq('{"id":"evt_dupe"}', {
518
- 'x-taskforce-runtime-mode': 'cloud',
519
- 'stripe-signature': 'sig'
520
- });
521
- const res = createMockRes();
522
- await webhookHandler(req, res, {});
523
- expect(res.statusCode).toBe(200);
524
- expect(JSON.parse(res.body)).toEqual({ received: true, duplicate: true });
525
- });
526
- it('returns 500 on processing failure so Stripe can retry', async () => {
527
- mockConstructEvent.mockReturnValue({
528
- id: 'evt_fail',
529
- type: 'invoice.paid',
530
- data: {
531
- object: {
532
- customer: 'cus_missing',
533
- lines: { data: [{ price: { id: 'price_1', recurring: { interval: 'month' } } }] }
534
- }
535
- }
536
- });
537
- const req = createMockReq('{"id":"evt_fail"}', {
538
- 'x-taskforce-runtime-mode': 'cloud',
539
- 'stripe-signature': 'sig'
540
- });
541
- const res = createMockRes();
542
- await webhookHandler(req, res, {});
543
- expect(res.statusCode).toBe(500);
544
- const failures = db.prepare(`SELECT event_id, reason FROM stripe_webhook_failures WHERE event_id = ?`).all('evt_fail');
545
- expect(failures.length).toBeGreaterThan(0);
546
- expect(failures[0]?.reason || '').toContain('Unable to resolve user for invoice event.');
547
- });
548
- it('preserves existing entitlement on unknown price_id and logs failure', async () => {
549
- const now = new Date().toISOString();
550
- db.prepare(`
551
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
552
- VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
553
- `).run(now, now);
554
- db.prepare(`
555
- INSERT INTO account_entitlements (
556
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
557
- ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?, ?)
558
- `).run(now, now, now);
559
- mockConstructEvent.mockReturnValue({
560
- id: 'evt_unknown_price',
561
- type: 'customer.subscription.updated',
562
- data: {
563
- object: {
564
- id: 'sub_123',
565
- customer: 'cus_123',
566
- status: 'active',
567
- trial_end: null,
568
- metadata: {},
569
- items: {
570
- data: [{
571
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
572
- price: { id: 'price_unknown', recurring: { interval: 'month' } }
573
- }]
574
- }
575
- }
576
- }
577
- });
578
- const req = createMockReq('{"id":"evt_unknown_price"}', {
579
- 'x-taskforce-runtime-mode': 'cloud',
580
- 'stripe-signature': 'sig'
581
- });
582
- const res = createMockRes();
583
- await webhookHandler(req, res, {});
584
- expect(res.statusCode).toBe(200);
585
- const entitlement = db.prepare(`
586
- SELECT plan_version_id
587
- FROM account_entitlements
588
- WHERE account_id = 'u1' AND tenant_id = 'default'
589
- `).get();
590
- expect(entitlement.plan_version_id).toBe('starter-v1');
591
- const failure = db.prepare(`
592
- SELECT reason
593
- FROM stripe_webhook_failures
594
- WHERE event_id = 'evt_unknown_price'
595
- ORDER BY created_at DESC
596
- LIMIT 1
597
- `).get();
598
- expect(failure.reason).toContain('Unknown price mapping');
599
- });
600
- it('keeps blocked accounts in checkout_pending when Stripe reports an incomplete subscription', async () => {
601
- const now = new Date().toISOString();
602
- db.prepare(`
603
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
604
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
605
- `).run(now, now);
606
- db.prepare(`
607
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
608
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
609
- `).run(now, now);
610
- db.prepare(`
611
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
612
- VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
613
- `).run(now, now);
614
- db.prepare(`
615
- INSERT INTO account_entitlements (
616
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
617
- ) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
618
- `).run(now, now, now);
619
- mockConstructEvent.mockReturnValue({
620
- id: 'evt_incomplete_subscription',
621
- type: 'customer.subscription.updated',
622
- data: {
623
- object: {
624
- id: 'sub_123',
625
- customer: 'cus_123',
626
- status: 'incomplete',
627
- trial_end: null,
628
- metadata: { planVersionId: 'pro-v1' },
629
- items: {
630
- data: [{
631
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
632
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
633
- }]
634
- }
635
- }
636
- }
637
- });
638
- const req = createMockReq('{"id":"evt_incomplete_subscription"}', {
639
- 'x-taskforce-runtime-mode': 'cloud',
640
- 'stripe-signature': 'sig'
641
- });
642
- const res = createMockRes();
643
- await webhookHandler(req, res, {});
644
- expect(res.statusCode).toBe(200);
645
- const entitlement = db.prepare(`
646
- SELECT plan_version_id, entitlement_state, updated_by
647
- FROM account_entitlements
648
- WHERE account_id = 'u1' AND tenant_id = 'default'
649
- LIMIT 1
650
- `).get();
651
- expect(entitlement).toEqual(expect.objectContaining({
652
- plan_version_id: 'pro-v1',
653
- entitlement_state: 'checkout_pending',
654
- updated_by: 'stripe-webhook'
655
- }));
656
- const billing = db.prepare(`
657
- SELECT entitlement_state, stripe_status, stripe_subscription_id
658
- FROM user_billing
659
- WHERE user_id = 'u1' AND tenant_id = 'default'
660
- LIMIT 1
661
- `).get();
662
- expect(billing).toEqual(expect.objectContaining({
663
- entitlement_state: 'checkout_pending',
664
- stripe_status: 'incomplete',
665
- stripe_subscription_id: 'sub_123'
666
- }));
667
- });
668
- it('preserves active access when Stripe reports an incomplete subscription for an already-active account', async () => {
669
- const now = new Date().toISOString();
670
- db.prepare(`
671
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
672
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
673
- `).run(now, now);
674
- db.prepare(`
675
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
676
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
677
- `).run(now, now);
678
- db.prepare(`
679
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
680
- VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
681
- `).run(now, now);
682
- db.prepare(`
683
- INSERT INTO account_entitlements (
684
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
685
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
686
- `).run(now, now, now);
687
- mockConstructEvent.mockReturnValue({
688
- id: 'evt_incomplete_active_account',
689
- type: 'customer.subscription.updated',
690
- data: {
691
- object: {
692
- id: 'sub_123',
693
- customer: 'cus_123',
694
- status: 'incomplete',
695
- trial_end: null,
696
- metadata: { planVersionId: 'pro-v1' },
697
- items: {
698
- data: [{
699
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
700
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
701
- }]
702
- }
703
- }
704
- }
705
- });
706
- const req = createMockReq('{"id":"evt_incomplete_active_account"}', {
707
- 'x-taskforce-runtime-mode': 'cloud',
708
- 'stripe-signature': 'sig'
709
- });
710
- const res = createMockRes();
711
- await webhookHandler(req, res, {});
712
- expect(res.statusCode).toBe(200);
713
- const entitlement = db.prepare(`
714
- SELECT entitlement_state, updated_by
715
- FROM account_entitlements
716
- WHERE account_id = 'u1' AND tenant_id = 'default'
717
- LIMIT 1
718
- `).get();
719
- expect(entitlement).toEqual(expect.objectContaining({
720
- entitlement_state: 'active',
721
- updated_by: 'stripe-webhook'
722
- }));
723
- const billing = db.prepare(`
724
- SELECT entitlement_state, stripe_status
725
- FROM user_billing
726
- WHERE user_id = 'u1' AND tenant_id = 'default'
727
- LIMIT 1
728
- `).get();
729
- expect(billing).toEqual(expect.objectContaining({
730
- entitlement_state: 'active',
731
- stripe_status: 'incomplete'
732
- }));
733
- });
734
- it('ignores superseded subscription webhook events for a stale checkout plan selection', async () => {
735
- const now = new Date().toISOString();
736
- db.prepare(`
737
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
738
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
739
- `).run(now, now);
740
- db.prepare(`
741
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
742
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
743
- `).run(now, now);
744
- db.prepare(`
745
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
746
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
747
- `).run(now, now);
748
- db.prepare(`
749
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
750
- VALUES ('u1', 'default', 'cus_123', 'checkout_pending', ?, ?)
751
- `).run(now, now);
752
- db.prepare(`
753
- INSERT INTO account_entitlements (
754
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
755
- ) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
756
- `).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
757
- mockConstructEvent.mockReturnValue({
758
- id: 'evt_stale_checkout_superseded',
759
- type: 'customer.subscription.updated',
760
- data: {
761
- object: {
762
- id: 'sub_123',
763
- customer: 'cus_123',
764
- status: 'active',
765
- trial_end: null,
766
- metadata: { planVersionId: 'pro-v1' },
767
- items: {
768
- data: [{
769
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
770
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
771
- }]
772
- }
773
- }
774
- }
775
- });
776
- const req = createMockReq('{"id":"evt_stale_checkout_superseded"}', {
777
- 'x-taskforce-runtime-mode': 'cloud',
778
- 'stripe-signature': 'sig'
779
- });
780
- const res = createMockRes();
781
- await webhookHandler(req, res, {});
782
- expect(res.statusCode).toBe(200);
783
- const entitlement = db.prepare(`
784
- SELECT plan_version_id, entitlement_state
785
- FROM account_entitlements
786
- WHERE account_id = 'u1' AND tenant_id = 'default'
787
- LIMIT 1
788
- `).get();
789
- expect(entitlement).toEqual(expect.objectContaining({
790
- plan_version_id: 'team-v1',
791
- entitlement_state: 'checkout_pending'
792
- }));
793
- const failure = db.prepare(`
794
- SELECT reason
795
- FROM stripe_webhook_failures
796
- WHERE event_id = 'evt_stale_checkout_superseded'
797
- ORDER BY created_at DESC
798
- LIMIT 1
799
- `).get();
800
- expect(String(failure?.reason || '')).toContain('no longer current for this account');
801
- });
802
- it('allows authoritative subscription webhooks to recover accounts after the local checkout expired', async () => {
803
- const now = new Date().toISOString();
804
- db.prepare(`
805
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
806
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
807
- `).run(now, now);
808
- db.prepare(`
809
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
810
- VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
811
- `).run(now, now);
812
- db.prepare(`
813
- INSERT INTO user_billing (
814
- user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at
815
- ) VALUES ('u1', 'default', 'cus_123', 'pending_plan_selection', ?, ?)
816
- `).run(now, now);
817
- db.prepare(`
818
- INSERT INTO account_entitlements (
819
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
820
- ) VALUES ('u1', 'default', 'team-v1', 'pending_plan_selection', ?, NULL, 'checkout-expired', ?, ?)
821
- `).run(now, now, now);
822
- mockConstructEvent.mockReturnValue({
823
- id: 'evt_checkout_expired_recovery',
824
- type: 'customer.subscription.updated',
825
- data: {
826
- object: {
827
- id: 'sub_123',
828
- customer: 'cus_123',
829
- status: 'active',
830
- trial_end: null,
831
- metadata: { planVersionId: 'team-v1' },
832
- items: {
833
- data: [{
834
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
835
- price: { id: 'price_team_month', recurring: { interval: 'month' } }
836
- }]
837
- }
838
- }
839
- }
840
- });
841
- const req = createMockReq('{"id":"evt_checkout_expired_recovery"}', {
842
- 'x-taskforce-runtime-mode': 'cloud',
843
- 'stripe-signature': 'sig'
844
- });
845
- const res = createMockRes();
846
- await webhookHandler(req, res, {});
847
- expect(res.statusCode).toBe(200);
848
- const entitlement = db.prepare(`
849
- SELECT plan_version_id, entitlement_state, updated_by
850
- FROM account_entitlements
851
- WHERE account_id = 'u1' AND tenant_id = 'default'
852
- LIMIT 1
853
- `).get();
854
- expect(entitlement).toEqual(expect.objectContaining({
855
- plan_version_id: 'team-v1',
856
- entitlement_state: 'active',
857
- updated_by: 'stripe-webhook'
858
- }));
859
- const failure = db.prepare(`
860
- SELECT reason
861
- FROM stripe_webhook_failures
862
- WHERE event_id = 'evt_checkout_expired_recovery'
863
- ORDER BY created_at DESC
864
- LIMIT 1
865
- `).get();
866
- expect(failure).toBeUndefined();
867
- });
868
- it('ignores stale subscription webhooks for active accounts when a newer checkout attempt is open', async () => {
869
- const now = new Date().toISOString();
870
- db.prepare(`
871
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
872
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
873
- `).run(now, now);
874
- db.prepare(`
875
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
876
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
877
- `).run(now, now);
878
- db.prepare(`
879
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
880
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
881
- `).run(now, now);
882
- db.prepare(`
883
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
884
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
885
- `).run(now, now);
886
- db.prepare(`
887
- INSERT INTO user_billing (
888
- user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
889
- ) VALUES ('u1', 'default', 'cus_123', 'starter', 'active', ?, ?)
890
- `).run(now, now);
891
- db.prepare(`
892
- INSERT INTO account_entitlements (
893
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
894
- ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
895
- `).run(now, now, now);
896
- db.prepare(`
897
- INSERT INTO billing_checkout_attempts (
898
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
899
- ) VALUES ('cs_newer', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
900
- `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
901
- mockConstructEvent.mockReturnValue({
902
- id: 'evt_active_stale_checkout',
903
- type: 'customer.subscription.updated',
904
- data: {
905
- object: {
906
- id: 'sub_stale',
907
- customer: 'cus_123',
908
- status: 'active',
909
- trial_end: null,
910
- metadata: { planVersionId: 'pro-v1' },
911
- items: {
912
- data: [{
913
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
914
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
915
- }]
916
- }
917
- }
918
- }
919
- });
920
- const req = createMockReq('{"id":"evt_active_stale_checkout"}', {
921
- 'x-taskforce-runtime-mode': 'cloud',
922
- 'stripe-signature': 'sig'
923
- });
924
- const res = createMockRes();
925
- await webhookHandler(req, res, {});
926
- expect(res.statusCode).toBe(200);
927
- const entitlement = db.prepare(`
928
- SELECT plan_version_id, entitlement_state
929
- FROM account_entitlements
930
- WHERE account_id = 'u1' AND tenant_id = 'default'
931
- LIMIT 1
932
- `).get();
933
- expect(entitlement).toEqual(expect.objectContaining({
934
- plan_version_id: 'starter-v1',
935
- entitlement_state: 'active'
936
- }));
937
- const failure = db.prepare(`
938
- SELECT reason
939
- FROM stripe_webhook_failures
940
- WHERE event_id = 'evt_active_stale_checkout'
941
- ORDER BY created_at DESC
942
- LIMIT 1
943
- `).get();
944
- expect(String(failure?.reason || '')).toContain('no longer current for this account');
945
- });
946
- it('purges expired webhook failures during webhook processing', async () => {
947
- const past = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
948
- const now = new Date().toISOString();
949
- db.prepare(`
950
- INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
951
- VALUES ('whf_old', 'default', 'evt_old', 'invoice.paid', 'old', '{}', ?, ?)
952
- `).run(past, now);
953
- db.prepare(`
954
- INSERT INTO stripe_webhook_events (event_id, tenant_id, event_type, processed_at)
955
- VALUES ('evt_dup_for_purge', 'default', 'invoice.paid', ?)
956
- `).run(now);
957
- mockConstructEvent.mockReturnValue({
958
- id: 'evt_dup_for_purge',
959
- type: 'invoice.paid',
960
- data: { object: { customer: 'cus_1', lines: { data: [] } } }
961
- });
962
- const req = createMockReq('{"id":"evt_dup_for_purge"}', {
963
- 'x-taskforce-runtime-mode': 'cloud',
964
- 'stripe-signature': 'sig'
965
- });
966
- const res = createMockRes();
967
- await webhookHandler(req, res, {});
968
- expect(res.statusCode).toBe(200);
969
- const row = db.prepare(`SELECT id FROM stripe_webhook_failures WHERE id = 'whf_old'`).get();
970
- expect(row).toBeUndefined();
971
- });
972
- it('creates checkout session with plans success/cancel URLs and selection context', async () => {
973
- const now = new Date().toISOString();
974
- db.prepare(`
975
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
976
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
977
- `).run(now, now);
978
- db.prepare(`
979
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
980
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
981
- `).run(now, now);
982
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
983
- expect(checkoutHandler).toBeTypeOf('function');
984
- const req = createMockReq(JSON.stringify({
985
- planVersionId: 'collab-v1',
986
- interval: 'month'
987
- }), {
988
- 'x-taskforce-runtime-mode': 'cloud',
989
- host: 'app.taskforcehq.ai'
990
- });
991
- const res = createMockRes();
992
- await checkoutHandler(req, res, {});
993
- expect(res.statusCode).toBe(200);
994
- expect(mockCheckoutCreate).toHaveBeenCalledTimes(1);
995
- const firstCall = mockCheckoutCreate.mock.calls[0];
996
- const args = (Array.isArray(firstCall) ? firstCall[0] : {});
997
- expect(String(args.success_url || '')).toContain('/?screen=plans&checkout=success');
998
- expect(String(args.success_url || '')).toContain('planId=collab');
999
- expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1000
- expect(String(args.success_url || '')).toContain('interval=month');
1001
- expect(String(args.cancel_url || '')).toContain('/?screen=plans&checkout=cancel');
1002
- expect(String(args.cancel_url || '')).toContain('planId=collab');
1003
- expect(String(args.cancel_url || '')).toContain('planVersionId=collab-v1');
1004
- expect(String(args.cancel_url || '')).toContain('interval=month');
1005
- expect(args.line_items?.[0]?.price).toBe('price_collab_month');
1006
- expect(args.customer).toBeUndefined();
1007
- expect(args.customer_email).toBe('owner@example.com');
1008
- });
1009
- it('creates checkout with the campaign price mapping when requested', async () => {
1010
- const now = new Date().toISOString();
1011
- db.prepare(`
1012
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1013
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1014
- `).run(now, now);
1015
- db.prepare(`
1016
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, active, created_at, updated_at)
1017
- VALUES ('default', 'collab-v1', 'month', 'public', 'price_collab_month', 1, ?, ?)
1018
- `).run(now, now);
1019
- db.prepare(`
1020
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, active, created_at, updated_at)
1021
- VALUES ('default', 'collab-v1', 'month', 'campaign', 'price_collab_campaign_month', 1, ?, ?)
1022
- `).run(now, now);
1023
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1024
- const req = createMockReq(JSON.stringify({
1025
- planVersionId: 'collab-v1',
1026
- interval: 'month',
1027
- pricingAudience: 'campaign'
1028
- }), {
1029
- 'x-taskforce-runtime-mode': 'cloud',
1030
- host: 'app.taskforcehq.ai'
1031
- });
1032
- const res = createMockRes();
1033
- await checkoutHandler?.(req, res, {});
1034
- expect(res.statusCode).toBe(200);
1035
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1036
- expect(args.line_items?.[0]?.price).toBe('price_collab_campaign_month');
1037
- expect(String(args.success_url || '')).toContain('pricingAudience=campaign');
1038
- expect(String(args.cancel_url || '')).toContain('pricingAudience=campaign');
1039
- expect(args.metadata?.pricingAudience).toBe('campaign');
1040
- expect(args.subscription_data?.metadata?.pricingAudience).toBe('campaign');
1041
- });
1042
- it('uses per-version trial days when creating checkout', async () => {
1043
- const now = new Date().toISOString();
1044
- db.prepare(`
1045
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1046
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{"trialDays":21}', ?, ?)
1047
- `).run(now, now);
1048
- db.prepare(`
1049
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1050
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1051
- `).run(now, now);
1052
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1053
- const req = createMockReq(JSON.stringify({
1054
- planVersionId: 'collab-v1',
1055
- interval: 'month'
1056
- }), {
1057
- 'x-taskforce-runtime-mode': 'cloud',
1058
- host: 'app.taskforcehq.ai'
1059
- });
1060
- const res = createMockRes();
1061
- await checkoutHandler(req, res, {});
1062
- expect(res.statusCode).toBe(200);
1063
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1064
- expect(args.subscription_data?.trial_period_days).toBe(21);
1065
- });
1066
- it('omits Stripe trial period when the selected version trialDays is 0', async () => {
1067
- const now = new Date().toISOString();
1068
- db.prepare(`
1069
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1070
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{"trialDays":0}', ?, ?)
1071
- `).run(now, now);
1072
- db.prepare(`
1073
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1074
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1075
- `).run(now, now);
1076
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1077
- const req = createMockReq(JSON.stringify({
1078
- planVersionId: 'collab-v1',
1079
- interval: 'month'
1080
- }), {
1081
- 'x-taskforce-runtime-mode': 'cloud',
1082
- host: 'app.taskforcehq.ai'
1083
- });
1084
- const res = createMockRes();
1085
- await checkoutHandler(req, res, {});
1086
- expect(res.statusCode).toBe(200);
1087
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1088
- expect(args.subscription_data?.trial_period_days).toBeUndefined();
1089
- });
1090
- it('creates checkout session with existing stripe customer and no customer_email override', async () => {
1091
- const now = new Date().toISOString();
1092
- db.prepare(`
1093
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1094
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1095
- `).run(now, now);
1096
- db.prepare(`
1097
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1098
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1099
- `).run(now, now);
1100
- db.prepare(`
1101
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
1102
- VALUES ('u1', 'default', 'cus_existing', 'active', ?, ?)
1103
- `).run(now, now);
1104
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1105
- const req = createMockReq(JSON.stringify({
1106
- planVersionId: 'collab-v1',
1107
- interval: 'month'
1108
- }), {
1109
- 'x-taskforce-runtime-mode': 'cloud',
1110
- host: 'app.taskforcehq.ai'
1111
- });
1112
- const res = createMockRes();
1113
- await checkoutHandler(req, res, {});
1114
- expect(res.statusCode).toBe(200);
1115
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1116
- expect(args.customer).toBe('cus_existing');
1117
- expect(args.customer_email).toBeUndefined();
1118
- });
1119
- it('records checkout_pending when a blocked account starts a paid checkout', async () => {
1120
- const now = new Date().toISOString();
1121
- db.prepare(`
1122
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1123
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1124
- `).run(now, now);
1125
- db.prepare(`
1126
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1127
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1128
- `).run(now, now);
1129
- db.prepare(`
1130
- INSERT INTO account_entitlements (
1131
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1132
- ) VALUES ('u1', 'default', 'starter-v1', 'pending_plan_selection', ?, ?, ?)
1133
- `).run(now, now, now);
1134
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1135
- const req = createMockReq(JSON.stringify({
1136
- planVersionId: 'collab-v1',
1137
- interval: 'month'
1138
- }), {
1139
- 'x-taskforce-runtime-mode': 'cloud',
1140
- host: 'app.taskforcehq.ai'
1141
- });
1142
- const res = createMockRes();
1143
- await checkoutHandler(req, res, {});
1144
- expect(res.statusCode).toBe(200);
1145
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1146
- checkoutState: 'checkout',
1147
- id: 'cs_test'
1148
- }));
1149
- const entitlement = db.prepare(`
1150
- SELECT plan_version_id, entitlement_state, updated_by
1151
- FROM account_entitlements
1152
- WHERE tenant_id = 'default' AND account_id = 'u1'
1153
- LIMIT 1
1154
- `).get();
1155
- expect(entitlement).toEqual(expect.objectContaining({
1156
- plan_version_id: 'collab-v1',
1157
- entitlement_state: 'checkout_pending',
1158
- updated_by: 'checkout-start'
1159
- }));
1160
- const billing = db.prepare(`
1161
- SELECT tier, entitlement_state, effective_until
1162
- FROM user_billing
1163
- WHERE tenant_id = 'default' AND user_id = 'u1'
1164
- LIMIT 1
1165
- `).get();
1166
- expect(billing).toEqual(expect.objectContaining({
1167
- tier: 'collab',
1168
- entitlement_state: 'checkout_pending'
1169
- }));
1170
- expect(typeof billing?.effective_until).toBe('string');
1171
- });
1172
- it('does not downgrade an active account to checkout_pending when starting paid checkout', async () => {
1173
- const now = new Date().toISOString();
1174
- db.prepare(`
1175
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1176
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1177
- `).run(now, now);
1178
- db.prepare(`
1179
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1180
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
1181
- `).run(now, now);
1182
- db.prepare(`
1183
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1184
- VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
1185
- `).run(now, now);
1186
- db.prepare(`
1187
- INSERT INTO account_entitlements (
1188
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1189
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, 'stripe-webhook', ?, ?)
1190
- `).run(now, now, now);
1191
- db.prepare(`
1192
- INSERT INTO user_billing (
1193
- user_id, tenant_id, stripe_customer_id, tier, entitlement_state, created_at, updated_at
1194
- ) VALUES ('u1', 'default', 'cus_existing', 'pro', 'active', ?, ?)
1195
- `).run(now, now);
1196
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1197
- const req = createMockReq(JSON.stringify({
1198
- planVersionId: 'team-v1',
1199
- interval: 'month'
1200
- }), {
1201
- 'x-taskforce-runtime-mode': 'cloud',
1202
- host: 'app.taskforcehq.ai'
1203
- });
1204
- const res = createMockRes();
1205
- await checkoutHandler(req, res, {});
1206
- expect(res.statusCode).toBe(200);
1207
- const entitlement = db.prepare(`
1208
- SELECT plan_version_id, entitlement_state, updated_by
1209
- FROM account_entitlements
1210
- WHERE tenant_id = 'default' AND account_id = 'u1'
1211
- LIMIT 1
1212
- `).get();
1213
- expect(entitlement).toEqual(expect.objectContaining({
1214
- plan_version_id: 'pro-v1',
1215
- entitlement_state: 'active',
1216
- updated_by: 'stripe-webhook'
1217
- }));
1218
- const billing = db.prepare(`
1219
- SELECT tier, entitlement_state
1220
- FROM user_billing
1221
- WHERE tenant_id = 'default' AND user_id = 'u1'
1222
- LIMIT 1
1223
- `).get();
1224
- expect(billing).toEqual(expect.objectContaining({
1225
- tier: 'pro',
1226
- entitlement_state: 'active'
1227
- }));
1228
- });
1229
- it('supersedes a stale checkout_pending selection when the user retries paid checkout', async () => {
1230
- const now = new Date().toISOString();
1231
- const previousExpiry = new Date(Date.now() - (60 * 60 * 1000)).toISOString();
1232
- db.prepare(`
1233
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1234
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1235
- `).run(now, now);
1236
- db.prepare(`
1237
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1238
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
1239
- `).run(now, now);
1240
- db.prepare(`
1241
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1242
- VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
1243
- `).run(now, now);
1244
- db.prepare(`
1245
- INSERT INTO account_entitlements (
1246
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
1247
- ) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
1248
- `).run(now, previousExpiry, now, now);
1249
- db.prepare(`
1250
- INSERT INTO user_billing (
1251
- user_id, tenant_id, tier, entitlement_state, effective_until, created_at, updated_at
1252
- ) VALUES ('u1', 'default', 'pro', 'checkout_pending', ?, ?, ?)
1253
- `).run(previousExpiry, now, now);
1254
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1255
- const req = createMockReq(JSON.stringify({
1256
- planVersionId: 'team-v1',
1257
- interval: 'month'
1258
- }), {
1259
- 'x-taskforce-runtime-mode': 'cloud',
1260
- host: 'app.taskforcehq.ai'
1261
- });
1262
- const res = createMockRes();
1263
- await checkoutHandler(req, res, {});
1264
- expect(res.statusCode).toBe(200);
1265
- const entitlement = db.prepare(`
1266
- SELECT plan_version_id, entitlement_state, effective_until
1267
- FROM account_entitlements
1268
- WHERE tenant_id = 'default' AND account_id = 'u1'
1269
- LIMIT 1
1270
- `).get();
1271
- expect(entitlement).toEqual(expect.objectContaining({
1272
- plan_version_id: 'team-v1',
1273
- entitlement_state: 'checkout_pending'
1274
- }));
1275
- expect(entitlement?.effective_until).not.toBe(previousExpiry);
1276
- const billing = db.prepare(`
1277
- SELECT tier, entitlement_state, effective_until
1278
- FROM user_billing
1279
- WHERE tenant_id = 'default' AND user_id = 'u1'
1280
- LIMIT 1
1281
- `).get();
1282
- expect(billing).toEqual(expect.objectContaining({
1283
- tier: 'team',
1284
- entitlement_state: 'checkout_pending'
1285
- }));
1286
- expect(billing?.effective_until).not.toBe(previousExpiry);
1287
- });
1288
- it('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
1289
- const now = new Date().toISOString();
1290
- db.prepare(`
1291
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1292
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1293
- `).run(now, now);
1294
- db.prepare(`
1295
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1296
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1297
- `).run(now, now);
1298
- mockCustomersList.mockResolvedValueOnce({
1299
- data: [{ id: 'cus_email_match' }]
1300
- });
1301
- mockSubscriptionList.mockResolvedValueOnce({ data: [] });
1302
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1303
- const req = createMockReq(JSON.stringify({
1304
- planVersionId: 'collab-v1',
1305
- interval: 'month'
1306
- }), {
1307
- 'x-taskforce-runtime-mode': 'cloud',
1308
- host: 'app.taskforcehq.ai'
1309
- });
1310
- const res = createMockRes();
1311
- await checkoutHandler(req, res, {});
1312
- expect(res.statusCode).toBe(200);
1313
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1314
- expect(args.customer).toBe('cus_email_match');
1315
- expect(args.customer_email).toBeUndefined();
1316
- });
1317
- it('builds checkout return URLs from explicit local proxy origin headers', async () => {
1318
- const now = new Date().toISOString();
1319
- db.prepare(`
1320
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1321
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1322
- `).run(now, now);
1323
- db.prepare(`
1324
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1325
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1326
- `).run(now, now);
1327
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1328
- const req = createMockReq(JSON.stringify({
1329
- planVersionId: 'collab-v1',
1330
- interval: 'month'
1331
- }), {
1332
- 'x-taskforce-runtime-mode': 'cloud',
1333
- host: 'performance-app.taskforcehq.ai',
1334
- 'x-taskforce-origin-host': 'localhost:5174',
1335
- 'x-taskforce-origin-proto': 'http',
1336
- 'x-taskforce-origin-base-url': 'http://localhost:5174',
1337
- 'x-taskforce-origin-runtime': 'local-proxy',
1338
- 'x-forwarded-host': 'localhost:5174',
1339
- 'x-forwarded-proto': 'http'
1340
- });
1341
- const res = createMockRes();
1342
- await checkoutHandler(req, res, {});
1343
- expect(res.statusCode).toBe(200);
1344
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1345
- expect(String(args.success_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=success');
1346
- expect(String(args.success_url || '')).toContain('planId=collab');
1347
- expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1348
- expect(String(args.success_url || '')).toContain('interval=month');
1349
- expect(String(args.cancel_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=cancel');
1350
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1351
- checkoutState: 'checkout',
1352
- returnBaseUrl: 'http://localhost:5174'
1353
- }));
1354
- });
1355
- it('prefers explicit proxy-origin headers over generic forwarded host values', async () => {
1356
- const now = new Date().toISOString();
1357
- db.prepare(`
1358
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1359
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1360
- `).run(now, now);
1361
- db.prepare(`
1362
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1363
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1364
- `).run(now, now);
1365
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1366
- const req = createMockReq(JSON.stringify({
1367
- planVersionId: 'collab-v1',
1368
- interval: 'month'
1369
- }), {
1370
- 'x-taskforce-runtime-mode': 'cloud',
1371
- host: 'performance-app.taskforcehq.ai',
1372
- 'x-taskforce-origin-host': 'localhost:5174',
1373
- 'x-taskforce-origin-proto': 'http',
1374
- 'x-taskforce-origin-runtime': 'local-proxy',
1375
- 'x-forwarded-host': 'performance-app.taskforcehq.ai',
1376
- 'x-forwarded-proto': 'https'
1377
- });
1378
- const res = createMockRes();
1379
- await checkoutHandler(req, res, {});
1380
- expect(res.statusCode).toBe(200);
1381
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1382
- expect(String(args.success_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=success');
1383
- expect(String(args.success_url || '')).toContain('planId=collab');
1384
- expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1385
- expect(String(args.success_url || '')).toContain('interval=month');
1386
- });
1387
- it('builds direct cloud checkout return URLs from the actual request protocol when forwarded proto is absent', async () => {
1388
- const now = new Date().toISOString();
1389
- db.prepare(`
1390
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1391
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1392
- `).run(now, now);
1393
- db.prepare(`
1394
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1395
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1396
- `).run(now, now);
1397
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1398
- const req = createMockReq(JSON.stringify({
1399
- planVersionId: 'collab-v1',
1400
- interval: 'month'
1401
- }), {
1402
- 'x-taskforce-runtime-mode': 'cloud',
1403
- host: '127.0.0.1:4173'
1404
- });
1405
- req.socket = { encrypted: false };
1406
- const res = createMockRes();
1407
- await checkoutHandler(req, res, {});
1408
- expect(res.statusCode).toBe(200);
1409
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1410
- expect(String(args.success_url || '')).toContain('http://127.0.0.1:4173/?screen=plans&checkout=success');
1411
- expect(String(args.success_url || '')).toContain('planId=collab');
1412
- expect(String(args.success_url || '')).toContain('planVersionId=collab-v1');
1413
- expect(String(args.success_url || '')).toContain('interval=month');
1414
- expect(String(args.cancel_url || '')).toContain('http://127.0.0.1:4173/?screen=plans&checkout=cancel');
1415
- });
1416
- it('applies a free plan immediately without creating a Stripe checkout session', async () => {
1417
- const now = new Date().toISOString();
1418
- db.prepare(`
1419
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1420
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
1421
- `).run(now, now);
1422
- db.prepare(`
1423
- 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)
1424
- VALUES ('default', 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, NULL, ?, ?)
1425
- `).run(now, now);
1426
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1427
- const req = createMockReq(JSON.stringify({
1428
- planVersionId: 'starter-v1',
1429
- interval: 'month'
1430
- }), {
1431
- 'x-taskforce-runtime-mode': 'cloud',
1432
- host: 'app.taskforcehq.ai'
1433
- });
1434
- const res = createMockRes();
1435
- await checkoutHandler(req, res, {});
1436
- expect(res.statusCode).toBe(200);
1437
- expect(mockCheckoutCreate).not.toHaveBeenCalled();
1438
- expect(res.body).toContain('"checkoutState":"free"');
1439
- });
1440
- it('falls back to stripe email collection when user identity email is unavailable', async () => {
1441
- routeHandlers = buildBillingRouteHandlers(db, { userIdentity: { userId: 'u1', email: null, systemRole: 'user', betaAccess: true } });
1442
- const now = new Date().toISOString();
1443
- db.prepare(`
1444
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1445
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1446
- `).run(now, now);
1447
- db.prepare(`
1448
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1449
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1450
- `).run(now, now);
1451
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1452
- const req = createMockReq(JSON.stringify({
1453
- planVersionId: 'collab-v1',
1454
- interval: 'month'
1455
- }), {
1456
- 'x-taskforce-runtime-mode': 'cloud',
1457
- host: 'app.taskforcehq.ai'
1458
- });
1459
- const res = createMockRes();
1460
- await checkoutHandler(req, res, {});
1461
- expect(res.statusCode).toBe(200);
1462
- const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
1463
- expect(args.customer).toBeUndefined();
1464
- expect(args.customer_email).toBeUndefined();
1465
- });
1466
- it('returns a clear error when the saved stripe customer no longer exists', async () => {
1467
- const now = new Date().toISOString();
1468
- db.prepare(`
1469
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1470
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
1471
- `).run(now, now);
1472
- db.prepare(`
1473
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1474
- VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
1475
- `).run(now, now);
1476
- db.prepare(`
1477
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
1478
- VALUES ('u1', 'default', 'cus_missing', 'active', ?, ?)
1479
- `).run(now, now);
1480
- mockCheckoutCreate.mockImplementationOnce(async () => {
1481
- const error = new Error('No such customer: cus_missing');
1482
- error.code = 'resource_missing';
1483
- throw error;
1484
- });
1485
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1486
- const req = createMockReq(JSON.stringify({
1487
- planVersionId: 'collab-v1',
1488
- interval: 'month'
1489
- }), {
1490
- 'x-taskforce-runtime-mode': 'cloud',
1491
- host: 'app.taskforcehq.ai'
1492
- });
1493
- const res = createMockRes();
1494
- await checkoutHandler(req, res, {});
1495
- expect(res.statusCode).toBe(409);
1496
- expect(JSON.parse(res.body)).toEqual({
1497
- error: 'The saved Stripe customer for this account could not be found. Please contact support before retrying checkout.',
1498
- code: 'BILLING_STRIPE_CUSTOMER_MISSING'
1499
- });
1500
- });
1501
- it('updates existing subscriptions without creating a checkout session', async () => {
1502
- const now = new Date().toISOString();
1503
- db.prepare(`
1504
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1505
- VALUES ('pro-v2', 'default', 'pro', 2, 1, '{}', ?, ?)
1506
- `).run(now, now);
1507
- db.prepare(`
1508
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1509
- VALUES ('default', 'pro-v2', 'year', 'price_pro_year', 1, ?, ?)
1510
- `).run(now, now);
1511
- db.prepare(`
1512
- INSERT INTO user_billing (
1513
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, entitlement_state, created_at, updated_at
1514
- ) VALUES ('u1', 'default', 'cus_existing', 'sub_existing', 'price_pro_month', 'month', 'active', ?, ?)
1515
- `).run(now, now);
1516
- db.prepare(`
1517
- INSERT INTO account_entitlements (
1518
- account_id, tenant_id, plan_version_id, entitlement_state, created_at, updated_at
1519
- ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?)
1520
- `).run(now, now);
1521
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1522
- const req = createMockReq(JSON.stringify({
1523
- planVersionId: 'pro-v2',
1524
- interval: 'year'
1525
- }), {
1526
- 'x-taskforce-runtime-mode': 'cloud',
1527
- host: 'app.taskforcehq.ai'
1528
- });
1529
- const res = createMockRes();
1530
- await checkoutHandler(req, res, {});
1531
- expect(res.statusCode).toBe(200);
1532
- expect(mockSubscriptionUpdate).toHaveBeenCalledWith('sub_existing', expect.objectContaining({
1533
- items: [{ id: 'si_123', price: 'price_pro_year' }]
1534
- }));
1535
- expect(mockCheckoutCreate).not.toHaveBeenCalled();
1536
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1537
- checkoutState: 'updated'
1538
- }));
1539
- });
1540
- it('does not create a second checkout session for the same paid plan', async () => {
1541
- const now = new Date().toISOString();
1542
- db.prepare(`
1543
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1544
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1545
- `).run(now, now);
1546
- db.prepare(`
1547
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1548
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1549
- `).run(now, now);
1550
- db.prepare(`
1551
- INSERT INTO user_billing (
1552
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
1553
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
1554
- `).run(now, now);
1555
- db.prepare(`
1556
- INSERT INTO account_entitlements (
1557
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1558
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
1559
- `).run(now, now, now);
1560
- mockSubscriptionList.mockResolvedValueOnce({
1561
- data: [{ id: 'sub_123', status: 'active' }]
1562
- });
1563
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1564
- const req = createMockReq(JSON.stringify({
1565
- planVersionId: 'pro-v1',
1566
- interval: 'month'
1567
- }), {
1568
- 'x-taskforce-runtime-mode': 'cloud',
1569
- host: 'app.taskforcehq.ai'
1570
- });
1571
- const res = createMockRes();
1572
- await checkoutHandler(req, res, {});
1573
- expect(res.statusCode).toBe(200);
1574
- expect(mockCheckoutCreate).not.toHaveBeenCalled();
1575
- expect(mockSubscriptionUpdate).not.toHaveBeenCalled();
1576
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1577
- checkoutState: 'already_current_plan',
1578
- planVersionId: 'pro-v1'
1579
- }));
1580
- });
1581
- it('discovers an active paid subscription by account email and blocks a duplicate purchase', async () => {
1582
- const now = new Date().toISOString();
1583
- db.prepare(`
1584
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1585
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1586
- `).run(now, now);
1587
- db.prepare(`
1588
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1589
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1590
- `).run(now, now);
1591
- mockCustomersList.mockResolvedValueOnce({
1592
- data: [{ id: 'cus_email_match' }]
1593
- });
1594
- mockSubscriptionList.mockResolvedValueOnce({
1595
- data: [{
1596
- id: 'sub_email_match',
1597
- customer: 'cus_email_match',
1598
- status: 'active',
1599
- trial_end: null,
1600
- metadata: { userId: 'u1', planVersionId: 'pro-v1' },
1601
- items: {
1602
- data: [{
1603
- id: 'si_email_match',
1604
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
1605
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
1606
- }]
1607
- }
1608
- }]
1609
- });
1610
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1611
- const req = createMockReq(JSON.stringify({
1612
- planVersionId: 'pro-v1',
1613
- interval: 'month'
1614
- }), {
1615
- 'x-taskforce-runtime-mode': 'cloud',
1616
- host: 'app.taskforcehq.ai'
1617
- });
1618
- const res = createMockRes();
1619
- await checkoutHandler(req, res, {});
1620
- expect(res.statusCode).toBe(200);
1621
- expect(mockCheckoutCreate).not.toHaveBeenCalled();
1622
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1623
- checkoutState: 'already_current_plan',
1624
- planVersionId: 'pro-v1'
1625
- }));
1626
- const mirrored = db.prepare(`
1627
- SELECT stripe_customer_id, stripe_subscription_id
1628
- FROM user_billing
1629
- WHERE tenant_id = 'default' AND user_id = 'u1'
1630
- LIMIT 1
1631
- `).get();
1632
- expect(mirrored).toEqual(expect.objectContaining({
1633
- stripe_customer_id: 'cus_email_match',
1634
- stripe_subscription_id: 'sub_email_match'
1635
- }));
1636
- });
1637
- it('updates the existing paid subscription and keeps canonical entitlement aligned with the new plan', async () => {
1638
- const now = new Date().toISOString();
1639
- db.prepare(`
1640
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1641
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1642
- `).run(now, now);
1643
- db.prepare(`
1644
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1645
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
1646
- `).run(now, now);
1647
- db.prepare(`
1648
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1649
- VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
1650
- `).run(now, now);
1651
- db.prepare(`
1652
- INSERT INTO user_billing (
1653
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
1654
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
1655
- `).run(now, now);
1656
- db.prepare(`
1657
- INSERT INTO account_entitlements (
1658
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1659
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
1660
- `).run(now, now, now);
1661
- mockSubscriptionList.mockResolvedValueOnce({
1662
- data: [{ id: 'sub_123', status: 'active' }]
1663
- });
1664
- mockSubscriptionUpdate.mockResolvedValueOnce({
1665
- id: 'sub_123',
1666
- customer: 'cus_123',
1667
- status: 'active',
1668
- trial_end: null,
1669
- metadata: { userId: 'u1', planVersionId: 'team-v1' },
1670
- items: {
1671
- data: [{
1672
- id: 'si_123',
1673
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
1674
- price: { id: 'price_team_month', recurring: { interval: 'month' } }
1675
- }]
1676
- }
1677
- });
1678
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1679
- const req = createMockReq(JSON.stringify({
1680
- planVersionId: 'team-v1',
1681
- interval: 'month'
1682
- }), {
1683
- 'x-taskforce-runtime-mode': 'cloud',
1684
- host: 'app.taskforcehq.ai'
1685
- });
1686
- const res = createMockRes();
1687
- await checkoutHandler(req, res, {});
1688
- expect(res.statusCode).toBe(200);
1689
- expect(mockCheckoutCreate).not.toHaveBeenCalled();
1690
- expect(mockSubscriptionUpdate).toHaveBeenCalledWith('sub_123', expect.objectContaining({
1691
- items: [{ id: 'si_123', price: 'price_team_month' }]
1692
- }));
1693
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1694
- checkoutState: 'updated',
1695
- planVersionId: 'team-v1'
1696
- }));
1697
- const entitlement = db.prepare(`
1698
- SELECT plan_version_id, entitlement_state, updated_by
1699
- FROM account_entitlements
1700
- WHERE tenant_id = 'default' AND account_id = 'u1'
1701
- LIMIT 1
1702
- `).get();
1703
- expect(entitlement).toEqual(expect.objectContaining({
1704
- plan_version_id: 'team-v1',
1705
- entitlement_state: 'active',
1706
- updated_by: 'checkout-confirm'
1707
- }));
1708
- const billingRow = db.prepare(`
1709
- SELECT stripe_price_id, billing_interval
1710
- FROM user_billing
1711
- WHERE tenant_id = 'default' AND user_id = 'u1'
1712
- LIMIT 1
1713
- `).get();
1714
- expect(billingRow).toEqual(expect.objectContaining({
1715
- stripe_price_id: 'price_team_month',
1716
- billing_interval: 'month'
1717
- }));
1718
- });
1719
- it('updates the existing subscription when only the interval changes on the same plan', async () => {
1720
- const now = new Date().toISOString();
1721
- db.prepare(`
1722
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1723
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1724
- `).run(now, now);
1725
- db.prepare(`
1726
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1727
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1728
- `).run(now, now);
1729
- db.prepare(`
1730
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1731
- VALUES ('default', 'pro-v1', 'year', 'price_pro_year', 1, ?, ?)
1732
- `).run(now, now);
1733
- db.prepare(`
1734
- INSERT INTO user_billing (
1735
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
1736
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
1737
- `).run(now, now);
1738
- db.prepare(`
1739
- INSERT INTO account_entitlements (
1740
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
1741
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
1742
- `).run(now, now, now);
1743
- mockSubscriptionList.mockResolvedValueOnce({
1744
- data: [{ id: 'sub_123', status: 'active' }]
1745
- });
1746
- mockSubscriptionUpdate.mockResolvedValueOnce({
1747
- id: 'sub_123',
1748
- customer: 'cus_123',
1749
- status: 'active',
1750
- trial_end: null,
1751
- metadata: { userId: 'u1', planVersionId: 'pro-v1' },
1752
- items: {
1753
- data: [{
1754
- id: 'si_123',
1755
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
1756
- price: { id: 'price_pro_year', recurring: { interval: 'year' } }
1757
- }]
1758
- }
1759
- });
1760
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
1761
- const req = createMockReq(JSON.stringify({
1762
- planVersionId: 'pro-v1',
1763
- interval: 'year'
1764
- }), {
1765
- 'x-taskforce-runtime-mode': 'cloud',
1766
- host: 'app.taskforcehq.ai'
1767
- });
1768
- const res = createMockRes();
1769
- await checkoutHandler(req, res, {});
1770
- expect(res.statusCode).toBe(200);
1771
- expect(mockCheckoutCreate).not.toHaveBeenCalled();
1772
- expect(mockSubscriptionUpdate).toHaveBeenCalledWith('sub_123', expect.objectContaining({
1773
- items: [{ id: 'si_123', price: 'price_pro_year' }]
1774
- }));
1775
- expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
1776
- checkoutState: 'updated',
1777
- planVersionId: 'pro-v1'
1778
- }));
1779
- });
1780
- it('confirms a successful checkout session, activates canonical entitlement, and closes the checkout attempt', async () => {
1781
- const now = new Date().toISOString();
1782
- db.prepare(`
1783
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1784
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1785
- `).run(now, now);
1786
- db.prepare(`
1787
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1788
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1789
- `).run(now, now);
1790
- db.prepare(`
1791
- INSERT INTO billing_checkout_attempts (
1792
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1793
- ) VALUES ('cs_success_123', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1794
- `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1795
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1796
- expect(confirmHandler).toBeTypeOf('function');
1797
- const req = createMockReq(JSON.stringify({
1798
- sessionId: 'cs_success_123'
1799
- }), {
1800
- 'x-taskforce-runtime-mode': 'cloud',
1801
- host: 'app.taskforcehq.ai'
1802
- });
1803
- const res = createMockRes();
1804
- await confirmHandler(req, res, {});
1805
- expect(res.statusCode).toBe(200);
1806
- expect(mockCheckoutRetrieve).toHaveBeenCalledWith('cs_success_123', expect.objectContaining({
1807
- expand: ['subscription']
1808
- }));
1809
- expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
1810
- const entitlement = db.prepare(`
1811
- SELECT plan_version_id, entitlement_state, updated_by
1812
- FROM account_entitlements
1813
- WHERE tenant_id = 'default' AND account_id = 'u1'
1814
- LIMIT 1
1815
- `).get();
1816
- expect(entitlement).toEqual(expect.objectContaining({
1817
- plan_version_id: 'pro-v1',
1818
- entitlement_state: 'active',
1819
- updated_by: 'checkout-confirm'
1820
- }));
1821
- const billingRow = db.prepare(`
1822
- SELECT stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status
1823
- FROM user_billing
1824
- WHERE tenant_id = 'default' AND user_id = 'u1'
1825
- LIMIT 1
1826
- `).get();
1827
- expect(billingRow).toEqual(expect.objectContaining({
1828
- stripe_customer_id: 'cus_123',
1829
- stripe_subscription_id: 'sub_123',
1830
- stripe_price_id: 'price_pro_month',
1831
- stripe_status: 'active'
1832
- }));
1833
- const attempt = db.prepare(`
1834
- SELECT status
1835
- FROM billing_checkout_attempts
1836
- WHERE tenant_id = 'default' AND session_id = 'cs_success_123'
1837
- LIMIT 1
1838
- `).get();
1839
- expect(attempt).toEqual(expect.objectContaining({
1840
- status: 'confirmed'
1841
- }));
1842
- });
1843
- it('does not activate entitlement when the checkout session is still incomplete', async () => {
1844
- const now = new Date().toISOString();
1845
- db.prepare(`
1846
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1847
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
1848
- `).run(now, now);
1849
- db.prepare(`
1850
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1851
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1852
- `).run(now, now);
1853
- db.prepare(`
1854
- INSERT INTO account_entitlements (
1855
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1856
- ) VALUES ('u1', 'default', 'pro-v1', 'checkout_pending', ?, 'checkout-start', ?, ?)
1857
- `).run(now, now, now);
1858
- mockCheckoutRetrieve.mockResolvedValueOnce({
1859
- id: 'cs_open_123',
1860
- mode: 'subscription',
1861
- status: 'open',
1862
- payment_status: 'unpaid',
1863
- customer: 'cus_123',
1864
- subscription: 'sub_123',
1865
- metadata: { userId: 'u1' }
1866
- });
1867
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1868
- const req = createMockReq(JSON.stringify({
1869
- sessionId: 'cs_open_123'
1870
- }), {
1871
- 'x-taskforce-runtime-mode': 'cloud',
1872
- host: 'app.taskforcehq.ai'
1873
- });
1874
- const res = createMockRes();
1875
- await confirmHandler(req, res, {});
1876
- expect(res.statusCode).toBe(409);
1877
- expect(JSON.parse(res.body)).toEqual({
1878
- error: 'Checkout session has not completed yet.',
1879
- code: 'CHECKOUT_SESSION_INCOMPLETE'
1880
- });
1881
- expect(mockSubscriptionRetrieve).not.toHaveBeenCalledWith('sub_123');
1882
- const entitlement = db.prepare(`
1883
- SELECT plan_version_id, entitlement_state, updated_by
1884
- FROM account_entitlements
1885
- WHERE tenant_id = 'default' AND account_id = 'u1'
1886
- LIMIT 1
1887
- `).get();
1888
- expect(entitlement).toEqual(expect.objectContaining({
1889
- plan_version_id: 'pro-v1',
1890
- entitlement_state: 'checkout_pending',
1891
- updated_by: 'checkout-start'
1892
- }));
1893
- });
1894
- it('keeps checkout confirmation retryable until Stripe attaches the subscription to the completed session', async () => {
1895
- const now = new Date().toISOString();
1896
- db.prepare(`
1897
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1898
- VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
1899
- `).run(now, now);
1900
- db.prepare(`
1901
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1902
- VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
1903
- `).run(now, now);
1904
- db.prepare(`
1905
- INSERT INTO account_entitlements (
1906
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1907
- ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
1908
- `).run(now, now, now);
1909
- db.prepare(`
1910
- INSERT INTO billing_checkout_attempts (
1911
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1912
- ) VALUES ('cs_pending_subscription', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1913
- `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1914
- mockCheckoutRetrieve.mockResolvedValueOnce({
1915
- id: 'cs_pending_subscription',
1916
- mode: 'subscription',
1917
- status: 'complete',
1918
- payment_status: 'paid',
1919
- customer: 'cus_123',
1920
- subscription: null,
1921
- metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1922
- });
1923
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
1924
- const req = createMockReq(JSON.stringify({
1925
- sessionId: 'cs_pending_subscription'
1926
- }), {
1927
- 'x-taskforce-runtime-mode': 'cloud',
1928
- host: 'app.taskforcehq.ai'
1929
- });
1930
- const res = createMockRes();
1931
- await confirmHandler(req, res, {});
1932
- expect(res.statusCode).toBe(409);
1933
- expect(JSON.parse(res.body)).toEqual({
1934
- error: 'Checkout session is still finalizing your subscription.',
1935
- code: 'CHECKOUT_SESSION_INCOMPLETE'
1936
- });
1937
- expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
1938
- const entitlement = db.prepare(`
1939
- SELECT plan_version_id, entitlement_state
1940
- FROM account_entitlements
1941
- WHERE tenant_id = 'default' AND account_id = 'u1'
1942
- LIMIT 1
1943
- `).get();
1944
- expect(entitlement).toEqual(expect.objectContaining({
1945
- plan_version_id: 'free-v1',
1946
- entitlement_state: 'active'
1947
- }));
1948
- const attempt = db.prepare(`
1949
- SELECT status
1950
- FROM billing_checkout_attempts
1951
- WHERE tenant_id = 'default' AND session_id = 'cs_pending_subscription'
1952
- LIMIT 1
1953
- `).get();
1954
- expect(attempt).toEqual(expect.objectContaining({
1955
- status: 'open'
1956
- }));
1957
- });
1958
- it('keeps checkout confirmation retryable until the subscription settles into an active commercial state', async () => {
1959
- const now = new Date().toISOString();
1960
- db.prepare(`
1961
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1962
- VALUES ('free-v1', 'default', 'free', 1, 1, '{}', ?, ?)
1963
- `).run(now, now);
1964
- db.prepare(`
1965
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
1966
- VALUES ('pro-v1', 'default', 'pro', 1, 0, '{}', ?, ?)
1967
- `).run(now, now);
1968
- db.prepare(`
1969
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
1970
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
1971
- `).run(now, now);
1972
- db.prepare(`
1973
- INSERT INTO user_billing (
1974
- user_id, tenant_id, tier, entitlement_state, created_at, updated_at
1975
- ) VALUES ('u1', 'default', 'free', 'active', ?, ?)
1976
- `).run(now, now);
1977
- db.prepare(`
1978
- INSERT INTO account_entitlements (
1979
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
1980
- ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'registration', ?, ?)
1981
- `).run(now, now, now);
1982
- db.prepare(`
1983
- INSERT INTO billing_checkout_attempts (
1984
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
1985
- ) VALUES ('cs_wait_for_active', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
1986
- `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
1987
- mockCheckoutRetrieve.mockResolvedValueOnce({
1988
- id: 'cs_wait_for_active',
1989
- mode: 'subscription',
1990
- status: 'complete',
1991
- payment_status: 'paid',
1992
- customer: 'cus_123',
1993
- subscription: 'sub_123',
1994
- metadata: { userId: 'u1', planVersionId: 'pro-v1' }
1995
- });
1996
- mockSubscriptionRetrieve.mockResolvedValueOnce({
1997
- id: 'sub_123',
1998
- customer: 'cus_123',
1999
- status: 'incomplete',
2000
- trial_end: null,
2001
- metadata: { userId: 'u1', planVersionId: 'pro-v1' },
2002
- items: {
2003
- data: [{
2004
- id: 'si_123',
2005
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
2006
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
2007
- }]
2008
- }
2009
- });
2010
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2011
- const req = createMockReq(JSON.stringify({
2012
- sessionId: 'cs_wait_for_active'
2013
- }), {
2014
- 'x-taskforce-runtime-mode': 'cloud',
2015
- host: 'app.taskforcehq.ai'
2016
- });
2017
- const res = createMockRes();
2018
- await confirmHandler(req, res, {});
2019
- expect(res.statusCode).toBe(409);
2020
- expect(JSON.parse(res.body)).toEqual({
2021
- error: 'Checkout session is still finalizing your plan.',
2022
- code: 'CHECKOUT_SESSION_INCOMPLETE'
2023
- });
2024
- const entitlement = db.prepare(`
2025
- SELECT plan_version_id, entitlement_state
2026
- FROM account_entitlements
2027
- WHERE tenant_id = 'default' AND account_id = 'u1'
2028
- LIMIT 1
2029
- `).get();
2030
- expect(entitlement).toEqual(expect.objectContaining({
2031
- plan_version_id: 'free-v1',
2032
- entitlement_state: 'active'
2033
- }));
2034
- const attempt = db.prepare(`
2035
- SELECT status
2036
- FROM billing_checkout_attempts
2037
- WHERE tenant_id = 'default' AND session_id = 'cs_wait_for_active'
2038
- LIMIT 1
2039
- `).get();
2040
- expect(attempt).toEqual(expect.objectContaining({
2041
- status: 'open'
2042
- }));
2043
- });
2044
- it('does not mutate billing mirror or canonical entitlement when the purchased plan version is still unresolved', async () => {
2045
- const now = new Date().toISOString();
2046
- db.prepare(`
2047
- INSERT INTO billing_checkout_attempts (
2048
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
2049
- ) VALUES ('cs_plan_unresolved', 'default', 'u1', 'pro-v1', 'month', 'open', ?, ?, ?)
2050
- `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
2051
- db.prepare(`
2052
- INSERT INTO account_entitlements (
2053
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
2054
- ) VALUES ('u1', 'default', 'free-v1', 'active', ?, 'seed', ?, ?)
2055
- `).run(now, now, now);
2056
- db.prepare(`
2057
- INSERT INTO user_billing (
2058
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, tier, entitlement_state, created_at, updated_at
2059
- ) VALUES ('u1', 'default', 'cus_existing', 'sub_existing', 'free', 'active', ?, ?)
2060
- `).run(now, now);
2061
- mockCheckoutRetrieve.mockResolvedValueOnce({
2062
- id: 'cs_plan_unresolved',
2063
- mode: 'subscription',
2064
- status: 'complete',
2065
- payment_status: 'paid',
2066
- customer: 'cus_123',
2067
- subscription: 'sub_unmapped',
2068
- metadata: { userId: 'u1' }
2069
- });
2070
- mockSubscriptionRetrieve.mockResolvedValueOnce({
2071
- id: 'sub_unmapped',
2072
- customer: 'cus_123',
2073
- status: 'active',
2074
- trial_end: null,
2075
- metadata: { userId: 'u1' },
2076
- items: {
2077
- data: [{
2078
- id: 'si_unmapped',
2079
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
2080
- price: { id: 'price_unmapped', recurring: { interval: 'month' } }
2081
- }]
2082
- }
2083
- });
2084
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2085
- const req = createMockReq(JSON.stringify({
2086
- sessionId: 'cs_plan_unresolved'
2087
- }), {
2088
- 'x-taskforce-runtime-mode': 'cloud',
2089
- host: 'app.taskforcehq.ai'
2090
- });
2091
- const res = createMockRes();
2092
- await confirmHandler(req, res, {});
2093
- expect(res.statusCode).toBe(409);
2094
- expect(JSON.parse(res.body)).toEqual({
2095
- error: 'Checkout completed, but the purchased plan could not be resolved yet.',
2096
- code: 'CHECKOUT_PLAN_VERSION_UNRESOLVED'
2097
- });
2098
- const entitlement = db.prepare(`
2099
- SELECT plan_version_id, entitlement_state
2100
- FROM account_entitlements
2101
- WHERE tenant_id = 'default' AND account_id = 'u1'
2102
- LIMIT 1
2103
- `).get();
2104
- expect(entitlement).toEqual(expect.objectContaining({
2105
- plan_version_id: 'free-v1',
2106
- entitlement_state: 'active'
2107
- }));
2108
- const billingRow = db.prepare(`
2109
- SELECT stripe_customer_id, stripe_subscription_id, tier, entitlement_state
2110
- FROM user_billing
2111
- WHERE tenant_id = 'default' AND user_id = 'u1'
2112
- LIMIT 1
2113
- `).get();
2114
- expect(billingRow).toEqual(expect.objectContaining({
2115
- stripe_customer_id: 'cus_existing',
2116
- stripe_subscription_id: 'sub_existing',
2117
- tier: 'free',
2118
- entitlement_state: 'active'
2119
- }));
2120
- const attempt = db.prepare(`
2121
- SELECT status
2122
- FROM billing_checkout_attempts
2123
- WHERE tenant_id = 'default' AND session_id = 'cs_plan_unresolved'
2124
- LIMIT 1
2125
- `).get();
2126
- expect(attempt).toEqual(expect.objectContaining({
2127
- status: 'open'
2128
- }));
2129
- });
2130
- it('rejects checkout confirmation when the session belongs to a different account', async () => {
2131
- mockCheckoutRetrieve.mockResolvedValueOnce({
2132
- id: 'cs_wrong_account',
2133
- mode: 'subscription',
2134
- status: 'complete',
2135
- payment_status: 'paid',
2136
- customer: 'cus_other',
2137
- subscription: 'sub_other',
2138
- metadata: { userId: 'u2' }
2139
- });
2140
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2141
- const req = createMockReq(JSON.stringify({
2142
- sessionId: 'cs_wrong_account'
2143
- }), {
2144
- 'x-taskforce-runtime-mode': 'cloud',
2145
- host: 'app.taskforcehq.ai'
2146
- });
2147
- const res = createMockRes();
2148
- await confirmHandler(req, res, {});
2149
- expect(res.statusCode).toBe(403);
2150
- expect(JSON.parse(res.body)).toEqual({
2151
- error: 'Checkout session does not belong to the current account.',
2152
- code: 'CHECKOUT_SESSION_ACCOUNT_MISMATCH'
2153
- });
2154
- expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
2155
- });
2156
- it('rejects checkout confirmation when a newer pending checkout superseded the session plan', async () => {
2157
- const now = new Date().toISOString();
2158
- db.prepare(`
2159
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2160
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2161
- `).run(now, now);
2162
- db.prepare(`
2163
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2164
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2165
- `).run(now, now);
2166
- db.prepare(`
2167
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
2168
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
2169
- `).run(now, now);
2170
- db.prepare(`
2171
- INSERT INTO account_entitlements (
2172
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, effective_until, updated_by, created_at, updated_at
2173
- ) VALUES ('u1', 'default', 'team-v1', 'checkout_pending', ?, ?, 'checkout-start', ?, ?)
2174
- `).run(now, new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
2175
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2176
- const req = createMockReq(JSON.stringify({
2177
- sessionId: 'cs_stale_123'
2178
- }), {
2179
- 'x-taskforce-runtime-mode': 'cloud',
2180
- host: 'app.taskforcehq.ai'
2181
- });
2182
- const res = createMockRes();
2183
- await confirmHandler(req, res, {});
2184
- expect(res.statusCode).toBe(409);
2185
- expect(JSON.parse(res.body)).toEqual({
2186
- error: 'This checkout session is no longer current for this account.',
2187
- code: 'CHECKOUT_SESSION_SUPERSEDED'
2188
- });
2189
- const entitlement = db.prepare(`
2190
- SELECT plan_version_id, entitlement_state
2191
- FROM account_entitlements
2192
- WHERE tenant_id = 'default' AND account_id = 'u1'
2193
- LIMIT 1
2194
- `).get();
2195
- expect(entitlement).toEqual(expect.objectContaining({
2196
- plan_version_id: 'team-v1',
2197
- entitlement_state: 'checkout_pending'
2198
- }));
2199
- });
2200
- it('records the latest active-account checkout attempt and rejects confirmation for a superseded session', async () => {
2201
- const now = new Date().toISOString();
2202
- db.prepare(`
2203
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2204
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
2205
- `).run(now, now);
2206
- db.prepare(`
2207
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2208
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2209
- `).run(now, now);
2210
- db.prepare(`
2211
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2212
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2213
- `).run(now, now);
2214
- db.prepare(`
2215
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
2216
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
2217
- `).run(now, now);
2218
- db.prepare(`
2219
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
2220
- VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
2221
- `).run(now, now);
2222
- db.prepare(`
2223
- INSERT INTO user_billing (
2224
- user_id, tenant_id, tier, entitlement_state, created_at, updated_at
2225
- ) VALUES ('u1', 'default', 'starter', 'active', ?, ?)
2226
- `).run(now, now);
2227
- db.prepare(`
2228
- INSERT INTO account_entitlements (
2229
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
2230
- ) VALUES ('u1', 'default', 'starter-v1', 'active', ?, 'free-plan-selection', ?, ?)
2231
- `).run(now, now, now);
2232
- mockCheckoutCreate
2233
- .mockResolvedValueOnce({
2234
- id: 'cs_old',
2235
- url: 'https://example.test/checkout/old',
2236
- expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
2237
- })
2238
- .mockResolvedValueOnce({
2239
- id: 'cs_new',
2240
- url: 'https://example.test/checkout/new',
2241
- expires_at: Math.floor(Date.now() / 1000) + (24 * 60 * 60)
2242
- });
2243
- const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
2244
- const firstReq = createMockReq(JSON.stringify({
2245
- planVersionId: 'pro-v1',
2246
- interval: 'month'
2247
- }), {
2248
- 'x-taskforce-runtime-mode': 'cloud',
2249
- host: 'app.taskforcehq.ai'
2250
- });
2251
- const firstRes = createMockRes();
2252
- await checkoutHandler(firstReq, firstRes, {});
2253
- expect(firstRes.statusCode).toBe(200);
2254
- const secondReq = createMockReq(JSON.stringify({
2255
- planVersionId: 'team-v1',
2256
- interval: 'month'
2257
- }), {
2258
- 'x-taskforce-runtime-mode': 'cloud',
2259
- host: 'app.taskforcehq.ai'
2260
- });
2261
- const secondRes = createMockRes();
2262
- await checkoutHandler(secondReq, secondRes, {});
2263
- expect(secondRes.statusCode).toBe(200);
2264
- const attempts = db.prepare(`
2265
- SELECT session_id, plan_version_id, status
2266
- FROM billing_checkout_attempts
2267
- WHERE tenant_id = 'default' AND user_id = 'u1'
2268
- ORDER BY session_id ASC
2269
- `).all();
2270
- expect(attempts).toEqual([
2271
- { session_id: 'cs_new', plan_version_id: 'team-v1', status: 'open' },
2272
- { session_id: 'cs_old', plan_version_id: 'pro-v1', status: 'superseded' }
2273
- ]);
2274
- mockCheckoutRetrieve.mockResolvedValueOnce({
2275
- id: 'cs_old',
2276
- mode: 'subscription',
2277
- status: 'complete',
2278
- payment_status: 'paid',
2279
- customer: 'cus_123',
2280
- subscription: 'sub_old',
2281
- metadata: { userId: 'u1', planVersionId: 'pro-v1' }
2282
- });
2283
- mockSubscriptionRetrieve.mockResolvedValueOnce({
2284
- id: 'sub_old',
2285
- customer: 'cus_123',
2286
- status: 'active',
2287
- trial_end: null,
2288
- metadata: { userId: 'u1', planVersionId: 'pro-v1' },
2289
- items: {
2290
- data: [{
2291
- id: 'si_old',
2292
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
2293
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
2294
- }]
2295
- }
2296
- });
2297
- const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
2298
- const confirmReq = createMockReq(JSON.stringify({
2299
- sessionId: 'cs_old'
2300
- }), {
2301
- 'x-taskforce-runtime-mode': 'cloud',
2302
- host: 'app.taskforcehq.ai'
2303
- });
2304
- const confirmRes = createMockRes();
2305
- await confirmHandler(confirmReq, confirmRes, {});
2306
- expect(confirmRes.statusCode).toBe(409);
2307
- expect(JSON.parse(confirmRes.body)).toEqual({
2308
- error: 'This checkout session is no longer current for this account.',
2309
- code: 'CHECKOUT_SESSION_SUPERSEDED'
2310
- });
2311
- });
2312
- it('ignores subscription webhooks for a stale subscription after a newer checkout already won', async () => {
2313
- const now = new Date().toISOString();
2314
- db.prepare(`
2315
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2316
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2317
- `).run(now, now);
2318
- db.prepare(`
2319
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2320
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2321
- `).run(now, now);
2322
- db.prepare(`
2323
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
2324
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
2325
- `).run(now, now);
2326
- db.prepare(`
2327
- INSERT INTO user_billing (
2328
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, tier, entitlement_state, created_at, updated_at
2329
- ) VALUES ('u1', 'default', 'cus_123', 'sub_current', 'team', 'active', ?, ?)
2330
- `).run(now, now);
2331
- db.prepare(`
2332
- INSERT INTO account_entitlements (
2333
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, updated_by, created_at, updated_at
2334
- ) VALUES ('u1', 'default', 'team-v1', 'active', ?, 'checkout-confirm', ?, ?)
2335
- `).run(now, now, now);
2336
- mockConstructEvent.mockReturnValue({
2337
- id: 'evt_stale_subscription_after_confirm',
2338
- type: 'customer.subscription.updated',
2339
- data: {
2340
- object: {
2341
- id: 'sub_old',
2342
- customer: 'cus_123',
2343
- status: 'active',
2344
- trial_end: null,
2345
- metadata: { planVersionId: 'pro-v1' },
2346
- items: {
2347
- data: [{
2348
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
2349
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
2350
- }]
2351
- }
2352
- }
2353
- }
2354
- });
2355
- const req = createMockReq('{"id":"evt_stale_subscription_after_confirm"}', {
2356
- 'x-taskforce-runtime-mode': 'cloud',
2357
- 'stripe-signature': 'sig'
2358
- });
2359
- const res = createMockRes();
2360
- await webhookHandler(req, res, {});
2361
- expect(res.statusCode).toBe(200);
2362
- const entitlement = db.prepare(`
2363
- SELECT plan_version_id, entitlement_state
2364
- FROM account_entitlements
2365
- WHERE account_id = 'u1' AND tenant_id = 'default'
2366
- LIMIT 1
2367
- `).get();
2368
- expect(entitlement).toEqual(expect.objectContaining({
2369
- plan_version_id: 'team-v1',
2370
- entitlement_state: 'active'
2371
- }));
2372
- const failure = db.prepare(`
2373
- SELECT reason
2374
- FROM stripe_webhook_failures
2375
- WHERE event_id = 'evt_stale_subscription_after_confirm'
2376
- ORDER BY created_at DESC
2377
- LIMIT 1
2378
- `).get();
2379
- expect(String(failure?.reason || '')).toContain('no longer current for this account');
2380
- });
2381
- it('lists webhook failure diagnostics for system admin', async () => {
2382
- const now = new Date().toISOString();
2383
- const old = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
2384
- db.prepare(`
2385
- INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
2386
- VALUES ('whf_keep_1', 'default', 'evt_1', 'invoice.paid', 'diagnostic-1', '{"id":"evt_1"}', ?, ?)
2387
- `).run(new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), now);
2388
- db.prepare(`
2389
- INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
2390
- VALUES ('whf_purge_1', 'default', 'evt_old', 'invoice.paid', 'diagnostic-old', '{"id":"evt_old"}', ?, ?)
2391
- `).run(old, now);
2392
- const route = routeHandlers.get('GET /api/taskforce/admin/billing/webhook-failures');
2393
- expect(route).toBeTypeOf('function');
2394
- const req = createMockReq('', {
2395
- 'x-taskforce-runtime-mode': 'cloud'
2396
- });
2397
- req.url = '/api/taskforce/admin/billing/webhook-failures?limit=10';
2398
- const res = createMockRes();
2399
- await route(req, res, {});
2400
- expect(res.statusCode).toBe(200);
2401
- const payload = JSON.parse(res.body);
2402
- expect(payload.purgedExpiredRows).toBeGreaterThanOrEqual(1);
2403
- expect(payload.total).toBeGreaterThanOrEqual(1);
2404
- expect(Array.isArray(payload.rows)).toBe(true);
2405
- expect(payload.rows[0]).toEqual(expect.objectContaining({
2406
- event_id: 'evt_1',
2407
- reason: 'diagnostic-1'
2408
- }));
2409
- });
2410
- it('lists checkout attempt diagnostics for system admin and purges old terminal rows', async () => {
2411
- const now = new Date().toISOString();
2412
- const stale = new Date(Date.now() - 20 * 24 * 60 * 60 * 1000).toISOString();
2413
- db.prepare(`
2414
- INSERT INTO billing_checkout_attempts (
2415
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
2416
- ) VALUES ('cs_keep_open', 'default', 'u1', 'team-v1', 'month', 'open', ?, ?, ?)
2417
- `).run(new Date(Date.now() + 60 * 60 * 1000).toISOString(), now, now);
2418
- db.prepare(`
2419
- INSERT INTO billing_checkout_attempts (
2420
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
2421
- ) VALUES ('cs_purge_old', 'default', 'u1', 'pro-v1', 'month', 'superseded', ?, ?, ?)
2422
- `).run(stale, stale, stale);
2423
- db.prepare(`
2424
- INSERT INTO billing_checkout_attempts (
2425
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
2426
- ) VALUES ('cs_expire_now', 'default', 'u2', 'pro-v1', 'month', 'open', ?, ?, ?)
2427
- `).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
2428
- const route = routeHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
2429
- expect(route).toBeTypeOf('function');
2430
- const req = createMockReq('', {
2431
- 'x-taskforce-runtime-mode': 'cloud'
2432
- });
2433
- req.url = '/api/taskforce/admin/billing/checkout-attempts?limit=10';
2434
- const res = createMockRes();
2435
- await route(req, res, {});
2436
- expect(res.statusCode).toBe(200);
2437
- const payload = JSON.parse(res.body);
2438
- expect(payload.purgedTerminalRows).toBeGreaterThanOrEqual(1);
2439
- expect(payload.expiredRowsNormalized).toBeGreaterThanOrEqual(1);
2440
- expect(payload.openCount).toBe(1);
2441
- expect(payload.rows).toEqual(expect.arrayContaining([
2442
- expect.objectContaining({ session_id: 'cs_keep_open', status: 'open' }),
2443
- expect.objectContaining({ session_id: 'cs_expire_now', status: 'expired' })
2444
- ]));
2445
- const purged = db.prepare(`
2446
- SELECT session_id
2447
- FROM billing_checkout_attempts
2448
- WHERE session_id = 'cs_purge_old'
2449
- LIMIT 1
2450
- `).get();
2451
- expect(purged).toBeUndefined();
2452
- });
2453
- it('lists active Stripe price options for the requested interval', async () => {
2454
- mockPricesList.mockResolvedValueOnce({
2455
- data: [
2456
- {
2457
- id: 'price_month_1',
2458
- active: true,
2459
- currency: 'usd',
2460
- unit_amount: 1900,
2461
- nickname: 'Monthly Core',
2462
- recurring: { interval: 'month' },
2463
- product: { id: 'prod_core', name: 'Core' }
2464
- },
2465
- {
2466
- id: 'price_year_1',
2467
- active: true,
2468
- currency: 'usd',
2469
- unit_amount: 19000,
2470
- nickname: 'Yearly Core',
2471
- recurring: { interval: 'year' },
2472
- product: { id: 'prod_core', name: 'Core' }
2473
- }
2474
- ],
2475
- has_more: false
2476
- });
2477
- const route = routeHandlers.get('GET /api/taskforce/admin/billing/stripe-price-options');
2478
- expect(route).toBeTypeOf('function');
2479
- const req = createMockReq('', {
2480
- 'x-taskforce-runtime-mode': 'cloud'
2481
- });
2482
- req.url = '/api/taskforce/admin/billing/stripe-price-options?interval=month';
2483
- const res = createMockRes();
2484
- await route(req, res, {});
2485
- expect(res.statusCode).toBe(200);
2486
- const body = JSON.parse(res.body);
2487
- expect(body.options).toEqual([
2488
- expect.objectContaining({
2489
- stripePriceId: 'price_month_1',
2490
- interval: 'month',
2491
- unitAmount: 1900,
2492
- currency: 'usd',
2493
- nickname: 'Monthly Core',
2494
- productId: 'prod_core',
2495
- productName: 'Core'
2496
- })
2497
- ]);
2498
- });
2499
- it('keeps active imported price mappings during Stripe import when the Stripe price is still active but lacks plan metadata', async () => {
2500
- const now = new Date().toISOString();
2501
- db.prepare(`
2502
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2503
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2504
- `).run(now, now);
2505
- db.prepare(`
2506
- 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)
2507
- VALUES ('default', 'collab-v1', 'month', 'price_manual', 'stripe', 1, 1900, 'usd', ?, ?)
2508
- `).run(now, now);
2509
- mockPricesList.mockResolvedValueOnce({
2510
- data: [
2511
- {
2512
- id: 'price_manual',
2513
- active: true,
2514
- currency: 'usd',
2515
- unit_amount: 1900,
2516
- recurring: { interval: 'month' },
2517
- metadata: {}
2518
- }
2519
- ],
2520
- has_more: false
2521
- });
2522
- const route = routeHandlers.get('POST /api/taskforce/admin/billing/prices/import');
2523
- expect(route).toBeTypeOf('function');
2524
- const req = createMockReq('', {
2525
- 'x-taskforce-runtime-mode': 'cloud'
2526
- });
2527
- const res = createMockRes();
2528
- await route(req, res, {});
2529
- expect(res.statusCode).toBe(200);
2530
- const row = db.prepare(`
2531
- SELECT active
2532
- FROM billing_price_mappings
2533
- WHERE tenant_id = 'default' AND stripe_price_id = 'price_manual'
2534
- LIMIT 1
2535
- `).get();
2536
- expect(row.active).toBe(1);
2537
- });
2538
- it('upserts manual Stripe price mappings and deactivates the prior active mapping', async () => {
2539
- const now = new Date().toISOString();
2540
- db.prepare(`
2541
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2542
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2543
- `).run(now, now);
2544
- db.prepare(`
2545
- 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)
2546
- VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1500, 'usd', ?, ?)
2547
- `).run(now, now);
2548
- mockPriceRetrieve.mockResolvedValueOnce({
2549
- id: 'price_new',
2550
- active: true,
2551
- currency: 'usd',
2552
- unit_amount: 2400,
2553
- recurring: { interval: 'month' }
2554
- });
2555
- const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2556
- expect(upsertRoute).toBeTypeOf('function');
2557
- const upsertReq = createMockReq(JSON.stringify({
2558
- planVersionId: 'collab-v1',
2559
- interval: 'month',
2560
- stripePriceId: 'price_new',
2561
- active: true
2562
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2563
- const upsertRes = createMockRes();
2564
- await upsertRoute(upsertReq, upsertRes, {});
2565
- expect(upsertRes.statusCode).toBe(200);
2566
- const body = JSON.parse(upsertRes.body);
2567
- expect(body.mapping).toEqual(expect.objectContaining({
2568
- plan_version_id: 'collab-v1',
2569
- billing_interval: 'month',
2570
- stripe_price_id: 'price_new',
2571
- pricing_type: 'stripe',
2572
- active: 1,
2573
- unit_amount: 2400,
2574
- currency: 'usd'
2575
- }));
2576
- const rows = db.prepare(`
2577
- SELECT stripe_price_id, active, unit_amount
2578
- FROM billing_price_mappings
2579
- WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
2580
- ORDER BY stripe_price_id ASC
2581
- `).all();
2582
- expect(rows).toEqual([
2583
- { stripe_price_id: 'price_new', active: 1, unit_amount: 2400 },
2584
- { stripe_price_id: 'price_old', active: 0, unit_amount: 1500 }
2585
- ]);
2586
- });
2587
- it('keeps campaign price mappings separate from public mappings in admin routes', async () => {
2588
- const now = new Date().toISOString();
2589
- db.prepare(`
2590
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2591
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2592
- `).run(now, now);
2593
- db.prepare(`
2594
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2595
- VALUES ('default', 'collab-v1', 'month', 'public', 'price_public', 'stripe', 1, 1500, 'usd', ?, ?)
2596
- `).run(now, now);
2597
- mockPriceRetrieve.mockResolvedValueOnce({
2598
- id: 'price_campaign',
2599
- active: true,
2600
- currency: 'usd',
2601
- unit_amount: 900,
2602
- recurring: { interval: 'month' }
2603
- });
2604
- const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2605
- const upsertReq = createMockReq(JSON.stringify({
2606
- planVersionId: 'collab-v1',
2607
- interval: 'month',
2608
- pricingAudience: 'campaign',
2609
- stripePriceId: 'price_campaign',
2610
- active: true
2611
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2612
- const upsertRes = createMockRes();
2613
- await upsertRoute(upsertReq, upsertRes, {});
2614
- expect(upsertRes.statusCode).toBe(200);
2615
- expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2616
- mapping: expect.objectContaining({
2617
- plan_version_id: 'collab-v1',
2618
- billing_interval: 'month',
2619
- pricing_audience: 'campaign',
2620
- stripe_price_id: 'price_campaign',
2621
- active: 1,
2622
- unit_amount: 900
2623
- })
2624
- }));
2625
- const rows = db.prepare(`
2626
- SELECT pricing_audience, stripe_price_id, active
2627
- FROM billing_price_mappings
2628
- WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
2629
- ORDER BY pricing_audience ASC
2630
- `).all();
2631
- expect(rows).toEqual([
2632
- { pricing_audience: 'campaign', stripe_price_id: 'price_campaign', active: 1 },
2633
- { pricing_audience: 'public', stripe_price_id: 'price_public', active: 1 }
2634
- ]);
2635
- });
2636
- it('rejects manual reuse of a Stripe price id across multiple plan versions', async () => {
2637
- const now = new Date().toISOString();
2638
- db.prepare(`
2639
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2640
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2641
- `).run(now, now);
2642
- db.prepare(`
2643
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2644
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2645
- `).run(now, now);
2646
- const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2647
- const firstReq = createMockReq(JSON.stringify({
2648
- planVersionId: 'collab-v1',
2649
- interval: 'month',
2650
- stripePriceId: 'price_shared',
2651
- active: true
2652
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2653
- const firstRes = createMockRes();
2654
- await upsertRoute(firstReq, firstRes, {});
2655
- expect(firstRes.statusCode).toBe(200);
2656
- const secondReq = createMockReq(JSON.stringify({
2657
- planVersionId: 'team-v1',
2658
- interval: 'month',
2659
- stripePriceId: 'price_shared',
2660
- active: true
2661
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2662
- const secondRes = createMockRes();
2663
- await upsertRoute(secondReq, secondRes, {});
2664
- expect(secondRes.statusCode).toBe(409);
2665
- expect(JSON.parse(secondRes.body)).toEqual(expect.objectContaining({
2666
- code: 'BILLING_PRICE_ALREADY_ASSIGNED'
2667
- }));
2668
- });
2669
- it('keeps reused active mappings during Stripe import when the shared Stripe price is still active', async () => {
2670
- const now = new Date().toISOString();
2671
- db.prepare(`
2672
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2673
- VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
2674
- `).run(now, now);
2675
- db.prepare(`
2676
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2677
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
2678
- `).run(now, now);
2679
- db.prepare(`
2680
- 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)
2681
- VALUES ('default', 'collab-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
2682
- `).run(now, now);
2683
- db.prepare(`
2684
- 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)
2685
- VALUES ('default', 'team-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
2686
- `).run(now, now);
2687
- mockPricesList.mockResolvedValueOnce({
2688
- data: [
2689
- {
2690
- id: 'price_shared',
2691
- active: true,
2692
- currency: 'usd',
2693
- unit_amount: 1900,
2694
- recurring: { interval: 'month' },
2695
- metadata: { taskforce_plan_version_id: 'collab-v1' }
2696
- }
2697
- ],
2698
- has_more: false
2699
- });
2700
- const route = routeHandlers.get('POST /api/taskforce/admin/billing/prices/import');
2701
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2702
- const res = createMockRes();
2703
- await route(req, res, {});
2704
- expect(res.statusCode).toBe(200);
2705
- const rows = db.prepare(`
2706
- SELECT plan_version_id, stripe_price_id, active
2707
- FROM billing_price_mappings
2708
- WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
2709
- ORDER BY plan_version_id ASC
2710
- `).all();
2711
- expect(rows).toEqual([
2712
- { plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
2713
- { plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
2714
- ]);
2715
- });
2716
- it('supports manual free price mappings and deactivation', async () => {
2717
- const now = new Date().toISOString();
2718
- db.prepare(`
2719
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2720
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
2721
- `).run(now, now);
2722
- db.prepare(`
2723
- 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)
2724
- VALUES ('default', 'starter-v1', 'month', 'price_old', 'stripe', 1, 1500, 'usd', ?, ?)
2725
- `).run(now, now);
2726
- const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
2727
- expect(upsertRoute).toBeTypeOf('function');
2728
- const upsertReq = createMockReq(JSON.stringify({
2729
- planVersionId: 'starter-v1',
2730
- interval: 'month',
2731
- pricingType: 'free',
2732
- active: true
2733
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2734
- const upsertRes = createMockRes();
2735
- await upsertRoute(upsertReq, upsertRes, {});
2736
- expect(upsertRes.statusCode).toBe(200);
2737
- expect(JSON.parse(upsertRes.body)).toEqual(expect.objectContaining({
2738
- mapping: expect.objectContaining({
2739
- plan_version_id: 'starter-v1',
2740
- billing_interval: 'month',
2741
- stripe_price_id: 'free:starter-v1:month',
2742
- pricing_type: 'free',
2743
- active: 1,
2744
- unit_amount: 0,
2745
- currency: null
2746
- })
2747
- }));
2748
- const deactivateRoute = routeHandlers.get('POST /api/taskforce/admin/billing/price-mappings/deactivate');
2749
- expect(deactivateRoute).toBeTypeOf('function');
2750
- const deactivateReq = createMockReq(JSON.stringify({
2751
- planVersionId: 'starter-v1',
2752
- interval: 'month'
2753
- }), { 'x-taskforce-runtime-mode': 'cloud' });
2754
- const deactivateRes = createMockRes();
2755
- await deactivateRoute(deactivateReq, deactivateRes, {});
2756
- expect(deactivateRes.statusCode).toBe(200);
2757
- expect(JSON.parse(deactivateRes.body)).toEqual(expect.objectContaining({
2758
- success: true,
2759
- changedRows: 2
2760
- }));
2761
- const rows = db.prepare(`
2762
- SELECT stripe_price_id, pricing_type, active
2763
- FROM billing_price_mappings
2764
- WHERE tenant_id = 'default' AND plan_version_id = 'starter-v1' AND billing_interval = 'month'
2765
- ORDER BY stripe_price_id ASC
2766
- `).all();
2767
- expect(rows).toEqual([
2768
- { stripe_price_id: 'free:starter-v1:month', pricing_type: 'free', active: 0 },
2769
- { stripe_price_id: 'price_old', pricing_type: 'stripe', active: 0 }
2770
- ]);
2771
- });
2772
- it('rejects webhook failure diagnostics for non-admin', async () => {
2773
- const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
2774
- const route = nonAdminHandlers.get('GET /api/taskforce/admin/billing/webhook-failures');
2775
- expect(route).toBeTypeOf('function');
2776
- const req = createMockReq('', {
2777
- 'x-taskforce-runtime-mode': 'cloud'
2778
- });
2779
- req.url = '/api/taskforce/admin/billing/webhook-failures';
2780
- const res = createMockRes();
2781
- await route(req, res, {});
2782
- expect(res.statusCode).toBe(403);
2783
- });
2784
- it('rejects checkout attempt diagnostics for non-admin', async () => {
2785
- const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
2786
- const route = nonAdminHandlers.get('GET /api/taskforce/admin/billing/checkout-attempts');
2787
- expect(route).toBeTypeOf('function');
2788
- const req = createMockReq('', {
2789
- 'x-taskforce-runtime-mode': 'cloud'
2790
- });
2791
- req.url = '/api/taskforce/admin/billing/checkout-attempts';
2792
- const res = createMockRes();
2793
- await route(req, res, {});
2794
- expect(res.statusCode).toBe(403);
2795
- });
2796
- it('returns public pricing catalog with cached Stripe amounts without auth', async () => {
2797
- const now = new Date().toISOString();
2798
- routeHandlers = buildBillingRouteHandlers(db);
2799
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2800
- expect(pricingRoute).toBeTypeOf('function');
2801
- db.prepare(`
2802
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2803
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2804
- `).run(now, now);
2805
- db.prepare(`
2806
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2807
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
2808
- `).run(now, now);
2809
- db.prepare(`
2810
- 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)
2811
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1500, 'usd', ?, ?)
2812
- `).run(now, now);
2813
- db.prepare(`
2814
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, pricing_audience, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at)
2815
- VALUES ('default', 'pro-v1', 'month', 'campaign', 'price_pro_campaign_month', 'stripe', 1, 900, 'usd', ?, ?)
2816
- `).run(now, now);
2817
- db.prepare(`
2818
- 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)
2819
- VALUES ('default', 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, NULL, ?, ?)
2820
- `).run(now, now);
2821
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2822
- const res = createMockRes();
2823
- await pricingRoute(req, res, {});
2824
- expect(res.statusCode).toBe(200);
2825
- const body = JSON.parse(res.body);
2826
- expect(body.plans).toBeInstanceOf(Array);
2827
- expect(body.pageTitle).toBe('Subscription Plans');
2828
- expect(body.pageDescription).toBe('Manage your subscription and explore available features. Your current plan is highlighted below.');
2829
- expect(body.pricingSource).toEqual({
2830
- environment: 'production',
2831
- runtimeMode: 'cloud'
2832
- });
2833
- // Only active plans with versions should be returned (pro, starter)
2834
- // inactive-plan (archived) and empty-plan (no versions) are excluded.
2835
- expect(body.plans.length).toBe(2);
2836
- const proPlan = body.plans.find((p) => p.planId === 'pro');
2837
- const freePlan = body.plans.find((p) => p.planId === 'starter');
2838
- expect(proPlan).toBeDefined();
2839
- expect(freePlan).toBeDefined();
2840
- // Pro plan has mapped prices
2841
- const monthPricing = proPlan.pricing.month;
2842
- expect(monthPricing).not.toBeNull();
2843
- expect(monthPricing.unitAmount).toBe(1500);
2844
- expect(monthPricing.currency).toBe('usd');
2845
- expect(monthPricing.stripePriceId).toBe('price_pro_month');
2846
- expect(proPlan.pricing.year).toBeNull();
2847
- expect(proPlan.campaignPricing.month).toEqual(expect.objectContaining({
2848
- pricingType: 'stripe',
2849
- unitAmount: 900,
2850
- currency: 'usd',
2851
- stripePriceId: 'price_pro_campaign_month',
2852
- interval: 'month'
2853
- }));
2854
- expect(proPlan.campaignPricing.year).toBeNull();
2855
- // Free plan is explicitly free, not "missing"
2856
- expect(freePlan.pricing.month.pricingType).toBe('free');
2857
- expect(freePlan.pricing.month.unitAmount).toBe(0);
2858
- expect(freePlan.pricing.month.stripePriceId).toBeNull();
2859
- expect(freePlan.pricing.year).toBeNull();
2860
- expect(proPlan.defaultVersion.trialDays).toBe(0);
2861
- });
2862
- it('invalidates cached public pricing when billing mappings change', async () => {
2863
- const now = new Date().toISOString();
2864
- routeHandlers = buildBillingRouteHandlers(db);
2865
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2866
- expect(pricingRoute).toBeTypeOf('function');
2867
- db.prepare(`
2868
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2869
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2870
- `).run(now, now);
2871
- db.prepare(`
2872
- INSERT INTO billing_price_mappings (
2873
- tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2874
- ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2875
- `).run(now, now);
2876
- const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2877
- const firstRes = createMockRes();
2878
- await pricingRoute(firstReq, firstRes, {});
2879
- expect(firstRes.statusCode).toBe(200);
2880
- const firstBody = JSON.parse(firstRes.body);
2881
- expect(firstBody.plans.find((p) => p.planId === 'pro')?.pricing?.month?.unitAmount).toBe(1200);
2882
- const updatedAt = new Date(Date.now() + 1000).toISOString();
2883
- db.prepare(`
2884
- UPDATE billing_price_mappings
2885
- SET unit_amount = 2400, updated_at = ?
2886
- WHERE tenant_id = 'default' AND plan_version_id = 'pro-v1' AND billing_interval = 'month'
2887
- `).run(updatedAt);
2888
- const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2889
- const secondRes = createMockRes();
2890
- await pricingRoute(secondReq, secondRes, {});
2891
- expect(secondRes.statusCode).toBe(200);
2892
- const secondBody = JSON.parse(secondRes.body);
2893
- expect(secondBody.plans.find((p) => p.planId === 'pro')?.pricing?.month?.unitAmount).toBe(2400);
2894
- });
2895
- it('invalidates cached public pricing when pricing page header copy changes', async () => {
2896
- const now = new Date().toISOString();
2897
- const siteSettings = {};
2898
- routeHandlers = buildBillingRouteHandlers(db, { siteSettings });
2899
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2900
- expect(pricingRoute).toBeTypeOf('function');
2901
- db.prepare(`
2902
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2903
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2904
- `).run(now, now);
2905
- db.prepare(`
2906
- INSERT INTO billing_price_mappings (
2907
- tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2908
- ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2909
- `).run(now, now);
2910
- const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2911
- const firstRes = createMockRes();
2912
- await pricingRoute(firstReq, firstRes, {});
2913
- expect(firstRes.statusCode).toBe(200);
2914
- expect(JSON.parse(firstRes.body)).toMatchObject({
2915
- pageTitle: 'Subscription Plans',
2916
- pageDescription: 'Manage your subscription and explore available features. Your current plan is highlighted below.'
2917
- });
2918
- siteSettings.pricingPageTitle = 'Choose Your Plan';
2919
- siteSettings.pricingPageDescription = 'Compare plans and pick the right fit for your team.';
2920
- const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2921
- const secondRes = createMockRes();
2922
- await pricingRoute(secondReq, secondRes, {});
2923
- expect(secondRes.statusCode).toBe(200);
2924
- expect(JSON.parse(secondRes.body)).toMatchObject({
2925
- pageTitle: 'Choose Your Plan',
2926
- pageDescription: 'Compare plans and pick the right fit for your team.'
2927
- });
2928
- });
2929
- it('invalidates cached public pricing when plan features change', async () => {
2930
- const now = new Date().toISOString();
2931
- routeHandlers = buildBillingRouteHandlers(db);
2932
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2933
- expect(pricingRoute).toBeTypeOf('function');
2934
- db.exec(`
2935
- CREATE TABLE IF NOT EXISTS plan_feature_matrix (
2936
- tenant_id TEXT NOT NULL,
2937
- plan_version_id TEXT NOT NULL,
2938
- feature_key TEXT NOT NULL,
2939
- access TEXT NOT NULL,
2940
- config_json TEXT,
2941
- created_at TEXT NOT NULL,
2942
- updated_at TEXT NOT NULL,
2943
- PRIMARY KEY (tenant_id, plan_version_id, feature_key)
2944
- )
2945
- `);
2946
- db.prepare(`
2947
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2948
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2949
- `).run(now, now);
2950
- db.prepare(`
2951
- INSERT INTO billing_price_mappings (
2952
- tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2953
- ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2954
- `).run(now, now);
2955
- db.prepare(`
2956
- INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
2957
- VALUES ('default', 'pro-v1', 'ai.chat', 'enabled', '{}', ?, ?)
2958
- `).run(now, now);
2959
- const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2960
- const firstRes = createMockRes();
2961
- await pricingRoute(firstReq, firstRes, {});
2962
- expect(firstRes.statusCode).toBe(200);
2963
- const firstBody = JSON.parse(firstRes.body);
2964
- expect(firstBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
2965
- expect.objectContaining({ featureKey: 'ai.chat' })
2966
- ]);
2967
- const updatedAt = new Date(Date.now() + 1000).toISOString();
2968
- db.prepare(`
2969
- UPDATE plan_feature_matrix
2970
- SET access = 'disabled', updated_at = ?
2971
- WHERE tenant_id = 'default' AND plan_version_id = 'pro-v1' AND feature_key = 'ai.chat'
2972
- `).run(updatedAt);
2973
- const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
2974
- const secondRes = createMockRes();
2975
- await pricingRoute(secondReq, secondRes, {});
2976
- expect(secondRes.statusCode).toBe(200);
2977
- const secondBody = JSON.parse(secondRes.body);
2978
- expect(secondBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([]);
2979
- });
2980
- it('includes public feature copy and invalidates cached public pricing when catalog overrides change', async () => {
2981
- const now = new Date().toISOString();
2982
- const siteSettings = {};
2983
- routeHandlers = buildBillingRouteHandlers(db, { siteSettings });
2984
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
2985
- expect(pricingRoute).toBeTypeOf('function');
2986
- db.prepare(`
2987
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
2988
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
2989
- `).run(now, now);
2990
- db.prepare(`
2991
- INSERT INTO billing_price_mappings (
2992
- tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
2993
- ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1200, 'usd', ?, ?)
2994
- `).run(now, now);
2995
- db.prepare(`
2996
- INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
2997
- VALUES ('default', 'pro-v1', 'collaboration.ai_profiles', 'enabled', '{}', ?, ?)
2998
- `).run(now, now);
2999
- db.prepare(`
3000
- INSERT INTO plan_feature_catalog_overrides (tenant_id, feature_key, public_label, public_description, created_at, updated_at)
3001
- VALUES ('default', 'collaboration.ai_profiles', 'AI teammates', 'Add reusable AI teammates to your workspace.', ?, ?)
3002
- `).run(now, now);
3003
- const firstReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3004
- const firstRes = createMockRes();
3005
- await pricingRoute(firstReq, firstRes, {});
3006
- expect(firstRes.statusCode).toBe(200);
3007
- const firstBody = JSON.parse(firstRes.body);
3008
- expect(firstBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
3009
- expect.objectContaining({
3010
- featureKey: 'collaboration.ai_profiles',
3011
- label: 'AI Profiles',
3012
- publicLabel: 'AI teammates',
3013
- publicDescription: 'Add reusable AI teammates to your workspace.',
3014
- publicDescriptionVisible: false
3015
- })
3016
- ]);
3017
- siteSettings.pricingPageShowPublicDescriptions = true;
3018
- const secondReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3019
- const secondRes = createMockRes();
3020
- await pricingRoute(secondReq, secondRes, {});
3021
- expect(secondRes.statusCode).toBe(200);
3022
- const secondBody = JSON.parse(secondRes.body);
3023
- expect(secondBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
3024
- expect.objectContaining({
3025
- featureKey: 'collaboration.ai_profiles',
3026
- publicDescriptionVisible: true
3027
- })
3028
- ]);
3029
- const updatedAt = new Date(Date.now() + 1000).toISOString();
3030
- db.prepare(`
3031
- UPDATE plan_feature_catalog_overrides
3032
- SET public_label = 'AI copilots', updated_at = ?
3033
- WHERE tenant_id = 'default' AND feature_key = 'collaboration.ai_profiles'
3034
- `).run(updatedAt);
3035
- const thirdReq = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3036
- const thirdRes = createMockRes();
3037
- await pricingRoute(thirdReq, thirdRes, {});
3038
- expect(thirdRes.statusCode).toBe(200);
3039
- const thirdBody = JSON.parse(thirdRes.body);
3040
- expect(thirdBody.plans.find((p) => p.planId === 'pro')?.features).toEqual([
3041
- expect.objectContaining({
3042
- featureKey: 'collaboration.ai_profiles',
3043
- publicLabel: 'AI copilots',
3044
- publicDescriptionVisible: true
3045
- })
3046
- ]);
3047
- });
3048
- it('orders public pricing features by catalog display order', async () => {
3049
- const now = new Date().toISOString();
3050
- routeHandlers = buildBillingRouteHandlers(db);
3051
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
3052
- expect(pricingRoute).toBeTypeOf('function');
3053
- db.prepare(`
3054
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, seat_limit, metadata_json, created_at, updated_at)
3055
- VALUES ('pro-v1', 'default', 'pro', 1, 1, 5, '{}', ?, ?)
3056
- `).run(now, now);
3057
- db.prepare(`
3058
- INSERT INTO billing_price_mappings (
3059
- tenant_id, plan_version_id, billing_interval, stripe_price_id, pricing_type, active, unit_amount, currency, created_at, updated_at
3060
- ) VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1800, 'usd', ?, ?)
3061
- `).run(now, now);
3062
- for (const feature of [
3063
- { key: 'collaboration.team_management', access: 'enabled', config: '{}' },
3064
- { key: 'collaboration.invites', access: 'enabled', config: '{}' },
3065
- { key: 'collaboration.seat_limit', access: 'limited', config: '{"seat_limit":5}' },
3066
- { key: 'context.uploads', access: 'enabled', config: '{"storageLimitMb":24}' },
3067
- { key: 'workspace.workspaces', access: 'enabled', config: '{"maxWorkspaces":1}' },
3068
- { key: 'collaboration.ai_profiles', access: 'limited', config: '{"maxAiProfiles":4}' }
3069
- ]) {
3070
- db.prepare(`
3071
- INSERT INTO plan_feature_matrix (tenant_id, plan_version_id, feature_key, access, config_json, created_at, updated_at)
3072
- VALUES ('default', 'pro-v1', ?, ?, ?, ?, ?)
3073
- `).run(feature.key, feature.access, feature.config, now, now);
3074
- }
3075
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3076
- const res = createMockRes();
3077
- await pricingRoute(req, res, {});
3078
- expect(res.statusCode).toBe(200);
3079
- const body = JSON.parse(res.body);
3080
- const orderedPlan = body.plans.find((plan) => plan.planId === 'pro');
3081
- expect(orderedPlan.features.map((feature) => feature.featureKey)).toEqual([
3082
- 'workspace.workspaces',
3083
- 'collaboration.ai_profiles',
3084
- 'context.uploads',
3085
- 'collaboration.team_management'
3086
- ]);
3087
- expect(orderedPlan.features.map((feature) => feature.featureKey)).not.toContain('collaboration.invites');
3088
- expect(orderedPlan.features.map((feature) => feature.featureKey)).not.toContain('collaboration.seat_limit');
3089
- });
3090
- it('returns the pricing environment derived from the cloud host', async () => {
3091
- routeHandlers = buildBillingRouteHandlers(db);
3092
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
3093
- expect(pricingRoute).toBeTypeOf('function');
3094
- const req = createMockReq('', {
3095
- 'x-taskforce-runtime-mode': 'cloud',
3096
- host: 'performance-app.taskforcehq.ai'
3097
- });
3098
- const res = createMockRes();
3099
- await pricingRoute(req, res, {});
3100
- expect(res.statusCode).toBe(200);
3101
- const body = JSON.parse(res.body);
3102
- expect(body.pageTitle).toBe('Subscription Plans');
3103
- expect(body.pricingSource).toEqual({
3104
- environment: 'performance',
3105
- runtimeMode: 'cloud'
3106
- });
3107
- });
3108
- it('rejects local runtime pricing requests so they cannot serve local catalog data', async () => {
3109
- routeHandlers = buildBillingRouteHandlers(db);
3110
- const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
3111
- expect(pricingRoute).toBeTypeOf('function');
3112
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'local' });
3113
- const res = createMockRes();
3114
- await pricingRoute(req, res, {});
3115
- expect(res.statusCode).toBe(500);
3116
- expect(JSON.parse(res.body)).toEqual({
3117
- error: 'Pricing must be served through the configured cloud pricing proxy in local runtime.'
3118
- });
3119
- });
3120
- it('returns plan display name in billing status payload', async () => {
3121
- const now = new Date().toISOString();
3122
- db.prepare(`
3123
- INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
3124
- VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
3125
- `).run(now, now);
3126
- db.prepare(`
3127
- INSERT INTO user_billing (
3128
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
3129
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_123', 'active', 'pro', 'month', 'active', ?, ?)
3130
- `).run(now, now);
3131
- const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
3132
- expect(statusRoute).toBeTypeOf('function');
3133
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3134
- const res = createMockRes();
3135
- await statusRoute(req, res, {});
3136
- expect(res.statusCode).toBe(200);
3137
- const body = JSON.parse(res.body);
3138
- expect(body.planId).toBe('pro');
3139
- expect(body.planName).toBe('Pro Plan');
3140
- expect(body.planVersionId).toBe('pro-v1');
3141
- expect(body.entitlementState).toBe('active');
3142
- });
3143
- it('prefers canonical checkout_pending state over stale billing mirror state in billing status payload', async () => {
3144
- routeHandlers = buildBillingRouteHandlers(db, {
3145
- accessStatus: {
3146
- gate: 'checkout_pending',
3147
- hasEntitlement: true,
3148
- canAccessApp: false,
3149
- canAccessPlans: true,
3150
- planSelectionRequired: false,
3151
- message: 'Complete checkout to continue.',
3152
- entitlement: {
3153
- accountId: 'u1',
3154
- planVersionId: 'pro-v1',
3155
- planId: 'pro',
3156
- state: 'checkout_pending',
3157
- effectiveAt: null,
3158
- effectiveUntil: null
3159
- }
3160
- }
3161
- });
3162
- const now = new Date().toISOString();
3163
- db.prepare(`
3164
- INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
3165
- VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
3166
- `).run(now, now);
3167
- db.prepare(`
3168
- INSERT INTO user_billing (
3169
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
3170
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'starter', 'month', 'active', ?, ?)
3171
- `).run(now, now);
3172
- const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
3173
- expect(statusRoute).toBeTypeOf('function');
3174
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3175
- const res = createMockRes();
3176
- await statusRoute(req, res, {});
3177
- expect(res.statusCode).toBe(200);
3178
- const body = JSON.parse(res.body);
3179
- expect(body).toEqual(expect.objectContaining({
3180
- planId: 'pro',
3181
- planName: 'Pro Plan',
3182
- planVersionId: 'pro-v1',
3183
- entitlementState: 'checkout_pending',
3184
- gate: 'checkout_pending',
3185
- message: 'Complete checkout to continue.',
3186
- planSelectionRequired: false,
3187
- billingConflictCode: null,
3188
- stripeStatus: 'active',
3189
- stripeCustomerId: 'cus_123',
3190
- stripeSubscriptionId: 'sub_123'
3191
- }));
3192
- });
3193
- it('returns checkout conflict code in billing status when the latest attempt expired', async () => {
3194
- routeHandlers = buildBillingRouteHandlers(db, {
3195
- accessStatus: {
3196
- gate: 'plan_selection_required',
3197
- hasEntitlement: true,
3198
- canAccessApp: false,
3199
- canAccessPlans: true,
3200
- planSelectionRequired: true,
3201
- message: 'Choose a plan to continue.',
3202
- entitlement: {
3203
- accountId: 'u1',
3204
- planVersionId: 'pro-v1',
3205
- planId: 'pro',
3206
- state: 'pending_plan_selection',
3207
- effectiveAt: null,
3208
- effectiveUntil: null
3209
- }
3210
- }
3211
- });
3212
- const now = new Date().toISOString();
3213
- db.prepare(`
3214
- INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
3215
- VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
3216
- `).run(now, now);
3217
- db.prepare(`
3218
- INSERT INTO billing_checkout_attempts (
3219
- session_id, tenant_id, user_id, plan_version_id, billing_interval, status, expires_at, created_at, updated_at
3220
- ) VALUES ('cs_expired_status', 'default', 'u1', 'pro-v1', 'month', 'expired', ?, ?, ?)
3221
- `).run(new Date(Date.now() - 60 * 1000).toISOString(), now, now);
3222
- const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
3223
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3224
- const res = createMockRes();
3225
- await statusRoute(req, res, {});
3226
- expect(res.statusCode).toBe(200);
3227
- const body = JSON.parse(res.body);
3228
- expect(body.billingConflictCode).toBe('CHECKOUT_SESSION_EXPIRED');
3229
- });
3230
- it('does not reconcile stale billing state from Stripe when billing status is requested', async () => {
3231
- const now = new Date().toISOString();
3232
- const stale = new Date(Date.now() - 60_000).toISOString();
3233
- db.prepare(`
3234
- INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
3235
- VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
3236
- `).run(now, now);
3237
- db.prepare(`
3238
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3239
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
3240
- `).run(now, now);
3241
- db.prepare(`
3242
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
3243
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
3244
- `).run(now, now);
3245
- db.prepare(`
3246
- INSERT INTO user_billing (
3247
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
3248
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_old', 'active', 'starter', 'month', 'active', ?, ?)
3249
- `).run(stale, stale);
3250
- db.prepare(`
3251
- INSERT INTO account_entitlements (
3252
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
3253
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
3254
- `).run(stale, stale, stale);
3255
- const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
3256
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3257
- const res = createMockRes();
3258
- await statusRoute(req, res, {});
3259
- expect(res.statusCode).toBe(200);
3260
- expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
3261
- expect(mockCustomersList).not.toHaveBeenCalled();
3262
- expect(mockSubscriptionList).not.toHaveBeenCalled();
3263
- const body = JSON.parse(res.body);
3264
- expect(body.planVersionId).toBe('pro-v1');
3265
- expect(body.planName).toBe('Pro Plan');
3266
- });
3267
- it('skips Stripe reconciliation for fresh billing mirror rows when billing status is requested', async () => {
3268
- const now = new Date().toISOString();
3269
- db.prepare(`
3270
- INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
3271
- VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
3272
- `).run(now, now);
3273
- db.prepare(`
3274
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3275
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
3276
- `).run(now, now);
3277
- db.prepare(`
3278
- INSERT INTO user_billing (
3279
- user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
3280
- ) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
3281
- `).run(now, now);
3282
- db.prepare(`
3283
- INSERT INTO account_entitlements (
3284
- account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
3285
- ) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
3286
- `).run(now, now, now);
3287
- const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
3288
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3289
- const res = createMockRes();
3290
- await statusRoute(req, res, {});
3291
- expect(res.statusCode).toBe(200);
3292
- expect(mockSubscriptionRetrieve).not.toHaveBeenCalled();
3293
- expect(mockCustomersList).not.toHaveBeenCalled();
3294
- expect(mockSubscriptionList).not.toHaveBeenCalled();
3295
- });
3296
- it('does not discover Stripe subscriptions by account email when billing status is requested', async () => {
3297
- const now = new Date().toISOString();
3298
- db.prepare(`
3299
- INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
3300
- VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
3301
- `).run(now, now);
3302
- db.prepare(`
3303
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3304
- VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
3305
- `).run(now, now);
3306
- db.prepare(`
3307
- INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
3308
- VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
3309
- `).run(now, now);
3310
- mockCustomersList.mockResolvedValueOnce({
3311
- data: [{ id: 'cus_email_match' }]
3312
- });
3313
- mockSubscriptionList.mockResolvedValueOnce({
3314
- data: [{
3315
- id: 'sub_email_match',
3316
- customer: 'cus_email_match',
3317
- status: 'active',
3318
- trial_end: null,
3319
- metadata: { userId: 'u1', planVersionId: 'pro-v1' },
3320
- items: {
3321
- data: [{
3322
- id: 'si_email_match',
3323
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
3324
- price: { id: 'price_pro_month', recurring: { interval: 'month' } }
3325
- }]
3326
- }
3327
- }]
3328
- });
3329
- const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
3330
- const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
3331
- const res = createMockRes();
3332
- await statusRoute(req, res, {});
3333
- expect(res.statusCode).toBe(200);
3334
- expect(mockCustomersList).not.toHaveBeenCalled();
3335
- expect(mockSubscriptionList).not.toHaveBeenCalled();
3336
- const body = JSON.parse(res.body);
3337
- expect(body.planId).toBe('pro');
3338
- expect(body.planVersionId).toBe('pro-v1');
3339
- expect(body.stripeCustomerId).toBeNull();
3340
- expect(body.stripeSubscriptionId).toBeNull();
3341
- });
3342
- it('prefers subscription metadata over shared price fallback during webhook processing', async () => {
3343
- const now = new Date().toISOString();
3344
- db.prepare(`
3345
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3346
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
3347
- `).run(now, now);
3348
- db.prepare(`
3349
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3350
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
3351
- `).run(now, now);
3352
- db.prepare(`
3353
- 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)
3354
- VALUES ('default', 'starter-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
3355
- `).run(now, now);
3356
- db.prepare(`
3357
- 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)
3358
- VALUES ('default', 'team-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
3359
- `).run(now, now);
3360
- db.prepare(`
3361
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
3362
- VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
3363
- `).run(now, now);
3364
- mockConstructEvent.mockReturnValue({
3365
- id: 'evt_shared_metadata',
3366
- type: 'customer.subscription.updated',
3367
- data: {
3368
- object: {
3369
- id: 'sub_123',
3370
- customer: 'cus_123',
3371
- status: 'active',
3372
- trial_end: null,
3373
- metadata: { planVersionId: 'team-v1' },
3374
- items: {
3375
- data: [{
3376
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
3377
- price: { id: 'price_shared', recurring: { interval: 'month' } }
3378
- }]
3379
- }
3380
- }
3381
- }
3382
- });
3383
- const req = createMockReq('{"id":"evt_shared_metadata"}', {
3384
- 'x-taskforce-runtime-mode': 'cloud',
3385
- 'stripe-signature': 'sig'
3386
- });
3387
- const res = createMockRes();
3388
- await webhookHandler(req, res, {});
3389
- expect(res.statusCode).toBe(200);
3390
- const entitlement = db.prepare(`
3391
- SELECT plan_version_id
3392
- FROM account_entitlements
3393
- WHERE account_id = 'u1' AND tenant_id = 'default'
3394
- LIMIT 1
3395
- `).get();
3396
- expect(entitlement.plan_version_id).toBe('team-v1');
3397
- });
3398
- it('logs webhook failure when shared price fallback is ambiguous and metadata is missing', async () => {
3399
- const now = new Date().toISOString();
3400
- db.prepare(`
3401
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3402
- VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
3403
- `).run(now, now);
3404
- db.prepare(`
3405
- INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
3406
- VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
3407
- `).run(now, now);
3408
- db.prepare(`
3409
- 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)
3410
- VALUES ('default', 'starter-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
3411
- `).run(now, now);
3412
- db.prepare(`
3413
- 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)
3414
- VALUES ('default', 'team-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
3415
- `).run(now, now);
3416
- db.prepare(`
3417
- INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
3418
- VALUES ('u1', 'default', 'cus_456', 'active', ?, ?)
3419
- `).run(now, now);
3420
- mockConstructEvent.mockReturnValue({
3421
- id: 'evt_shared_ambiguous',
3422
- type: 'customer.subscription.updated',
3423
- data: {
3424
- object: {
3425
- id: 'sub_456',
3426
- customer: 'cus_456',
3427
- status: 'active',
3428
- trial_end: null,
3429
- metadata: {},
3430
- items: {
3431
- data: [{
3432
- current_period_end: Math.floor(Date.now() / 1000) + 86400,
3433
- price: { id: 'price_shared', recurring: { interval: 'month' } }
3434
- }]
3435
- }
3436
- }
3437
- }
3438
- });
3439
- const req = createMockReq('{"id":"evt_shared_ambiguous"}', {
3440
- 'x-taskforce-runtime-mode': 'cloud',
3441
- 'stripe-signature': 'sig'
3442
- });
3443
- const res = createMockRes();
3444
- await webhookHandler(req, res, {});
3445
- expect(res.statusCode).toBe(200);
3446
- const failure = db.prepare(`
3447
- SELECT reason
3448
- FROM stripe_webhook_failures
3449
- WHERE event_id = 'evt_shared_ambiguous'
3450
- ORDER BY created_at DESC
3451
- LIMIT 1
3452
- `).get();
3453
- expect(failure.reason).toContain('Ambiguous price mapping');
3454
- });
3455
- });