@taskforcehq/taskforce 0.3.169 → 0.3.300
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.
- package/README.md +52 -21
- package/dist/Taskforce.module.css +3394 -5981
- package/dist/TaskforceCore.CategoryFallback.test.js +5 -2
- package/dist/TaskforceCore.filter-persistence.test.js +402 -0
- package/dist/TaskforceCore.js +388 -71
- package/dist/TaskforceCore.routing.test.js +311 -0
- package/dist/TaskforceCore.test.js +3344 -615
- package/dist/TaskforceWidget.js +3 -2
- package/dist/__tests__/localizationCatalogs.test.js +48 -31
- package/dist/__tests__/workflowSimulation.test.js +2 -2
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.d.ts +60 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.js +324 -0
- package/dist/annotatedAttachments/AnnotatedAttachmentSessionStore.test.js +278 -0
- package/dist/annotatedAttachments/payload.d.ts +2 -0
- package/dist/annotatedAttachments/payload.js +54 -0
- package/dist/annotatedAttachments/payload.test.js +67 -0
- package/dist/annotatedAttachments/service.d.ts +49 -0
- package/dist/annotatedAttachments/service.js +121 -0
- package/dist/annotatedAttachments/types.d.ts +86 -0
- package/dist/cli.js +74 -25
- package/dist/compat/deploymentCompat.d.ts +12 -0
- package/dist/compat/deploymentCompat.js +43 -0
- package/dist/compat/workspaceSyncCompat.d.ts +6 -0
- package/dist/compat/workspaceSyncCompat.js +79 -0
- package/dist/compat/workspaceSyncCompat.test.js +87 -0
- package/dist/components/features/AgentsModule.d.ts +5 -0
- package/dist/components/features/AgentsModule.js +131 -0
- package/dist/components/features/AgentsModule.test.js +102 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +24 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +1942 -0
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +2831 -0
- package/dist/components/features/DocumentGenerateModal.js +8 -16
- package/dist/components/features/DocumentIndex.d.ts +3 -4
- package/dist/components/features/DocumentIndex.js +22 -10
- package/dist/components/features/DocumentViewer.d.ts +6 -2
- package/dist/components/features/DocumentViewer.js +1000 -18
- package/dist/components/features/DocumentWorkspace.d.ts +40 -2
- package/dist/components/features/DocumentWorkspace.js +96 -16
- package/dist/components/features/DocumentWorkspace.test.js +1202 -0
- package/dist/components/features/InitiativesModule.d.ts +22 -0
- package/dist/components/features/InitiativesModule.js +33 -0
- package/dist/components/features/TaskSettings.d.ts +41 -32
- package/dist/components/features/TaskSettings.js +798 -313
- package/dist/components/features/TaskSettings.test.js +1221 -47
- package/dist/components/features/WorkflowsModule.d.ts +27 -0
- package/dist/components/features/WorkflowsModule.js +21 -0
- package/dist/components/features/documentWorkspaceFilters.d.ts +10 -0
- package/dist/components/features/documentWorkspaceFilters.js +11 -0
- package/dist/components/features/settings/CategoryManager.d.ts +2 -0
- package/dist/components/features/settings/CategoryManager.js +15 -8
- package/dist/components/features/settings/PriorityManager.d.ts +18 -0
- package/dist/components/features/settings/PriorityManager.js +51 -0
- package/dist/components/features/settings/TaxonomyEditor.d.ts +2 -1
- package/dist/components/features/settings/TaxonomyEditor.js +41 -18
- package/dist/components/features/settings/TaxonomyLibraryManager.d.ts +26 -0
- package/dist/components/features/settings/TaxonomyLibraryManager.js +301 -0
- package/dist/components/features/settings/TaxonomyLibraryManager.test.js +141 -0
- package/dist/components/features/settings/TaxonomyManager.d.ts +35 -12
- package/dist/components/features/settings/TaxonomyManager.js +59 -27
- package/dist/components/features/settings/TypeManager.d.ts +11 -0
- package/dist/components/features/settings/TypeManager.js +48 -11
- package/dist/components/features/settings/{WorkflowEditorPrototype.d.ts → WorkflowEditor.d.ts} +2 -2
- package/dist/components/features/settings/{WorkflowEditorPrototype.js → WorkflowEditor.js} +3 -3
- package/dist/components/task/TaskActionHeader.d.ts +12 -2
- package/dist/components/task/TaskActionHeader.js +64 -8
- package/dist/components/task/TaskActionHeader.test.js +73 -0
- package/dist/components/task/TaskCard.d.ts +14 -16
- package/dist/components/task/TaskCard.js +144 -174
- package/dist/components/task/TaskCard.test.js +416 -64
- package/dist/components/task/TaskContextUpload.d.ts +6 -5
- package/dist/components/task/TaskContextUpload.js +199 -64
- package/dist/components/task/TaskContextUpload.test.js +331 -14
- package/dist/components/task/TaskForm.d.ts +23 -29
- package/dist/components/task/TaskForm.js +348 -169
- package/dist/components/task/TaskForm.test.js +419 -23
- package/dist/components/task/TaskKanban.d.ts +20 -21
- package/dist/components/task/TaskKanban.js +176 -316
- package/dist/components/task/TaskKanban.planning-drop.test.js +93 -0
- package/dist/components/task/TaskList.d.ts +22 -11
- package/dist/components/task/TaskList.js +98 -12
- package/dist/components/task/TaskList.test.js +206 -7
- package/dist/components/task/TaskReferenceBadge.d.ts +12 -0
- package/dist/components/task/TaskReferenceBadge.js +5 -0
- package/dist/components/task/TaskStatusActions.d.ts +12 -0
- package/dist/components/task/TaskStatusActions.js +18 -0
- package/dist/components/task/TaskStatusActions.test.js +45 -0
- package/dist/components/ui/ImageReferenceBadge.d.ts +12 -0
- package/dist/components/ui/ImageReferenceBadge.js +5 -0
- package/dist/components/ui/LevelSelector.d.ts +2 -1
- package/dist/components/ui/LevelSelector.js +2 -2
- package/dist/components/ui/Markdown.d.ts +1 -2
- package/dist/components/ui/Markdown.js +2 -13
- package/dist/components/ui/Modal.d.ts +3 -2
- package/dist/components/ui/Modal.js +4 -3
- package/dist/components/ui/MultiSelectFilter.d.ts +3 -1
- package/dist/components/ui/MultiSelectFilter.js +2 -2
- package/dist/components/ui/ReferenceBadgeButton.d.ts +13 -0
- package/dist/components/ui/ReferenceBadgeButton.js +6 -0
- package/dist/components/ui/TaxonomyDropdown.d.ts +7 -1
- package/dist/components/ui/TaxonomyDropdown.js +58 -23
- package/dist/components/ui/TopNoticeLayer.d.ts +7 -0
- package/dist/components/ui/TopNoticeLayer.js +14 -0
- package/dist/components/ui/TopNoticeLayer.test.js +25 -0
- package/dist/components/views/AppShellHeader.d.ts +13 -0
- package/dist/components/views/AppShellHeader.js +19 -0
- package/dist/components/views/AppShellHeader.test.js +21 -0
- package/dist/components/views/PlanComparisonPage.d.ts +73 -0
- package/dist/components/views/PlanComparisonPage.js +196 -0
- package/dist/components/views/PlanComparisonPage.test.js +251 -0
- package/dist/components/views/PlansPage.d.ts +14 -0
- package/dist/components/views/PlansPage.js +355 -0
- package/dist/components/views/StandaloneLayout.d.ts +2 -2
- package/dist/components/views/StandaloneLayout.js +2212 -1194
- package/dist/components/views/WidgetView.d.ts +2 -2
- package/dist/components/views/WidgetView.js +149 -43
- package/dist/components/views/chrome/WorkspaceChromeSections.d.ts +57 -0
- package/dist/components/views/chrome/WorkspaceChromeSections.js +38 -0
- package/dist/components/views/chrome/WorkspaceChromeSections.test.js +54 -0
- package/dist/components/views/chrome/workspaceChrome.d.ts +31 -0
- package/dist/components/views/chrome/workspaceChrome.js +108 -0
- package/dist/components/views/chrome/workspaceChrome.test.js +52 -0
- package/dist/components/views/panels/DocumentFilterToolbar.d.ts +10 -0
- package/dist/components/views/panels/DocumentFilterToolbar.js +7 -0
- package/dist/components/views/panels/DocumentFilterToolbar.test.js +19 -0
- package/dist/components/views/panels/FilterToolbar.d.ts +66 -0
- package/dist/components/views/panels/FilterToolbar.js +21 -0
- package/dist/components/views/panels/FilterToolbar.test.d.ts +1 -0
- package/dist/components/views/panels/FilterToolbar.test.js +45 -0
- package/dist/components/views/panels/FilterToolbarControls.d.ts +46 -0
- package/dist/components/views/panels/FilterToolbarControls.js +61 -0
- package/dist/components/views/panels/PlanningDrawer.d.ts +115 -0
- package/dist/components/views/panels/PlanningDrawer.js +220 -0
- package/dist/components/views/panels/ScheduleSidebar.d.ts +52 -0
- package/dist/components/views/panels/ScheduleSidebar.js +127 -0
- package/dist/components/views/panels/scheduleUtils.d.ts +14 -0
- package/dist/components/views/panels/scheduleUtils.js +79 -0
- package/dist/components/views/teamManagementAccess.d.ts +6 -0
- package/dist/components/views/teamManagementAccess.js +18 -0
- package/dist/components/views/teamManagementAccess.test.d.ts +1 -0
- package/dist/components/views/teamManagementAccess.test.js +44 -0
- package/dist/config/clientDeployment.d.ts +2 -0
- package/dist/config/clientDeployment.js +13 -0
- package/dist/config/cloudEnvironment.d.ts +15 -0
- package/dist/config/cloudEnvironment.js +60 -0
- package/dist/config/deployment.d.ts +20 -0
- package/dist/config/deployment.js +67 -0
- package/dist/config/deployment.test.d.ts +1 -0
- package/dist/config/deployment.test.js +88 -0
- package/dist/config/envSchema.js +36 -8
- package/dist/config/envSchema.test.js +28 -0
- package/dist/core/AiProfileService.d.ts +66 -0
- package/dist/core/AiProfileService.js +320 -0
- package/dist/core/AiProfileService.test.d.ts +1 -0
- package/dist/core/AiProfileService.test.js +164 -0
- package/dist/core/AiProfiles.test.d.ts +1 -0
- package/dist/core/AiProfiles.test.js +449 -0
- package/dist/core/AttachmentLinkService.d.ts +15 -0
- package/dist/core/AttachmentLinkService.js +157 -0
- package/dist/core/AuthTokenService.d.ts +44 -0
- package/dist/core/AuthTokenService.js +207 -0
- package/dist/core/CreatedBy.test.js +134 -6
- package/dist/core/DeletedTaskLifecycleService.d.ts +33 -0
- package/dist/core/DeletedTaskLifecycleService.js +158 -0
- package/dist/core/DeletedTaskTrash.test.d.ts +1 -0
- package/dist/core/DeletedTaskTrash.test.js +104 -0
- package/dist/core/EntitlementsPolicy.test.js +78 -5
- package/dist/core/GlobalSettingsService.d.ts +16 -0
- package/dist/core/GlobalSettingsService.js +370 -0
- package/dist/core/JsonBackupRuntime.test.d.ts +1 -0
- package/dist/core/JsonBackupRuntime.test.js +155 -0
- package/dist/core/McpTokenService.d.ts +118 -0
- package/dist/core/McpTokenService.js +921 -0
- package/dist/core/PlanEntitlementService.d.ts +201 -0
- package/dist/core/PlanEntitlementService.js +1442 -0
- package/dist/core/PlanVersionPolicy.test.d.ts +1 -0
- package/dist/core/PlanVersionPolicy.test.js +178 -0
- package/dist/core/PlanningEntities.test.d.ts +1 -0
- package/dist/core/PlanningEntities.test.js +95 -0
- package/dist/core/ScheduleFields.test.js +15 -19
- package/dist/core/SignupMonetizationSettings.test.d.ts +1 -0
- package/dist/core/SignupMonetizationSettings.test.js +159 -0
- package/dist/core/Stats.test.d.ts +1 -0
- package/dist/core/Stats.test.js +36 -0
- package/dist/core/SyncReconciliationService.d.ts +93 -0
- package/dist/core/SyncReconciliationService.js +406 -0
- package/dist/core/SystemAdmin.test.js +578 -13
- package/dist/core/TaskActivityService.d.ts +21 -0
- package/dist/core/TaskActivityService.js +130 -0
- package/dist/core/TaskAttachmentsCanonical.test.d.ts +1 -0
- package/dist/core/TaskAttachmentsCanonical.test.js +306 -0
- package/dist/core/TaskAuditTimeline.test.d.ts +1 -0
- package/dist/core/TaskAuditTimeline.test.js +132 -0
- package/dist/core/TaskTaxonomyValidation.test.js +338 -15
- package/dist/core/Taskforce.d.ts +930 -183
- package/dist/core/Taskforce.ids.test.js +252 -0
- package/dist/core/Taskforce.js +3848 -2532
- package/dist/core/Taskforce.mcpAuth.test.d.ts +1 -0
- package/dist/core/Taskforce.mcpAuth.test.js +343 -0
- package/dist/core/Taskforce.realtimeMutation.test.js +57 -2
- package/dist/core/TaxonomySettingsService.d.ts +18 -0
- package/dist/core/TaxonomySettingsService.js +162 -0
- package/dist/core/WorkspaceDelete.test.js +32 -13
- package/dist/core/WorkspaceLifecycleService.d.ts +11 -0
- package/dist/core/WorkspaceLifecycleService.js +88 -0
- package/dist/core/WorkspacePermissions.test.js +95 -0
- package/dist/core/WorkspacePlanMode.test.js +114 -1
- package/dist/core/WorkspacePreferenceService.d.ts +41 -0
- package/dist/core/WorkspacePreferenceService.js +246 -0
- package/dist/core/WorkspaceSyncSettings.test.d.ts +1 -0
- package/dist/core/WorkspaceSyncSettings.test.js +65 -0
- package/dist/core/__tests__/TaskAttachmentsCanonical.test.d.ts +1 -0
- package/dist/core/__tests__/TaskAttachmentsCanonical.test.js +212 -0
- package/dist/core/shared.d.ts +36 -0
- package/dist/core/shared.js +128 -0
- package/dist/core/types.d.ts +614 -0
- package/dist/core/types.js +27 -0
- package/dist/documentReviews/DocumentReviewCommentStore.d.ts +56 -0
- package/dist/documentReviews/DocumentReviewCommentStore.js +230 -0
- package/dist/documentReviews/DocumentReviewSessionStore.d.ts +52 -0
- package/dist/documentReviews/DocumentReviewSessionStore.js +193 -0
- package/dist/documentReviews/DocumentReviewSessionStore.test.d.ts +1 -0
- package/dist/documentReviews/DocumentReviewSessionStore.test.js +127 -0
- package/dist/documentReviews/service.d.ts +68 -0
- package/dist/documentReviews/service.js +210 -0
- package/dist/documentReviews/types.d.ts +58 -0
- package/dist/documentReviews/types.js +1 -0
- package/dist/hooks/auth/useAuthGuardPolicy.d.ts +2 -0
- package/dist/hooks/auth/useAuthGuardPolicy.js +1 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.d.ts +15 -0
- package/dist/hooks/tasks/useCurrentTaskRelations.js +37 -0
- package/dist/hooks/tasks/useTaskActions.d.ts +3 -0
- package/dist/hooks/tasks/useTaskActions.js +2 -0
- package/dist/hooks/tasks/useTaskEditorNavigation.d.ts +65 -0
- package/dist/hooks/tasks/useTaskEditorNavigation.js +235 -0
- package/dist/hooks/tasks/useTaskFilters.d.ts +2 -0
- package/dist/hooks/tasks/useTaskFilters.js +2 -0
- package/dist/hooks/tasks/useTaskFormActions.d.ts +58 -0
- package/dist/hooks/tasks/useTaskFormActions.js +365 -0
- package/dist/hooks/tasks/useTaskRelationships.d.ts +16 -0
- package/dist/hooks/tasks/useTaskRelationships.js +64 -0
- package/dist/hooks/ui/useResourceExport.d.ts +19 -0
- package/dist/hooks/ui/useResourceExport.js +84 -0
- package/dist/hooks/ui/useSettingsModel.d.ts +203 -0
- package/dist/hooks/ui/useSettingsModel.js +125 -0
- package/dist/hooks/ui/useSettingsPersistence.d.ts +36 -0
- package/dist/hooks/ui/useSettingsPersistence.js +254 -0
- package/dist/hooks/ui/useSettingsTaxonomyManagement.d.ts +119 -0
- package/dist/hooks/ui/useSettingsTaxonomyManagement.js +536 -0
- package/dist/hooks/ui/useUiNotice.d.ts +1 -0
- package/dist/hooks/ui/useUiNotice.js +1 -0
- package/dist/hooks/ui/useWorkflowTemplates.d.ts +37 -0
- package/dist/hooks/ui/useWorkflowTemplates.js +111 -0
- package/dist/hooks/ui/useZenMode.d.ts +5 -0
- package/dist/hooks/ui/useZenMode.js +32 -0
- package/dist/hooks/useFilterSort.d.ts +60 -0
- package/dist/hooks/useFilterSort.js +362 -0
- package/dist/hooks/useFilterSort.test.d.ts +1 -0
- package/dist/hooks/useFilterSort.test.js +333 -0
- package/dist/hooks/useRealtimeSync.d.ts +1 -0
- package/dist/hooks/useRealtimeSync.js +24 -9
- package/dist/hooks/useRealtimeSync.test.js +28 -1
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.d.ts +1 -0
- package/dist/hooks/useSyncOrchestrator.aiProfiles.test.js +34 -0
- package/dist/hooks/useSyncOrchestrator.d.ts +65 -103
- package/dist/hooks/useSyncOrchestrator.js +1538 -670
- package/dist/hooks/useSyncOrchestrator.retry-closure.test.js +700 -174
- package/dist/hooks/useTaskData.d.ts +35 -0
- package/dist/hooks/useTaskData.js +239 -0
- package/dist/hooks/useTaskData.test.d.ts +1 -0
- package/dist/hooks/useTaskData.test.js +119 -0
- package/dist/hooks/useTaskMutations.d.ts +46 -0
- package/dist/hooks/useTaskMutations.js +472 -0
- package/dist/hooks/useTaskMutations.test.d.ts +1 -0
- package/dist/hooks/useTaskMutations.test.js +80 -0
- package/dist/hooks/useTaskforce.contract.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.contract.test.js +7 -0
- package/dist/hooks/useTaskforce.d.ts +260 -295
- package/dist/hooks/useTaskforce.js +1954 -2623
- package/dist/hooks/useTaskforce.priority-filters.test.js +6 -11
- package/dist/hooks/useTaskforce.sync-behavior.test.d.ts +1 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +1120 -0
- package/dist/hooks/useUiNotice.d.ts +16 -0
- package/dist/hooks/useUiNotice.js +49 -0
- package/dist/hooks/useWorkspaceSyncController.d.ts +23 -46
- package/dist/hooks/useWorkspaceSyncController.js +280 -461
- package/dist/hooks/useWorkspaceSyncController.test.js +106 -199
- package/dist/hooks/workspace/useTaskforceApiRouting.d.ts +8 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.js +91 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.d.ts +1 -0
- package/dist/hooks/workspace/useTaskforceApiRouting.test.js +60 -0
- package/dist/hooks/workspace/useWorkspaceSyncController.d.ts +1 -0
- package/dist/hooks/workspace/useWorkspaceSyncController.js +1 -0
- package/dist/hooks/workspace/workspaceLocalState.d.ts +30 -0
- package/dist/hooks/workspace/workspaceLocalState.js +101 -0
- package/dist/localization/locales/en-US.d.ts +1 -8
- package/dist/localization/locales/en-US.js +4 -11
- package/dist/localization/locales/es-419.js +2 -9
- package/dist/localization/locales/pt-BR.js +2 -9
- package/dist/localization/localizationCatalogs.test.d.ts +1 -0
- package/dist/localization/localizationCatalogs.test.js +37 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +9 -15
- package/dist/mcp/clientIntegrations.d.ts +33 -0
- package/dist/mcp/clientIntegrations.js +199 -0
- package/dist/mcp/clientIntegrations.test.d.ts +1 -0
- package/dist/mcp/clientIntegrations.test.js +110 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/optionResolution.test.js +6 -2
- package/dist/mcp/runtime.d.ts +120 -0
- package/dist/mcp/runtime.js +5668 -0
- package/dist/mcp/runtime.test.d.ts +1 -0
- package/dist/mcp/runtime.test.js +3293 -0
- package/dist/mcp/server.js +2 -1793
- package/dist/mcp/structuredCommentValidation.test.d.ts +1 -0
- package/dist/{__tests__ → mcp}/structuredCommentValidation.test.js +1 -1
- package/dist/mcp/structuredDocValidation.js +1 -2
- package/dist/mcp/structuredDocValidation.test.d.ts +1 -0
- package/dist/{__tests__ → mcp}/structuredDocValidation.test.js +9 -4
- package/dist/mcp/toolRegistry.d.ts +570 -0
- package/dist/mcp/toolRegistry.js +90 -0
- package/dist/mcp/toolRegistry.test.d.ts +1 -0
- package/dist/mcp/toolRegistry.test.js +30 -0
- package/dist/migrations/billingSchemaParity.test.js +111 -0
- package/dist/migrations/taskSchemaMigrations.d.ts +5 -0
- package/dist/migrations/taskSchemaMigrations.js +681 -39
- package/dist/migrations/taskSchemaMigrations.test.d.ts +1 -0
- package/dist/migrations/taskSchemaMigrations.test.js +210 -0
- package/dist/plugins/vite.js +13 -7
- package/dist/public/apple-touch-icon.png +0 -0
- package/dist/public/archive/phoenix/apple-touch-icon.png +0 -0
- package/dist/public/archive/phoenix/favicon.ico +0 -0
- package/dist/public/archive/phoenix/site.webmanifest +1 -0
- package/dist/public/favicon-96x96.png +0 -0
- package/dist/public/favicon.ico +0 -0
- package/dist/public/favicon.svg +17 -0
- package/dist/public/site.webmanifest +21 -1
- package/dist/public/web-app-manifest-192x192.png +0 -0
- package/dist/public/web-app-manifest-512x512.png +0 -0
- package/dist/releaseVersion.test.d.ts +1 -0
- package/dist/{__tests__/releaseVersion.test.js → releaseVersion.test.js} +1 -1
- package/dist/resources/templates/WORKFLOW_V2_POLICY.md +1 -1
- package/dist/resources/templates/workflow-sources/workflowDocs.mjs +62 -52
- package/dist/resources/templates/workflows/collaborate.yaml +11 -5
- package/dist/resources/templates/workflows/do.yaml +8 -2
- package/dist/resources/templates/workflows/evaluate.yaml +13 -7
- package/dist/resources/templates/workflows/execute.yaml +40 -23
- package/dist/resources/templates/workflows/plan.yaml +15 -8
- package/dist/resources/templates/workflows/review.yaml +14 -8
- package/dist/runtime/appGateDefinitions.d.ts +32 -0
- package/dist/runtime/appGateDefinitions.js +32 -0
- package/dist/runtime/appGates.d.ts +29 -0
- package/dist/runtime/appGates.js +53 -0
- package/dist/runtime/appGates.test.d.ts +1 -0
- package/dist/runtime/appGates.test.js +42 -0
- package/dist/runtime/migrationFlags.d.ts +1 -0
- package/dist/runtime/migrationFlags.js +1 -0
- package/dist/server/annotatedAttachmentsRoutes.test.d.ts +1 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +473 -0
- package/dist/server/auth.js +14 -2
- package/dist/server/auth.test.js +21 -1
- package/dist/server/cors.js +11 -5
- package/dist/server/cors.test.js +6 -0
- package/dist/server/documentReviewRoutes.test.d.ts +1 -0
- package/dist/server/documentReviewRoutes.test.js +157 -0
- package/dist/server/index.cookieProxy.test.js +46 -1
- package/dist/server/index.d.ts +20 -0
- package/dist/server/index.js +168 -41
- package/dist/server/index.rateLimit.test.js +6 -0
- package/dist/server/index.test.js +133 -2
- package/dist/server/mockStripe.d.ts +72 -0
- package/dist/server/mockStripe.js +357 -0
- package/dist/server/realtimeSyncWs.js +4 -0
- package/dist/server/routes/admin.d.ts +114 -0
- package/dist/server/routes/admin.js +2226 -0
- package/dist/server/routes/annotatedAttachments.d.ts +24 -0
- package/dist/server/routes/annotatedAttachments.js +235 -0
- package/dist/server/routes/auth.d.ts +61 -0
- package/dist/server/routes/auth.js +1296 -0
- package/dist/server/{routes.billing.d.ts → routes/billing.d.ts} +3 -2
- package/dist/server/routes/billing.js +2001 -0
- package/dist/server/routes/billing.test.d.ts +1 -0
- package/dist/server/routes/billing.test.js +1631 -0
- package/dist/server/routes/documentReviews.d.ts +11 -0
- package/dist/server/routes/documentReviews.js +191 -0
- package/dist/server/routes/documents.d.ts +118 -0
- package/dist/server/routes/documents.js +1113 -0
- package/dist/server/routes/resources.d.ts +25 -0
- package/dist/server/routes/resources.js +225 -0
- package/dist/server/routes/shared.d.ts +35 -0
- package/dist/server/routes/shared.js +305 -0
- package/dist/server/routes/shared.test.d.ts +1 -0
- package/dist/server/routes/shared.test.js +32 -0
- package/dist/server/{routes.sync.d.ts → routes/sync.d.ts} +5 -5
- package/dist/server/routes/sync.integration.test.d.ts +1 -0
- package/dist/server/routes/sync.integration.test.js +2003 -0
- package/dist/server/routes/sync.js +3143 -0
- package/dist/server/routes/tasks.d.ts +72 -0
- package/dist/server/routes/tasks.js +1252 -0
- package/dist/server/routes/workspaces.d.ts +32 -0
- package/dist/server/routes/workspaces.js +307 -0
- package/dist/server/routes.d.ts +8 -9
- package/dist/server/routes.js +968 -4092
- package/dist/server/routes.test.js +9043 -2415
- package/dist/server/runtimeMode.d.ts +1 -1
- package/dist/server/runtimeMode.js +3 -18
- package/dist/server/runtimeMode.test.js +3 -3
- package/dist/server/securityHeaders.d.ts +1 -1
- package/dist/server/securityHeaders.js +65 -6
- package/dist/server/securityHeaders.test.js +40 -2
- package/dist/services/templateEngine.test.d.ts +1 -0
- package/dist/{__tests__ → services}/templateEngine.test.js +1 -1
- package/dist/services/templates.test.d.ts +1 -0
- package/dist/{__tests__ → services}/templates.test.js +1 -1
- package/dist/services/workflowDocumentFixtures.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowDocumentFixtures.test.js +4 -4
- package/dist/services/workflowExportMatrix.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowExportMatrix.test.js +1 -1
- package/dist/services/workflowExportSnapshots.test.d.ts +1 -0
- package/dist/services/workflowGeneration.test.d.ts +1 -0
- package/dist/services/workflowSimulation.test.d.ts +1 -0
- package/dist/services/workflowSimulation.test.js +113 -0
- package/dist/services/workflowV2Policy.test.d.ts +1 -0
- package/dist/{__tests__ → services}/workflowV2Policy.test.js +2 -2
- package/dist/shared/aiProfileSurfaceType.d.ts +9 -0
- package/dist/shared/aiProfileSurfaceType.js +49 -0
- package/dist/shared/customTaxonomyLibrary.d.ts +11 -0
- package/dist/shared/customTaxonomyLibrary.js +276 -0
- package/dist/shared/customTaxonomyLibrary.test.d.ts +1 -0
- package/dist/shared/customTaxonomyLibrary.test.js +20 -0
- package/dist/shared/taxonomyCatalog.d.ts +19 -0
- package/dist/shared/taxonomyCatalog.js +36 -0
- package/dist/shared/taxonomyDefaults.d.ts +29 -0
- package/dist/shared/taxonomyDefaults.js +26 -0
- package/dist/shared/taxonomyLibrary.d.ts +15 -0
- package/dist/shared/taxonomyLibrary.js +98 -0
- package/dist/shared/taxonomyLibrary.test.d.ts +1 -0
- package/dist/shared/taxonomyLibrary.test.js +26 -0
- package/dist/shared/taxonomyState.d.ts +36 -0
- package/dist/shared/taxonomyState.js +73 -0
- package/dist/shared/taxonomyState.test.d.ts +1 -0
- package/dist/shared/taxonomyState.test.js +71 -0
- package/dist/shared/workflowDefaults.d.ts +9 -0
- package/dist/shared/workflowDefaults.js +11 -0
- package/dist/storage/documentIntegrity.d.ts +2 -0
- package/dist/storage/documentIntegrity.js +34 -17
- package/dist/storage/documentPurge.d.ts +2 -1
- package/dist/storage/documentPurge.js +35 -8
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.js +2 -0
- package/dist/storage/objectStorage.d.ts +1 -1
- package/dist/storage/objectStorageClient.d.ts +6 -4
- package/dist/storage/objectStorageClient.js +77 -29
- package/dist/storage/objectStorageClient.test.d.ts +1 -0
- package/dist/storage/objectStorageClient.test.js +57 -0
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.d.ts +1 -0
- package/dist/storage/objectStorageConfig.test.js +38 -0
- package/dist/storage/postgresAdapter.d.ts +5 -0
- package/dist/storage/postgresAdapter.js +2 -2
- package/dist/storage/postgresAdapter.test.d.ts +1 -0
- package/dist/storage/postgresAdapter.test.js +29 -0
- package/dist/storage/taskAssetStore.d.ts +7 -4
- package/dist/storage/taskAssetStore.js +65 -60
- package/dist/storage/workspaceAssetStore.d.ts +122 -0
- package/dist/storage/workspaceAssetStore.js +496 -0
- package/dist/storage/workspaceAssetStore.test.d.ts +1 -0
- package/dist/storage/workspaceAssetStore.test.js +501 -0
- package/dist/sync/cloudSyncApi.d.ts +33 -2
- package/dist/sync/cloudSyncApi.js +37 -15
- package/dist/sync/cloudSyncApi.test.js +60 -3
- package/dist/sync/index.d.ts +2 -0
- package/dist/sync/index.js +3 -0
- package/dist/sync/syncService.d.ts +25 -0
- package/dist/sync/syncService.js +184 -16
- package/dist/sync/syncService.test.js +289 -25
- package/dist/sync/workspaceRepair.d.ts +114 -0
- package/dist/sync/workspaceRepair.js +566 -0
- package/dist/sync/workspaceSyncModel.d.ts +230 -2
- package/dist/sync/workspaceSyncModel.js +456 -17
- package/dist/sync/workspaceSyncModel.test.js +642 -0
- package/dist/sync/workspaceSyncState.d.ts +12 -0
- package/dist/sync/workspaceSyncState.js +12 -0
- package/dist/sync/workspaceSyncSurface.d.ts +33 -0
- package/dist/sync/workspaceSyncSurface.js +74 -0
- package/dist/types.d.ts +143 -18
- package/dist/types.js +9 -24
- package/dist/ui/assets/AgentsModule-TVmCgizS.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-CAyhnPeR.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-BOCw5e4D.js +250 -0
- package/dist/ui/assets/DocumentWorkspace-C_8T8oz-.css +1 -0
- package/dist/ui/assets/InitiativesModule-CN0r02Oi.js +1 -0
- package/dist/ui/assets/PlansPage-BlVC_lRq.css +1 -0
- package/dist/ui/assets/PlansPage-FcQSNLZd.js +1 -0
- package/dist/ui/assets/TaskSettings-BwB9NtmJ.css +1 -0
- package/dist/ui/assets/TaskSettings-Cw5zYD1L.js +8 -0
- package/dist/ui/assets/WorkflowsModule-CY9R2zPB.js +5 -0
- package/dist/ui/assets/index-Bzqy5QGm.js +6 -0
- package/dist/ui/assets/index-DxRiI3tY.css +1 -0
- package/dist/ui/assets/vendor-dnd-DRzYolkg.js +5 -0
- package/dist/ui/assets/{vendor-icons-ZFG3SGwo.js → vendor-icons-QZyhEwgT.js} +1 -1
- package/dist/ui/assets/{vendor-markdown-CKYkQHfj.js → vendor-markdown-BUxTU7dS.js} +1 -1
- package/dist/ui/assets/vendor-react-CKJs5o3c.js +61 -0
- package/dist/ui/assets/vendor-router-BbWMxlnO.js +3 -0
- package/dist/ui/branding/logos/logo.png +0 -0
- package/dist/ui/branding/logos/logo_dark.svg +27 -0
- package/dist/ui/branding/logos/logo_favicon.svg +35 -0
- package/dist/ui/branding/logos/logo_light.svg +27 -0
- package/dist/ui/favicon/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/archive/phoenix/apple-touch-icon.png +0 -0
- package/dist/ui/favicon/archive/phoenix/favicon.ico +0 -0
- package/dist/ui/favicon/archive/phoenix/site.webmanifest +1 -0
- package/dist/ui/favicon/favicon-96x96.png +0 -0
- package/dist/ui/favicon/favicon.ico +0 -0
- package/dist/ui/favicon/favicon.svg +17 -0
- package/dist/ui/favicon/site.webmanifest +21 -1
- package/dist/ui/favicon/web-app-manifest-192x192.png +0 -0
- package/dist/ui/favicon/web-app-manifest-512x512.png +0 -0
- package/dist/ui/index.html +10 -7
- package/dist/ui/product/screen_01.png +0 -0
- package/dist/ui/product/screen_02.png +0 -0
- package/dist/ui/product/screen_03.png +0 -0
- package/dist/utils/annotatedAttachments.d.ts +20 -0
- package/dist/utils/annotatedAttachments.js +74 -0
- package/dist/utils/annotatedAttachments.test.d.ts +1 -0
- package/dist/utils/annotatedAttachments.test.js +108 -0
- package/dist/utils/assignees.d.ts +26 -0
- package/dist/utils/assignees.js +132 -0
- package/dist/utils/canonicalAssetKind.d.ts +16 -0
- package/dist/utils/canonicalAssetKind.js +39 -0
- package/dist/utils/canonicalAssetKind.test.d.ts +1 -0
- package/dist/utils/canonicalAssetKind.test.js +24 -0
- package/dist/utils/coalescedRefresh.d.ts +5 -0
- package/dist/utils/coalescedRefresh.js +16 -0
- package/dist/utils/coalescedRefresh.test.d.ts +1 -0
- package/dist/utils/coalescedRefresh.test.js +30 -0
- package/dist/utils/constants.d.ts +16 -3
- package/dist/utils/constants.icons.test.js +2 -1
- package/dist/utils/constants.js +70 -13
- package/dist/utils/contextAssetEvents.d.ts +7 -0
- package/dist/utils/contextAssetEvents.js +6 -0
- package/dist/utils/contextFiles.d.ts +14 -3
- package/dist/utils/contextFiles.js +40 -8
- package/dist/utils/documentManagerApi.d.ts +6 -0
- package/dist/utils/documentManagerApi.js +19 -0
- package/dist/utils/documentNames.d.ts +24 -0
- package/dist/utils/documentNames.js +91 -0
- package/dist/utils/documentReferences.d.ts +9 -0
- package/dist/utils/documentReferences.js +11 -0
- package/dist/utils/entityReferences.d.ts +9 -0
- package/dist/utils/entityReferences.js +32 -0
- package/dist/utils/imageReferences.d.ts +9 -0
- package/dist/utils/imageReferences.js +11 -0
- package/dist/utils/planParser.d.ts +2 -2
- package/dist/utils/planParser.js +11 -11
- package/dist/utils/planParser.test.d.ts +1 -0
- package/dist/{__tests__ → utils}/planParser.test.js +22 -22
- package/dist/utils/planningReferences.d.ts +17 -0
- package/dist/utils/planningReferences.js +30 -0
- package/dist/utils/planningReferences.test.d.ts +1 -0
- package/dist/utils/planningReferences.test.js +17 -0
- package/dist/utils/taskActivity.d.ts +9 -0
- package/dist/utils/taskActivity.js +147 -0
- package/dist/utils/taskActivity.test.d.ts +1 -0
- package/dist/utils/taskActivity.test.js +34 -0
- package/dist/utils/taskDraftDefaults.d.ts +21 -0
- package/dist/utils/taskDraftDefaults.js +40 -0
- package/dist/utils/taskDraftDefaults.test.d.ts +1 -0
- package/dist/utils/taskDraftDefaults.test.js +34 -0
- package/dist/utils/taskNormalization.d.ts +25 -0
- package/dist/utils/taskNormalization.js +161 -0
- package/dist/utils/taskNormalization.test.d.ts +1 -0
- package/dist/utils/taskNormalization.test.js +158 -0
- package/dist/utils/taskReferences.d.ts +5 -0
- package/dist/utils/taskReferences.js +11 -0
- package/dist/utils/taskforceApiClient.d.ts +2 -0
- package/dist/utils/taskforceApiClient.js +101 -3
- package/dist/utils/taskforceApiClient.test.d.ts +1 -0
- package/dist/utils/taskforceApiClient.test.js +44 -0
- package/dist/utils/taxonomyFilterOptions.d.ts +2 -0
- package/dist/utils/taxonomyFilterOptions.js +17 -0
- package/dist/utils/taxonomyFormDefaults.d.ts +3 -0
- package/dist/utils/taxonomyFormDefaults.js +28 -0
- package/dist/utils/taxonomyFormDefaults.test.d.ts +1 -0
- package/dist/utils/taxonomyFormDefaults.test.js +63 -0
- package/dist/utils/taxonomyLifecycle.d.ts +3 -0
- package/dist/utils/taxonomyLifecycle.js +17 -0
- package/dist/utils/taxonomySorting.d.ts +7 -0
- package/dist/utils/taxonomySorting.js +74 -0
- package/dist/utils/theme.d.ts +15 -0
- package/dist/utils/theme.js +23 -0
- package/dist/utils/uiNotice.d.ts +7 -0
- package/dist/utils/uiNotice.js +6 -0
- package/dist/utils/uiNotice.test.d.ts +1 -0
- package/dist/utils/uiNotice.test.js +18 -0
- package/dist/utils/workspaceIdentity.d.ts +14 -0
- package/dist/utils/workspaceIdentity.js +21 -0
- package/dist/utils/workspaceIdentity.test.js +30 -1
- package/dist/utils/workspaceSyncPresentation.d.ts +20 -0
- package/dist/utils/workspaceSyncPresentation.js +21 -0
- package/dist/utils/workspaceSyncPresentation.test.d.ts +1 -0
- package/dist/utils/workspaceSyncPresentation.test.js +36 -0
- package/package.json +28 -23
- package/scripts/bootstrap-playwright-auth.mjs +155 -0
- package/scripts/check-compat-boundaries.mjs +41 -0
- package/scripts/check-env.ts +9 -2
- package/scripts/debug-login.mjs +32 -0
- package/scripts/migrate-planning-model.ts +233 -0
- package/scripts/migrate-task-data-assets.mjs +408 -0
- package/scripts/playwright-auth.sh +11 -0
- package/scripts/run-dev-proxy.sh +2 -0
- package/scripts/update-homebrew-formula.sh +20 -3
- package/scripts/with-esbuild.sh +11 -0
- package/src/resources/templates/WORKFLOW_V2_POLICY.md +1 -1
- package/src/resources/templates/workflow-sources/workflowDocs.mjs +62 -52
- package/src/resources/templates/workflows/collaborate.yaml +11 -5
- package/src/resources/templates/workflows/do.yaml +8 -2
- package/src/resources/templates/workflows/evaluate.yaml +13 -7
- package/src/resources/templates/workflows/execute.yaml +40 -23
- package/src/resources/templates/workflows/plan.yaml +15 -8
- package/src/resources/templates/workflows/review.yaml +14 -8
- package/dist/TaskforceScreenshot.d.ts +0 -11
- package/dist/TaskforceScreenshot.js +0 -113
- package/dist/compat/priorityCompat.d.ts +0 -2
- package/dist/compat/priorityCompat.js +0 -24
- package/dist/components/features/settings/TaxonomyManager 2.d.ts +0 -52
- package/dist/components/features/settings/TaxonomyManager 2.js +0 -75
- package/dist/components/task/TaskKanban 2.d.ts +0 -76
- package/dist/components/task/TaskKanban 2.js +0 -1004
- package/dist/components/task/TaskScreenshotSection.d.ts +0 -15
- package/dist/components/task/TaskScreenshotSection.js +0 -22
- package/dist/components/views/StandaloneLayout 2.d.ts +0 -8
- package/dist/components/views/StandaloneLayout 2.js +0 -1660
- package/dist/core/InitiativeTemplates.test.js +0 -50
- package/dist/core/ParentLifecycle.test.js +0 -98
- package/dist/migrations/clientMigrations.d.ts +0 -9
- package/dist/migrations/clientMigrations.js +0 -74
- package/dist/migrations/flags.d.ts +0 -6
- package/dist/migrations/flags.js +0 -8
- package/dist/migrations/taskMigrations.d.ts +0 -71
- package/dist/migrations/taskMigrations.js +0 -236
- package/dist/migrations/taskMigrations.test.js +0 -34
- package/dist/server/routes.billing.js +0 -991
- package/dist/server/routes.billing.test.js +0 -387
- package/dist/server/routes.sync.integration.test.js +0 -470
- package/dist/server/routes.sync.js +0 -1141
- package/dist/soak/syncSoakHarness.d.ts +0 -6
- package/dist/soak/syncSoakHarness.js +0 -10
- package/dist/ui/assets/index-0W5vyCpn.css +0 -1
- package/dist/ui/assets/index-MCZuNNfn.js +0 -23
- package/dist/ui/assets/logo_white_01-0Vx-iIIv.png +0 -0
- package/dist/ui/assets/vendor-dnd-BI1K77C1.js +0 -5
- package/dist/ui/assets/vendor-react-DdIDsh7f.js +0 -9
- package/dist/ui/assets/vendor-router-BtiNU7Dl.js +0 -3
- package/dist/utils/taskHierarchy.d.ts +0 -4
- package/dist/utils/taskHierarchy.js +0 -69
- package/dist/utils/taskHierarchy.test.js +0 -38
- package/dist/utils/taskParenting.d.ts +0 -3
- package/dist/utils/taskParenting.js +0 -24
- package/dist/utils/taskParenting.test.js +0 -21
- package/dist/utils/taskRelations.d.ts +0 -6
- package/dist/utils/taskRelations.js +0 -24
- package/dist/utils/taskRelations.test.js +0 -34
- package/scripts/check-compat-boundaries.js +0 -48
- /package/dist/{__tests__/planParser.test.d.ts → TaskforceCore.filter-persistence.test.d.ts} +0 -0
- /package/dist/{__tests__/releaseVersion.test.d.ts → TaskforceCore.routing.test.d.ts} +0 -0
- /package/dist/{__tests__/structuredCommentValidation.test.d.ts → annotatedAttachments/AnnotatedAttachmentSessionStore.test.d.ts} +0 -0
- /package/dist/{__tests__/structuredDocValidation.test.d.ts → annotatedAttachments/payload.test.d.ts} +0 -0
- /package/dist/{__tests__/templateEngine.test.d.ts → annotatedAttachments/types.js} +0 -0
- /package/dist/{__tests__/templates.test.d.ts → compat/workspaceSyncCompat.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowDocumentFixtures.test.d.ts → components/features/AgentsModule.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowExportMatrix.test.d.ts → components/features/AnnotatedAttachmentWorkspace.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowExportSnapshots.test.d.ts → components/features/DocumentWorkspace.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowGeneration.test.d.ts → components/features/settings/TaxonomyLibraryManager.test.d.ts} +0 -0
- /package/dist/{__tests__/workflowV2Policy.test.d.ts → components/task/TaskActionHeader.test.d.ts} +0 -0
- /package/dist/{core/InitiativeTemplates.test.d.ts → components/task/TaskKanban.planning-drop.test.d.ts} +0 -0
- /package/dist/{core/ParentLifecycle.test.d.ts → components/task/TaskStatusActions.test.d.ts} +0 -0
- /package/dist/{migrations/taskMigrations.test.d.ts → components/ui/TopNoticeLayer.test.d.ts} +0 -0
- /package/dist/{server/routes.billing.test.d.ts → components/views/AppShellHeader.test.d.ts} +0 -0
- /package/dist/{server/routes.sync.integration.test.d.ts → components/views/PlanComparisonPage.test.d.ts} +0 -0
- /package/dist/{utils/taskHierarchy.test.d.ts → components/views/chrome/WorkspaceChromeSections.test.d.ts} +0 -0
- /package/dist/{utils/taskParenting.test.d.ts → components/views/chrome/workspaceChrome.test.d.ts} +0 -0
- /package/dist/{utils/taskRelations.test.d.ts → components/views/panels/DocumentFilterToolbar.test.d.ts} +0 -0
- /package/dist/public/{android-chrome-192x192.png → archive/phoenix/android-chrome-192x192.png} +0 -0
- /package/dist/public/{android-chrome-512x512.png → archive/phoenix/android-chrome-512x512.png} +0 -0
- /package/dist/public/{favicon-16x16.png → archive/phoenix/favicon-16x16.png} +0 -0
- /package/dist/public/{favicon-32x32.png → archive/phoenix/favicon-32x32.png} +0 -0
- /package/dist/public/{favicon.png → archive/phoenix/favicon.png} +0 -0
- /package/dist/{__tests__ → services}/workflowExportSnapshots.test.js +0 -0
- /package/dist/{__tests__ → services}/workflowGeneration.test.js +0 -0
- /package/dist/ui/favicon/{android-chrome-192x192.png → archive/phoenix/android-chrome-192x192.png} +0 -0
- /package/dist/ui/favicon/{android-chrome-512x512.png → archive/phoenix/android-chrome-512x512.png} +0 -0
- /package/dist/ui/favicon/{favicon-16x16.png → archive/phoenix/favicon-16x16.png} +0 -0
- /package/dist/ui/favicon/{favicon-32x32.png → archive/phoenix/favicon-32x32.png} +0 -0
- /package/dist/ui/favicon/{favicon.png → archive/phoenix/favicon.png} +0 -0
|
@@ -0,0 +1,1631 @@
|
|
|
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 () => ({ id: 'cs_test', url: 'https://example.test/checkout' }));
|
|
9
|
+
const mockCheckoutRetrieve = vi.fn(async (id) => ({ id, mode: 'subscription', customer: 'cus_123', subscription: 'sub_123', metadata: { userId: 'u1' } }));
|
|
10
|
+
const mockPortalCreate = vi.fn(async () => ({ id: 'bps_test', url: 'https://example.test/portal' }));
|
|
11
|
+
const mockSubscriptionCancel = vi.fn(async () => ({ id: 'sub_test', status: 'canceled' }));
|
|
12
|
+
const mockSubscriptionList = vi.fn(async () => ({ data: [] }));
|
|
13
|
+
const mockCustomersList = vi.fn(async () => ({ data: [] }));
|
|
14
|
+
const mockSubscriptionUpdate = vi.fn(async (id, input) => ({
|
|
15
|
+
id,
|
|
16
|
+
customer: 'cus_123',
|
|
17
|
+
status: 'active',
|
|
18
|
+
trial_end: null,
|
|
19
|
+
metadata: {
|
|
20
|
+
userId: 'u1',
|
|
21
|
+
planVersionId: String(input?.metadata?.planVersionId || 'pro-v1')
|
|
22
|
+
},
|
|
23
|
+
items: {
|
|
24
|
+
data: [{
|
|
25
|
+
id: 'si_123',
|
|
26
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
27
|
+
price: { id: String(input?.items?.[0]?.price || 'price_pro_month'), recurring: { interval: 'month' } }
|
|
28
|
+
}]
|
|
29
|
+
}
|
|
30
|
+
}));
|
|
31
|
+
const mockSubscriptionRetrieve = vi.fn(async (id) => ({
|
|
32
|
+
id,
|
|
33
|
+
customer: 'cus_123',
|
|
34
|
+
status: 'active',
|
|
35
|
+
trial_end: null,
|
|
36
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
37
|
+
items: {
|
|
38
|
+
data: [{
|
|
39
|
+
id: 'si_123',
|
|
40
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
41
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
42
|
+
}]
|
|
43
|
+
}
|
|
44
|
+
}));
|
|
45
|
+
vi.mock('stripe', () => ({
|
|
46
|
+
default: class StripeMock {
|
|
47
|
+
webhooks = {
|
|
48
|
+
constructEvent: mockConstructEvent
|
|
49
|
+
};
|
|
50
|
+
prices = {
|
|
51
|
+
list: mockPricesList,
|
|
52
|
+
retrieve: mockPriceRetrieve
|
|
53
|
+
};
|
|
54
|
+
checkout = {
|
|
55
|
+
sessions: {
|
|
56
|
+
create: mockCheckoutCreate,
|
|
57
|
+
retrieve: mockCheckoutRetrieve
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
billingPortal = {
|
|
61
|
+
sessions: {
|
|
62
|
+
create: mockPortalCreate
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
customers = {
|
|
66
|
+
list: mockCustomersList
|
|
67
|
+
};
|
|
68
|
+
subscriptions = {
|
|
69
|
+
cancel: mockSubscriptionCancel,
|
|
70
|
+
retrieve: mockSubscriptionRetrieve,
|
|
71
|
+
update: mockSubscriptionUpdate,
|
|
72
|
+
list: mockSubscriptionList
|
|
73
|
+
};
|
|
74
|
+
constructor(_apiKey) { }
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
function createMockReq(body, headers = {}) {
|
|
78
|
+
const req = Readable.from([Buffer.from(body, 'utf8')]);
|
|
79
|
+
req.headers = headers;
|
|
80
|
+
return req;
|
|
81
|
+
}
|
|
82
|
+
function createMockRes() {
|
|
83
|
+
const response = {
|
|
84
|
+
statusCode: 200,
|
|
85
|
+
headers: {},
|
|
86
|
+
body: '',
|
|
87
|
+
writeHead: vi.fn((statusCode, headers) => {
|
|
88
|
+
response.statusCode = statusCode;
|
|
89
|
+
response.headers = headers || {};
|
|
90
|
+
return response;
|
|
91
|
+
}),
|
|
92
|
+
end: vi.fn((payload) => {
|
|
93
|
+
response.body = String(payload || '');
|
|
94
|
+
return response;
|
|
95
|
+
})
|
|
96
|
+
};
|
|
97
|
+
return response;
|
|
98
|
+
}
|
|
99
|
+
function setupBillingSchema(db) {
|
|
100
|
+
db.exec(`
|
|
101
|
+
CREATE TABLE user_billing (
|
|
102
|
+
user_id TEXT PRIMARY KEY,
|
|
103
|
+
tenant_id TEXT NOT NULL,
|
|
104
|
+
stripe_customer_id TEXT,
|
|
105
|
+
stripe_subscription_id TEXT,
|
|
106
|
+
stripe_price_id TEXT,
|
|
107
|
+
stripe_status TEXT,
|
|
108
|
+
tier TEXT NOT NULL DEFAULT '',
|
|
109
|
+
billing_interval TEXT,
|
|
110
|
+
entitlement_state TEXT NOT NULL DEFAULT 'active',
|
|
111
|
+
trial_end TEXT,
|
|
112
|
+
current_period_end TEXT,
|
|
113
|
+
effective_until TEXT,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
updated_at TEXT NOT NULL
|
|
116
|
+
);
|
|
117
|
+
CREATE TABLE stripe_webhook_events (
|
|
118
|
+
event_id TEXT PRIMARY KEY,
|
|
119
|
+
tenant_id TEXT NOT NULL,
|
|
120
|
+
event_type TEXT NOT NULL,
|
|
121
|
+
processed_at TEXT NOT NULL
|
|
122
|
+
);
|
|
123
|
+
CREATE TABLE stripe_webhook_failures (
|
|
124
|
+
id TEXT PRIMARY KEY,
|
|
125
|
+
tenant_id TEXT NOT NULL,
|
|
126
|
+
event_id TEXT NOT NULL,
|
|
127
|
+
event_type TEXT,
|
|
128
|
+
reason TEXT NOT NULL,
|
|
129
|
+
payload_excerpt TEXT,
|
|
130
|
+
purge_after TEXT NOT NULL,
|
|
131
|
+
created_at TEXT NOT NULL
|
|
132
|
+
);
|
|
133
|
+
CREATE TABLE billing_price_mappings (
|
|
134
|
+
tenant_id TEXT NOT NULL,
|
|
135
|
+
plan_version_id TEXT NOT NULL,
|
|
136
|
+
billing_interval TEXT NOT NULL,
|
|
137
|
+
stripe_price_id TEXT NOT NULL,
|
|
138
|
+
pricing_type TEXT NOT NULL DEFAULT 'stripe',
|
|
139
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
140
|
+
unit_amount INTEGER,
|
|
141
|
+
currency TEXT,
|
|
142
|
+
created_at TEXT NOT NULL,
|
|
143
|
+
updated_at TEXT NOT NULL,
|
|
144
|
+
PRIMARY KEY (tenant_id, plan_version_id, billing_interval, stripe_price_id)
|
|
145
|
+
);
|
|
146
|
+
CREATE UNIQUE INDEX idx_billing_price_mappings_active_interval
|
|
147
|
+
ON billing_price_mappings (tenant_id, plan_version_id, billing_interval)
|
|
148
|
+
WHERE active = 1;
|
|
149
|
+
CREATE TABLE plan_versions (
|
|
150
|
+
plan_version_id TEXT PRIMARY KEY,
|
|
151
|
+
tenant_id TEXT NOT NULL,
|
|
152
|
+
plan_id TEXT NOT NULL,
|
|
153
|
+
version_number INTEGER NOT NULL,
|
|
154
|
+
is_default INTEGER NOT NULL DEFAULT 0,
|
|
155
|
+
seat_limit INTEGER,
|
|
156
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
157
|
+
created_at TEXT NOT NULL,
|
|
158
|
+
updated_at TEXT NOT NULL
|
|
159
|
+
);
|
|
160
|
+
CREATE TABLE plan_catalog (
|
|
161
|
+
plan_id TEXT PRIMARY KEY,
|
|
162
|
+
tenant_id TEXT NOT NULL,
|
|
163
|
+
display_name TEXT NOT NULL,
|
|
164
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
165
|
+
created_at TEXT NOT NULL,
|
|
166
|
+
updated_at TEXT NOT NULL
|
|
167
|
+
);
|
|
168
|
+
CREATE TABLE account_entitlements (
|
|
169
|
+
account_id TEXT PRIMARY KEY,
|
|
170
|
+
tenant_id TEXT NOT NULL,
|
|
171
|
+
plan_version_id TEXT NOT NULL,
|
|
172
|
+
entitlement_state TEXT NOT NULL DEFAULT 'active',
|
|
173
|
+
effective_at TEXT,
|
|
174
|
+
effective_until TEXT,
|
|
175
|
+
updated_by TEXT,
|
|
176
|
+
created_at TEXT NOT NULL,
|
|
177
|
+
updated_at TEXT NOT NULL
|
|
178
|
+
);
|
|
179
|
+
CREATE TABLE system_settings (
|
|
180
|
+
tenant_id TEXT NOT NULL,
|
|
181
|
+
settings_key TEXT NOT NULL,
|
|
182
|
+
payload TEXT,
|
|
183
|
+
updated_at TEXT NOT NULL,
|
|
184
|
+
PRIMARY KEY (tenant_id, settings_key)
|
|
185
|
+
);
|
|
186
|
+
`);
|
|
187
|
+
}
|
|
188
|
+
function buildBillingRouteHandlers(db, opts) {
|
|
189
|
+
const routes = new Map();
|
|
190
|
+
const tenantId = 'default';
|
|
191
|
+
const core = {
|
|
192
|
+
getDatabaseAdapter: () => db,
|
|
193
|
+
getTenantId: () => tenantId,
|
|
194
|
+
createUserBillingRecord: (userId) => {
|
|
195
|
+
const now = new Date().toISOString();
|
|
196
|
+
db.prepare(`
|
|
197
|
+
INSERT INTO user_billing (
|
|
198
|
+
user_id, tenant_id, tier, entitlement_state, created_at, updated_at
|
|
199
|
+
) VALUES (?, ?, '', 'active', ?, ?)
|
|
200
|
+
ON CONFLICT (user_id) DO NOTHING
|
|
201
|
+
`).run(userId, tenantId, now, now);
|
|
202
|
+
},
|
|
203
|
+
listPlanCatalog: () => [
|
|
204
|
+
{ planId: 'pro', displayName: 'Pro Plan', status: 'active', createdAt: '', updatedAt: '' },
|
|
205
|
+
{ planId: 'inactive-plan', displayName: 'Inactive', status: 'archived', createdAt: '', updatedAt: '' },
|
|
206
|
+
{ planId: 'empty-plan', displayName: 'Empty', status: 'active', createdAt: '', updatedAt: '' },
|
|
207
|
+
{ planId: 'starter', displayName: 'Starter', status: 'active', createdAt: '', updatedAt: '' }
|
|
208
|
+
],
|
|
209
|
+
listPlanVersions: (planId) => {
|
|
210
|
+
return db.prepare(`
|
|
211
|
+
SELECT plan_version_id, plan_id, version_number, is_default, seat_limit, metadata_json
|
|
212
|
+
FROM plan_versions
|
|
213
|
+
WHERE tenant_id = ? AND plan_id = ?
|
|
214
|
+
ORDER BY version_number ASC
|
|
215
|
+
`).all(tenantId, planId).map((row) => {
|
|
216
|
+
const metadata = row?.metadata_json ? JSON.parse(String(row.metadata_json)) : {};
|
|
217
|
+
return {
|
|
218
|
+
planVersionId: String(row?.plan_version_id || ''),
|
|
219
|
+
planId: String(row?.plan_id || ''),
|
|
220
|
+
versionNumber: Number(row?.version_number || 1),
|
|
221
|
+
isDefault: Number(row?.is_default || 0) === 1,
|
|
222
|
+
seatLimit: row?.seat_limit === null || row?.seat_limit === undefined ? null : Number(row.seat_limit),
|
|
223
|
+
trialDays: Number.isFinite(Number(metadata?.trialDays)) ? Number(metadata.trialDays) : 0,
|
|
224
|
+
metadata,
|
|
225
|
+
features: {},
|
|
226
|
+
createdAt: '',
|
|
227
|
+
updatedAt: ''
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
getAccountEntitlement: () => ({
|
|
232
|
+
accountId: 'u1',
|
|
233
|
+
planVersionId: 'pro-v1',
|
|
234
|
+
planId: 'pro',
|
|
235
|
+
state: 'active',
|
|
236
|
+
effectiveAt: null,
|
|
237
|
+
effectiveUntil: null,
|
|
238
|
+
updatedBy: null,
|
|
239
|
+
createdAt: '',
|
|
240
|
+
updatedAt: ''
|
|
241
|
+
}),
|
|
242
|
+
getAccountAccessStatus: () => ({
|
|
243
|
+
gate: 'ok',
|
|
244
|
+
hasEntitlement: true,
|
|
245
|
+
canAccessApp: true,
|
|
246
|
+
canAccessPlans: true,
|
|
247
|
+
signupMonetizationStrategy: 'auto_assign_default',
|
|
248
|
+
planSelectionRequired: false,
|
|
249
|
+
message: null,
|
|
250
|
+
entitlement: {
|
|
251
|
+
accountId: 'u1',
|
|
252
|
+
planVersionId: 'pro-v1',
|
|
253
|
+
planId: 'pro',
|
|
254
|
+
state: 'active',
|
|
255
|
+
effectiveAt: null,
|
|
256
|
+
effectiveUntil: null
|
|
257
|
+
}
|
|
258
|
+
}),
|
|
259
|
+
getBillingMirrorProfileForPlanVersion: (planVersionId) => ({
|
|
260
|
+
planId: planVersionId.startsWith('pro') ? 'pro' : 'starter',
|
|
261
|
+
entitlementState: 'active'
|
|
262
|
+
}),
|
|
263
|
+
resolveUserIdentity: () => opts?.userIdentity === undefined
|
|
264
|
+
? {
|
|
265
|
+
userId: 'u1',
|
|
266
|
+
email: 'owner@example.com',
|
|
267
|
+
systemRole: 'user',
|
|
268
|
+
betaAccess: true
|
|
269
|
+
}
|
|
270
|
+
: opts.userIdentity,
|
|
271
|
+
updateAccountEntitlement: vi.fn().mockReturnValue({
|
|
272
|
+
accountId: 'u1',
|
|
273
|
+
planVersionId: 'starter-v1',
|
|
274
|
+
planId: 'starter',
|
|
275
|
+
state: 'active',
|
|
276
|
+
effectiveAt: null,
|
|
277
|
+
effectiveUntil: null
|
|
278
|
+
})
|
|
279
|
+
};
|
|
280
|
+
registerBillingRoutes({
|
|
281
|
+
addRoute: (method, path, handler) => routes.set(`${method} ${path}`, handler),
|
|
282
|
+
core,
|
|
283
|
+
context: {
|
|
284
|
+
getRuntimeMode: () => 'cloud'
|
|
285
|
+
},
|
|
286
|
+
ensureAuthenticatedUser: () => true,
|
|
287
|
+
ensureAdminRole: () => opts?.isAdmin !== false,
|
|
288
|
+
requestUserId: () => 'u1',
|
|
289
|
+
resolveErrorStatusCode: (_error, fallback = 500) => fallback,
|
|
290
|
+
auditAdminAction: () => { }
|
|
291
|
+
});
|
|
292
|
+
return routes;
|
|
293
|
+
}
|
|
294
|
+
describe('Billing Webhook Routes', () => {
|
|
295
|
+
const originalEnv = { ...process.env };
|
|
296
|
+
let db;
|
|
297
|
+
let webhookHandler;
|
|
298
|
+
let routeHandlers;
|
|
299
|
+
beforeEach(() => {
|
|
300
|
+
vi.clearAllMocks();
|
|
301
|
+
db = new Database(':memory:');
|
|
302
|
+
setupBillingSchema(db);
|
|
303
|
+
routeHandlers = buildBillingRouteHandlers(db);
|
|
304
|
+
webhookHandler = routeHandlers.get('POST /api/taskforce/billing/webhook');
|
|
305
|
+
process.env.TASKFORCE_BILLING_ENABLED = 'true';
|
|
306
|
+
process.env.TASKFORCE_STRIPE_SECRET_KEY = 'sk_test_123';
|
|
307
|
+
process.env.TASKFORCE_STRIPE_WEBHOOK_SECRET = 'whsec_123';
|
|
308
|
+
});
|
|
309
|
+
afterEach(() => {
|
|
310
|
+
process.env = { ...originalEnv };
|
|
311
|
+
db.close();
|
|
312
|
+
});
|
|
313
|
+
it('returns 400 for invalid webhook signature', async () => {
|
|
314
|
+
mockConstructEvent.mockImplementation(() => {
|
|
315
|
+
throw new Error('invalid signature');
|
|
316
|
+
});
|
|
317
|
+
const req = createMockReq('{"id":"evt_bad"}', {
|
|
318
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
319
|
+
'stripe-signature': 'bad_signature'
|
|
320
|
+
});
|
|
321
|
+
const res = createMockRes();
|
|
322
|
+
await webhookHandler(req, res, {});
|
|
323
|
+
expect(res.statusCode).toBe(400);
|
|
324
|
+
});
|
|
325
|
+
it('returns 200 no-op for duplicate event id', async () => {
|
|
326
|
+
const now = new Date().toISOString();
|
|
327
|
+
db.prepare(`
|
|
328
|
+
INSERT INTO stripe_webhook_events (event_id, tenant_id, event_type, processed_at)
|
|
329
|
+
VALUES (?, 'default', ?, ?)
|
|
330
|
+
`).run('evt_dupe', 'invoice.paid', now);
|
|
331
|
+
mockConstructEvent.mockReturnValue({
|
|
332
|
+
id: 'evt_dupe',
|
|
333
|
+
type: 'invoice.paid',
|
|
334
|
+
data: {
|
|
335
|
+
object: {
|
|
336
|
+
customer: 'cus_1',
|
|
337
|
+
lines: { data: [{ price: { id: 'price_1', recurring: { interval: 'month' } } }] }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
const req = createMockReq('{"id":"evt_dupe"}', {
|
|
342
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
343
|
+
'stripe-signature': 'sig'
|
|
344
|
+
});
|
|
345
|
+
const res = createMockRes();
|
|
346
|
+
await webhookHandler(req, res, {});
|
|
347
|
+
expect(res.statusCode).toBe(200);
|
|
348
|
+
expect(JSON.parse(res.body)).toEqual({ received: true, duplicate: true });
|
|
349
|
+
});
|
|
350
|
+
it('returns 500 on processing failure so Stripe can retry', async () => {
|
|
351
|
+
mockConstructEvent.mockReturnValue({
|
|
352
|
+
id: 'evt_fail',
|
|
353
|
+
type: 'invoice.paid',
|
|
354
|
+
data: {
|
|
355
|
+
object: {
|
|
356
|
+
customer: 'cus_missing',
|
|
357
|
+
lines: { data: [{ price: { id: 'price_1', recurring: { interval: 'month' } } }] }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
const req = createMockReq('{"id":"evt_fail"}', {
|
|
362
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
363
|
+
'stripe-signature': 'sig'
|
|
364
|
+
});
|
|
365
|
+
const res = createMockRes();
|
|
366
|
+
await webhookHandler(req, res, {});
|
|
367
|
+
expect(res.statusCode).toBe(500);
|
|
368
|
+
const failures = db.prepare(`SELECT event_id, reason FROM stripe_webhook_failures WHERE event_id = ?`).all('evt_fail');
|
|
369
|
+
expect(failures.length).toBeGreaterThan(0);
|
|
370
|
+
expect(failures[0]?.reason || '').toContain('Unable to resolve user for invoice event.');
|
|
371
|
+
});
|
|
372
|
+
it('preserves existing entitlement on unknown price_id and logs failure', async () => {
|
|
373
|
+
const now = new Date().toISOString();
|
|
374
|
+
db.prepare(`
|
|
375
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
376
|
+
VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
|
|
377
|
+
`).run(now, now);
|
|
378
|
+
db.prepare(`
|
|
379
|
+
INSERT INTO account_entitlements (
|
|
380
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
381
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?, ?)
|
|
382
|
+
`).run(now, now, now);
|
|
383
|
+
mockConstructEvent.mockReturnValue({
|
|
384
|
+
id: 'evt_unknown_price',
|
|
385
|
+
type: 'customer.subscription.updated',
|
|
386
|
+
data: {
|
|
387
|
+
object: {
|
|
388
|
+
id: 'sub_123',
|
|
389
|
+
customer: 'cus_123',
|
|
390
|
+
status: 'active',
|
|
391
|
+
trial_end: null,
|
|
392
|
+
metadata: {},
|
|
393
|
+
items: {
|
|
394
|
+
data: [{
|
|
395
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
396
|
+
price: { id: 'price_unknown', recurring: { interval: 'month' } }
|
|
397
|
+
}]
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
const req = createMockReq('{"id":"evt_unknown_price"}', {
|
|
403
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
404
|
+
'stripe-signature': 'sig'
|
|
405
|
+
});
|
|
406
|
+
const res = createMockRes();
|
|
407
|
+
await webhookHandler(req, res, {});
|
|
408
|
+
expect(res.statusCode).toBe(200);
|
|
409
|
+
const entitlement = db.prepare(`
|
|
410
|
+
SELECT plan_version_id
|
|
411
|
+
FROM account_entitlements
|
|
412
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
413
|
+
`).get();
|
|
414
|
+
expect(entitlement.plan_version_id).toBe('starter-v1');
|
|
415
|
+
const failure = db.prepare(`
|
|
416
|
+
SELECT reason
|
|
417
|
+
FROM stripe_webhook_failures
|
|
418
|
+
WHERE event_id = 'evt_unknown_price'
|
|
419
|
+
ORDER BY created_at DESC
|
|
420
|
+
LIMIT 1
|
|
421
|
+
`).get();
|
|
422
|
+
expect(failure.reason).toContain('Unknown price mapping');
|
|
423
|
+
});
|
|
424
|
+
it('purges expired webhook failures during webhook processing', async () => {
|
|
425
|
+
const past = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
426
|
+
const now = new Date().toISOString();
|
|
427
|
+
db.prepare(`
|
|
428
|
+
INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
|
|
429
|
+
VALUES ('whf_old', 'default', 'evt_old', 'invoice.paid', 'old', '{}', ?, ?)
|
|
430
|
+
`).run(past, now);
|
|
431
|
+
db.prepare(`
|
|
432
|
+
INSERT INTO stripe_webhook_events (event_id, tenant_id, event_type, processed_at)
|
|
433
|
+
VALUES ('evt_dup_for_purge', 'default', 'invoice.paid', ?)
|
|
434
|
+
`).run(now);
|
|
435
|
+
mockConstructEvent.mockReturnValue({
|
|
436
|
+
id: 'evt_dup_for_purge',
|
|
437
|
+
type: 'invoice.paid',
|
|
438
|
+
data: { object: { customer: 'cus_1', lines: { data: [] } } }
|
|
439
|
+
});
|
|
440
|
+
const req = createMockReq('{"id":"evt_dup_for_purge"}', {
|
|
441
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
442
|
+
'stripe-signature': 'sig'
|
|
443
|
+
});
|
|
444
|
+
const res = createMockRes();
|
|
445
|
+
await webhookHandler(req, res, {});
|
|
446
|
+
expect(res.statusCode).toBe(200);
|
|
447
|
+
const row = db.prepare(`SELECT id FROM stripe_webhook_failures WHERE id = 'whf_old'`).get();
|
|
448
|
+
expect(row).toBeUndefined();
|
|
449
|
+
});
|
|
450
|
+
it('creates checkout session with plans success/cancel URLs and selection context', async () => {
|
|
451
|
+
const now = new Date().toISOString();
|
|
452
|
+
db.prepare(`
|
|
453
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
454
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
455
|
+
`).run(now, now);
|
|
456
|
+
db.prepare(`
|
|
457
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
458
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
459
|
+
`).run(now, now);
|
|
460
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
461
|
+
expect(checkoutHandler).toBeTypeOf('function');
|
|
462
|
+
const req = createMockReq(JSON.stringify({
|
|
463
|
+
planVersionId: 'collab-v1',
|
|
464
|
+
interval: 'month'
|
|
465
|
+
}), {
|
|
466
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
467
|
+
host: 'app.taskforcehq.ai'
|
|
468
|
+
});
|
|
469
|
+
const res = createMockRes();
|
|
470
|
+
await checkoutHandler(req, res, {});
|
|
471
|
+
expect(res.statusCode).toBe(200);
|
|
472
|
+
expect(mockCheckoutCreate).toHaveBeenCalledTimes(1);
|
|
473
|
+
const firstCall = mockCheckoutCreate.mock.calls[0];
|
|
474
|
+
const args = (Array.isArray(firstCall) ? firstCall[0] : {});
|
|
475
|
+
expect(String(args.success_url || '')).toContain('/?screen=plans&checkout=success');
|
|
476
|
+
expect(String(args.cancel_url || '')).toContain('/?screen=plans&checkout=cancel');
|
|
477
|
+
expect(String(args.cancel_url || '')).toContain('planId=collab');
|
|
478
|
+
expect(String(args.cancel_url || '')).toContain('planVersionId=collab-v1');
|
|
479
|
+
expect(String(args.cancel_url || '')).toContain('interval=month');
|
|
480
|
+
expect(args.line_items?.[0]?.price).toBe('price_collab_month');
|
|
481
|
+
expect(args.customer).toBeUndefined();
|
|
482
|
+
expect(args.customer_email).toBe('owner@example.com');
|
|
483
|
+
});
|
|
484
|
+
it('uses per-version trial days when creating checkout', async () => {
|
|
485
|
+
const now = new Date().toISOString();
|
|
486
|
+
db.prepare(`
|
|
487
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
488
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{"trialDays":21}', ?, ?)
|
|
489
|
+
`).run(now, now);
|
|
490
|
+
db.prepare(`
|
|
491
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
492
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
493
|
+
`).run(now, now);
|
|
494
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
495
|
+
const req = createMockReq(JSON.stringify({
|
|
496
|
+
planVersionId: 'collab-v1',
|
|
497
|
+
interval: 'month'
|
|
498
|
+
}), {
|
|
499
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
500
|
+
host: 'app.taskforcehq.ai'
|
|
501
|
+
});
|
|
502
|
+
const res = createMockRes();
|
|
503
|
+
await checkoutHandler(req, res, {});
|
|
504
|
+
expect(res.statusCode).toBe(200);
|
|
505
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
506
|
+
expect(args.subscription_data?.trial_period_days).toBe(21);
|
|
507
|
+
});
|
|
508
|
+
it('omits Stripe trial period when the selected version trialDays is 0', async () => {
|
|
509
|
+
const now = new Date().toISOString();
|
|
510
|
+
db.prepare(`
|
|
511
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
512
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{"trialDays":0}', ?, ?)
|
|
513
|
+
`).run(now, now);
|
|
514
|
+
db.prepare(`
|
|
515
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
516
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
517
|
+
`).run(now, now);
|
|
518
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
519
|
+
const req = createMockReq(JSON.stringify({
|
|
520
|
+
planVersionId: 'collab-v1',
|
|
521
|
+
interval: 'month'
|
|
522
|
+
}), {
|
|
523
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
524
|
+
host: 'app.taskforcehq.ai'
|
|
525
|
+
});
|
|
526
|
+
const res = createMockRes();
|
|
527
|
+
await checkoutHandler(req, res, {});
|
|
528
|
+
expect(res.statusCode).toBe(200);
|
|
529
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
530
|
+
expect(args.subscription_data?.trial_period_days).toBeUndefined();
|
|
531
|
+
});
|
|
532
|
+
it('creates checkout session with existing stripe customer and no customer_email override', async () => {
|
|
533
|
+
const now = new Date().toISOString();
|
|
534
|
+
db.prepare(`
|
|
535
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
536
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
537
|
+
`).run(now, now);
|
|
538
|
+
db.prepare(`
|
|
539
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
540
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
541
|
+
`).run(now, now);
|
|
542
|
+
db.prepare(`
|
|
543
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
544
|
+
VALUES ('u1', 'default', 'cus_existing', 'active', ?, ?)
|
|
545
|
+
`).run(now, now);
|
|
546
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
547
|
+
const req = createMockReq(JSON.stringify({
|
|
548
|
+
planVersionId: 'collab-v1',
|
|
549
|
+
interval: 'month'
|
|
550
|
+
}), {
|
|
551
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
552
|
+
host: 'app.taskforcehq.ai'
|
|
553
|
+
});
|
|
554
|
+
const res = createMockRes();
|
|
555
|
+
await checkoutHandler(req, res, {});
|
|
556
|
+
expect(res.statusCode).toBe(200);
|
|
557
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
558
|
+
expect(args.customer).toBe('cus_existing');
|
|
559
|
+
expect(args.customer_email).toBeUndefined();
|
|
560
|
+
});
|
|
561
|
+
it('reuses an existing Stripe customer discovered by account email before creating checkout', async () => {
|
|
562
|
+
const now = new Date().toISOString();
|
|
563
|
+
db.prepare(`
|
|
564
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
565
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
566
|
+
`).run(now, now);
|
|
567
|
+
db.prepare(`
|
|
568
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
569
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
570
|
+
`).run(now, now);
|
|
571
|
+
mockCustomersList.mockResolvedValueOnce({
|
|
572
|
+
data: [{ id: 'cus_email_match' }]
|
|
573
|
+
});
|
|
574
|
+
mockSubscriptionList.mockResolvedValueOnce({ data: [] });
|
|
575
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
576
|
+
const req = createMockReq(JSON.stringify({
|
|
577
|
+
planVersionId: 'collab-v1',
|
|
578
|
+
interval: 'month'
|
|
579
|
+
}), {
|
|
580
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
581
|
+
host: 'app.taskforcehq.ai'
|
|
582
|
+
});
|
|
583
|
+
const res = createMockRes();
|
|
584
|
+
await checkoutHandler(req, res, {});
|
|
585
|
+
expect(res.statusCode).toBe(200);
|
|
586
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
587
|
+
expect(args.customer).toBe('cus_email_match');
|
|
588
|
+
expect(args.customer_email).toBeUndefined();
|
|
589
|
+
});
|
|
590
|
+
it('builds checkout return URLs from explicit local proxy origin headers', async () => {
|
|
591
|
+
const now = new Date().toISOString();
|
|
592
|
+
db.prepare(`
|
|
593
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
594
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
595
|
+
`).run(now, now);
|
|
596
|
+
db.prepare(`
|
|
597
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
598
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
599
|
+
`).run(now, now);
|
|
600
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
601
|
+
const req = createMockReq(JSON.stringify({
|
|
602
|
+
planVersionId: 'collab-v1',
|
|
603
|
+
interval: 'month'
|
|
604
|
+
}), {
|
|
605
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
606
|
+
host: 'performance-app.taskforcehq.ai',
|
|
607
|
+
'x-taskforce-origin-host': 'localhost:5174',
|
|
608
|
+
'x-taskforce-origin-proto': 'http',
|
|
609
|
+
'x-taskforce-origin-base-url': 'http://localhost:5174',
|
|
610
|
+
'x-taskforce-origin-runtime': 'local-proxy',
|
|
611
|
+
'x-forwarded-host': 'localhost:5174',
|
|
612
|
+
'x-forwarded-proto': 'http'
|
|
613
|
+
});
|
|
614
|
+
const res = createMockRes();
|
|
615
|
+
await checkoutHandler(req, res, {});
|
|
616
|
+
expect(res.statusCode).toBe(200);
|
|
617
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
618
|
+
expect(String(args.success_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=success');
|
|
619
|
+
expect(String(args.cancel_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=cancel');
|
|
620
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
621
|
+
checkoutState: 'checkout',
|
|
622
|
+
returnBaseUrl: 'http://localhost:5174'
|
|
623
|
+
}));
|
|
624
|
+
});
|
|
625
|
+
it('prefers explicit proxy-origin headers over generic forwarded host values', async () => {
|
|
626
|
+
const now = new Date().toISOString();
|
|
627
|
+
db.prepare(`
|
|
628
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
629
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
630
|
+
`).run(now, now);
|
|
631
|
+
db.prepare(`
|
|
632
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
633
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
634
|
+
`).run(now, now);
|
|
635
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
636
|
+
const req = createMockReq(JSON.stringify({
|
|
637
|
+
planVersionId: 'collab-v1',
|
|
638
|
+
interval: 'month'
|
|
639
|
+
}), {
|
|
640
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
641
|
+
host: 'performance-app.taskforcehq.ai',
|
|
642
|
+
'x-taskforce-origin-host': 'localhost:5174',
|
|
643
|
+
'x-taskforce-origin-proto': 'http',
|
|
644
|
+
'x-taskforce-origin-runtime': 'local-proxy',
|
|
645
|
+
'x-forwarded-host': 'performance-app.taskforcehq.ai',
|
|
646
|
+
'x-forwarded-proto': 'https'
|
|
647
|
+
});
|
|
648
|
+
const res = createMockRes();
|
|
649
|
+
await checkoutHandler(req, res, {});
|
|
650
|
+
expect(res.statusCode).toBe(200);
|
|
651
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
652
|
+
expect(String(args.success_url || '')).toContain('http://localhost:5174/?screen=plans&checkout=success');
|
|
653
|
+
});
|
|
654
|
+
it('builds direct cloud checkout return URLs from the actual request protocol when forwarded proto is absent', async () => {
|
|
655
|
+
const now = new Date().toISOString();
|
|
656
|
+
db.prepare(`
|
|
657
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
658
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
659
|
+
`).run(now, now);
|
|
660
|
+
db.prepare(`
|
|
661
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
662
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
663
|
+
`).run(now, now);
|
|
664
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
665
|
+
const req = createMockReq(JSON.stringify({
|
|
666
|
+
planVersionId: 'collab-v1',
|
|
667
|
+
interval: 'month'
|
|
668
|
+
}), {
|
|
669
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
670
|
+
host: '127.0.0.1:4173'
|
|
671
|
+
});
|
|
672
|
+
req.socket = { encrypted: false };
|
|
673
|
+
const res = createMockRes();
|
|
674
|
+
await checkoutHandler(req, res, {});
|
|
675
|
+
expect(res.statusCode).toBe(200);
|
|
676
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
677
|
+
expect(String(args.success_url || '')).toContain('http://127.0.0.1:4173/?screen=plans&checkout=success');
|
|
678
|
+
expect(String(args.cancel_url || '')).toContain('http://127.0.0.1:4173/?screen=plans&checkout=cancel');
|
|
679
|
+
});
|
|
680
|
+
it('applies a free plan immediately without creating a Stripe checkout session', async () => {
|
|
681
|
+
const now = new Date().toISOString();
|
|
682
|
+
db.prepare(`
|
|
683
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
684
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
685
|
+
`).run(now, now);
|
|
686
|
+
db.prepare(`
|
|
687
|
+
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)
|
|
688
|
+
VALUES ('default', 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, NULL, ?, ?)
|
|
689
|
+
`).run(now, now);
|
|
690
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
691
|
+
const req = createMockReq(JSON.stringify({
|
|
692
|
+
planVersionId: 'starter-v1',
|
|
693
|
+
interval: 'month'
|
|
694
|
+
}), {
|
|
695
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
696
|
+
host: 'app.taskforcehq.ai'
|
|
697
|
+
});
|
|
698
|
+
const res = createMockRes();
|
|
699
|
+
await checkoutHandler(req, res, {});
|
|
700
|
+
expect(res.statusCode).toBe(200);
|
|
701
|
+
expect(mockCheckoutCreate).not.toHaveBeenCalled();
|
|
702
|
+
expect(res.body).toContain('"checkoutState":"free"');
|
|
703
|
+
});
|
|
704
|
+
it('falls back to stripe email collection when user identity email is unavailable', async () => {
|
|
705
|
+
routeHandlers = buildBillingRouteHandlers(db, { userIdentity: { userId: 'u1', email: null, systemRole: 'user', betaAccess: true } });
|
|
706
|
+
const now = new Date().toISOString();
|
|
707
|
+
db.prepare(`
|
|
708
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
709
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
710
|
+
`).run(now, now);
|
|
711
|
+
db.prepare(`
|
|
712
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
713
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
714
|
+
`).run(now, now);
|
|
715
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
716
|
+
const req = createMockReq(JSON.stringify({
|
|
717
|
+
planVersionId: 'collab-v1',
|
|
718
|
+
interval: 'month'
|
|
719
|
+
}), {
|
|
720
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
721
|
+
host: 'app.taskforcehq.ai'
|
|
722
|
+
});
|
|
723
|
+
const res = createMockRes();
|
|
724
|
+
await checkoutHandler(req, res, {});
|
|
725
|
+
expect(res.statusCode).toBe(200);
|
|
726
|
+
const args = mockCheckoutCreate.mock.calls.at(-1)?.[0];
|
|
727
|
+
expect(args.customer).toBeUndefined();
|
|
728
|
+
expect(args.customer_email).toBeUndefined();
|
|
729
|
+
});
|
|
730
|
+
it('returns a clear error when the saved stripe customer no longer exists', async () => {
|
|
731
|
+
const now = new Date().toISOString();
|
|
732
|
+
db.prepare(`
|
|
733
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
734
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
735
|
+
`).run(now, now);
|
|
736
|
+
db.prepare(`
|
|
737
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
738
|
+
VALUES ('default', 'collab-v1', 'month', 'price_collab_month', 1, ?, ?)
|
|
739
|
+
`).run(now, now);
|
|
740
|
+
db.prepare(`
|
|
741
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
742
|
+
VALUES ('u1', 'default', 'cus_missing', 'active', ?, ?)
|
|
743
|
+
`).run(now, now);
|
|
744
|
+
mockCheckoutCreate.mockImplementationOnce(async () => {
|
|
745
|
+
const error = new Error('No such customer: cus_missing');
|
|
746
|
+
error.code = 'resource_missing';
|
|
747
|
+
throw error;
|
|
748
|
+
});
|
|
749
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
750
|
+
const req = createMockReq(JSON.stringify({
|
|
751
|
+
planVersionId: 'collab-v1',
|
|
752
|
+
interval: 'month'
|
|
753
|
+
}), {
|
|
754
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
755
|
+
host: 'app.taskforcehq.ai'
|
|
756
|
+
});
|
|
757
|
+
const res = createMockRes();
|
|
758
|
+
await checkoutHandler(req, res, {});
|
|
759
|
+
expect(res.statusCode).toBe(409);
|
|
760
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
761
|
+
error: 'The saved Stripe customer for this account could not be found. Please contact support before retrying checkout.',
|
|
762
|
+
code: 'BILLING_STRIPE_CUSTOMER_MISSING'
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
it('updates existing subscriptions without creating a checkout session', async () => {
|
|
766
|
+
const now = new Date().toISOString();
|
|
767
|
+
db.prepare(`
|
|
768
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
769
|
+
VALUES ('pro-v2', 'default', 'pro', 2, 1, '{}', ?, ?)
|
|
770
|
+
`).run(now, now);
|
|
771
|
+
db.prepare(`
|
|
772
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
773
|
+
VALUES ('default', 'pro-v2', 'year', 'price_pro_year', 1, ?, ?)
|
|
774
|
+
`).run(now, now);
|
|
775
|
+
db.prepare(`
|
|
776
|
+
INSERT INTO user_billing (
|
|
777
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, billing_interval, entitlement_state, created_at, updated_at
|
|
778
|
+
) VALUES ('u1', 'default', 'cus_existing', 'sub_existing', 'price_pro_month', 'month', 'active', ?, ?)
|
|
779
|
+
`).run(now, now);
|
|
780
|
+
db.prepare(`
|
|
781
|
+
INSERT INTO account_entitlements (
|
|
782
|
+
account_id, tenant_id, plan_version_id, entitlement_state, created_at, updated_at
|
|
783
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?)
|
|
784
|
+
`).run(now, now);
|
|
785
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
786
|
+
const req = createMockReq(JSON.stringify({
|
|
787
|
+
planVersionId: 'pro-v2',
|
|
788
|
+
interval: 'year'
|
|
789
|
+
}), {
|
|
790
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
791
|
+
host: 'app.taskforcehq.ai'
|
|
792
|
+
});
|
|
793
|
+
const res = createMockRes();
|
|
794
|
+
await checkoutHandler(req, res, {});
|
|
795
|
+
expect(res.statusCode).toBe(200);
|
|
796
|
+
expect(mockSubscriptionUpdate).toHaveBeenCalledWith('sub_existing', expect.objectContaining({
|
|
797
|
+
items: [{ id: 'si_123', price: 'price_pro_year' }]
|
|
798
|
+
}));
|
|
799
|
+
expect(mockCheckoutCreate).not.toHaveBeenCalled();
|
|
800
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
801
|
+
checkoutState: 'updated'
|
|
802
|
+
}));
|
|
803
|
+
});
|
|
804
|
+
it('does not create a second checkout session for the same paid plan', async () => {
|
|
805
|
+
const now = new Date().toISOString();
|
|
806
|
+
db.prepare(`
|
|
807
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
808
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
809
|
+
`).run(now, now);
|
|
810
|
+
db.prepare(`
|
|
811
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
812
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
813
|
+
`).run(now, now);
|
|
814
|
+
db.prepare(`
|
|
815
|
+
INSERT INTO user_billing (
|
|
816
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
817
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
|
|
818
|
+
`).run(now, now);
|
|
819
|
+
db.prepare(`
|
|
820
|
+
INSERT INTO account_entitlements (
|
|
821
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
822
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
|
|
823
|
+
`).run(now, now, now);
|
|
824
|
+
mockSubscriptionList.mockResolvedValueOnce({
|
|
825
|
+
data: [{ id: 'sub_123', status: 'active' }]
|
|
826
|
+
});
|
|
827
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
828
|
+
const req = createMockReq(JSON.stringify({
|
|
829
|
+
planVersionId: 'pro-v1',
|
|
830
|
+
interval: 'month'
|
|
831
|
+
}), {
|
|
832
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
833
|
+
host: 'app.taskforcehq.ai'
|
|
834
|
+
});
|
|
835
|
+
const res = createMockRes();
|
|
836
|
+
await checkoutHandler(req, res, {});
|
|
837
|
+
expect(res.statusCode).toBe(200);
|
|
838
|
+
expect(mockCheckoutCreate).not.toHaveBeenCalled();
|
|
839
|
+
expect(mockSubscriptionUpdate).not.toHaveBeenCalled();
|
|
840
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
841
|
+
checkoutState: 'already_current_plan',
|
|
842
|
+
planVersionId: 'pro-v1'
|
|
843
|
+
}));
|
|
844
|
+
});
|
|
845
|
+
it('discovers an active paid subscription by account email and blocks a duplicate purchase', async () => {
|
|
846
|
+
const now = new Date().toISOString();
|
|
847
|
+
db.prepare(`
|
|
848
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
849
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
850
|
+
`).run(now, now);
|
|
851
|
+
db.prepare(`
|
|
852
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
853
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
854
|
+
`).run(now, now);
|
|
855
|
+
mockCustomersList.mockResolvedValueOnce({
|
|
856
|
+
data: [{ id: 'cus_email_match' }]
|
|
857
|
+
});
|
|
858
|
+
mockSubscriptionList.mockResolvedValueOnce({
|
|
859
|
+
data: [{
|
|
860
|
+
id: 'sub_email_match',
|
|
861
|
+
customer: 'cus_email_match',
|
|
862
|
+
status: 'active',
|
|
863
|
+
trial_end: null,
|
|
864
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
865
|
+
items: {
|
|
866
|
+
data: [{
|
|
867
|
+
id: 'si_email_match',
|
|
868
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
869
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
870
|
+
}]
|
|
871
|
+
}
|
|
872
|
+
}]
|
|
873
|
+
});
|
|
874
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
875
|
+
const req = createMockReq(JSON.stringify({
|
|
876
|
+
planVersionId: 'pro-v1',
|
|
877
|
+
interval: 'month'
|
|
878
|
+
}), {
|
|
879
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
880
|
+
host: 'app.taskforcehq.ai'
|
|
881
|
+
});
|
|
882
|
+
const res = createMockRes();
|
|
883
|
+
await checkoutHandler(req, res, {});
|
|
884
|
+
expect(res.statusCode).toBe(200);
|
|
885
|
+
expect(mockCheckoutCreate).not.toHaveBeenCalled();
|
|
886
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
887
|
+
checkoutState: 'already_current_plan',
|
|
888
|
+
planVersionId: 'pro-v1'
|
|
889
|
+
}));
|
|
890
|
+
const mirrored = db.prepare(`
|
|
891
|
+
SELECT stripe_customer_id, stripe_subscription_id
|
|
892
|
+
FROM user_billing
|
|
893
|
+
WHERE tenant_id = 'default' AND user_id = 'u1'
|
|
894
|
+
LIMIT 1
|
|
895
|
+
`).get();
|
|
896
|
+
expect(mirrored).toEqual(expect.objectContaining({
|
|
897
|
+
stripe_customer_id: 'cus_email_match',
|
|
898
|
+
stripe_subscription_id: 'sub_email_match'
|
|
899
|
+
}));
|
|
900
|
+
});
|
|
901
|
+
it('updates the existing paid subscription instead of creating a second checkout', async () => {
|
|
902
|
+
const now = new Date().toISOString();
|
|
903
|
+
db.prepare(`
|
|
904
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
905
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
906
|
+
`).run(now, now);
|
|
907
|
+
db.prepare(`
|
|
908
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
909
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
910
|
+
`).run(now, now);
|
|
911
|
+
db.prepare(`
|
|
912
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
913
|
+
VALUES ('default', 'team-v1', 'month', 'price_team_month', 1, ?, ?)
|
|
914
|
+
`).run(now, now);
|
|
915
|
+
db.prepare(`
|
|
916
|
+
INSERT INTO user_billing (
|
|
917
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
918
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
|
|
919
|
+
`).run(now, now);
|
|
920
|
+
db.prepare(`
|
|
921
|
+
INSERT INTO account_entitlements (
|
|
922
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
923
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
|
|
924
|
+
`).run(now, now, now);
|
|
925
|
+
mockSubscriptionList.mockResolvedValueOnce({
|
|
926
|
+
data: [{ id: 'sub_123', status: 'active' }]
|
|
927
|
+
});
|
|
928
|
+
mockSubscriptionUpdate.mockResolvedValueOnce({
|
|
929
|
+
id: 'sub_123',
|
|
930
|
+
customer: 'cus_123',
|
|
931
|
+
status: 'active',
|
|
932
|
+
trial_end: null,
|
|
933
|
+
metadata: { userId: 'u1', planVersionId: 'team-v1' },
|
|
934
|
+
items: {
|
|
935
|
+
data: [{
|
|
936
|
+
id: 'si_123',
|
|
937
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
938
|
+
price: { id: 'price_team_month', recurring: { interval: 'month' } }
|
|
939
|
+
}]
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
943
|
+
const req = createMockReq(JSON.stringify({
|
|
944
|
+
planVersionId: 'team-v1',
|
|
945
|
+
interval: 'month'
|
|
946
|
+
}), {
|
|
947
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
948
|
+
host: 'app.taskforcehq.ai'
|
|
949
|
+
});
|
|
950
|
+
const res = createMockRes();
|
|
951
|
+
await checkoutHandler(req, res, {});
|
|
952
|
+
expect(res.statusCode).toBe(200);
|
|
953
|
+
expect(mockCheckoutCreate).not.toHaveBeenCalled();
|
|
954
|
+
expect(mockSubscriptionUpdate).toHaveBeenCalledWith('sub_123', expect.objectContaining({
|
|
955
|
+
items: [{ id: 'si_123', price: 'price_team_month' }]
|
|
956
|
+
}));
|
|
957
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
958
|
+
checkoutState: 'updated',
|
|
959
|
+
planVersionId: 'team-v1'
|
|
960
|
+
}));
|
|
961
|
+
const entitlement = db.prepare(`
|
|
962
|
+
SELECT plan_version_id
|
|
963
|
+
FROM account_entitlements
|
|
964
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
965
|
+
LIMIT 1
|
|
966
|
+
`).get();
|
|
967
|
+
expect(entitlement?.plan_version_id).toBe('team-v1');
|
|
968
|
+
});
|
|
969
|
+
it('updates the existing subscription when only the interval changes on the same plan', async () => {
|
|
970
|
+
const now = new Date().toISOString();
|
|
971
|
+
db.prepare(`
|
|
972
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
973
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
974
|
+
`).run(now, now);
|
|
975
|
+
db.prepare(`
|
|
976
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
977
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
978
|
+
`).run(now, now);
|
|
979
|
+
db.prepare(`
|
|
980
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
981
|
+
VALUES ('default', 'pro-v1', 'year', 'price_pro_year', 1, ?, ?)
|
|
982
|
+
`).run(now, now);
|
|
983
|
+
db.prepare(`
|
|
984
|
+
INSERT INTO user_billing (
|
|
985
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
986
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_pro_month', 'active', 'pro', 'month', 'active', ?, ?)
|
|
987
|
+
`).run(now, now);
|
|
988
|
+
db.prepare(`
|
|
989
|
+
INSERT INTO account_entitlements (
|
|
990
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
991
|
+
) VALUES ('u1', 'default', 'pro-v1', 'active', ?, ?, ?)
|
|
992
|
+
`).run(now, now, now);
|
|
993
|
+
mockSubscriptionList.mockResolvedValueOnce({
|
|
994
|
+
data: [{ id: 'sub_123', status: 'active' }]
|
|
995
|
+
});
|
|
996
|
+
mockSubscriptionUpdate.mockResolvedValueOnce({
|
|
997
|
+
id: 'sub_123',
|
|
998
|
+
customer: 'cus_123',
|
|
999
|
+
status: 'active',
|
|
1000
|
+
trial_end: null,
|
|
1001
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
1002
|
+
items: {
|
|
1003
|
+
data: [{
|
|
1004
|
+
id: 'si_123',
|
|
1005
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1006
|
+
price: { id: 'price_pro_year', recurring: { interval: 'year' } }
|
|
1007
|
+
}]
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
const checkoutHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session');
|
|
1011
|
+
const req = createMockReq(JSON.stringify({
|
|
1012
|
+
planVersionId: 'pro-v1',
|
|
1013
|
+
interval: 'year'
|
|
1014
|
+
}), {
|
|
1015
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1016
|
+
host: 'app.taskforcehq.ai'
|
|
1017
|
+
});
|
|
1018
|
+
const res = createMockRes();
|
|
1019
|
+
await checkoutHandler(req, res, {});
|
|
1020
|
+
expect(res.statusCode).toBe(200);
|
|
1021
|
+
expect(mockCheckoutCreate).not.toHaveBeenCalled();
|
|
1022
|
+
expect(mockSubscriptionUpdate).toHaveBeenCalledWith('sub_123', expect.objectContaining({
|
|
1023
|
+
items: [{ id: 'si_123', price: 'price_pro_year' }]
|
|
1024
|
+
}));
|
|
1025
|
+
expect(JSON.parse(res.body)).toEqual(expect.objectContaining({
|
|
1026
|
+
checkoutState: 'updated',
|
|
1027
|
+
planVersionId: 'pro-v1'
|
|
1028
|
+
}));
|
|
1029
|
+
});
|
|
1030
|
+
it('confirms a successful checkout session and updates the entitlement immediately', async () => {
|
|
1031
|
+
const now = new Date().toISOString();
|
|
1032
|
+
db.prepare(`
|
|
1033
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1034
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1035
|
+
`).run(now, now);
|
|
1036
|
+
db.prepare(`
|
|
1037
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1038
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1039
|
+
`).run(now, now);
|
|
1040
|
+
const confirmHandler = routeHandlers.get('POST /api/taskforce/billing/checkout-session/confirm');
|
|
1041
|
+
expect(confirmHandler).toBeTypeOf('function');
|
|
1042
|
+
const req = createMockReq(JSON.stringify({
|
|
1043
|
+
sessionId: 'cs_success_123'
|
|
1044
|
+
}), {
|
|
1045
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1046
|
+
host: 'app.taskforcehq.ai'
|
|
1047
|
+
});
|
|
1048
|
+
const res = createMockRes();
|
|
1049
|
+
await confirmHandler(req, res, {});
|
|
1050
|
+
expect(res.statusCode).toBe(200);
|
|
1051
|
+
expect(mockCheckoutRetrieve).toHaveBeenCalledWith('cs_success_123', expect.objectContaining({
|
|
1052
|
+
expand: ['subscription']
|
|
1053
|
+
}));
|
|
1054
|
+
expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
|
|
1055
|
+
const entitlement = db.prepare(`
|
|
1056
|
+
SELECT plan_version_id, entitlement_state
|
|
1057
|
+
FROM account_entitlements
|
|
1058
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1059
|
+
LIMIT 1
|
|
1060
|
+
`).get();
|
|
1061
|
+
expect(entitlement).toEqual(expect.objectContaining({
|
|
1062
|
+
plan_version_id: 'pro-v1',
|
|
1063
|
+
entitlement_state: 'active'
|
|
1064
|
+
}));
|
|
1065
|
+
});
|
|
1066
|
+
it('lists webhook failure diagnostics for system admin', async () => {
|
|
1067
|
+
const now = new Date().toISOString();
|
|
1068
|
+
const old = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
|
1069
|
+
db.prepare(`
|
|
1070
|
+
INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
|
|
1071
|
+
VALUES ('whf_keep_1', 'default', 'evt_1', 'invoice.paid', 'diagnostic-1', '{"id":"evt_1"}', ?, ?)
|
|
1072
|
+
`).run(new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(), now);
|
|
1073
|
+
db.prepare(`
|
|
1074
|
+
INSERT INTO stripe_webhook_failures (id, tenant_id, event_id, event_type, reason, payload_excerpt, purge_after, created_at)
|
|
1075
|
+
VALUES ('whf_purge_1', 'default', 'evt_old', 'invoice.paid', 'diagnostic-old', '{"id":"evt_old"}', ?, ?)
|
|
1076
|
+
`).run(old, now);
|
|
1077
|
+
const route = routeHandlers.get('GET /api/taskforce/admin/billing/webhook-failures');
|
|
1078
|
+
expect(route).toBeTypeOf('function');
|
|
1079
|
+
const req = createMockReq('', {
|
|
1080
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1081
|
+
});
|
|
1082
|
+
req.url = '/api/taskforce/admin/billing/webhook-failures?limit=10';
|
|
1083
|
+
const res = createMockRes();
|
|
1084
|
+
await route(req, res, {});
|
|
1085
|
+
expect(res.statusCode).toBe(200);
|
|
1086
|
+
const payload = JSON.parse(res.body);
|
|
1087
|
+
expect(payload.purgedExpiredRows).toBeGreaterThanOrEqual(1);
|
|
1088
|
+
expect(payload.total).toBeGreaterThanOrEqual(1);
|
|
1089
|
+
expect(Array.isArray(payload.rows)).toBe(true);
|
|
1090
|
+
expect(payload.rows[0]).toEqual(expect.objectContaining({
|
|
1091
|
+
event_id: 'evt_1',
|
|
1092
|
+
reason: 'diagnostic-1'
|
|
1093
|
+
}));
|
|
1094
|
+
});
|
|
1095
|
+
it('lists stripe price options filtered by interval for system admin', async () => {
|
|
1096
|
+
mockPricesList.mockResolvedValueOnce({
|
|
1097
|
+
data: [
|
|
1098
|
+
{ id: 'price_month_1', active: true, currency: 'usd', unit_amount: 1200, recurring: { interval: 'month' }, nickname: 'Monthly', product: 'prod_1' },
|
|
1099
|
+
{ id: 'price_year_1', active: true, currency: 'usd', unit_amount: 12000, recurring: { interval: 'year' }, nickname: 'Yearly', product: 'prod_1' }
|
|
1100
|
+
],
|
|
1101
|
+
has_more: false
|
|
1102
|
+
});
|
|
1103
|
+
const route = routeHandlers.get('GET /api/taskforce/admin/billing/stripe-price-options');
|
|
1104
|
+
expect(route).toBeTypeOf('function');
|
|
1105
|
+
const req = createMockReq('', {
|
|
1106
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1107
|
+
});
|
|
1108
|
+
req.url = '/api/taskforce/admin/billing/stripe-price-options?interval=month';
|
|
1109
|
+
const res = createMockRes();
|
|
1110
|
+
await route(req, res, {});
|
|
1111
|
+
expect(res.statusCode).toBe(200);
|
|
1112
|
+
const body = JSON.parse(res.body);
|
|
1113
|
+
expect(Array.isArray(body.options)).toBe(true);
|
|
1114
|
+
expect(body.options.length).toBe(1);
|
|
1115
|
+
expect(body.options[0]).toEqual(expect.objectContaining({
|
|
1116
|
+
stripePriceId: 'price_month_1',
|
|
1117
|
+
interval: 'month'
|
|
1118
|
+
}));
|
|
1119
|
+
});
|
|
1120
|
+
it('keeps active manual price mappings during Stripe import when the price is still active but lacks plan metadata', async () => {
|
|
1121
|
+
const now = new Date().toISOString();
|
|
1122
|
+
db.prepare(`
|
|
1123
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1124
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1125
|
+
`).run(now, now);
|
|
1126
|
+
db.prepare(`
|
|
1127
|
+
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)
|
|
1128
|
+
VALUES ('default', 'collab-v1', 'month', 'price_manual', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1129
|
+
`).run(now, now);
|
|
1130
|
+
mockPricesList.mockResolvedValueOnce({
|
|
1131
|
+
data: [
|
|
1132
|
+
{
|
|
1133
|
+
id: 'price_manual',
|
|
1134
|
+
active: true,
|
|
1135
|
+
currency: 'usd',
|
|
1136
|
+
unit_amount: 1900,
|
|
1137
|
+
recurring: { interval: 'month' },
|
|
1138
|
+
metadata: {}
|
|
1139
|
+
}
|
|
1140
|
+
],
|
|
1141
|
+
has_more: false
|
|
1142
|
+
});
|
|
1143
|
+
const route = routeHandlers.get('POST /api/taskforce/admin/billing/prices/import');
|
|
1144
|
+
expect(route).toBeTypeOf('function');
|
|
1145
|
+
const req = createMockReq('', {
|
|
1146
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1147
|
+
});
|
|
1148
|
+
const res = createMockRes();
|
|
1149
|
+
await route(req, res, {});
|
|
1150
|
+
expect(res.statusCode).toBe(200);
|
|
1151
|
+
const row = db.prepare(`
|
|
1152
|
+
SELECT active
|
|
1153
|
+
FROM billing_price_mappings
|
|
1154
|
+
WHERE tenant_id = 'default' AND stripe_price_id = 'price_manual'
|
|
1155
|
+
LIMIT 1
|
|
1156
|
+
`).get();
|
|
1157
|
+
expect(row.active).toBe(1);
|
|
1158
|
+
});
|
|
1159
|
+
it('upserts price mappings and deactivates prior active mapping for same version interval', async () => {
|
|
1160
|
+
const now = new Date().toISOString();
|
|
1161
|
+
db.prepare(`
|
|
1162
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1163
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1164
|
+
`).run(now, now);
|
|
1165
|
+
db.prepare(`
|
|
1166
|
+
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)
|
|
1167
|
+
VALUES ('default', 'collab-v1', 'month', 'price_old', 'stripe', 1, 1100, 'usd', ?, ?)
|
|
1168
|
+
`).run(now, now);
|
|
1169
|
+
mockPriceRetrieve.mockResolvedValueOnce({
|
|
1170
|
+
id: 'price_new',
|
|
1171
|
+
active: true,
|
|
1172
|
+
currency: 'usd',
|
|
1173
|
+
unit_amount: 1500,
|
|
1174
|
+
recurring: { interval: 'month' }
|
|
1175
|
+
});
|
|
1176
|
+
const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
|
|
1177
|
+
expect(upsertRoute).toBeTypeOf('function');
|
|
1178
|
+
const upsertReq = createMockReq(JSON.stringify({
|
|
1179
|
+
planVersionId: 'collab-v1',
|
|
1180
|
+
interval: 'month',
|
|
1181
|
+
stripePriceId: 'price_new',
|
|
1182
|
+
active: true
|
|
1183
|
+
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1184
|
+
const upsertRes = createMockRes();
|
|
1185
|
+
await upsertRoute(upsertReq, upsertRes, {});
|
|
1186
|
+
expect(upsertRes.statusCode).toBe(200);
|
|
1187
|
+
const rows = db.prepare(`
|
|
1188
|
+
SELECT stripe_price_id, active
|
|
1189
|
+
FROM billing_price_mappings
|
|
1190
|
+
WHERE tenant_id = 'default' AND plan_version_id = 'collab-v1' AND billing_interval = 'month'
|
|
1191
|
+
ORDER BY stripe_price_id ASC
|
|
1192
|
+
`).all();
|
|
1193
|
+
expect(rows).toEqual([
|
|
1194
|
+
{ stripe_price_id: 'price_new', active: 1 },
|
|
1195
|
+
{ stripe_price_id: 'price_old', active: 0 }
|
|
1196
|
+
]);
|
|
1197
|
+
});
|
|
1198
|
+
it('allows the same stripe price id to be assigned to multiple plan versions', async () => {
|
|
1199
|
+
const now = new Date().toISOString();
|
|
1200
|
+
db.prepare(`
|
|
1201
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1202
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1203
|
+
`).run(now, now);
|
|
1204
|
+
db.prepare(`
|
|
1205
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1206
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1207
|
+
`).run(now, now);
|
|
1208
|
+
mockPriceRetrieve.mockResolvedValue({
|
|
1209
|
+
id: 'price_shared',
|
|
1210
|
+
active: true,
|
|
1211
|
+
currency: 'usd',
|
|
1212
|
+
unit_amount: 1900,
|
|
1213
|
+
recurring: { interval: 'month' }
|
|
1214
|
+
});
|
|
1215
|
+
const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
|
|
1216
|
+
const firstReq = createMockReq(JSON.stringify({
|
|
1217
|
+
planVersionId: 'collab-v1',
|
|
1218
|
+
interval: 'month',
|
|
1219
|
+
stripePriceId: 'price_shared',
|
|
1220
|
+
active: true
|
|
1221
|
+
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1222
|
+
const firstRes = createMockRes();
|
|
1223
|
+
await upsertRoute(firstReq, firstRes, {});
|
|
1224
|
+
expect(firstRes.statusCode).toBe(200);
|
|
1225
|
+
const secondReq = createMockReq(JSON.stringify({
|
|
1226
|
+
planVersionId: 'team-v1',
|
|
1227
|
+
interval: 'month',
|
|
1228
|
+
stripePriceId: 'price_shared',
|
|
1229
|
+
active: true
|
|
1230
|
+
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1231
|
+
const secondRes = createMockRes();
|
|
1232
|
+
await upsertRoute(secondReq, secondRes, {});
|
|
1233
|
+
expect(secondRes.statusCode).toBe(200);
|
|
1234
|
+
const rows = db.prepare(`
|
|
1235
|
+
SELECT plan_version_id, stripe_price_id, active
|
|
1236
|
+
FROM billing_price_mappings
|
|
1237
|
+
WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
|
|
1238
|
+
ORDER BY plan_version_id ASC
|
|
1239
|
+
`).all();
|
|
1240
|
+
expect(rows).toEqual([
|
|
1241
|
+
{ plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
|
|
1242
|
+
{ plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
|
|
1243
|
+
]);
|
|
1244
|
+
});
|
|
1245
|
+
it('keeps reused mappings for other plan versions during Stripe import', async () => {
|
|
1246
|
+
const now = new Date().toISOString();
|
|
1247
|
+
db.prepare(`
|
|
1248
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1249
|
+
VALUES ('collab-v1', 'default', 'collab', 1, 1, '{}', ?, ?)
|
|
1250
|
+
`).run(now, now);
|
|
1251
|
+
db.prepare(`
|
|
1252
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1253
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1254
|
+
`).run(now, now);
|
|
1255
|
+
db.prepare(`
|
|
1256
|
+
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)
|
|
1257
|
+
VALUES ('default', 'collab-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1258
|
+
`).run(now, now);
|
|
1259
|
+
db.prepare(`
|
|
1260
|
+
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)
|
|
1261
|
+
VALUES ('default', 'team-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1262
|
+
`).run(now, now);
|
|
1263
|
+
mockPricesList.mockResolvedValueOnce({
|
|
1264
|
+
data: [
|
|
1265
|
+
{
|
|
1266
|
+
id: 'price_shared',
|
|
1267
|
+
active: true,
|
|
1268
|
+
currency: 'usd',
|
|
1269
|
+
unit_amount: 1900,
|
|
1270
|
+
recurring: { interval: 'month' },
|
|
1271
|
+
metadata: { taskforce_plan_version_id: 'collab-v1' }
|
|
1272
|
+
}
|
|
1273
|
+
],
|
|
1274
|
+
has_more: false
|
|
1275
|
+
});
|
|
1276
|
+
const route = routeHandlers.get('POST /api/taskforce/admin/billing/prices/import');
|
|
1277
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1278
|
+
const res = createMockRes();
|
|
1279
|
+
await route(req, res, {});
|
|
1280
|
+
expect(res.statusCode).toBe(200);
|
|
1281
|
+
const rows = db.prepare(`
|
|
1282
|
+
SELECT plan_version_id, stripe_price_id, active
|
|
1283
|
+
FROM billing_price_mappings
|
|
1284
|
+
WHERE tenant_id = 'default' AND stripe_price_id = 'price_shared'
|
|
1285
|
+
ORDER BY plan_version_id ASC
|
|
1286
|
+
`).all();
|
|
1287
|
+
expect(rows).toEqual([
|
|
1288
|
+
{ plan_version_id: 'collab-v1', stripe_price_id: 'price_shared', active: 1 },
|
|
1289
|
+
{ plan_version_id: 'team-v1', stripe_price_id: 'price_shared', active: 1 }
|
|
1290
|
+
]);
|
|
1291
|
+
});
|
|
1292
|
+
it('upserts explicit free pricing without requiring a Stripe price id', async () => {
|
|
1293
|
+
const now = new Date().toISOString();
|
|
1294
|
+
db.prepare(`
|
|
1295
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1296
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
1297
|
+
`).run(now, now);
|
|
1298
|
+
const upsertRoute = routeHandlers.get('PUT /api/taskforce/admin/billing/price-mappings');
|
|
1299
|
+
expect(upsertRoute).toBeTypeOf('function');
|
|
1300
|
+
const upsertReq = createMockReq(JSON.stringify({
|
|
1301
|
+
planVersionId: 'starter-v1',
|
|
1302
|
+
interval: 'month',
|
|
1303
|
+
pricingType: 'free',
|
|
1304
|
+
active: true
|
|
1305
|
+
}), { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1306
|
+
const upsertRes = createMockRes();
|
|
1307
|
+
await upsertRoute(upsertReq, upsertRes, {});
|
|
1308
|
+
expect(upsertRes.statusCode).toBe(200);
|
|
1309
|
+
const row = db.prepare(`
|
|
1310
|
+
SELECT stripe_price_id, pricing_type, active, unit_amount
|
|
1311
|
+
FROM billing_price_mappings
|
|
1312
|
+
WHERE tenant_id = 'default' AND plan_version_id = 'starter-v1' AND billing_interval = 'month'
|
|
1313
|
+
LIMIT 1
|
|
1314
|
+
`).get();
|
|
1315
|
+
expect(row.stripe_price_id).toBe('free:starter-v1:month');
|
|
1316
|
+
expect(row.pricing_type).toBe('free');
|
|
1317
|
+
expect(row.active).toBe(1);
|
|
1318
|
+
expect(row.unit_amount).toBe(0);
|
|
1319
|
+
});
|
|
1320
|
+
it('rejects webhook failure diagnostics for non-admin', async () => {
|
|
1321
|
+
const nonAdminHandlers = buildBillingRouteHandlers(db, { isAdmin: false });
|
|
1322
|
+
const route = nonAdminHandlers.get('GET /api/taskforce/admin/billing/webhook-failures');
|
|
1323
|
+
expect(route).toBeTypeOf('function');
|
|
1324
|
+
const req = createMockReq('', {
|
|
1325
|
+
'x-taskforce-runtime-mode': 'cloud'
|
|
1326
|
+
});
|
|
1327
|
+
req.url = '/api/taskforce/admin/billing/webhook-failures';
|
|
1328
|
+
const res = createMockRes();
|
|
1329
|
+
await route(req, res, {});
|
|
1330
|
+
expect(res.statusCode).toBe(403);
|
|
1331
|
+
});
|
|
1332
|
+
it('returns public pricing catalog with cached Stripe amounts without auth', async () => {
|
|
1333
|
+
const now = new Date().toISOString();
|
|
1334
|
+
routeHandlers = buildBillingRouteHandlers(db);
|
|
1335
|
+
const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
|
|
1336
|
+
expect(pricingRoute).toBeTypeOf('function');
|
|
1337
|
+
db.prepare(`
|
|
1338
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1339
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1340
|
+
`).run(now, now);
|
|
1341
|
+
db.prepare(`
|
|
1342
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1343
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
1344
|
+
`).run(now, now);
|
|
1345
|
+
db.prepare(`
|
|
1346
|
+
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)
|
|
1347
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 'stripe', 1, 1500, 'usd', ?, ?)
|
|
1348
|
+
`).run(now, now);
|
|
1349
|
+
db.prepare(`
|
|
1350
|
+
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)
|
|
1351
|
+
VALUES ('default', 'starter-v1', 'month', 'free:starter-v1:month', 'free', 1, 0, NULL, ?, ?)
|
|
1352
|
+
`).run(now, now);
|
|
1353
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1354
|
+
const res = createMockRes();
|
|
1355
|
+
await pricingRoute(req, res, {});
|
|
1356
|
+
expect(res.statusCode).toBe(200);
|
|
1357
|
+
const body = JSON.parse(res.body);
|
|
1358
|
+
expect(body.plans).toBeInstanceOf(Array);
|
|
1359
|
+
expect(body.pricingSource).toEqual({
|
|
1360
|
+
environment: 'production',
|
|
1361
|
+
runtimeMode: 'cloud'
|
|
1362
|
+
});
|
|
1363
|
+
// Only active plans with versions should be returned (pro, starter)
|
|
1364
|
+
// inactive-plan (archived) and empty-plan (no versions) are excluded.
|
|
1365
|
+
expect(body.plans.length).toBe(2);
|
|
1366
|
+
const proPlan = body.plans.find((p) => p.planId === 'pro');
|
|
1367
|
+
const freePlan = body.plans.find((p) => p.planId === 'starter');
|
|
1368
|
+
expect(proPlan).toBeDefined();
|
|
1369
|
+
expect(freePlan).toBeDefined();
|
|
1370
|
+
// Pro plan has mapped prices
|
|
1371
|
+
const monthPricing = proPlan.pricing.month;
|
|
1372
|
+
expect(monthPricing).not.toBeNull();
|
|
1373
|
+
expect(monthPricing.unitAmount).toBe(1500);
|
|
1374
|
+
expect(monthPricing.currency).toBe('usd');
|
|
1375
|
+
expect(monthPricing.stripePriceId).toBe('price_pro_month');
|
|
1376
|
+
expect(proPlan.pricing.year).toBeNull();
|
|
1377
|
+
// Free plan is explicitly free, not "missing"
|
|
1378
|
+
expect(freePlan.pricing.month.pricingType).toBe('free');
|
|
1379
|
+
expect(freePlan.pricing.month.unitAmount).toBe(0);
|
|
1380
|
+
expect(freePlan.pricing.month.stripePriceId).toBeNull();
|
|
1381
|
+
expect(freePlan.pricing.year).toBeNull();
|
|
1382
|
+
expect(proPlan.defaultVersion.trialDays).toBe(0);
|
|
1383
|
+
});
|
|
1384
|
+
it('returns the pricing environment derived from the cloud host', async () => {
|
|
1385
|
+
routeHandlers = buildBillingRouteHandlers(db);
|
|
1386
|
+
const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
|
|
1387
|
+
expect(pricingRoute).toBeTypeOf('function');
|
|
1388
|
+
const req = createMockReq('', {
|
|
1389
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1390
|
+
host: 'performance-app.taskforcehq.ai'
|
|
1391
|
+
});
|
|
1392
|
+
const res = createMockRes();
|
|
1393
|
+
await pricingRoute(req, res, {});
|
|
1394
|
+
expect(res.statusCode).toBe(200);
|
|
1395
|
+
const body = JSON.parse(res.body);
|
|
1396
|
+
expect(body.pricingSource).toEqual({
|
|
1397
|
+
environment: 'performance',
|
|
1398
|
+
runtimeMode: 'cloud'
|
|
1399
|
+
});
|
|
1400
|
+
});
|
|
1401
|
+
it('rejects local runtime pricing requests so they cannot serve local catalog data', async () => {
|
|
1402
|
+
routeHandlers = buildBillingRouteHandlers(db);
|
|
1403
|
+
const pricingRoute = routeHandlers.get('GET /api/taskforce/public/pricing');
|
|
1404
|
+
expect(pricingRoute).toBeTypeOf('function');
|
|
1405
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'local' });
|
|
1406
|
+
const res = createMockRes();
|
|
1407
|
+
await pricingRoute(req, res, {});
|
|
1408
|
+
expect(res.statusCode).toBe(500);
|
|
1409
|
+
expect(JSON.parse(res.body)).toEqual({
|
|
1410
|
+
error: 'Pricing must be served through the configured cloud pricing proxy in local runtime.'
|
|
1411
|
+
});
|
|
1412
|
+
});
|
|
1413
|
+
it('returns plan display name in billing status payload', async () => {
|
|
1414
|
+
const now = new Date().toISOString();
|
|
1415
|
+
db.prepare(`
|
|
1416
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
1417
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
1418
|
+
`).run(now, now);
|
|
1419
|
+
db.prepare(`
|
|
1420
|
+
INSERT INTO user_billing (
|
|
1421
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
1422
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_123', 'active', 'pro', 'month', 'active', ?, ?)
|
|
1423
|
+
`).run(now, now);
|
|
1424
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
1425
|
+
expect(statusRoute).toBeTypeOf('function');
|
|
1426
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1427
|
+
const res = createMockRes();
|
|
1428
|
+
await statusRoute(req, res, {});
|
|
1429
|
+
expect(res.statusCode).toBe(200);
|
|
1430
|
+
const body = JSON.parse(res.body);
|
|
1431
|
+
expect(body.planId).toBe('pro');
|
|
1432
|
+
expect(body.planName).toBe('Pro Plan');
|
|
1433
|
+
expect(body.planVersionId).toBe('pro-v1');
|
|
1434
|
+
expect(body.entitlementState).toBe('active');
|
|
1435
|
+
});
|
|
1436
|
+
it('reconciles stale entitlement from Stripe when billing status is requested', async () => {
|
|
1437
|
+
const now = new Date().toISOString();
|
|
1438
|
+
db.prepare(`
|
|
1439
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
1440
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
1441
|
+
`).run(now, now);
|
|
1442
|
+
db.prepare(`
|
|
1443
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1444
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1445
|
+
`).run(now, now);
|
|
1446
|
+
db.prepare(`
|
|
1447
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1448
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1449
|
+
`).run(now, now);
|
|
1450
|
+
db.prepare(`
|
|
1451
|
+
INSERT INTO user_billing (
|
|
1452
|
+
user_id, tenant_id, stripe_customer_id, stripe_subscription_id, stripe_price_id, stripe_status, tier, billing_interval, entitlement_state, created_at, updated_at
|
|
1453
|
+
) VALUES ('u1', 'default', 'cus_123', 'sub_123', 'price_old', 'active', 'starter', 'month', 'active', ?, ?)
|
|
1454
|
+
`).run(now, now);
|
|
1455
|
+
db.prepare(`
|
|
1456
|
+
INSERT INTO account_entitlements (
|
|
1457
|
+
account_id, tenant_id, plan_version_id, entitlement_state, effective_at, created_at, updated_at
|
|
1458
|
+
) VALUES ('u1', 'default', 'starter-v1', 'active', ?, ?, ?)
|
|
1459
|
+
`).run(now, now, now);
|
|
1460
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
1461
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1462
|
+
const res = createMockRes();
|
|
1463
|
+
await statusRoute(req, res, {});
|
|
1464
|
+
expect(res.statusCode).toBe(200);
|
|
1465
|
+
expect(mockSubscriptionRetrieve).toHaveBeenCalledWith('sub_123');
|
|
1466
|
+
const entitlement = db.prepare(`
|
|
1467
|
+
SELECT plan_version_id
|
|
1468
|
+
FROM account_entitlements
|
|
1469
|
+
WHERE tenant_id = 'default' AND account_id = 'u1'
|
|
1470
|
+
LIMIT 1
|
|
1471
|
+
`).get();
|
|
1472
|
+
expect(entitlement?.plan_version_id).toBe('pro-v1');
|
|
1473
|
+
});
|
|
1474
|
+
it('repairs billing status from an active Stripe subscription discovered by account email', async () => {
|
|
1475
|
+
const now = new Date().toISOString();
|
|
1476
|
+
db.prepare(`
|
|
1477
|
+
INSERT INTO plan_catalog (plan_id, tenant_id, display_name, status, created_at, updated_at)
|
|
1478
|
+
VALUES ('pro', 'default', 'Pro Plan', 'active', ?, ?)
|
|
1479
|
+
`).run(now, now);
|
|
1480
|
+
db.prepare(`
|
|
1481
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1482
|
+
VALUES ('pro-v1', 'default', 'pro', 1, 1, '{}', ?, ?)
|
|
1483
|
+
`).run(now, now);
|
|
1484
|
+
db.prepare(`
|
|
1485
|
+
INSERT INTO billing_price_mappings (tenant_id, plan_version_id, billing_interval, stripe_price_id, active, created_at, updated_at)
|
|
1486
|
+
VALUES ('default', 'pro-v1', 'month', 'price_pro_month', 1, ?, ?)
|
|
1487
|
+
`).run(now, now);
|
|
1488
|
+
mockCustomersList.mockResolvedValueOnce({
|
|
1489
|
+
data: [{ id: 'cus_email_match' }]
|
|
1490
|
+
});
|
|
1491
|
+
mockSubscriptionList.mockResolvedValueOnce({
|
|
1492
|
+
data: [{
|
|
1493
|
+
id: 'sub_email_match',
|
|
1494
|
+
customer: 'cus_email_match',
|
|
1495
|
+
status: 'active',
|
|
1496
|
+
trial_end: null,
|
|
1497
|
+
metadata: { userId: 'u1', planVersionId: 'pro-v1' },
|
|
1498
|
+
items: {
|
|
1499
|
+
data: [{
|
|
1500
|
+
id: 'si_email_match',
|
|
1501
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1502
|
+
price: { id: 'price_pro_month', recurring: { interval: 'month' } }
|
|
1503
|
+
}]
|
|
1504
|
+
}
|
|
1505
|
+
}]
|
|
1506
|
+
});
|
|
1507
|
+
const statusRoute = routeHandlers.get('GET /api/taskforce/billing/status');
|
|
1508
|
+
const req = createMockReq('', { 'x-taskforce-runtime-mode': 'cloud' });
|
|
1509
|
+
const res = createMockRes();
|
|
1510
|
+
await statusRoute(req, res, {});
|
|
1511
|
+
expect(res.statusCode).toBe(200);
|
|
1512
|
+
const body = JSON.parse(res.body);
|
|
1513
|
+
expect(body.planId).toBe('pro');
|
|
1514
|
+
expect(body.planVersionId).toBe('pro-v1');
|
|
1515
|
+
expect(body.stripeCustomerId).toBe('cus_email_match');
|
|
1516
|
+
expect(body.stripeSubscriptionId).toBe('sub_email_match');
|
|
1517
|
+
});
|
|
1518
|
+
it('prefers subscription metadata over shared price fallback during webhook processing', async () => {
|
|
1519
|
+
const now = new Date().toISOString();
|
|
1520
|
+
db.prepare(`
|
|
1521
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1522
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
1523
|
+
`).run(now, now);
|
|
1524
|
+
db.prepare(`
|
|
1525
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1526
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1527
|
+
`).run(now, now);
|
|
1528
|
+
db.prepare(`
|
|
1529
|
+
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)
|
|
1530
|
+
VALUES ('default', 'starter-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1531
|
+
`).run(now, now);
|
|
1532
|
+
db.prepare(`
|
|
1533
|
+
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)
|
|
1534
|
+
VALUES ('default', 'team-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1535
|
+
`).run(now, now);
|
|
1536
|
+
db.prepare(`
|
|
1537
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
1538
|
+
VALUES ('u1', 'default', 'cus_123', 'active', ?, ?)
|
|
1539
|
+
`).run(now, now);
|
|
1540
|
+
mockConstructEvent.mockReturnValue({
|
|
1541
|
+
id: 'evt_shared_metadata',
|
|
1542
|
+
type: 'customer.subscription.updated',
|
|
1543
|
+
data: {
|
|
1544
|
+
object: {
|
|
1545
|
+
id: 'sub_123',
|
|
1546
|
+
customer: 'cus_123',
|
|
1547
|
+
status: 'active',
|
|
1548
|
+
trial_end: null,
|
|
1549
|
+
metadata: { planVersionId: 'team-v1' },
|
|
1550
|
+
items: {
|
|
1551
|
+
data: [{
|
|
1552
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1553
|
+
price: { id: 'price_shared', recurring: { interval: 'month' } }
|
|
1554
|
+
}]
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
});
|
|
1559
|
+
const req = createMockReq('{"id":"evt_shared_metadata"}', {
|
|
1560
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1561
|
+
'stripe-signature': 'sig'
|
|
1562
|
+
});
|
|
1563
|
+
const res = createMockRes();
|
|
1564
|
+
await webhookHandler(req, res, {});
|
|
1565
|
+
expect(res.statusCode).toBe(200);
|
|
1566
|
+
const entitlement = db.prepare(`
|
|
1567
|
+
SELECT plan_version_id
|
|
1568
|
+
FROM account_entitlements
|
|
1569
|
+
WHERE account_id = 'u1' AND tenant_id = 'default'
|
|
1570
|
+
LIMIT 1
|
|
1571
|
+
`).get();
|
|
1572
|
+
expect(entitlement.plan_version_id).toBe('team-v1');
|
|
1573
|
+
});
|
|
1574
|
+
it('logs webhook failure when shared price fallback is ambiguous and metadata is missing', async () => {
|
|
1575
|
+
const now = new Date().toISOString();
|
|
1576
|
+
db.prepare(`
|
|
1577
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1578
|
+
VALUES ('starter-v1', 'default', 'starter', 1, 1, '{}', ?, ?)
|
|
1579
|
+
`).run(now, now);
|
|
1580
|
+
db.prepare(`
|
|
1581
|
+
INSERT INTO plan_versions (plan_version_id, tenant_id, plan_id, version_number, is_default, metadata_json, created_at, updated_at)
|
|
1582
|
+
VALUES ('team-v1', 'default', 'team', 1, 1, '{}', ?, ?)
|
|
1583
|
+
`).run(now, now);
|
|
1584
|
+
db.prepare(`
|
|
1585
|
+
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)
|
|
1586
|
+
VALUES ('default', 'starter-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1587
|
+
`).run(now, now);
|
|
1588
|
+
db.prepare(`
|
|
1589
|
+
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)
|
|
1590
|
+
VALUES ('default', 'team-v1', 'month', 'price_shared', 'stripe', 1, 1900, 'usd', ?, ?)
|
|
1591
|
+
`).run(now, now);
|
|
1592
|
+
db.prepare(`
|
|
1593
|
+
INSERT INTO user_billing (user_id, tenant_id, stripe_customer_id, entitlement_state, created_at, updated_at)
|
|
1594
|
+
VALUES ('u1', 'default', 'cus_456', 'active', ?, ?)
|
|
1595
|
+
`).run(now, now);
|
|
1596
|
+
mockConstructEvent.mockReturnValue({
|
|
1597
|
+
id: 'evt_shared_ambiguous',
|
|
1598
|
+
type: 'customer.subscription.updated',
|
|
1599
|
+
data: {
|
|
1600
|
+
object: {
|
|
1601
|
+
id: 'sub_456',
|
|
1602
|
+
customer: 'cus_456',
|
|
1603
|
+
status: 'active',
|
|
1604
|
+
trial_end: null,
|
|
1605
|
+
metadata: {},
|
|
1606
|
+
items: {
|
|
1607
|
+
data: [{
|
|
1608
|
+
current_period_end: Math.floor(Date.now() / 1000) + 86400,
|
|
1609
|
+
price: { id: 'price_shared', recurring: { interval: 'month' } }
|
|
1610
|
+
}]
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
});
|
|
1615
|
+
const req = createMockReq('{"id":"evt_shared_ambiguous"}', {
|
|
1616
|
+
'x-taskforce-runtime-mode': 'cloud',
|
|
1617
|
+
'stripe-signature': 'sig'
|
|
1618
|
+
});
|
|
1619
|
+
const res = createMockRes();
|
|
1620
|
+
await webhookHandler(req, res, {});
|
|
1621
|
+
expect(res.statusCode).toBe(200);
|
|
1622
|
+
const failure = db.prepare(`
|
|
1623
|
+
SELECT reason
|
|
1624
|
+
FROM stripe_webhook_failures
|
|
1625
|
+
WHERE event_id = 'evt_shared_ambiguous'
|
|
1626
|
+
ORDER BY created_at DESC
|
|
1627
|
+
LIMIT 1
|
|
1628
|
+
`).get();
|
|
1629
|
+
expect(failure.reason).toContain('Ambiguous price mapping');
|
|
1630
|
+
});
|
|
1631
|
+
});
|